quitar el proyecto Gradle propio y lo que dependia de el

La APK se monta sobre la oficial con sobre_oficial.sh; el proyecto Gradle, sus
scripts, el workflow de gitea, la guia en PDF y el pad de usabilidad ya no se usan.
This commit is contained in:
hacklab 2026-10-24 15:11:41 +02:00
parent e4bbbc23f2
commit 2d79e6779c
30 changed files with 17 additions and 2330 deletions

8
android/.gitignore vendored
View file

@ -1,7 +1,3 @@
# artefactos y binarios grandes: los prepara scripts/prepare.sh
app/src/main/jniLibs/
app/src/main/assets/nodejs-project.zip
.gradle/
# lo que monta y baja scripts/sobre_oficial.sh
build/
app/build/
local.properties
.tools/

View file

@ -1,99 +1,21 @@
# Wrapper Android propio
# APK
Proyecto Gradle para construir la APK de la rama sin depender del APK oficial como
molde.
## Por que existe
Nota (19/10/2026): desde la 1.3.x la APK oficial ya pide microfono y camara y trae un
puente nativo de audio y camara (`.audio_bridge`) para el Telefono y las Salas. Por eso
la APK de la rama se monta ahora sobre la oficial con `scripts/sobre_oficial.sh`, y el
proyecto Gradle de esta carpeta queda sin uso. Lo de abajo describe la situacion anterior.
El APK que se venia usando es el de epsylon re-firmado, y su `classes.dex` **no
implementa `WebChromeClient.onPermissionRequest`**. Sin ese metodo el WebView de
Android deniega por defecto toda peticion de `getUserMedia()`, de modo que las
llamadas no pueden funcionar por mucho que se parchee el manifest:
la lista de permisos es estatica y hay que declararla al compilar.
Este wrapper resuelve las dos puertas: declara los permisos y los concede al
contenido web solo tras el consentimiento del usuario.
## Permisos: declarados, no concedidos
`CAMERA`, `RECORD_AUDIO` y `MODIFY_AUDIO_SETTINGS` figuran en el manifest porque
Android exige que la lista sea fija. Pero son permisos peligrosos: **se piden en
tiempo de ejecucion la primera vez que se pulsa Llamar**, no al instalar. Si nadie
llama, no se pide nada y la app no accede a la camara ni al microfono.
`uses-feature ... required="false"` evita excluir de la tienda a dispositivos sin
camara o sin microfono.
## Que reutiliza y que es nuevo
Las tres librerias nativas se toman tal cual del APK existente:
- `libnode.so` — nodejs-mobile **v18.20.4** oficial (OpenSSL 3.0.13+quic), un
artefacto publico; no hay que compilar Node.
- `libnative-lib.so` — 6,8 KB, un solo simbolo JNI:
`Java_com_solarnethub_oasis_OasisActivity_startNodeWithArguments`.
- `libc++_shared.so`
Por eso el `namespace` del modulo es `com.solarnethub.oasis`: JNI resuelve por
nombre y la clase puente tiene que vivir en ese paquete. El `applicationId` si es
propio (`net.laenre.oasis`), asi que la app se instala junto a la oficial sin
pisarla.
Si algun dia se quiere soltar el `.so` de epsylon, reimplementar esos 6,8 KB son
unas 40 lineas de C++ contra las cabeceras de nodejs-mobile, y ahi si haria falta
el NDK.
## Mejoras sobre el APK actual
- `targetSdk 35` (el actual se quedo en uno ya caducado para la tienda).
- Trafico en claro permitido **solo hacia loopback** via
`network_security_config`, en vez de `usesCleartextTraffic="true"` global.
- `allowBackup="false"`: `adb backup` deja de poder extraer `~/.ssb`.
- El backend corre en un servicio en primer plano, en el proceso `:node`, asi que
sobrevive en segundo plano — imprescindible para recibir llamadas. El APK actual
declara `FOREGROUND_SERVICE` pero no registra ningun servicio.
- Al descomprimir el backend se descartan las entradas con rutas sospechosas
(zip slip).
## Construir
Con Android Studio basta con `./scripts/android-studio.sh`: clona el Oasis oficial,
descarga su APK de la misma version y empaqueta el backend. Luego *File > Open >*
esta carpeta y *Build > Build APK(s)*. A mano, paso a paso:
La APK se monta sobre la oficial del dev con `scripts/sobre_oficial.sh`: se queda con
su app Android tal cual (ventana, servicio, notificaciones y el puente nativo de audio y
cámara, `.audio_bridge`), le cambia el backend por el de la rama y le pone paquete
propio, `net.laenre.oasis`, para convivir con la oficial.
```sh
# 1. dependencias del backend (una vez)
(cd ../src/server && npm install)
# 2. librerias nativas + empaquetado del backend
./scripts/prepare.sh ../../oasis_mobile # o la ruta de un APK
# 3. compilar
./gradlew assembleDebug # o assembleRelease
./scripts/sobre_oficial.sh # baja la oficial de la versión y la monta
OASIS_VERSION=x.y.z ./scripts/sobre_oficial.sh # otra versión del dev
```
Para firmar la release, por entorno (la keystore nunca al repositorio):
Hace falta Java 17 o más y las build-tools de Android (zipalign y apksigner); apktool
se baja solo. Sin clave se firma con la de depuración. Para repartirla, con la clave
del hacklab por entorno (nunca al repositorio): `OASIS_KEYSTORE`, `OASIS_KEYSTORE_PASS`
y `OASIS_KEY_ALIAS`.
```sh
export OASIS_KEYSTORE=/ruta/oasis-alfa-key.jks
export OASIS_KEYSTORE_PASS=...
export OASIS_KEY_ALIAS=oasis
./gradlew assembleRelease
```
`icono/` es el icono adaptativo; `sobre_oficial.sh` lo copia encima del de la oficial.
## Requisitos
JDK 17 o superior **con compilador**. El OpenJDK del sistema puede ser solo
runtime; `gradle.properties` apunta al JDK que trae Android Studio. Cambia esa
linea si compilas en otra maquina.
## Estado
Compila y genera APK con los permisos y el `onPermissionRequest` en su sitio.
**Falta probarlo en un dispositivo real**: que arranque el backend, que la
interfaz cargue y que `getUserMedia` resuelva al pulsar Llamar.
Hasta el 19/10/2026 hubo aquí un proyecto Gradle propio. Se quitó: no tenía el puente
de audio del dev y en él no había llamadas. Está en la historia de git.

