From 48cafbbe59deb2dc7f844020d50b25dfd20cee27 Mon Sep 17 00:00:00 2001 From: s1to Date: Wed, 19 Aug 2026 10:30:37 +0200 Subject: [PATCH] android: selector de ficheros, espera del backend y re-extraccion Cuatro arreglos del envoltorio, todos vistos al probarlo en el emulador: - onShowFileChooser: sin el, el navegador interno no abre nada al pulsar "Choose File", asi que no se podia subir ninguna imagen. El APK oficial si lo implementa; era una regresion frente a el. - La marca de re-extraccion del backend era el versionName. Al reconstruir la APK sin subir version, la app se quedaba con el backend anterior y ningun cambio se aplicaba. Pasa a ser lastUpdateTime del paquete, que cambia en cada instalacion. (openFd sobre el asset no vale: va comprimido dentro del APK y no admite descriptor.) - La espera del backend sondeaba con HEAD, que el backend rechaza con 400. Ahora abre un socket TCP contra 127.0.0.1:3000, que es lo unico que hay que saber, y espera hasta cinco minutos en vez de uno: descomprimir 231 MB y levantar SSB tarda mas en un movil lento. Mientras, pinta una pantalla de estado con los segundos transcurridos, en vez de dejar el WebView en blanco. - targetUrl/onNewIntent para poder abrir una ruta concreta desde un intent. network_security_config.xml tenia base-config detras de domain-config, que el esquema no admite. Corregido el orden y restaurada la referencia en el manifiesto, que se habia sustituido por usesCleartextTraffic=true al depurar: eso abria texto en claro hacia cualquier host, no solo hacia loopback. --- .../java/net/laenre/oasis/MainActivity.kt | 113 ++++++++++++++++-- .../main/java/net/laenre/oasis/NodeService.kt | 6 +- .../main/res/xml/network_security_config.xml | 4 +- 3 files changed, 107 insertions(+), 16 deletions(-) diff --git a/android/app/src/main/java/net/laenre/oasis/MainActivity.kt b/android/app/src/main/java/net/laenre/oasis/MainActivity.kt index e5963d0..17a87fe 100644 --- a/android/app/src/main/java/net/laenre/oasis/MainActivity.kt +++ b/android/app/src/main/java/net/laenre/oasis/MainActivity.kt @@ -8,8 +8,11 @@ import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper +import android.util.Log import android.webkit.PermissionRequest +import android.webkit.ValueCallback import android.webkit.WebChromeClient +import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient @@ -17,13 +20,19 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import com.solarnethub.oasis.BuildConfig -import java.net.HttpURLConnection -import java.net.URL class MainActivity : AppCompatActivity() { private lateinit var web: WebView private var pending: PermissionRequest? = null + private var fileCallback: ValueCallback>? = null + + private val pickFiles = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { res -> + // Si se cancela hay que devolver null igualmente: si no, el queda + // bloqueado y no vuelve a abrir el selector nunca mas. + fileCallback?.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(res.resultCode, res.data)) + fileCallback = null + } private val askAndroid = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted -> val req = pending @@ -42,6 +51,9 @@ class MainActivity : AppCompatActivity() { 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) @@ -70,6 +82,22 @@ class MainActivity : AppCompatActivity() { // toda peticion de getUserMedia y no hay videollamada posible, aunque los // permisos esten declarados en el manifest. Es la puerta que faltaba. web.webChromeClient = object : WebChromeClient() { + override fun onShowFileChooser( + view: WebView?, + callback: ValueCallback>?, + params: FileChooserParams? + ): Boolean { + fileCallback?.onReceiveValue(null) // descartar uno anterior sin resolver + fileCallback = callback + return try { + pickFiles.launch(params?.createIntent()) + true + } catch (e: Exception) { + fileCallback = null + false + } + } + override fun onPermissionRequest(request: PermissionRequest) { runOnUiThread { if (!isLocal(request.origin)) { request.deny(); return@runOnUiThread } @@ -94,7 +122,7 @@ class MainActivity : AppCompatActivity() { } } - waitForBackend() + waitAndLoad() } private fun has(p: String) = @@ -105,17 +133,76 @@ class MainActivity : AppCompatActivity() { return (h == "127.0.0.1" || h == "localhost") && u.port == 3000 } - /** Sondea el backend y carga la interfaz en cuanto responde. */ - private fun waitForBackend(attempt: Int = 0) { + private fun showStatus(attempt: Int) { + val s = attempt / 2 + web.loadDataWithBaseURL(null, """ + + +

Oasis

+

Preparando la aplicacion…
El primer arranque descomprime los datos y + puede tardar unos minutos.

${s}s

+ """.trimIndent(), "text/html", "utf-8", null) + } + + /** + * Carga la interfaz reintentando hasta que el backend responda. + * + * Se hace con el propio WebView en vez de sondear con HttpURLConnection: es el + * canal que de verdad va a servir la app, y evita depender de que la peticion de + * comprobacion pase los mismos filtros (el backend, por ejemplo, responde 400 a HEAD). + */ + private var loadAttempt = 0 + private var loaded = false + + /** + * 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() { + showStatus(0) + Log.i("OasisNode", "esperando al backend en 127.0.0.1:3000") Thread { - val up = try { - val c = URL("http://127.0.0.1:3000/").openConnection() as HttpURLConnection - c.connectTimeout = 1500; c.readTimeout = 1500; c.requestMethod = "HEAD" - c.responseCode in 200..399 - } catch (_: Exception) { false } - Handler(Looper.getMainLooper()).post { - if (up) web.loadUrl("http://127.0.0.1:3000/") - else if (attempt < 120) Handler(Looper.getMainLooper()).postDelayed({ waitForBackend(attempt + 1) }, 500) + 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 + Log.i("OasisNode", "backend listo tras " + (i / 2) + "s, cargando interfaz") + web.loadUrl(targetUrl()) + } + return@Thread + } + if (i % 8 == 0) Handler(Looper.getMainLooper()).post { if (!loaded) showStatus(i) } + Thread.sleep(500); i++ } }.start() } diff --git a/android/app/src/main/java/net/laenre/oasis/NodeService.kt b/android/app/src/main/java/net/laenre/oasis/NodeService.kt index 3f9cc3d..9bd6f07 100644 --- a/android/app/src/main/java/net/laenre/oasis/NodeService.kt +++ b/android/app/src/main/java/net/laenre/oasis/NodeService.kt @@ -84,7 +84,11 @@ class NodeService : Service() { /** Descomprime assets/nodejs-project.zip la primera vez y tras cada actualizacion. */ private fun extractProjectIfNeeded(root: File) { val stamp = File(root, ".version") - val current = packageManager.getPackageInfo(packageName, 0).versionName ?: "0" + // 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() diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml index 6dd281f..ca015e3 100644 --- a/android/app/src/main/res/xml/network_security_config.xml +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -1,11 +1,11 @@ + El orden importa: el esquema de Android exige base-config antes que domain-config. --> + 127.0.0.1 localhost -