From 2d79e6779ca080c221a57ea9c78e8d8c42740542 Mon Sep 17 00:00:00 2001 From: hacklab Date: Sat, 24 Oct 2026 15:11:41 +0200 Subject: [PATCH] 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. --- .gitea/workflows/apk.yml | 68 ---- .gitignore | 1 + android/.gitignore | 8 +- android/README.md | 106 +----- android/app/build.gradle.kts | 66 ---- android/app/src/main/AndroidManifest.xml | 64 ---- .../com/solarnethub/oasis/OasisActivity.kt | 22 -- .../java/net/laenre/oasis/MainActivity.kt | 304 ---------------- .../main/java/net/laenre/oasis/NodeService.kt | 139 ------- .../res/drawable/ic_launcher_foreground.xml | 12 - .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 - .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 - .../res/values/ic_launcher_background.xml | 4 - android/app/src/main/res/values/strings.xml | 6 - android/app/src/main/res/values/themes.xml | 8 - .../main/res/xml/network_security_config.xml | 11 - android/build.gradle.kts | 4 - android/gradle.properties | 7 - android/gradle/wrapper/gradle-wrapper.jar | Bin 43583 -> 0 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 - android/gradlew | 252 ------------- android/gradlew.bat | 94 ----- android/scripts/android-studio.sh | 76 ---- android/scripts/prepare.sh | 81 ----- android/scripts/release.sh | 175 --------- android/settings.gradle.kts | 9 - docs/USABILIDAD_PAD.txt | 254 ------------- docs/capturas/generar_portada.py | 213 ----------- docs/guia_pdf/README.md | 7 - docs/guia_pdf/guia.html | 339 ------------------ 30 files changed, 17 insertions(+), 2330 deletions(-) delete mode 100644 .gitea/workflows/apk.yml delete mode 100644 android/app/build.gradle.kts delete mode 100644 android/app/src/main/AndroidManifest.xml delete mode 100644 android/app/src/main/java/com/solarnethub/oasis/OasisActivity.kt delete mode 100644 android/app/src/main/java/net/laenre/oasis/MainActivity.kt delete mode 100644 android/app/src/main/java/net/laenre/oasis/NodeService.kt delete mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground.xml delete mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml delete mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml delete mode 100644 android/app/src/main/res/values/ic_launcher_background.xml delete mode 100644 android/app/src/main/res/values/strings.xml delete mode 100644 android/app/src/main/res/values/themes.xml delete mode 100644 android/app/src/main/res/xml/network_security_config.xml delete mode 100644 android/build.gradle.kts delete mode 100644 android/gradle.properties delete mode 100644 android/gradle/wrapper/gradle-wrapper.jar delete mode 100644 android/gradle/wrapper/gradle-wrapper.properties delete mode 100755 android/gradlew delete mode 100644 android/gradlew.bat delete mode 100755 android/scripts/android-studio.sh delete mode 100755 android/scripts/prepare.sh delete mode 100755 android/scripts/release.sh delete mode 100644 android/settings.gradle.kts delete mode 100644 docs/USABILIDAD_PAD.txt delete mode 100644 docs/capturas/generar_portada.py delete mode 100644 docs/guia_pdf/README.md delete mode 100644 docs/guia_pdf/guia.html 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.

${s}s