View file

@ -1,66 +0,0 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
// El .so nativo exporta Java_com_solarnethub_oasis_OasisActivity_startNodeWithArguments,
// asi que la clase puente tiene que vivir en ese paquete para que el simbolo resuelva.
// El applicationId si es propio: la app se instala junto a la oficial sin pisarla.
namespace = "com.solarnethub.oasis"
compileSdk = 35
defaultConfig {
applicationId = "net.laenre.oasis"
minSdk = 24
targetSdk = 35
// release.sh los pasa por entorno; a mano, el nombre sale de package.json (como
// en el backend) para que la APK no diga ser otra version que la que lleva dentro
val pkgVersion = Regex("\"version\"\\s*:\\s*\"([^\"]+)\"")
.find(rootProject.file("../package.json").readText())?.groupValues?.get(1) ?: "0.0.0"
versionCode = (System.getenv("OASIS_VERSION_CODE") ?: "1").toInt()
versionName = System.getenv("OASIS_VERSION_NAME") ?: pkgVersion
ndk { abiFilters += listOf("arm64-v8a") }
}
signingConfigs {
create("release") {
val ks = System.getenv("OASIS_KEYSTORE")
if (ks != null) {
storeFile = file(ks)
storePassword = System.getenv("OASIS_KEYSTORE_PASS")
keyAlias = System.getenv("OASIS_KEY_ALIAS") ?: "oasis"
keyPassword = System.getenv("OASIS_KEY_PASS") ?: System.getenv("OASIS_KEYSTORE_PASS")
}
}
}
buildTypes {
release {
isMinifyEnabled = false
if (System.getenv("OASIS_KEYSTORE") != null) signingConfig = signingConfigs.getByName("release")
}
}
buildFeatures { buildConfig = true }
packaging {
jniLibs {
// Comprimidas, como en la APK oficial: libnode.so pasa de 50 a 16 MB en la
// descarga. Sin comprimir (false) se cargaria por mmap desde la APK, pero eso
// solo ahorra unos milisegundos al arrancar y cuesta 35 MB a cada descarga.
useLegacyPackaging = true
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = "17" }
}
dependencies {
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.core:core-ktx:1.13.1")
}

View file

@ -1,64 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- descubrir otros moviles en la misma wifi: el anuncio LAN de SSB va por UDP
broadcast/multicast, y Android lo filtra si no se coge un MulticastLock -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- solo para guardar descargas en Android 9 o anterior; desde el 10 no hace falta -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Camara y microfono para el Telefono y las Salas de Oasis.
Se declaran aqui porque Android exige que la lista sea estatica, pero NO se
conceden al instalar: son permisos peligrosos y se piden en tiempo de ejecucion
la primera vez que se llama o se entra en una sala. Si nadie llama, no se pide nada y la app
nunca accede a la camara ni al microfono. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- required=false para no excluir de la tienda a dispositivos sin camara o sin microfono -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:fullBackupContent="false"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.Oasis">
<!-- El backend Node vive en su propio proceso: asi sobrevive en segundo plano
y se puede reiniciar limpio (nodejs-mobile solo permite arrancar node una
vez por proceso). -->
<service
android:name="net.laenre.oasis.NodeService"
android:process=":node"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- La lista de configChanges tiene que ser completa: si falta una sola (y la
que faltaba era smallestScreenSize), rotar destruye y recrea la Activity,
el WebView se pierde y vuelve a cargar la URL, a veces antes de que el
backend escuche. -->
<activity
android:name="net.laenre.oasis.MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|keyboardHidden|keyboard|navigation|uiMode|fontScale|layoutDirection"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -1,22 +0,0 @@
package com.solarnethub.oasis
/**
* Puente con el runtime de Node.
*
* El nombre de la clase y del metodo no son libres: libnative-lib.so exporta
* Java_com_solarnethub_oasis_OasisActivity_startNodeWithArguments, y JNI resuelve
* por nombre. De ahi que el namespace del modulo sea com.solarnethub.oasis aunque
* el applicationId sea net.laenre.oasis.
*
* No es una Activity pese al nombre; solo declara el metodo nativo.
*/
object OasisActivity {
init {
System.loadLibrary("node")
System.loadLibrary("native-lib")
}
/** Arranca node en el hilo actual. No retorna hasta que el runtime termina. */
@JvmStatic
external fun startNodeWithArguments(arguments: Array<String>): Int
}

View file

