diff --git a/.gitea/workflows/apk.yml b/.gitea/workflows/apk.yml
deleted file mode 100644
index bfe5053..0000000
--- a/.gitea/workflows/apk.yml
+++ /dev/null
@@ -1,68 +0,0 @@
-# Construye la APK y la publica en las releases.
-#
-# Con un tag v* se publica directamente: crear el tag ya es la decision de sacar
-# version, y es la que enlaza el README (releases/latest). Lanzada a mano desde
-# la interfaz se queda en borrador, para revisarla antes.
-#
-# NO se dispara sola con cada commit: la APK son mas de 100 MB y el backend tarda
-# en empaquetarse. Se lanza a mano desde la interfaz de gitea, o al crear un tag.
-#
-# El runner necesita: JDK 17 con compilador, el SDK de Android (plataforma 35 y
-# build-tools), node, zip y unzip. Un runner pelado NO vale.
-#
-# Secretos que hay que dar de alta en el repositorio:
-# OASIS_KEYSTORE_B64 el almacen de claves en base64
-# OASIS_KEYSTORE_PASS su contraseña
-# OASIS_KEY_ALIAS el alias (por defecto "oasis")
-#
-# El token para publicar lo pone gitea solo en GITEA_TOKEN.
-
-name: APK
-
-on:
- workflow_dispatch:
- push:
- tags:
- - 'v*'
-
-jobs:
- build:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0 # release.sh cuenta commits para el versionCode
-
- - name: Traer el APK oficial de la misma version (node_modules y libnode.so)
- run: |
- v=$(node -p "require('./src/server/package.json').version")
- curl -sfL -o /tmp/oasis-base.apk \
- "https://github.com/epsylon/oasis/releases/download/oasis-android-v$v/oasis-v$v.apk"
- echo "OASIS_APK_BASE=/tmp/oasis-base.apk" >> "$GITHUB_ENV"
-
- - name: Restaurar el almacen de claves
- run: |
- echo "${{ secrets.OASIS_KEYSTORE_B64 }}" | base64 -d > /tmp/oasis.jks
- chmod 600 /tmp/oasis.jks
-
- - name: Construir, verificar y publicar
- env:
- JAVA_HOME: /usr/lib/jvm/temurin-17-jdk-amd64
- ANDROID_HOME: /opt/android-sdk
- OASIS_KEYSTORE: /tmp/oasis.jks
- OASIS_KEYSTORE_PASS: ${{ secrets.OASIS_KEYSTORE_PASS }}
- OASIS_KEY_ALIAS: ${{ secrets.OASIS_KEY_ALIAS }}
- OASIS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
- OASIS_GITEA_URL: ${{ github.server_url }}
- OASIS_GITEA_REPO: ${{ github.repository }}
- run: |
- cd android
- if [ "${{ github.ref_type }}" = "tag" ]; then
- ./scripts/release.sh --release --publish --public
- else
- ./scripts/release.sh --release --publish
- fi
-
- - name: Borrar el almacen de claves
- if: always()
- run: rm -f /tmp/oasis.jks /tmp/oasis-base.apk
diff --git a/.gitignore b/.gitignore
index 1fafaa8..724afd9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@ node_modules/
src/server/node_modules
.phone_state.json
android/.tools/
+src/server/.update_required
diff --git a/android/.gitignore b/android/.gitignore
index c98f46b..c6f7b99 100644
--- a/android/.gitignore
+++ b/android/.gitignore
@@ -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/
diff --git a/android/README.md b/android/README.md
index 8834c99..aa6966f 100644
--- a/android/README.md
+++ b/android/README.md
@@ -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.
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
deleted file mode 100644
index 732b084..0000000
--- a/android/app/build.gradle.kts
+++ /dev/null
@@ -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")
-}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
deleted file mode 100644
index 8d2a56b..0000000
--- a/android/app/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,64 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/app/src/main/java/com/solarnethub/oasis/OasisActivity.kt b/android/app/src/main/java/com/solarnethub/oasis/OasisActivity.kt
deleted file mode 100644
index 42dffc5..0000000
--- a/android/app/src/main/java/com/solarnethub/oasis/OasisActivity.kt
+++ /dev/null
@@ -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): Int
-}
diff --git a/android/app/src/main/java/net/laenre/oasis/MainActivity.kt b/android/app/src/main/java/net/laenre/oasis/MainActivity.kt
deleted file mode 100644
index e500875..0000000
--- a/android/app/src/main/java/net/laenre/oasis/MainActivity.kt
+++ /dev/null
@@ -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>? = null
-
- private val pickFiles = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { res ->
- // Si se cancela hay que devolver null igualmente: si no, el 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>?,
- 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()
- 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, """
-
-
-
Oasis
-
Preparando la aplicación… El primer arranque descomprime el
- servidor y puede tardar un minuto. No hace falta hacer nada.