- """.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() - } -} diff --git a/android/app/src/main/java/net/laenre/oasis/NodeService.kt b/android/app/src/main/java/net/laenre/oasis/NodeService.kt deleted file mode 100644 index f7796ee..0000000 --- a/android/app/src/main/java/net/laenre/oasis/NodeService.kt +++ /dev/null @@ -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) - } -} diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index 0de041c..0000000 --- a/android/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 5f349f7..0000000 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index 5f349f7..0000000 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml deleted file mode 100644 index c78bd37..0000000 --- a/android/app/src/main/res/values/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #121212 - diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml deleted file mode 100644 index 302c0e2..0000000 --- a/android/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - Oasis - Servicio de Oasis - Oasis esta activo - diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml deleted file mode 100644 index beb201d..0000000 --- a/android/app/src/main/res/values/themes.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml deleted file mode 100644 index ca015e3..0000000 --- a/android/app/src/main/res/xml/network_security_config.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - 127.0.0.1 - localhost - - diff --git a/android/build.gradle.kts b/android/build.gradle.kts deleted file mode 100644 index b06934c..0000000 --- a/android/build.gradle.kts +++ /dev/null @@ -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 -} diff --git a/android/gradle.properties b/android/gradle.properties deleted file mode 100644 index 38a7b2a..0000000 --- a/android/gradle.properties +++ /dev/null @@ -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. diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index a4b76b9530d66f5e68d973ea569d8e19de379189..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index df97d72..0000000 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -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 diff --git a/android/gradlew b/android/gradlew deleted file mode 100755 index f5feea6..0000000 --- a/android/gradlew +++ /dev/null @@ -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" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat deleted file mode 100644 index 9b42019..0000000 --- a/android/gradlew.bat +++ /dev/null @@ -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 diff --git a/android/scripts/android-studio.sh b/android/scripts/android-studio.sh deleted file mode 100755 index 8380130..0000000 --- a/android/scripts/android-studio.sh +++ /dev/null @@ -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 < 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 diff --git a/android/scripts/prepare.sh b/android/scripts/prepare.sh deleted file mode 100755 index b7a7ee6..0000000 --- a/android/scripts/prepare.sh +++ /dev/null @@ -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)" diff --git a/android/scripts/release.sh b/android/scripts/release.sh deleted file mode 100755 index a8f78c1..0000000 --- a/android/scripts/release.sh +++ /dev/null @@ -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 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts deleted file mode 100644 index 17fd342..0000000 --- a/android/settings.gradle.kts +++ /dev/null @@ -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") diff --git a/docs/USABILIDAD_PAD.txt b/docs/USABILIDAD_PAD.txt deleted file mode 100644 index 0eb5712..0000000 --- a/docs/USABILIDAD_PAD.txt +++ /dev/null @@ -1,254 +0,0 @@ -Prueba de usabilidad - Oasis Mobile 1.3.3 (rama PRUEBAS, tema Acampada) -19/10/2026 - emulador Pixel 9, Android 16, 1080x2424; tambien 720x1280 y letra al 130 % -Escenario: alguien de la acampada que entra, cambia a castellano, activa tribus -y parlamento, crea tribus, vota y abre una sala de voz. - -================================= -Botones complejos de interpretar -================================= - -- Los iconos del lanzador son letras del alfabeto vai (ꔙ ꕿ ꖒ ꕒ ꔗ...). No dicen - nada de lo que hacen: hay que leer siempre la etiqueta de debajo. -- Karvan (⧗) y Nodos (⧖) llevan casi el mismo reloj de arena. - Videos lleva algo que parece un simbolo de dolar. -- En la sala de voz los botones 🎤 🎥 ⛔ no tienen texto ni descripcion. - ⛔ no se sabe si es colgar, silenciar o salir. -- En cada tarjeta de tribu el primer boton es una ✕ roja: es BORRAR TRIBU y - borra con un solo toque, sin preguntar. Probado: la tribu desaparece. -- En "Crear tribu" el texto CREAR TRIBU sale tres veces (boton de arriba, - titulo y boton de enviar). El de arriba recarga la pagina y borra lo escrito. -- Dos campos seguidos se llaman "Ubicacion" (lugar y enlace de mapa). -- "Estado: Privada/Publica" y "Modo: Estricto/Abierta" no explican que - significan. Por defecto sale Privada + Estricto. -- Votacion: las opciones salen mezcladas en ingles y castellano - (YES, NO, ABSTENTION, CONFUNDIDO, SEGUIR MAYORIA, SIN INTERES), partidas por - silabas y la tabla se corta por la derecha. -- Jerga a la vista: "server relay", "Synced-peers", "HcT/HcH", "TASA ECO", - la clave @...ed25519 completa en el pie y en el buzon. - -================================= -Inicio intuitivo "empezar" -================================= - -- El aviso "Tu avatar aun no esta configurado - EMPEZAR" sale arriba en TODAS - las paginas. En pantalla pequena se come casi una quinta parte. EMPEZAR no - dice que va a pasar (es poner foto y nombre). -- En Bienvenida el primer boton, el mas grande, es "AHORA NO". Lo facil es - saltarse todo. -- El nombre viene relleno con un trozo de la clave (jlYELyx4). Parece un nombre - ya puesto y no lo es. -- El idioma sale en el del movil la primera vez (el emulador estaba en ingles). - Cambiarlo despues costaba: Ajustes, bajar a la segunda pantalla, desplegable, - Espanol y boton "Establecer idioma". -- La pantalla de carga si sale en castellano y tarda unos 10 s: bien. -- Ajustes mide unas 7 pantallas. Lo que mas se toca (idioma, aspecto) queda - debajo de Identidades, que es lo mas tecnico. -- Al abrir una seccion apagada (p. ej. Tribus) te manda sin avisar a la tabla - de Modulos. Deberia decir "Tribus esta apagado - Activar". -- Invitar a una sala pide el @feed-id de la otra persona. Nadie lo sabe de - memoria: hace falta elegir de los contactos o compartir un enlace / QR. - -================================= -Reduccion de funcionalidades - "skin activistas" -================================= - -- El flujo "activistas" NO activa tribus, parlamento ni votaciones. Para una - acampada con 12 tribus es justo lo que falta. Propuesta: anadir tribes, - parliament y votes al flujo activistas (o hacer un flujo "acampada"). -- Activar un modulo a mano: la pagina Modulos son 52 filas, unas 11 pantallas. - Tribus esta a 7800 px de altura y la casilla mide 16x16 px (lo minimo - recomendable son 48). El boton Guardar esta al final del todo. -- Propuesta: en movil, Modulos como interruptores grandes agrupados - (Hablar / Organizarse / Compartir) y guardado al tocar. -- Cada tribu que creas te manda a ti mismo un mensaje del POLITICALBOT - ("Ha comenzado un nuevo ciclo de gobierno... Anarchy"). 12 tribus = 12 avisos - en el buzon. En movil sobra o deberia agruparse. - -================================= -Voz y turnos de palabra -================================= - -- LLAMAR en Karvan pide primero la CAMARA aunque solo quieras hablar. Si dices - que no a la camara la llamada no arranca, aunque des el micro. Sale el aviso - "pendiente del wrapper", que es una nota interna. - Arreglo sencillo: LLAMAR empieza solo con audio y 🎥 anade video. - (src/client/public/js/karvan.js, funcion startCall) -- Solo el boton 🎤 suelto funciona con audio. -- La llamada de Karvan se corta al cambiar de pagina (probado: el micro se para - al entrar en Parlamento). La sala que sigue viva al navegar es la de Mumble, - que va en otra app. -- Propuesta: en Parlamento y en cada tribu, boton "Entrar a la sala de voz" que - abra mumble://mumble.acampada26s.net/. -- Turnos de palabra con tiempo: no existe. Encaja como cola de "pido palabra" - dentro de la sala (lista + cronometro visible para todas). - -================================= -Listado de tribus -================================= - -- Confirmado: cada tribu ocupa casi una pantalla porque todas repiten la misma - imagen generica grande. 12 tribus = 10 pantallas de scroll. -- Propuesta: lista compacta (nombre + miembros + publica/privada, una linea por - tribu), imagen solo si la tribu tiene una propia, y el buscador arriba fijo. - -================================= -Votaciones: semaforo / termometro -================================= - -- El servidor ya acepta opciones propias (campo "options" separado por comas), - pero el formulario no lo ofrece: siempre salen las 6 opciones fijas. -- Propuesta: al crear, elegir tipo: Si/No - Semaforo (verde, amarillo, rojo) - - Termometro (1 a 5). Sale casi gratis. -- Quien crea la votacion no puede votar en ella y la pantalla no lo explica. - En una asamblea, quien facilita se queda sin voto. -- El selector de fecha es el nativo de Android y propone una semana: bien. - -================================= -Lo que va bien -================================= - -- Sin desbordes horizontales en 1080 ni en 720 de ancho (salvo tablas). -- Tema Acampada legible, botones grandes, buen contraste. -- La barra de abajo (Actividad, Chats, Publicar, Buzon) se entiende sola. -- Actualizar encima de la 0.9.5 no pierde la identidad ni los datos. - -Pendiente de probar: primer arranque en limpio (instalacion nueva), dos moviles -en la misma sala, y la barra de abajo que tapa media pantalla con el teclado -abierto (visto en Crear tribu). - -================================= -Arreglado en PRUEBAS (19/10/2026) -================================= - -- Borrar pide confirmacion ("¿Borrar para siempre? Si, borrar") y la X va la - ultima de la fila. Vale para tribus y para todo el contenido. -- Karvan: Llamar empieza solo con voz y la camara se anade con su boton. - Botones con icono y nombre (Llamar, Micro, Camara, Colgar). Sin el recuadro - gris en llamadas de voz. Ayuda para invitar. Textos en los 11 idiomas. -- Votaciones: al crear se elige Si/No, Semaforo (verde, amarillo, rojo) o - Termometro (1 a 5). Resultados en barras. Aviso a quien la crea. -- Flujo activistas con tribus, parlamento, votaciones y eventos. -- Abrir una seccion apagada dice cual es y ofrece "Activar" de un toque. -- Modulos: agrupados como el lanzador, interruptores grandes, Guardar fijo. -- Tribus en filas compactas (12 tribus: de 10 pantallas a unas 3). Dentro de - una tribu, las 25 secciones en una tira de fichas + "Mas". -- Iconos del lanzador y de la barra: dibujos claros en vez de letras vai. - La barra de abajo marca la seccion abierta y se esconde con el teclado. -- Ajustes empieza por idioma y aspecto; los idiomas se eligen de un toque. -- Aviso del avatar solo en la portada y en una linea. "Ahora no" al final de - la bienvenida. -- Pie de una linea: "Aun no sincronizas con nadie" / "Sincronizando con N". -- Fuera la jerga: tasa ECO, HcT/HcH, "server relay". -- Espacio: un solo margen de 14px (las fichas perdian 74px por lado). - Letra: Anton solo en titulos y en el boton principal. - -================================= -Sigue pendiente -================================= - -- Invitar a una sala o mandar un privado eligiendo de los contactos, no - pegando el @id. -- Turnos de palabra con tiempo en la sala de voz. -- Boton "Entrar a la sala de voz" (Mumble) en Parlamento y en cada tribu. -- Quien crea una votacion sigue sin poder votar (regla de upstream: cambiarla - haria que los nodos contaran distinto). -- Probar un primer arranque en limpio y dos moviles en la misma sala. - -================================= -Segunda ronda (19/10/2026, tarde) -================================= - -Desde ahora cada arreglo respeta docs/FRONTERA_UPSTREAM.md: primero CSS propio; -si hace falta HTML, una funcion propia y una linea en la vista del dev; nada en -backend, middleware ni modelos del dev. - -- Boton principal de cada modulo (Crear chat, foro, pad, feed, evento...): el - mismo en todos. Antes unos a todo lo ancho, otros estrechos, otros con otra - letra. [CSS] -- Listas vacias: icono, texto centrado y recuadro discontinuo, en vez de una - linea suelta ("No se encontraron chats."). [CSS] -- El pie se queda abajo aunque la pagina sea corta. [CSS] -- Buzon: cada mensaje en pocas lineas (remitente y fecha arriba, el aviso, el - titulo y tres lineas de texto). Fuera Para (eres tu), Tasa ECO y Cifrado, que - eran iguales en todos. Antes un aviso ocupaba una pantalla. [CSS] -- Busqueda: primero el campo de buscar; las fechas y el ID despues. [CSS] -- Nodos: Refrescar, Quitar inactivos y Exportar en rejilla. [CSS] -- Workflow propio "Acampada" (chats, Karvan, pads, foros, wiki, tribus, - parlamento, votaciones, eventos...) en Ajustes y en la bienvenida, sin tocar - el "Activismo" del dev. [fichero propio] - -Visto y pendiente de esta ronda: -- Buzon: Marcar como leido / Archivar / Borrar siguen uno debajo de otro. -- Buzon: los avisos del PoliticalBot de las tribus propias siguen llegando - (es upstream; se puede agrupar en la vista, no quitar). -- Los ID @...ed25519 siguen a la vista en perfil, buzon y nodos: falta mostrar - el nombre y dejar el ID para "copiar". - -================================= -Tercera ronda: emulador limpio (19/10/2026, noche) -================================= - -Emulador nuevo (Pixel 9, Android 16, 12 GB) para probar una instalacion desde -cero con la APK sin Karvan (codigo 102). - -- Primer arranque: 18 segundos hasta la portada. Bien. -- Quien entra por primera vez no ve por donde empezar: el lanzador ocupa toda la - pantalla y el aviso "pon tu nombre y tu foto" quedaba a 1.300 px, abajo del - todo. Arreglado: el aviso va encima del lanzador. [CSS] -- El boton No molestar del Telefono ocupaba una fila entera arriba de cada - pagina. Arreglado: va en la barra de arriba, junto al avatar. [CSS] -- Ajustes marcaba English aunque se hubiera elegido otro idioma. Arreglado. [vista propia] -- LLAMADAS: en esta APK el Telefono dice "las llamadas no estan disponibles". La - APK oficial del dev trae un puente nativo de audio y camara (.audio_bridge) y - la nuestra no. Hay que escribirlo en la app Android o construir sobre la APK - oficial. Mientras tanto, en la rama no hay llamadas. -- El avatar por defecto (una calavera con gafas) asusta un poco para quien - empieza. Es del dev; se puede cambiar solo en el skin. - -Con la APK montada sobre la oficial (1.3.3-hacklab, mismo emulador): -- Llamadas: ahora SI. El Telefono ofrece "Nueva llamada" y en una sala el micro - graba de verdad (comprobado en Android: VOICE_COMMUNICATION activo) y se corta - al salir. -- Turnos de palabra: las Salas del dev ya tienen "Levantar la mano". Es la base de - lo que pedia el pad; falta el cronometro. -- Dentro de una sala, los controles (silenciar, camara, levantar la mano, salir) - quedan muy abajo, debajo de la ficha de la sala. Habria que tenerlos a mano - mientras se esta dentro. [pendiente, CSS] -- La app del dev pide el permiso de camara nada mas abrirse, antes de ensenar - nada. Es su app; no se toca. -- El boton de crear sala se tapa con el teclado al escribir el titulo: hay que - cerrar el teclado para verlo. - -================================= -Cuarta ronda: tamaños y sistemas (19/10/2026) -================================= - -Con la navegación nueva (portada = actividad, pestaña Menú abajo). - -- Android 16, Pixel 9 (APK real): menú, atrás con gesto y con tres botones, ir a un - módulo desde la hoja y volver. Bien. -- Fallaba: volver atrás desde un módulo abierto desde la hoja dejaba la portada con - la hoja abierta. Arreglado. [script propio] -- Android 9 (WebView 69, el que trae sin actualizar): fichas pegadas, hoja del menú - sin pintar, grupos montados y No molestar encima de la hoja. Arreglado. [CSS] -- Android 11 a 360 dp (motor 83): nueve pantallas sin desbordar, sin textos de - menos de 12 px ni botones de menos de 40 px. La barra de cinco cabe (70 dp cada una). -- En emulador, Android 9 y 11 no arrancan la APK (ni la nuestra ni la oficial): el - node arm64 no corre en el traductor ARM de x86. En un móvil de verdad no pasa; falta - probarlo en uno físico con Android 9-11. -- Tableta y letra al 130 %: bien. -- Bienvenida, paso Tipo: había que elegir una tarjeta y bajar seis tarjetas grandes - hasta Guardar. Ahora cada opción es un botón: un toque y queda guardado, el paso se - marca y se abre el siguiente. Una fila por opción con miniatura (en rejilla de tres, - "Conversaciones" se partía a 360 px). En Ajustes, lo mismo, y la sección va justo - después del idioma para encontrarla fácil. [vista propia + una línea en welcome_view - y settings_view] -- Bienvenida: faltaba elegir para qué se usa Oasis (el backend del dev quita ese paso - en el móvil). Vuelve como paso "Perfil", el segundo, justo después de la identidad: - Activismo primero, luego Acampada y el resto, cada uno con una línea de para qué - sirve. Un toque y guardado. En Ajustes, la misma lista. [vista propia + una línea - en welcome_view y settings_view] -- Con el perfil Activismo (45 módulos) los botones Personal/Social de la barra de - arriba no cabían a 360 px y tapaban el logo. Ya no hacían falta (filtraban el - lanzador, que ahora está en la hoja del menú): quitados. [vista propia] diff --git a/docs/capturas/generar_portada.py b/docs/capturas/generar_portada.py deleted file mode 100644 index dc2dd08..0000000 --- a/docs/capturas/generar_portada.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env python3 -"""Portada y esquemas del README. - -Las capturas de movil (01..06) se hacen aparte, con la app arrancada en un nodo de -pruebas a 412x915 y escala 2, en español y con lo que trae de serie (perfil Minimo y -colores del dev; 03 a 06 con el perfil Activista). Este script compone 00-portada.png -a partir de ellas, con los colores de serie, y dibuja esquema-interfaz.png, -esquema-rama.png y esquema-llamadas.png (estos aun con la paleta de Acampada). -El texto vive aqui: se cambia y se relanza, nunca se retocan los PNG a mano. - - python3 docs/capturas/generar_portada.py -""" -import math -from pathlib import Path -from PIL import Image, ImageDraw, ImageFont - -HERE = Path(__file__).resolve().parent -ANTON = HERE.parents[1] / "src/client/assets/fonts/Anton-Regular.ttf" -SANS = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" - -BG, FG, NEON, MUTED = "#ffffff", "#000000", "#00e5ff", "#555555" -# colores de serie del movil (los de la APK oficial del dev) -DEV_BG, DEV_CARD, DEV_FG, DEV_ACCENT, DEV_MUTED = "#121212", "#1e1e1e", "#ffd700", "#ffa300", "#bbbbbb" -SANS_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" - - -def font(path, size): - return ImageFont.truetype(str(path), size) - - -def phone(shot, width, fg=FG, neon=NEON): - """Una captura dentro de un marco grueso con una sombra de color debajo.""" - im = Image.open(HERE / shot).convert("RGB") - h = round(im.height * width / im.width) - im = im.resize((width, h), Image.LANCZOS) - pad, radius, shadow = 10, 34, 10 - frame = Image.new("RGBA", (width + 2 * pad, h + 2 * pad + shadow), (0, 0, 0, 0)) - d = ImageDraw.Draw(frame) - d.rounded_rectangle((0, shadow, width + 2 * pad - 1, h + 2 * pad + shadow - 1), radius, fill=neon) - d.rounded_rectangle((0, 0, width + 2 * pad - 1, h + 2 * pad - 1), radius, fill=fg) - mask = Image.new("L", im.size, 0) - ImageDraw.Draw(mask).rounded_rectangle((0, 0, width - 1, h - 1), radius - pad, fill=255) - frame.paste(im, (pad, pad), mask) - return frame - - -def portada(): - W, H = 1600, 1100 - out = Image.new("RGB", (W, H), DEV_BG) - d = ImageDraw.Draw(out) - d.text((80, 40), "OASIS MOBILE", font=font(SANS_BOLD, 84), fill=DEV_FG) - bottom = d.textbbox((80, 40), "OASIS MOBILE", font=font(SANS_BOLD, 84))[3] - d.rectangle((80, bottom + 12, 420, bottom + 18), fill=DEV_ACCENT) - d.text((80, 186), "Rama PRUEBAS del hacklab · base Oasis 1.3.5 · lo que sale al instalarla", - font=font(SANS, 27), fill=DEV_MUTED) - shots = [("01-actividad.png", "Actividad al abrir"), ("02-menu.png", "Menú, perfil Mínimo"), - ("03-menu-activista.png", "Menú, perfil Activista"), ("04-tribu.png", "Una tribu")] - pw, gap, top = 320, 50, 260 - x = (W - (4 * (pw + 20) + 3 * gap)) // 2 - cap = font(SANS_BOLD, 26) - for shot, label in shots: - fr = phone(shot, pw, fg="#000000", neon=DEV_ACCENT) - out.paste(fr, (x, top), fr) - tw = d.textlength(label, font=cap) - d.text((x + (fr.width - tw) / 2, top + fr.height + 18), label, font=cap, fill=DEV_FG) - x += fr.width + gap - out.save(HERE / "00-portada.png", optimize=True) - - -def caja(d, box, titulo, lineas, relleno=BG): - x0, y0, x1, y1 = box - d.rounded_rectangle((x0, y0 + 6, x1, y1 + 6), 18, fill=FG) - d.rounded_rectangle(box, 18, fill=relleno, outline=FG, width=4) - t = font(ANTON, 30) - d.text(((x0 + x1 - d.textlength(titulo, font=t)) / 2, y0 + 18), titulo, font=t, fill=FG) - s = font(SANS, 18) - for i, l in enumerate(lineas): - d.text(((x0 + x1 - d.textlength(l, font=s)) / 2, y0 + 70 + i * 26), l, font=s, fill=FG) - - -def esquema(): - W, H = 1400, 840 - out = Image.new("RGB", (W, H), BG) - d = ImageDraw.Draw(out) - d.text((46, 26), "LA INTERFAZ DE MÓVIL, POR DENTRO", font=font(ANTON, 46), fill=FG) - bottom = d.textbbox((46, 26), "LA INTERFAZ DE MÓVIL, POR DENTRO", font=font(ANTON, 46))[3] - d.rectangle((46, bottom + 10, 260, bottom + 16), fill=FG) - d.text((46, bottom + 30), "Casi sesenta módulos no caben en una pantalla de teléfono. Lo primero es lo que pasa; el resto, en el menú.", - font=font(SANS, 18), fill=MUTED) - - # el movil esquematico - px0, py0, px1, py1 = 110, 170, 470, 790 - d.rounded_rectangle((px0, py0, px1, py1), 30, outline=FG, width=5) - d.rounded_rectangle((px0 + 16, py0 + 20, px1 - 16, py0 + 84), 14, fill=BG, outline=FG, width=3) - s = font(SANS, 15) - d.text((px0 + 46, py0 + 42), "logo · no molestar · avatar", font=s, fill=FG) - # en medio, la actividad: lo que ha pasado en la red - y = py0 + 110 - d.text((px0 + 40, y), "ACTIVIDAD", font=font(ANTON, 22), fill=FG) - d.line((px0 + 40, y + 32, px1 - 40, y + 32), fill=FG, width=3) - y += 52 - for i in range(4): - d.rounded_rectangle((px0 + 40, y, px1 - 40, y + 86), 12, fill=BG, outline=FG, width=3) - d.rectangle((px0 + 56, y + 18, px0 + 150, y + 30), fill=FG) - d.rectangle((px0 + 56, y + 44, px1 - 70, y + 52), fill=MUTED) - d.rectangle((px0 + 56, y + 62, px1 - 120, y + 70), fill=MUTED) - y += 104 - d.rounded_rectangle((px0 + 16, py1 - 84, px1 - 16, py1 - 20), 14, fill=NEON, outline=FG, width=3) - d.text((px0 + 62, py1 - 62), "4 atajos que eliges tú · MENÚ", font=s, fill=FG) - - bx0, bx1 = 560, 1320 - cajas = [ - ((bx0, 190, bx1, 340), "ARRIBA — LA BARRA SUPERIOR", - ["El logo vuelve al inicio y el avatar abre tu perfil.", - "En medio, el botón de No molestar del Teléfono.", - "Con muchos módulos activos salen también los filtros Personal y Social."], BG), - ((bx0, 375, bx1, 565), "EN MEDIO — LA ACTIVIDAD", - ["Al abrir la app se ve lo que ha pasado en la red, no un menú.", - "Dentro de cada módulo, los filtros van en fichas pequeñas", - "y el botón principal (crear tribu, crear sala…) siempre igual."], BG), - ((bx0, 600, bx1, 750), "ABAJO — LOS ATAJOS Y EL MENÚ", - ["Cuatro atajos a tu elección y una quinta pestaña fija, Menú,", - "que abre desde abajo una hoja con todos los módulos por secciones", - "(Comunidad, Biblioteca, Gobernanza…). Está en todas las pantallas."], NEON), - ] - flechas = [((px1 + 10, py0 + 52), (bx0 - 12, 262)), ((px1 + 10, 440), (bx0 - 12, 470)), - ((px1 + 10, py1 - 52), (bx0 - 12, 675))] - for (a, b) in flechas: - ang = math.atan2(b[1] - a[1], b[0] - a[0]) - tip = lambda da, r: (b[0] - r * math.cos(ang + da), b[1] - r * math.sin(ang + da)) - d.line((a, tip(0, 12)), fill=FG, width=4) - d.polygon([b, tip(0.45, 20), tip(-0.45, 20)], fill=FG) - for box, t, l, r in cajas: - caja(d, box, t, l, r) - out.save(HERE / "esquema-interfaz.png", optimize=True) - - -def flecha(d, a, b, ancho=4): - ang = math.atan2(b[1] - a[1], b[0] - a[0]) - tip = lambda da, r: (b[0] - r * math.cos(ang + da), b[1] - r * math.sin(ang + da)) - d.line((a, tip(0, 12)), fill=FG, width=ancho) - d.polygon([b, tip(0.45, 20), tip(-0.45, 20)], fill=FG) - - -def titulo(d, texto, sub): - d.text((46, 26), texto, font=font(ANTON, 46), fill=FG) - bottom = d.textbbox((46, 26), texto, font=font(ANTON, 46))[3] - d.rectangle((46, bottom + 10, 260, bottom + 16), fill=FG) - d.text((46, bottom + 30), sub, font=font(SANS, 18), fill=MUTED) - - -def esquema_rama(): - """Como va la rama encima de Oasis: lo del dev abajo, lo nuestro encima.""" - W, H = 1400, 760 - out = Image.new("RGB", (W, H), BG) - d = ImageDraw.Draw(out) - titulo(d, "CÓMO VA LA RAMA ENCIMA DE OASIS", - "Oasis lo cambia el dev, casi una versión al día. La rama solo cambia cómo se ve y se usa en el móvil.") - capa = [ - ((60, 210, 380, 400), "ASPECTO", - ["mobile_base.css: la maqueta", "mobile_ux.css: márgenes, letra,", "botones y piezas nuevas", - "Acampada.css: el skin"], NEON), - ((400, 210, 720, 400), "PANTALLAS", - ["mobile_ui.js y las vistas", "mobile_*.js. En una vista", "del dev, como mucho una", "línea que las llama"], NEON), - ((740, 210, 1060, 400), "RUTAS", - ["fork_routes.js, delante", "del router del dev: lo", "que no atiende pasa al", "suyo"], NEON), - ((1080, 210, 1340, 400), "ANDROID", - ["la app del dev tal cual,", "con nuestro backend", "dentro y paquete", "propio"], NEON), - ] - for box, t, l, r in capa: - caja(d, box, t, l, r) - d.text((60, 166), "LO DE LA RAMA", font=font(ANTON, 26), fill=FG) - for x in (220, 560, 900, 1210): - flecha(d, (x, 418), (x, 478)) - caja(d, (60, 500, 1340, 690), "OASIS DEL DEV: NO SE TOCA", - ["backend.js y su middleware · los modelos (lo que se publica y cómo se cuenta) · SSB y su arranque", - "las traducciones · style.css y mobile.css · los temas, también OasisMobile.css", - "Comparado con su APK oficial, la rama cambia 17 ficheros suyos y 561 líneas. Ver GUIA_DE_DESARROLLO.md"], BG) - out.save(HERE / "esquema-rama.png", optimize=True) - - -def esquema_llamadas(): - """Las llamadas son las del dev; en Android pasan por un puente nativo de la app.""" - W, H = 1400, 700 - out = Image.new("RGB", (W, H), BG) - d = ImageDraw.Draw(out) - titulo(d, "LLAMADAS: LAS DEL DEV", - "Teléfono y Salas son de Oasis. En Android el sonido y la cámara pasan por un puente nativo de la app.") - cajas = [ - ((50, 200, 360, 430), "TELÉFONO Y SALAS", - ["De Oasis, tal cual.", "Llamadas de uno a uno", "con buzón de voz; salas", "de hasta 50, con vídeo,", "grabación y emisión."], BG), - ((400, 200, 700, 430), "LOS ENCHUFES", - ["phone_*_android.js", "(del dev): se conectan", "a un puerto local que", "deja la app en", ".audio_bridge"], BG), - ((740, 200, 1040, 430), "EL PUENTE NATIVO", - ["Dentro de la app:", "R graba el micro", "P reproduce", "V saca la cámara", "S emite un fichero"], NEON), - ((1080, 200, 1350, 430), "EL MÓVIL", - ["Micrófono, cámara", "y altavoz, con los", "permisos que se piden", "la primera vez."], BG), - ] - for box, t, l, r in cajas: - caja(d, box, t, l, r) - for a, b in (((362, 315), (398, 315)), ((702, 315), (738, 315)), ((1042, 315), (1078, 315))): - flecha(d, a, b) - caja(d, (50, 480, 1350, 630), "DÓNDE ESTÁ CADA COSA", - ["El puente está en la app Android del dev, que no tiene el código publicado. Por eso la APK de la", - "rama se monta sobre la oficial: su app tal cual, nuestro backend dentro y paquete propio."], BG) - out.save(HERE / "esquema-llamadas.png", optimize=True) - - -if __name__ == "__main__": - import sys - # sin argumentos, todo; con nombres, solo esos (p. ej. "portada") - for name in sys.argv[1:] or ["portada", "esquema", "esquema_rama", "esquema_llamadas"]: - globals()[name]() diff --git a/docs/guia_pdf/README.md b/docs/guia_pdf/README.md deleted file mode 100644 index acb06a5..0000000 --- a/docs/guia_pdf/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# PDF de la guia para el dev - -Fuente de Oasis_Mobile_Guia_de_desarrollo.pdf (5 paginas A4). Para sacarlo: - - chromium --headless=new --no-pdf-header-footer --print-to-pdf=guia.pdf guia.html - -Comprobar con `pdfinfo guia.pdf` que siguen siendo 5 paginas. diff --git a/docs/guia_pdf/guia.html b/docs/guia_pdf/guia.html deleted file mode 100644 index fd1d932..0000000 --- a/docs/guia_pdf/guia.html +++ /dev/null @@ -1,339 +0,0 @@ - - - - -Oasis Mobile · Guía de desarrollo - - - - - -
-
-

Oasis Mobile

-
Guía de desarrollo de la rama del hacklab
-
Propuesta para epsylon · base Oasis 1.3.3 · rama PRUEBAS · 19 de octubre de 2026
- -
-
39
versiones de Oasis entre la 1.0.1 y la 1.3.3
-
17
ficheros tuyos que la rama cambia, frente a tu APK oficial
-
2
líneas nuestras en backend.js
-
0
cambios en modelos, middleware o traducciones
-
- -

Qué es esta rama

-
    -
  • Una forma de usar Oasis en el móvil pensada para una acampada: tribus como comisiones, votaciones de asamblea, salas de voz.
  • -
  • No es otro Oasis. Es tu APK oficial con una capa encima: skins, unas pocas vistas, un router propio y textos.
  • -
  • Se instala junto a la oficial con otro paquete (net.laenre.oasis) y habla con la misma red.
  • -
-

Qué te pedimos

-
    -
  • Que revises si la frontera que proponemos (página 3) te parece bien: qué es tuyo y no tocamos, y qué es nuestro.
  • -
  • Que nos digas si alguno de los puntos de la página 5 te interesa para Oasis, para dejar de llevarlo nosotros.
  • -
- -
- - - - Tu APK oficial - app Android, puente de - audio, node_modules - - + capa de la rama - skins, vistas mobile_*, - router propio, textos - - Oasis hacklab - otro paquete, misma red, - convive con la oficial - - - -
-
Oasis Mobile · Guía de desarrollo1 / 5
-
- - -
-

La regla

-

Oasis lo cambias tú. Nosotros cambiamos cómo se ve y se usa en el móvil, y lo hacemos en ficheros nuestros.

- -
- - - LA CAPA DE LA RAMA - - - Aspecto - mobile_base.cssmobile_ux.cssAcampada.css - - Pantallas - mobile_ui.jsmobile_*_view.jshive_views.js - - Rutas - fork_routes.jsmobile_layer.jsfork_workflows.js - - Textos y APK - i18n_fork.js(11 idiomas)sobre_oficial.sh - - - - - una línea de enganche, solo con OASIS_MOBILE=1 - - OASIS: TU CÓDIGO, TAL CUAL - - backend.jsy su middleware - modelosqué se publica y se cuenta - SSB y servidorphone_module, puente - traducciones y temasoasis_*.js, style.css… - - -
La rama son cuatro piezas propias encima de tu código. Lo único que entra en tus ficheros son líneas de enganche.
-
- -
-
-

Por qué así

-
    -
  • Sacas versión casi cada día y cada una llega en un solo commit: no se pueden coger cambios sueltos.
  • -
  • backend.js cambió en 34 de 39 versiones, main_views.js en 31, las traducciones en 31.
  • -
  • Cada línea nuestra en esos ficheros sería un conflicto en la siguiente integración.
  • -
  • Si tocáramos un modelo, nuestro móvil contaría distinto que tu escritorio (votos, tribus, gobierno).
  • -
-
-
-

Cómo se mide

-
    -
  • La referencia para el móvil es el nodejs-project de tu APK oficial, no tu git: ahí van tus cambios de móvil.
  • -
  • Hoy la rama cambia 17 ficheros tuyos y 561 líneas; en backend.js, 2.
  • -
  • En cada versión tuya se repite la comparación y se revisa si ha desaparecido alguna clase CSS de la que tiramos.
  • -
-
-
-
Oasis Mobile · Guía de desarrollo2 / 5
-
- - -
-

Qué se toca y qué no

-

Tres zonas. La de en medio es la única que entra en tus ficheros, y solo con una línea.

-
-
- No se toca -
    -
  • backend.js, rutas y cadena de middleware
  • -
  • Modelos de src/models: lo que se publica en el log y cómo se cuenta
  • -
  • src/server: arranque de SSB, dependencias, phone_module
  • -
  • request_guards.js y la CSP: nada en línea
  • -
  • Traducciones oasis_*.js
  • -
  • style.css, mobile.css y los temas, también OasisMobile.css
  • -
-
-
- Una línea -
    -
  • Tus vistas: main_views.js, tribes_view.js, vote_view.js, settings_view.js, welcome_view.js
  • -
  • La lógica va en mobile_ui.js; en tu vista, una llamada
  • -
  • Siempre detrás de OASIS_MOBILE === '1': en escritorio todo queda como lo dejas
  • -
  • Si reescribes la vista, se vuelve a poner la línea
  • -
-
-
- Nuestro -
    -
  • Hojas: mobile_base.css, mobile_ux.css, Acampada.css
  • -
  • Vistas: mobile_ui.js, mobile_modules_view.js, hive_views.js, mobile_icons.js
  • -
  • Router: fork_routes.js y mobile_layer.js
  • -
  • Workflows: fork_workflows.js
  • -
  • Textos: i18n_fork.js
  • -
  • APK: sobre_oficial.sh
  • -
-
-
- -

Ejemplos concretos

- - - - - - - - -
Lo que hacía faltaCómo se ha hechoZona
Votar en semáforo o termómetro, como en asambleaTu servidor ya acepta options; solo se añade el selector en el formularioUna línea
Quien crea una votación no puede votarEs una regla de tu modelo: no se cambia, se explica en pantallaNuestro
Avisar de que una sección está apagada y encenderlaNuestro router mira tu redirección a /modules, sin tocar checkModNuestro
Workflow para acampadasSe registra acampada en tus arrays; activists queda como el tuyoNuestro
Llamadas y salasLas tuyas, con tu puente de audio: la APK se monta sobre la oficialNo se toca
Borrar con confirmaciónFunción en mobile_ui.js, enganchada en renderContentActionsUna línea
-
Oasis Mobile · Guía de desarrollo3 / 5
-
- - -
-

Cómo funciona por dentro

- -

Una petición en el móvil

-
- - - - WebViewde tu app - Tu middlewarecontexto, idioma,CSP - fork_routesrutas propias onext() al tuyo - Tus rutasy vistas - mobile_uisi la vistalo llama - - - - - Lo que contestan tus rutas no se mira ni se cambia - Nuestro router va justo delante del tuyo: una sola línea en backend.js lo monta. - -
- -
-
-

Hojas de estilo, en este orden

-
- - - 1 · style.css y mobile.css (tuyas) - 2 · mobile_base.css: maqueta de la rama - 3 · skin: Acampada.css o tu OasisMobile.css - 4 · activista.css (solo con Acampada) - 5 · mobile_ux.css: piezas comunes, la última - - -
-
    -
  • Con tu skin OasisMobile se ve tu tema tal cual, encima de nuestra maqueta.
  • -
  • Colores por variables --ux-*: valen para los dos skins.
  • -
-
-
-

La APK, montada sobre la tuya

-
- - - - tu APK oficial de la misma versión - se desmonta (apktool, versión fija) - backend de la rama + tu node_modules - paquete net.laenre.oasis - se monta, se alinea y se firma - - - -
-
    -
  • Tu app tal cual: puente de audio, notificaciones, permisos.
  • -
  • Se cambian también los authorities, el permiso propio y las acciones de llamada, para no cruzarse con la tuya.
  • -
-
-
-
Oasis Mobile · Guía de desarrollo4 / 5
-
- - -
-

Cómo seguimos

- -
-
-

Antes de cada commit

-
    -
  • Nada de la zona roja en el diff; en tus vistas, solo líneas de enganche.
  • -
  • node --check y el servidor local responde en las pantallas principales.
  • -
  • Visto a 360 y 412 px, en tableta, con la letra al 130 % y en un WebView viejo.
  • -
  • Textos nuevos en los 11 idiomas, en i18n_fork.js.
  • -
  • Nada en línea: la CSP lo bloquea.
  • -
-

Cuando sacas versión

-
    -
  • Se coge la tuya tal cual; para el móvil, la de tu APK.
  • -
  • Se vuelven a poner los enganches en tus vistas.
  • -
  • Se repite la medición y se revisan las clases CSS.
  • -
  • Se monta la APK sobre tu oficial de esa versión.
  • -
-
-
-

Navegación en el móvil

-
    -
  • Al abrir se ve la actividad, no un menú.
  • -
  • Abajo, cuatro atajos que elige cada cual y una pestaña Menú que abre todos los módulos desde abajo.
  • -
  • Probado en el emulador: dos toques a cualquier módulo desde cualquier pantalla.
  • -
-

Probado

-
    -
  • Android 16 (APK real): 412 y 360 dp, tableta de 800 dp, letra al 130 %, gestos y tres botones.
  • -
  • Motores viejos: WebView 69 (Android 9) y 83 (Android 11), con alternativas donde falta :has, gap o inset.
  • -
  • Llamadas: en una sala el micrófono graba y se corta al salir.
  • -
-
-
- -

Lo que podría pasar a Oasis

-

Cosas que hoy llevamos en tus ficheros y que nos gustaría que fueran tuyas, para dejar de mantenerlas aparte:

- - - - - - -
QuéDónde está ahoraPor qué te puede interesar
Limpiar metadatos de las fotos también en AndroidblobHandler.js (190 líneas)Sin sharp en el móvil, hoy las fotos suben con el GPS dentro
Varias identidades en el mismo móvilssb_config.js (30 líneas)Una persona, varias cuentas, con aviso si dos comparten clave
DNS del sistema primeromain.js (45 líneas)Respeta la VPN y el DNS privado de Android
Selector de idioma con el idioma actualnuestro, en mobile_ui.jssettings_view lee selectedLanguage al cargar y siempre marca English
- -
-

Para aprobar

-
    -
  • ¿Te parece bien la frontera de la página 3?
  • -
  • ¿Te parece bien que la APK de la rama se monte sobre tu oficial, con otro paquete?
  • -
  • ¿Quieres alguno de los cuatro puntos de arriba en Oasis?
  • -
-
-
Oasis Mobile · Guía de desarrollo · hacklab · gitea.laenre.net/hacklab/OASIS_MOBILE5 / 5
-
- - -