@ -1,304 +0,0 @@
package net.laenre.oasis
import android.Manifest
import android.app.DownloadManager
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.webkit.PermissionRequest
import android.webkit.URLUtil
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import com.solarnethub.oasis.BuildConfig
class MainActivity : AppCompatActivity() {
private lateinit var web: WebView
private var pending: PermissionRequest? = null
private var fileCallback: ValueCallback<Array<Uri>>? = null
private val pickFiles = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { res ->
// Si se cancela hay que devolver null igualmente: si no, el <input> queda
// bloqueado y no vuelve a abrir el selector nunca mas.
fileCallback?.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(res.resultCode, res.data))
fileCallback = null
}
private val askAndroid = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted ->
val req = pending
pending = null
if (req == null) return@registerForActivityResult
// Solo se concede al contenido web lo que el sistema nos ha concedido a nosotros.
val allowed = req.resources.filter { res ->
when (res) {
PermissionRequest.RESOURCE_VIDEO_CAPTURE -> granted[Manifest.permission.CAMERA] == true
PermissionRequest.RESOURCE_AUDIO_CAPTURE -> granted[Manifest.permission.RECORD_AUDIO] == true
else -> false
}
}.toTypedArray()
if (allowed.isEmpty()) req.deny() else req.grant(allowed)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= 33 && !has("android.permission.POST_NOTIFICATIONS")) {
askAndroid.launch(arrayOf("android.permission.POST_NOTIFICATIONS"))
}
startService(Intent(this, NodeService::class.java))
web = WebView(this)
setContentView(edgeToEdge(web))
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
web.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
mediaPlaybackRequiresUserGesture = false // permite reproducir el audio remoto de la llamada
allowFileAccess = false
allowContentAccess = false
}
// La navegacion se queda dentro del backend local; lo de fuera va al navegador.
web.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(v: WebView?, r: WebResourceRequest?): Boolean {
val u = r?.url ?: return false
if (isLocal(u)) return false
startActivity(Intent(Intent.ACTION_VIEW, u))
return true
}
/**
* Red de seguridad: si una pagina del backend no carga, se vuelve a esperar
* y se reintenta, en vez de dejar el ERR_CONNECTION_REFUSED de Chromium en
* pantalla. Pasa cuando el backend todavia no escucha (arranque lento), y
* tambien cuando se ha reiniciado —al cambiar de identidad, o porque Android
* mato el proceso del servicio estando en segundo plano—.
*/
override fun onPageFinished(v: WebView?, url: String?) {
// se recuerda para poder volver aqui si luego hay que reintentar
if (url != null && url.startsWith("http://127.0.0.1:3000")) lastGoodUrl = url
}
override fun onReceivedError(v: WebView?, req: WebResourceRequest?, err: WebResourceError?) {
if (req?.isForMainFrame != true) return // un css o una imagen no cuentan
val u = req.url ?: return
if (!isLocal(u)) return // paginas de fuera, ni tocarlas
Log.w("OasisNode", "la pagina no ha cargado (${'$'}u); reintentando")
loaded = false
waitAndLoad()
}
}
// Sin este WebChromeClient.onPermissionRequest, el WebView DENIEGA por defecto
// toda peticion de getUserMedia y no hay videollamada posible, aunque los
// permisos esten declarados en el manifest. Es la puerta que faltaba.
web.webChromeClient = object : WebChromeClient() {
override fun onShowFileChooser(
view: WebView?,
callback: ValueCallback<Array<Uri>>?,
params: FileChooserParams?
): Boolean {
fileCallback?.onReceiveValue(null) // descartar uno anterior sin resolver
fileCallback = callback
return try {
pickFiles.launch(params?.createIntent())
true
} catch (e: Exception) {
fileCallback = null
false
}
}
override fun onPermissionRequest(request: PermissionRequest) {
runOnUiThread {
if (!isLocal(request.origin)) { request.deny(); return@runOnUiThread }
val needed = mutableListOf<String>()
for (res in request.resources) {
when (res) {
PermissionRequest.RESOURCE_VIDEO_CAPTURE ->
if (!has(Manifest.permission.CAMERA)) needed += Manifest.permission.CAMERA
PermissionRequest.RESOURCE_AUDIO_CAPTURE ->
if (!has(Manifest.permission.RECORD_AUDIO)) needed += Manifest.permission.RECORD_AUDIO
}
}
if (needed.isEmpty()) {
request.grant(request.resources)
} else {
// Aqui es donde el usuario ve el dialogo del sistema: al pulsar Llamar,
// no al instalar la app.
pending = request
askAndroid.launch(needed.toTypedArray())
}
}
}
}
// Lo que la pagina manda descargar (un PDF, un fichero adjunto) va al gestor de
// descargas de Android; sin esto el WebView no hace nada al tocar el enlace.
web.setDownloadListener { url, userAgent, disposition, mime, _ -> download(url, userAgent, disposition, mime) }
waitAndLoad()
}
/**
* La pantalla entera, por debajo de las barras del sistema (Android 15 lo impone
* con targetSdk 35, y en versiones anteriores se pide igual para que se vea lo
* mismo). El contenedor deja de margen lo que ocupan la barra de estado, la de
* navegacion, el recorte de la camara y el teclado cuando esta abierto, asi la
* barra de abajo de Oasis sube con el teclado en vez de quedar tapada.
* El fondo blanco y los iconos oscuros son los del tema Acampada.
*/
private fun edgeToEdge(content: WebView): FrameLayout {
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
val root = FrameLayout(this)
root.setBackgroundColor(Color.WHITE)
root.addView(content, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
WindowCompat.getInsetsController(window, root).apply {
isAppearanceLightStatusBars = true
isAppearanceLightNavigationBars = true
}
ViewCompat.setOnApplyWindowInsetsListener(root) { v, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
v.setPadding(bars.left, bars.top, bars.right, maxOf(bars.bottom, ime.bottom))
WindowInsetsCompat.CONSUMED
}
return root
}
/** Descarga por el gestor de Android, a la carpeta Descargas, con aviso al terminar. */
private fun download(url: String, userAgent: String?, disposition: String?, mime: String?) {
try {
val name = URLUtil.guessFileName(url, disposition, mime)
val req = DownloadManager.Request(Uri.parse(url))
.setTitle(name)
.setMimeType(mime)
.addRequestHeader("User-Agent", userAgent ?: "")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, name)
(getSystemService(DOWNLOAD_SERVICE) as DownloadManager).enqueue(req)
Toast.makeText(this, name, Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Log.w("OasisNode", "no se pudo descargar ${'$'}url: ${'$'}e")
// si el gestor no puede, que lo abra otra aplicacion
try { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } catch (_: Exception) {}
}
}
private fun has(p: String) =
ContextCompat.checkSelfPermission(this, p) == PackageManager.PERMISSION_GRANTED
private fun isLocal(u: Uri?): Boolean {
val h = u?.host ?: return false
return (h == "127.0.0.1" || h == "localhost") && u.port == 3000
}
private fun showStatus(attempt: Int) {
val s = attempt / 2
web.loadDataWithBaseURL(null, """
<html><head><meta name='viewport' content='width=device-width,initial-scale=1'>
<style>body{background:#fff;color:#000;font-family:sans-serif;display:flex;
height:100vh;margin:0;align-items:center;justify-content:center;text-align:center}
div{padding:24px}h2{text-transform:uppercase;border-bottom:3px solid #000;padding-bottom:6px}
p{font-size:15px;line-height:1.5}</style></head>
<body><div><h2>Oasis</h2>
<p>Preparando la aplicaci&oacute;n&hellip;<br>El primer arranque descomprime el
servidor y puede tardar un minuto. No hace falta hacer nada.</p><p>${s}s</p></div></body></html>
""".trimIndent(), "text/html", "utf-8", null)
}
/**
* Carga la interfaz reintentando hasta que el backend responda.
*
* Se hace con el propio WebView en vez de sondear con HttpURLConnection: es el
* canal que de verdad va a servir la app, y evita depender de que la peticion de
* comprobacion pase los mismos filtros (el backend, por ejemplo, responde 400 a HEAD).
*/
private var loadAttempt = 0
private var loaded = false
@Volatile private var waiting = false // hay un hilo esperando al backend
private var lastGoodUrl: String? = null // ultima pagina que cargo bien
/**
* Espera a que el backend acepte conexiones y entonces carga la interfaz.
*
* La comprobacion es un socket TCP, no una peticion HTTP: no depende del metodo
* (el backend responde 400 a HEAD), ni de redirecciones (/ redirige a /activity),
* ni de la politica de trafico en claro. Si el puerto acepta, esta listo.
*
* La pantalla de estado se pinta ANTES de empezar a esperar, nunca entre medias:
* cargarla mientras el WebView trae la pagina buena cancelaria esa carga.
*/
/** Ruta a abrir: la del intent si viene con una, o la raiz. */
private fun targetUrl(): String {
val d = intent?.data
if (d != null && isLocal(d)) return d.toString()
val path = intent?.getStringExtra("path")
if (!path.isNullOrBlank() && path.startsWith("/")) return "http://127.0.0.1:3000${'$'}path"
return "http://127.0.0.1:3000/"
}
override fun onNewIntent(newIntent: Intent) {
super.onNewIntent(newIntent)
intent = newIntent
if (loaded) web.loadUrl(targetUrl())
}
private fun waitAndLoad() {
// onReceivedError puede dispararse varias veces seguidas (la pagina y sus
// recursos); sin esto se acumularian hilos esperando al mismo puerto
if (waiting) return
waiting = true
showStatus(0)
Log.i("OasisNode", "esperando al backend en 127.0.0.1:3000")
Thread {
var i = 0
while (i < 600 && !loaded) { // hasta 5 minutos
val ok = try {
java.net.Socket().use { it.connect(java.net.InetSocketAddress("127.0.0.1", 3000), 1000); true }
} catch (e: Exception) {
if (i % 10 == 0) Log.w("OasisNode", "socket #" + i + ": " + e.javaClass.simpleName + ": " + e.message)
false
}
if (ok) {
Handler(Looper.getMainLooper()).post {
loaded = true
waiting = false
Log.i("OasisNode", "backend listo tras " + (i / 2) + "s, cargando interfaz")
web.loadUrl(lastGoodUrl ?: targetUrl())
}
return@Thread
}
if (i % 8 == 0) Handler(Looper.getMainLooper()).post { if (!loaded) showStatus(i) }
Thread.sleep(500); i++
}
waiting = false
}.start()
}
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
if (web.canGoBack()) web.goBack() else super.onBackPressed()
}
}

