quitar el proyecto Gradle propio y lo que dependia de el

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

View file

@ -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

1
.gitignore vendored
View file

@ -4,3 +4,4 @@ node_modules/
src/server/node_modules
.phone_state.json
android/.tools/
src/server/.update_required

8
android/.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

View file

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

252
android/gradlew vendored
View file

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

94
android/gradlew.bat vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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/<canal de la tribu>.
- 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]

View file

@ -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]()

View file

@ -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.

View file

@ -1,339 +0,0 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Oasis Mobile · Guía de desarrollo</title>
<style>
@font-face { font-family: "Anton"; src: url("file:///home/sito/COFRE/CODERS/OASIS_MOBILE/src/client/assets/fonts/Anton-Regular.ttf"); }
@page { size: A4; margin: 0; }
:root { --fg: #0b0b0b; --muted: #5a5a5a; --line: #0b0b0b; --cyan: #00d7f0; --soft: #f1f4f5; --red: #c4003b; --amber: #c98a00; --green: #0a8f3c; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: #fff; color: var(--fg); font-family: "Inter", "DejaVu Sans", sans-serif; font-size: 10.5pt; line-height: 1.45; }
.page { width: 210mm; height: 297mm; padding: 16mm 17mm 14mm; position: relative; page-break-after: always; overflow: hidden; }
.page:last-child { page-break-after: auto; }
h1, h2, h3 { font-family: "Anton", Impact, sans-serif; font-weight: 400; text-transform: uppercase; margin: 0; letter-spacing: .01em; line-height: 1; }
h1 { font-size: 54pt; }
h2 { font-size: 30pt; margin-bottom: 4mm; }
h2 + .rule { width: 34mm; height: 2.2mm; background: var(--fg); margin: 0 0 6mm; }
h3 { font-size: 14pt; margin: 0 0 2.5mm; }
p { margin: 0 0 3mm; }
.lead { font-size: 12pt; color: var(--muted); margin-bottom: 6mm; }
ul { margin: 0 0 3mm; padding-left: 5mm; }
li { margin: 0 0 1.6mm; }
li::marker { color: var(--fg); }
code { font-family: "DejaVu Sans Mono", monospace; font-size: 8.8pt; background: var(--soft); padding: .2mm 1.2mm; border-radius: 1mm; }
.foot { position: absolute; left: 17mm; right: 17mm; bottom: 8mm; display: flex; justify-content: space-between; font-size: 8pt; color: var(--muted); border-top: .4mm solid #ddd; padding-top: 2mm; }
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 7mm; }
.cols3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 5mm; }
.card { border: .6mm solid var(--fg); border-radius: 3mm; padding: 4.5mm 5mm; }
.card.cyan { background: #e2fbff; }
.tag { display: inline-block; font-family: "Anton"; text-transform: uppercase; font-size: 11pt; padding: .6mm 3mm; border-radius: 1.5mm; color: #fff; margin-bottom: 3mm; }
.tag.red { background: var(--red); } .tag.amber { background: var(--amber); } .tag.green { background: var(--green); }
.num { font-family: "Anton"; font-size: 34pt; line-height: 1; }
.kpi { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4mm; margin: 6mm 0; }
.kpi div { border-top: 1.2mm solid var(--fg); padding-top: 2mm; }
.kpi span { display: block; font-size: 8.5pt; color: var(--muted); }
figure { margin: 0 0 5mm; }
figcaption { font-size: 8.5pt; color: var(--muted); margin-top: 1.5mm; }
svg text { font-family: "Inter", sans-serif; }
.cover { background: #fff; }
.cover .band { position: absolute; left: 0; right: 0; top: 0; height: 9mm; background: var(--cyan); }
.cover h1 { margin-top: 30mm; font-size: 64pt; }
.cover .sub { font-family: "Anton"; text-transform: uppercase; font-size: 24pt; margin-top: 4mm; }
.cover .meta { margin-top: 8mm; font-size: 11pt; color: var(--muted); }
.ask { border-left: 2.2mm solid var(--cyan); background: var(--soft); padding: 4mm 5mm; border-radius: 0 3mm 3mm 0; }
table { width: 100%; border-collapse: collapse; font-size: 9.5pt; }
th { text-align: left; font-family: "Anton"; font-weight: 400; text-transform: uppercase; font-size: 10pt; border-bottom: .6mm solid var(--fg); padding: 1.5mm 2mm; }
td { border-bottom: .3mm solid #ddd; padding: 1.6mm 2mm; vertical-align: top; }
table.tight td { padding: 1.1mm 2mm; }
</style>
</head>
<body>
<!-- 1. PORTADA -->
<section class="page cover">
<div class="band"></div>
<h1>Oasis Mobile</h1>
<div class="sub">Guía de desarrollo de la rama del hacklab</div>
<div class="meta">Propuesta para epsylon · base Oasis 1.3.3 · rama PRUEBAS · 19 de octubre de 2026</div>
<div class="kpi">
<div><div class="num">39</div><span>versiones de Oasis entre la 1.0.1 y la 1.3.3</span></div>
<div><div class="num">17</div><span>ficheros tuyos que la rama cambia, frente a tu APK oficial</span></div>
<div><div class="num">2</div><span>líneas nuestras en <code>backend.js</code></span></div>
<div><div class="num">0</div><span>cambios en modelos, middleware o traducciones</span></div>
</div>
<h3>Qué es esta rama</h3>
<ul>
<li>Una forma de usar Oasis en el móvil pensada para una acampada: tribus como comisiones, votaciones de asamblea, salas de voz.</li>
<li>No es otro Oasis. Es tu APK oficial con una capa encima: skins, unas pocas vistas, un router propio y textos.</li>
<li>Se instala junto a la oficial con otro paquete (<code>net.laenre.oasis</code>) y habla con la misma red.</li>
</ul>
<h3>Qué te pedimos</h3>
<ul>
<li>Que revises si la frontera que proponemos (página 3) te parece bien: qué es tuyo y no tocamos, y qué es nuestro.</li>
<li>Que nos digas si alguno de los puntos de la página 5 te interesa para Oasis, para dejar de llevarlo nosotros.</li>
</ul>
<figure style="margin-top:6mm">
<svg viewBox="0 0 700 150" width="100%">
<defs><marker id="ar0" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#0b0b0b"/></marker></defs>
<rect x="2" y="20" width="200" height="110" rx="10" fill="#fff" stroke="#0b0b0b" stroke-width="2.5"/>
<text x="102" y="58" text-anchor="middle" font-size="17" font-weight="700">Tu APK oficial</text>
<text x="102" y="82" text-anchor="middle" font-size="12" fill="#5a5a5a">app Android, puente de</text>
<text x="102" y="98" text-anchor="middle" font-size="12" fill="#5a5a5a">audio, node_modules</text>
<rect x="250" y="20" width="200" height="110" rx="10" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2.5"/>
<text x="350" y="58" text-anchor="middle" font-size="17" font-weight="700">+ capa de la rama</text>
<text x="350" y="82" text-anchor="middle" font-size="12" fill="#5a5a5a">skins, vistas mobile_*,</text>
<text x="350" y="98" text-anchor="middle" font-size="12" fill="#5a5a5a">router propio, textos</text>
<rect x="498" y="20" width="200" height="110" rx="10" fill="#0b0b0b" stroke="#0b0b0b" stroke-width="2.5"/>
<text x="598" y="58" text-anchor="middle" font-size="17" font-weight="700" fill="#fff">Oasis hacklab</text>
<text x="598" y="82" text-anchor="middle" font-size="12" fill="#ddd">otro paquete, misma red,</text>
<text x="598" y="98" text-anchor="middle" font-size="12" fill="#ddd">convive con la oficial</text>
<line x1="204" y1="75" x2="244" y2="75" stroke="#0b0b0b" stroke-width="2.5" marker-end="url(#ar0)"/>
<line x1="452" y1="75" x2="492" y2="75" stroke="#0b0b0b" stroke-width="2.5" marker-end="url(#ar0)"/>
</svg>
</figure>
<div class="foot"><span>Oasis Mobile · Guía de desarrollo</span><span>1 / 5</span></div>
</section>
<!-- 2. LA REGLA -->
<section class="page">
<h2>La regla</h2><div class="rule"></div>
<p class="lead">Oasis lo cambias tú. Nosotros cambiamos cómo se ve y se usa en el móvil, y lo hacemos en ficheros nuestros.</p>
<figure>
<svg viewBox="0 0 700 330" width="100%">
<defs><marker id="ar1" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#0b0b0b"/></marker></defs>
<text x="0" y="16" font-size="13" font-weight="700" letter-spacing="1">LA CAPA DE LA RAMA</text>
<g font-size="12">
<rect x="0" y="28" width="166" height="96" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/>
<text x="83" y="54" text-anchor="middle" font-size="15" font-weight="700">Aspecto</text>
<text x="83" y="76" text-anchor="middle">mobile_base.css</text><text x="83" y="92" text-anchor="middle">mobile_ux.css</text><text x="83" y="108" text-anchor="middle">Acampada.css</text>
<rect x="178" y="28" width="166" height="96" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/>
<text x="261" y="54" text-anchor="middle" font-size="15" font-weight="700">Pantallas</text>
<text x="261" y="76" text-anchor="middle">mobile_ui.js</text><text x="261" y="92" text-anchor="middle">mobile_*_view.js</text><text x="261" y="108" text-anchor="middle">hive_views.js</text>
<rect x="356" y="28" width="166" height="96" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/>
<text x="439" y="54" text-anchor="middle" font-size="15" font-weight="700">Rutas</text>
<text x="439" y="76" text-anchor="middle">fork_routes.js</text><text x="439" y="92" text-anchor="middle">mobile_layer.js</text><text x="439" y="108" text-anchor="middle">fork_workflows.js</text>
<rect x="534" y="28" width="166" height="96" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/>
<text x="617" y="54" text-anchor="middle" font-size="15" font-weight="700">Textos y APK</text>
<text x="617" y="76" text-anchor="middle">i18n_fork.js</text><text x="617" y="92" text-anchor="middle">(11 idiomas)</text><text x="617" y="108" text-anchor="middle">sobre_oficial.sh</text>
</g>
<g stroke="#0b0b0b" stroke-width="2" marker-end="url(#ar1)">
<line x1="83" y1="130" x2="83" y2="176"/><line x1="261" y1="130" x2="261" y2="176"/><line x1="439" y1="130" x2="439" y2="176"/><line x1="617" y1="130" x2="617" y2="176"/>
</g>
<text x="270" y="158" font-size="11" fill="#5a5a5a">una línea de enganche, solo con OASIS_MOBILE=1</text>
<rect x="0" y="184" width="700" height="140" rx="10" fill="#fff" stroke="#0b0b0b" stroke-width="3"/>
<text x="350" y="216" text-anchor="middle" font-size="18" font-weight="700">OASIS: TU CÓDIGO, TAL CUAL</text>
<g font-size="12" text-anchor="middle">
<rect x="18" y="234" width="152" height="70" rx="7" fill="#f1f4f5"/><text x="94" y="262" font-weight="700">backend.js</text><text x="94" y="280" fill="#5a5a5a">y su middleware</text>
<rect x="188" y="234" width="152" height="70" rx="7" fill="#f1f4f5"/><text x="264" y="262" font-weight="700">modelos</text><text x="264" y="280" fill="#5a5a5a">qué se publica y se cuenta</text>
<rect x="358" y="234" width="152" height="70" rx="7" fill="#f1f4f5"/><text x="434" y="262" font-weight="700">SSB y servidor</text><text x="434" y="280" fill="#5a5a5a">phone_module, puente</text>
<rect x="528" y="234" width="154" height="70" rx="7" fill="#f1f4f5"/><text x="605" y="262" font-weight="700">traducciones y temas</text><text x="605" y="280" fill="#5a5a5a">oasis_*.js, style.css…</text>
</g>
</svg>
<figcaption>La rama son cuatro piezas propias encima de tu código. Lo único que entra en tus ficheros son líneas de enganche.</figcaption>
</figure>
<div class="cols">
<div>
<h3>Por qué así</h3>
<ul>
<li>Sacas versión casi cada día y cada una llega en un solo commit: no se pueden coger cambios sueltos.</li>
<li><code>backend.js</code> cambió en 34 de 39 versiones, <code>main_views.js</code> en 31, las traducciones en 31.</li>
<li>Cada línea nuestra en esos ficheros sería un conflicto en la siguiente integración.</li>
<li>Si tocáramos un modelo, nuestro móvil contaría distinto que tu escritorio (votos, tribus, gobierno).</li>
</ul>
</div>
<div>
<h3>Cómo se mide</h3>
<ul>
<li>La referencia para el móvil es el <code>nodejs-project</code> de tu APK oficial, no tu git: ahí van tus cambios de móvil.</li>
<li>Hoy la rama cambia 17 ficheros tuyos y 561 líneas; en <code>backend.js</code>, 2.</li>
<li>En cada versión tuya se repite la comparación y se revisa si ha desaparecido alguna clase CSS de la que tiramos.</li>
</ul>
</div>
</div>
<div class="foot"><span>Oasis Mobile · Guía de desarrollo</span><span>2 / 5</span></div>
</section>
<!-- 3. LA FRONTERA -->
<section class="page">
<h2>Qué se toca y qué no</h2><div class="rule"></div>
<p class="lead">Tres zonas. La de en medio es la única que entra en tus ficheros, y solo con una línea.</p>
<div class="cols3">
<div class="card">
<span class="tag red">No se toca</span>
<ul>
<li><code>backend.js</code>, rutas y cadena de middleware</li>
<li>Modelos de <code>src/models</code>: lo que se publica en el log y cómo se cuenta</li>
<li><code>src/server</code>: arranque de SSB, dependencias, <code>phone_module</code></li>
<li><code>request_guards.js</code> y la CSP: nada en línea</li>
<li>Traducciones <code>oasis_*.js</code></li>
<li><code>style.css</code>, <code>mobile.css</code> y los temas, también <code>OasisMobile.css</code></li>
</ul>
</div>
<div class="card">
<span class="tag amber">Una línea</span>
<ul>
<li>Tus vistas: <code>main_views.js</code>, <code>tribes_view.js</code>, <code>vote_view.js</code>, <code>settings_view.js</code>, <code>welcome_view.js</code></li>
<li>La lógica va en <code>mobile_ui.js</code>; en tu vista, una llamada</li>
<li>Siempre detrás de <code>OASIS_MOBILE === '1'</code>: en escritorio todo queda como lo dejas</li>
<li>Si reescribes la vista, se vuelve a poner la línea</li>
</ul>
</div>
<div class="card cyan">
<span class="tag green">Nuestro</span>
<ul>
<li>Hojas: <code>mobile_base.css</code>, <code>mobile_ux.css</code>, <code>Acampada.css</code></li>
<li>Vistas: <code>mobile_ui.js</code>, <code>mobile_modules_view.js</code>, <code>hive_views.js</code>, <code>mobile_icons.js</code></li>
<li>Router: <code>fork_routes.js</code> y <code>mobile_layer.js</code></li>
<li>Workflows: <code>fork_workflows.js</code></li>
<li>Textos: <code>i18n_fork.js</code></li>
<li>APK: <code>sobre_oficial.sh</code></li>
</ul>
</div>
</div>
<h3 style="margin-top:4mm">Ejemplos concretos</h3>
<table class="tight">
<tr><th>Lo que hacía falta</th><th>Cómo se ha hecho</th><th>Zona</th></tr>
<tr><td>Votar en semáforo o termómetro, como en asamblea</td><td>Tu servidor ya acepta <code>options</code>; solo se añade el selector en el formulario</td><td>Una línea</td></tr>
<tr><td>Quien crea una votación no puede votar</td><td>Es una regla de tu modelo: no se cambia, se explica en pantalla</td><td>Nuestro</td></tr>
<tr><td>Avisar de que una sección está apagada y encenderla</td><td>Nuestro router mira tu redirección a <code>/modules</code>, sin tocar <code>checkMod</code></td><td>Nuestro</td></tr>
<tr><td>Workflow para acampadas</td><td>Se registra <code>acampada</code> en tus arrays; <code>activists</code> queda como el tuyo</td><td>Nuestro</td></tr>
<tr><td>Llamadas y salas</td><td>Las tuyas, con tu puente de audio: la APK se monta sobre la oficial</td><td>No se toca</td></tr>
<tr><td>Borrar con confirmación</td><td>Función en <code>mobile_ui.js</code>, enganchada en <code>renderContentActions</code></td><td>Una línea</td></tr>
</table>
<div class="foot"><span>Oasis Mobile · Guía de desarrollo</span><span>3 / 5</span></div>
</section>
<!-- 4. CÓMO FUNCIONA -->
<section class="page">
<h2>Cómo funciona por dentro</h2><div class="rule"></div>
<h3>Una petición en el móvil</h3>
<figure>
<svg viewBox="0 0 700 175" width="100%">
<defs><marker id="ar2" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#0b0b0b"/></marker></defs>
<g font-size="12" text-anchor="middle">
<rect x="0" y="40" width="110" height="80" rx="9" fill="#fff" stroke="#0b0b0b" stroke-width="2"/><text x="55" y="76" font-weight="700">WebView</text><text x="55" y="94" fill="#5a5a5a">de tu app</text>
<rect x="140" y="40" width="130" height="80" rx="9" fill="#f1f4f5" stroke="#0b0b0b" stroke-width="2"/><text x="205" y="70" font-weight="700">Tu middleware</text><text x="205" y="88" fill="#5a5a5a">contexto, idioma,</text><text x="205" y="102" fill="#5a5a5a">CSP</text>
<rect x="300" y="40" width="130" height="80" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/><text x="365" y="70" font-weight="700">fork_routes</text><text x="365" y="88" fill="#5a5a5a">rutas propias o</text><text x="365" y="102" fill="#5a5a5a">next() al tuyo</text>
<rect x="460" y="40" width="110" height="80" rx="9" fill="#f1f4f5" stroke="#0b0b0b" stroke-width="2"/><text x="515" y="76" font-weight="700">Tus rutas</text><text x="515" y="94" fill="#5a5a5a">y vistas</text>
<rect x="600" y="40" width="100" height="80" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/><text x="650" y="70" font-weight="700">mobile_ui</text><text x="650" y="88" fill="#5a5a5a">si la vista</text><text x="650" y="102" fill="#5a5a5a">lo llama</text>
</g>
<g stroke="#0b0b0b" stroke-width="2" marker-end="url(#ar2)">
<line x1="112" y1="80" x2="134" y2="80"/><line x1="272" y1="80" x2="294" y2="80"/><line x1="432" y1="80" x2="454" y2="80"/><line x1="572" y1="80" x2="594" y2="80"/>
</g>
<text x="440" y="168" text-anchor="middle" font-size="11" fill="#5a5a5a">Lo que contestan tus rutas no se mira ni se cambia</text>
<text x="0" y="22" font-size="11" fill="#5a5a5a">Nuestro router va justo delante del tuyo: una sola línea en backend.js lo monta.</text>
</svg>
</figure>
<div class="cols">
<div>
<h3>Hojas de estilo, en este orden</h3>
<figure>
<svg viewBox="0 0 330 200" width="100%">
<g font-size="12">
<rect x="0" y="0" width="330" height="34" rx="6" fill="#f1f4f5" stroke="#0b0b0b"/><text x="12" y="22">1 · style.css y mobile.css (tuyas)</text>
<rect x="0" y="40" width="330" height="34" rx="6" fill="#e2fbff" stroke="#0b0b0b"/><text x="12" y="62">2 · mobile_base.css: maqueta de la rama</text>
<rect x="0" y="80" width="330" height="34" rx="6" fill="#fff" stroke="#0b0b0b"/><text x="12" y="102">3 · skin: Acampada.css o tu OasisMobile.css</text>
<rect x="0" y="120" width="330" height="34" rx="6" fill="#e2fbff" stroke="#0b0b0b"/><text x="12" y="142">4 · activista.css (solo con Acampada)</text>
<rect x="0" y="160" width="330" height="34" rx="6" fill="#0b0b0b"/><text x="12" y="182" fill="#fff">5 · mobile_ux.css: piezas comunes, la última</text>
</g>
</svg>
</figure>
<ul>
<li>Con tu skin OasisMobile se ve tu tema tal cual, encima de nuestra maqueta.</li>
<li>Colores por variables <code>--ux-*</code>: valen para los dos skins.</li>
</ul>
</div>
<div>
<h3>La APK, montada sobre la tuya</h3>
<figure>
<svg viewBox="0 0 330 200" width="100%">
<defs><marker id="ar3" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#0b0b0b"/></marker></defs>
<g font-size="12">
<rect x="0" y="0" width="330" height="30" rx="6" fill="#fff" stroke="#0b0b0b"/><text x="12" y="20">tu APK oficial de la misma versión</text>
<rect x="0" y="42" width="330" height="30" rx="6" fill="#f1f4f5" stroke="#0b0b0b"/><text x="12" y="62">se desmonta (apktool, versión fija)</text>
<rect x="0" y="84" width="330" height="30" rx="6" fill="#e2fbff" stroke="#0b0b0b"/><text x="12" y="104">backend de la rama + tu node_modules</text>
<rect x="0" y="126" width="330" height="30" rx="6" fill="#e2fbff" stroke="#0b0b0b"/><text x="12" y="146">paquete net.laenre.oasis</text>
<rect x="0" y="168" width="330" height="30" rx="6" fill="#0b0b0b"/><text x="12" y="188" fill="#fff">se monta, se alinea y se firma</text>
</g>
<g stroke="#0b0b0b" stroke-width="1.6" marker-end="url(#ar3)"><line x1="300" y1="31" x2="300" y2="40"/><line x1="300" y1="73" x2="300" y2="82"/><line x1="300" y1="115" x2="300" y2="124"/><line x1="300" y1="157" x2="300" y2="166"/></g>
</svg>
</figure>
<ul>
<li>Tu app tal cual: puente de audio, notificaciones, permisos.</li>
<li>Se cambian también los authorities, el permiso propio y las acciones de llamada, para no cruzarse con la tuya.</li>
</ul>
</div>
</div>
<div class="foot"><span>Oasis Mobile · Guía de desarrollo</span><span>4 / 5</span></div>
</section>
<!-- 5. TRABAJAR JUNTOS -->
<section class="page">
<h2>Cómo seguimos</h2><div class="rule"></div>
<div class="cols">
<div>
<h3>Antes de cada commit</h3>
<ul>
<li>Nada de la zona roja en el diff; en tus vistas, solo líneas de enganche.</li>
<li><code>node --check</code> y el servidor local responde en las pantallas principales.</li>
<li>Visto a 360 y 412 px, en tableta, con la letra al 130 % y en un WebView viejo.</li>
<li>Textos nuevos en los 11 idiomas, en <code>i18n_fork.js</code>.</li>
<li>Nada en línea: la CSP lo bloquea.</li>
</ul>
<h3>Cuando sacas versión</h3>
<ul>
<li>Se coge la tuya tal cual; para el móvil, la de tu APK.</li>
<li>Se vuelven a poner los enganches en tus vistas.</li>
<li>Se repite la medición y se revisan las clases CSS.</li>
<li>Se monta la APK sobre tu oficial de esa versión.</li>
</ul>
</div>
<div>
<h3>Navegación en el móvil</h3>
<ul>
<li>Al abrir se ve la actividad, no un menú.</li>
<li>Abajo, cuatro atajos que elige cada cual y una pestaña Menú que abre todos los módulos desde abajo.</li>
<li>Probado en el emulador: dos toques a cualquier módulo desde cualquier pantalla.</li>
</ul>
<h3>Probado</h3>
<ul>
<li>Android 16 (APK real): 412 y 360 dp, tableta de 800 dp, letra al 130 %, gestos y tres botones.</li>
<li>Motores viejos: WebView 69 (Android 9) y 83 (Android 11), con alternativas donde falta <code>:has</code>, <code>gap</code> o <code>inset</code>.</li>
<li>Llamadas: en una sala el micrófono graba y se corta al salir.</li>
</ul>
</div>
</div>
<h3 style="margin-top:2mm">Lo que podría pasar a Oasis</h3>
<p>Cosas que hoy llevamos en tus ficheros y que nos gustaría que fueran tuyas, para dejar de mantenerlas aparte:</p>
<table>
<tr><th>Qué</th><th>Dónde está ahora</th><th>Por qué te puede interesar</th></tr>
<tr><td>Limpiar metadatos de las fotos también en Android</td><td><code>blobHandler.js</code> (190 líneas)</td><td>Sin <code>sharp</code> en el móvil, hoy las fotos suben con el GPS dentro</td></tr>
<tr><td>Varias identidades en el mismo móvil</td><td><code>ssb_config.js</code> (30 líneas)</td><td>Una persona, varias cuentas, con aviso si dos comparten clave</td></tr>
<tr><td>DNS del sistema primero</td><td><code>main.js</code> (45 líneas)</td><td>Respeta la VPN y el DNS privado de Android</td></tr>
<tr><td>Selector de idioma con el idioma actual</td><td>nuestro, en <code>mobile_ui.js</code></td><td><code>settings_view</code> lee <code>selectedLanguage</code> al cargar y siempre marca English</td></tr>
</table>
<div class="ask" style="margin-top:3mm">
<h3>Para aprobar</h3>
<ul style="margin:0">
<li>¿Te parece bien la frontera de la página 3?</li>
<li>¿Te parece bien que la APK de la rama se monte sobre tu oficial, con otro paquete?</li>
<li>¿Quieres alguno de los cuatro puntos de arriba en Oasis?</li>
</ul>
</div>
<div class="foot"><span>Oasis Mobile · Guía de desarrollo · hacklab · gitea.laenre.net/hacklab/OASIS_MOBILE</span><span>5 / 5</span></div>
</section>
</body>
</html>