View file

@ -1,139 +0,0 @@
package net.laenre.oasis
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.wifi.WifiManager
import android.os.Build
import android.os.IBinder
import android.system.Os
import android.util.Log
import com.solarnethub.oasis.OasisActivity
import com.solarnethub.oasis.R
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipInputStream
/**
* Servicio en primer plano que sostiene el backend Node.
*
* Vive en el proceso :node por dos razones: nodejs-mobile solo permite arrancar el
* runtime una vez por proceso —cambiar de identidad exige matar el proceso, no vale
* reiniciar dentro—, y asi el backend sobrevive cuando la app pasa a segundo plano,
* que es imprescindible para recibir llamadas.
*/
class NodeService : Service() {
companion object {
private const val TAG = "OasisNode"
private const val CHANNEL = "oasis-node"
private const val NOTIF_ID = 1
@Volatile private var started = false
}
// Sin esto Android tira los paquetes multicast/broadcast que llegan por wifi y el
// descubrimiento LAN de SSB no ve a nadie: dos moviles en la misma red no se encuentran.
private var multicastLock: WifiManager.MulticastLock? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
startForeground(NOTIF_ID, buildNotification())
acquireMulticastLock()
if (started) return
started = true
Thread({ runNode() }, "node-main").start()
}
override fun onDestroy() {
try { multicastLock?.takeIf { it.isHeld }?.release() } catch (e: Throwable) { Log.w(TAG, "soltando MulticastLock", e) }
super.onDestroy()
}
private fun acquireMulticastLock() {
if (multicastLock?.isHeld == true) return
try {
val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
multicastLock = wifi.createMulticastLock("oasis-lan").apply { setReferenceCounted(false); acquire() }
} catch (e: Throwable) {
// sin wifi o sin permiso: los pubs siguen funcionando, solo se pierde la LAN
Log.w(TAG, "no se ha podido coger el MulticastLock; sin descubrimiento LAN", e)
}
}
private fun buildNotification(): Notification {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val ch = NotificationChannel(CHANNEL, getString(R.string.node_channel), NotificationManager.IMPORTANCE_LOW)
ch.setShowBadge(false)
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel(ch)
}
val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) Notification.Builder(this, CHANNEL)
else @Suppress("DEPRECATION") Notification.Builder(this)
return b.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.node_running))
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setOngoing(true)
.build()
}
private fun runNode() {
try {
val root = File(filesDir, "nodejs-project")
extractProjectIfNeeded(root)
val main = File(root, "main.js")
if (!main.exists()) { Log.e(TAG, "no encuentro main.js en ${root.absolutePath}"); return }
// main.js hace process.env.HOME || resolve(__dirname,'..','..'). En Android HOME
// suele venir como "/" —no escribible—, asi que se fija explicitamente al
// almacenamiento privado de la app: ahi cuelga ~/.ssb.
val home = filesDir.parentFile ?: filesDir
val tmp = File(home, "tmp").apply { mkdirs() }
Os.setenv("HOME", home.absolutePath, true)
Os.setenv("TMPDIR", tmp.absolutePath, true)
Log.i(TAG, "HOME=${home.absolutePath}")
val rc = OasisActivity.startNodeWithArguments(arrayOf("node", main.absolutePath))
Log.w(TAG, "el runtime de node ha terminado con codigo $rc")
} catch (e: Throwable) {
Log.e(TAG, "fallo arrancando node", e)
}
}
/** Descomprime assets/nodejs-project.zip la primera vez y tras cada actualizacion. */
private fun extractProjectIfNeeded(root: File) {
val stamp = File(root, ".version")
// Marca por tamano y fecha del asset, no por versionName: al reconstruir la APK
// sin subir la version, con versionName la app seguiria con el backend anterior.
// lastUpdateTime cambia en cada instalacion, tambien reinstalando la misma version.
// (openFd no vale: el asset va comprimido dentro del APK y no admite descriptor.)
val current = packageManager.getPackageInfo(packageName, 0).lastUpdateTime.toString()
if (stamp.exists() && stamp.readText() == current) return
if (root.exists()) root.deleteRecursively()
root.mkdirs()
assets.open("nodejs-project.zip").use { input ->
ZipInputStream(input).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
val out = File(root, entry.name.removePrefix("nodejs-project/"))
// no confiar en las rutas del zip: evita escribir fuera del directorio (zip slip)
if (!out.canonicalPath.startsWith(root.canonicalPath + File.separator) &&
out.canonicalPath != root.canonicalPath) {
Log.w(TAG, "entrada de zip descartada por ruta sospechosa: ${entry.name}")
} else if (entry.isDirectory) {
out.mkdirs()
} else {
out.parentFile?.mkdirs()
FileOutputStream(out).use { fos -> zip.copyTo(fos) }
}
zip.closeEntry()
entry = zip.nextEntry
}
}
}
stamp.writeText(current)
}
}

View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Hexagono, el mismo motivo que la rejilla de navegacion de la interfaz -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<path
android:pathData="M54,28 L74,40 L74,64 L54,76 L34,64 L34,40 Z"
android:strokeColor="#FFB400" android:strokeWidth="5" android:fillColor="#00000000"/>
<path
android:pathData="M54,44 L63,49 L63,59 L54,64 L45,59 L45,49 Z"
android:fillColor="#FFB400"/>
</vector>

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#121212</color>
</resources>

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Oasis</string>
<string name="node_channel">Servicio de Oasis</string>
<string name="node_running">Oasis esta activo</string>
</resources>

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Oasis" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowBackground">#FFFFFF</item>
<item name="android:statusBarColor">#FFFFFF</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>

View file

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- El backend corre en 127.0.0.1:3000 sin TLS, asi que hay que permitir texto en
claro, pero SOLO hacia loopback. El resto de la red queda en cifrado obligatorio.
El orden importa: el esquema de Android exige base-config antes que domain-config. -->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>

View file

@ -1,4 +0,0 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}

View file

@ -1,7 +0,0 @@
org.gradle.jvmargs=-Xmx2048m
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
# Gradle usa el JDK de JAVA_HOME (17 o superior, con javac). No se fija aqui la
# ruta: cada maquina y el runner tienen el suyo.

Binary file not shown.

View file

@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
android/gradlew vendored
View file

@ -1,252 +0,0 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
android/gradlew.bat vendored
View file

@ -1,94 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -1,76 +0,0 @@
#!/usr/bin/env bash
#
# Deja el proyecto listo para compilarlo desde Android Studio.
#
# Android Studio solo compila: no sabe traer el Oasis oficial, ni sacar libnode.so
# del APK de epsylon, ni empaquetar el backend. Esto lo hace todo de una vez:
#
# 1. clona el Oasis oficial en ../upstream, en el commit de .upstream-commit
# (de ahi salen las dependencias del backend, src/base/node_modules)
# 2. descarga el APK oficial de la misma version a android/native/
# (de ahi salen libnode.so y los modulos nativos de Node para Android)
# 3. lanza prepare.sh, que copia las librerias y empaqueta el backend
#
# Uso, desde la raiz del repo o desde android/:
# ./android/scripts/android-studio.sh
#
# Despues, en Android Studio: File > Open > la carpeta android/, esperar a que
# termine el Gradle Sync, y Build > Build App Bundle(s) / APK(s) > Build APK(s).
#
# Cada vez que cambies algo en src/ o en main.js, vuelve a lanzarlo: la APK lleva
# el backend empaquetado y Android Studio no lo rehace solo.
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)" # android/
REPO="$(cd "$HERE/.." && pwd)" # raiz del repo
UPSTREAM="${OASIS_UPSTREAM:-$REPO/../upstream}"
say() { printf '\n== %s\n' "$*"; }
die() { printf '\n!! %s\n' "$*" >&2; exit 1; }
for cmd in git node curl zip unzip; do
command -v "$cmd" >/dev/null || die "falta '$cmd': instalalo y vuelve a lanzarlo"
done
# --- 1) el Oasis oficial, en el mismo commit que src/ -----------------------------
COMMIT="$(cat "$REPO/.upstream-commit")"
if [ ! -d "$UPSTREAM/.git" ]; then
say "clonando el Oasis oficial en $UPSTREAM (unos 300 MB, solo la primera vez)"
git clone https://github.com/epsylon/oasis "$UPSTREAM"
fi
if [ "$(git -C "$UPSTREAM" rev-parse HEAD)" != "$COMMIT" ]; then
say "poniendo el Oasis oficial en el commit $COMMIT"
git -C "$UPSTREAM" fetch --quiet origin || true
git -C "$UPSTREAM" checkout --quiet "$COMMIT"
fi
# --- 2) el APK oficial de la misma version ----------------------------------------
VERSION="$(node -p "require('$REPO/src/server/package.json').version")"
BASE="$HERE/native/oasis-v$VERSION.apk"
if [ ! -f "$BASE" ]; then
say "descargando el APK oficial $VERSION (unos 50 MB, solo la primera vez)"
mkdir -p "$HERE/native"
curl -fL --progress-bar -o "$BASE.part" \
"https://github.com/epsylon/oasis/releases/download/oasis-android-v$VERSION/oasis-v$VERSION.apk" \
|| die "no se ha podido descargar el APK oficial de la $VERSION"
mv "$BASE.part" "$BASE"
fi
# --- 3) librerias y backend --------------------------------------------------------
say "empaquetando el backend (un minuto o dos)"
OASIS_UPSTREAM="$UPSTREAM" "$HERE/scripts/prepare.sh" "$BASE"
cat <<EOF
== listo
Ahora, en Android Studio:
1. File > Open > $HERE
2. espera a que termine el Gradle Sync (la primera vez descarga bastante)
3. Build > Build App Bundle(s) / APK(s) > Build APK(s)
La APK sale en:
$HERE/app/build/outputs/apk/debug/app-debug.apk
Si cambias algo en src/ o en main.js, vuelve a lanzar este script antes de compilar.
EOF

View file

@ -1,81 +0,0 @@
#!/usr/bin/env bash
# Prepara lo que el proyecto Android no lleva en git:
# - las librerias nativas (libnode.so son 49 MB; no tiene sentido versionarlas)
# - assets/nodejs-project.zip con el backend Node y sus dependencias
#
# Uso: ./scripts/prepare.sh [ruta-a-un-apk-o-directorio-con-lib/arm64-v8a]
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
REPO="$(cd "$HERE/.." && pwd)"
JNI="$HERE/app/src/main/jniLibs/arm64-v8a"
ASSETS="$HERE/app/src/main/assets"
SRC="${1:-}"
mkdir -p "$JNI" "$ASSETS"
# --- 1) librerias nativas -----------------------------------------------------
need_libs=(libnode.so libnative-lib.so libc++_shared.so)
missing=0
for l in "${need_libs[@]}"; do [ -f "$JNI/$l" ] || missing=1; done
if [ "$missing" = "1" ]; then
if [ -z "$SRC" ]; then
echo "Faltan las librerias nativas en $JNI"
echo "Pasa un APK o un directorio que tenga lib/arm64-v8a/, por ejemplo:"
echo " ./scripts/prepare.sh ../../oasis_mobile"
exit 1
fi
if [ -d "$SRC/lib/arm64-v8a" ]; then
cp "$SRC/lib/arm64-v8a"/*.so "$JNI/"
elif [ -f "$SRC" ]; then
tmp="$(mktemp -d)"; unzip -qo "$SRC" 'lib/arm64-v8a/*' -d "$tmp"
cp "$tmp/lib/arm64-v8a"/*.so "$JNI/"; rm -rf "$tmp"
else
echo "No encuentro librerias en $SRC"; exit 1
fi
echo "librerias nativas copiadas:"; ls -la "$JNI" | tail -n +2 | awk '{print " "$NF" ("$5" bytes)"}'
fi
# --- 2) backend + dependencias ---------------------------------------------
# El node_modules del movil esta podado: son ~137 MB frente a los 1,4 GB de una
# instalacion de escritorio. En vez de reinstalarlo, se reutiliza el del APK base:
# tiene que ser la APK oficial de la MISMA version que src/ (ahora 1.3.3): desde la
# 1.1 el backend usa ssb-db2 y el node_modules de una 0.9.x ya no le sirve.
BASE_ZIP=""
if [ -n "$SRC" ] && [ -f "$SRC" ]; then
tmpz="$(mktemp -d)"; unzip -qo "$SRC" 'assets/nodejs-project.zip' -d "$tmpz" 2>/dev/null || true
[ -f "$tmpz/assets/nodejs-project.zip" ] && BASE_ZIP="$tmpz/assets/nodejs-project.zip"
fi
work="$(mktemp -d)"
if [ -n "$BASE_ZIP" ]; then
( cd "$work" && unzip -q "$BASE_ZIP" )
mv "$work/nodejs-project/src/server/node_modules" "$work/_nm"
rm -rf "$work/nodejs-project/src"
elif [ -d "$REPO/src/server/node_modules" ]; then
mkdir -p "$work/nodejs-project"
cp -r "$REPO/src/server/node_modules" "$work/_nm"
else
echo "No hay node_modules. Pasa un APK base del que sacarlo, o instalalas:"
echo " (cd $REPO/src/server && npm install --omit=dev)"
exit 1
fi
mkdir -p "$work/nodejs-project"
cp -r "$REPO/src" "$work/nodejs-project/"
# backend.js lee README.md con readFileSync SIN try/catch (linea ~1690):
# si falta, el backend muere con ENOENT al arrancar.
cp "$REPO/main.js" "$REPO/package.json" "$REPO/README.md" "$work/nodejs-project/" 2>/dev/null || true
test -f "$work/nodejs-project/README.md" || { echo "FALTA README.md: el backend no arrancaria"; exit 1; }
rm -rf "$work/nodejs-project/src/server/node_modules"
mv "$work/_nm" "$work/nodejs-project/src/server/node_modules"
# --- 3) empaquetar ------------------------------------------------------------
# Con compresion: sin ella el backend son ~145 MB y la APK se iria por encima de 200.
rm -f "$ASSETS/nodejs-project.zip"
( cd "$work" && zip -q -r "$ASSETS/nodejs-project.zip" nodejs-project )
rm -rf "$work" "${tmpz:-/nonexistent}"
echo "backend empaquetado: $(du -h "$ASSETS/nodejs-project.zip" | cut -f1) en assets/nodejs-project.zip"
echo
echo "Listo. Ahora: ./gradlew assembleDebug (o assembleRelease para firmar)"

View file

@ -1,175 +0,0 @@
#!/usr/bin/env bash
#
# Construye la APK, la verifica y (si se le pasa un token) publica la release en
# gitea. Pensado para lanzarlo a mano o desde un runner; hace lo mismo en los dos
# sitios, que es lo que evita el clasico "en mi maquina si sale".
#
# Uso:
# ./scripts/release.sh # compila debug y verifica
# ./scripts/release.sh --release # compila release firmada
# ./scripts/release.sh --release --publish # borrador, para revisarla
# ./scripts/release.sh --release --publish --public # publicada: la descarga del README
#
# Variables:
# OASIS_KEYSTORE, OASIS_KEYSTORE_PASS, OASIS_KEY_ALIAS firma (obligatorias con --release)
# OASIS_GITEA_TOKEN publicar (obligatoria con --publish)
# OASIS_APK_BASE APK del que sacar node_modules la primera vez
# OASIS_GITEA_URL por defecto https://gitea.laenre.net
# OASIS_GITEA_REPO por defecto hacklab/OASIS_MOBILE
# ANDROID_HOME por defecto ~/Android/Sdk
# JAVA_HOME un JDK 17 o superior CON compilador
#
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
REPO="$(cd "$HERE/.." && pwd)"
cd "$HERE"
MODE=debug
PUBLISH=0
PUBLIC=0
for arg in "$@"; do
case "$arg" in
--release) MODE=release ;;
--publish) PUBLISH=1 ;;
--public) PUBLIC=1 ;;
*) echo "opcion desconocida: $arg"; exit 2 ;;
esac
done
GITEA_URL="${OASIS_GITEA_URL:-https://gitea.laenre.net}"
GITEA_REPO="${OASIS_GITEA_REPO:-hacklab/OASIS_MOBILE}"
SDK="${ANDROID_HOME:-$HOME/Android/Sdk}"
say() { printf '\n== %s\n' "$*"; }
die() { printf '\n!! %s\n' "$*" >&2; exit 1; }
# --- 1) version -------------------------------------------------------------
# El nombre sale de package.json, que es de donde lo saca tambien el backend, para
# que la version que muestra la aplicacion y la de la APK no puedan discrepar.
VERSION_NAME="${OASIS_VERSION_NAME:-$(node -p "require('$REPO/package.json').version" 2>/dev/null || echo 0.0.0)}"
# El codigo tiene que subir en cada publicacion o Android rechaza la instalacion:
# el numero de commits sirve y no hay que llevarlo a mano.
VERSION_CODE="${OASIS_VERSION_CODE:-$(git -C "$REPO" rev-list --count HEAD 2>/dev/null || echo 1)}"
TAG="v${VERSION_NAME}-${VERSION_CODE}"
export OASIS_VERSION_NAME="$VERSION_NAME" OASIS_VERSION_CODE="$VERSION_CODE"
say "version ${VERSION_NAME} (codigo ${VERSION_CODE}), modo ${MODE}"
# --- 2) comprobaciones previas ---------------------------------------------
[ -n "${JAVA_HOME:-}" ] || die "define JAVA_HOME apuntando a un JDK con compilador"
[ -x "${JAVA_HOME}/bin/javac" ] || die "en ${JAVA_HOME} no hay javac: es un runtime, no un JDK"
# apksigner y aapt2 son scripts que llaman a "java" a pelo: sin esto fallaban con
# "java: no encontrado" y parecia que la APK estaba mal firmada
export PATH="$JAVA_HOME/bin:$PATH"
[ -d "$SDK" ] || die "no encuentro el SDK de Android en $SDK (define ANDROID_HOME)"
if [ "$MODE" = release ]; then
[ -n "${OASIS_KEYSTORE:-}" ] || die "--release necesita OASIS_KEYSTORE"
[ -f "$OASIS_KEYSTORE" ] || die "no existe el almacen de claves: $OASIS_KEYSTORE"
[ -n "${OASIS_KEYSTORE_PASS:-}" ] || die "--release necesita OASIS_KEYSTORE_PASS"
fi
if [ "$PUBLISH" = 1 ]; then
[ -n "${OASIS_GITEA_TOKEN:-}" ] || die "--publish necesita OASIS_GITEA_TOKEN"
[ "$MODE" = release ] || die "no se publica una APK de depuracion"
fi
# --- 3) backend empaquetado -------------------------------------------------
# prepare.sh es idempotente pero tarda; se rehace siempre porque el backend cambia
# en cada commit y una APK con el backend viejo es peor que no tener APK.
# La primera vez hace falta un APK base del que sacar node_modules: el del movil
# esta podado y sin binarios nativos, y un npm install normal no lo reproduce.
say "empaquetando el backend"
./scripts/prepare.sh ${OASIS_APK_BASE:+"$OASIS_APK_BASE"} \
|| die "prepare.sh ha fallado (define OASIS_APK_BASE con un APK del que sacar node_modules)"
ZIP=app/src/main/assets/nodejs-project.zip
[ -f "$ZIP" ] || die "falta $ZIP"
ZIP_MB=$(( $(stat -c%s "$ZIP") / 1024 / 1024 ))
[ "$ZIP_MB" -ge 20 ] || die "el backend empaquetado son $ZIP_MB MB: se ha quedado a medias"
say "backend: ${ZIP_MB} MB"
# --- 4) compilar ------------------------------------------------------------
say "compilando"
if [ "$MODE" = release ]; then
./gradlew --quiet assembleRelease
APK=$(ls app/build/outputs/apk/release/*.apk | head -1)
else
./gradlew --quiet assembleDebug
APK=$(ls app/build/outputs/apk/debug/*.apk | head -1)
fi
[ -f "$APK" ] || die "no se ha generado ninguna APK"
say "APK: $APK ($(( $(stat -c%s "$APK") / 1024 / 1024 )) MB)"
# --- 5) verificar -----------------------------------------------------------
# Estas tres comprobaciones son las que han cazado fallos de verdad: una APK que
# compila e instala puede no llevar el backend dentro, o haber perdido los permisos.
BT=$(ls -d "$SDK"/build-tools/* 2>/dev/null | sort -V | tail -1)
[ -n "$BT" ] || die "no encuentro build-tools en $SDK"
say "verificando"
# ojo con pipefail: grep -q cierra la tuberia y el productor muere con SIGPIPE,
# asi que se vuelca a fichero antes de mirar
LISTING=$(mktemp); PERMS=$(mktemp)
trap 'rm -f "$LISTING" "$PERMS"' EXIT
unzip -l "$APK" > "$LISTING"
"$BT/aapt2" dump permissions "$APK" > "$PERMS" 2>/dev/null || true
grep -q "assets/nodejs-project.zip" "$LISTING" || die "la APK no lleva el backend dentro"
grep -q "android.permission.CAMERA" "$PERMS" || die "la APK no declara CAMERA: no podria hacer videollamadas"
grep -q "android.permission.RECORD_AUDIO" "$PERMS" || die "la APK no declara RECORD_AUDIO"
grep -q "libnode.so" "$LISTING" || die "la APK no lleva el runtime de node"
grep -q "CHANGE_WIFI_MULTICAST_STATE" "$PERMS" || die "la APK no declara CHANGE_WIFI_MULTICAST_STATE: no veria otros moviles en la misma wifi"
# Una APK debuggable deja leer por adb los datos de la app, clave secreta incluida.
if "$BT/aapt2" dump badging "$APK" 2>/dev/null | grep -q "application-debuggable"; then
[ "$MODE" = release ] && die "la APK release ha salido debuggable"
say "OJO: APK de depuracion (debuggable). Solo para probar en tu movil, no la repartas"
fi
# La oficial pesa ~54 MB; si esta se dispara es que prepare.sh ha dejado de adelgazar
APK_MB=$(( $(stat -c%s "$APK") / 1024 / 1024 ))
[ "$APK_MB" -le 80 ] || say "OJO: la APK son ${APK_MB} MB (la oficial ~54): revisa que mete prepare.sh"
if [ "$MODE" = release ]; then
"$BT/apksigner" verify --print-certs "$APK" > /tmp/oasis-signer.txt 2>&1 \
|| { cat /tmp/oasis-signer.txt >&2; die "la APK no esta firmada correctamente"; }
FINGERPRINT=$(grep -m1 "SHA-256 digest" /tmp/oasis-signer.txt | awk '{print $NF}')
say "huella del certificado: $FINGERPRINT"
fi
SHA=$(sha256sum "$APK" | cut -d' ' -f1)
say "sha256 de la APK: $SHA"
# --- 6) publicar ------------------------------------------------------------
if [ "$PUBLISH" != 1 ]; then
say "listo (sin publicar)"
exit 0
fi
say "publicando $TAG en $GITEA_REPO"
API="$GITEA_URL/api/v1/repos/$GITEA_REPO"
AUTH="Authorization: token $OASIS_GITEA_TOKEN"
NOTES=$(printf 'Rama experimental del hacklab sobre el Oasis de epsylon.\n\nNO es Oasis oficial: https://github.com/epsylon/oasis\n\nVersion base: %s (codigo %s)\n\nsha256 de la APK: %s\nHuella del certificado: %s\n\nComprueba la huella antes de instalar:\n\n apksigner verify --print-certs oasis.apk\n' \
"$VERSION_NAME" "$VERSION_CODE" "$SHA" "${FINGERPRINT:-sin firmar}")
RELEASE_ID=$(curl -sf -X POST "$API/releases" -H "$AUTH" -H "Content-Type: application/json" \
-d "$(node -e '
const [tag, name, body] = process.argv.slice(1);
const pub = process.argv[4] === "1";
process.stdout.write(JSON.stringify({ tag_name: tag, name, body, draft: !pub, prerelease: !pub }));
' "$TAG" "Oasis Mobile $TAG" "$NOTES" "$PUBLIC")" | node -pe 'JSON.parse(require("fs").readFileSync(0)).id' 2>/dev/null) \
|| die "no se ha podido crear la release (token sin permiso, o el tag ya existe)"
# Siempre con el mismo nombre: el README enlaza a releases/download/latest/oasis-mobile.apk,
# y la version ya va en el tag y en el titulo de la release.
curl -sf -X POST "$API/releases/$RELEASE_ID/assets?name=oasis-mobile.apk" \
-H "$AUTH" -F "attachment=@$APK" >/dev/null \
|| die "la release se ha creado pero no se ha podido subir la APK"
if [ "$PUBLIC" = 1 ]; then
say "publicada: $GITEA_URL/$GITEA_REPO/releases/latest"
else
say "publicada como borrador: $GITEA_URL/$GITEA_REPO/releases"
say "revisala y quita el borrador a mano (o usa --public); no se publica sola a proposito"
fi

View file

@ -1,9 +0,0 @@
pluginManagement {
repositories { google(); mavenCentral(); gradlePluginPortal() }
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { google(); mavenCentral() }
}
rootProject.name = "OasisMobile"
include(":app")