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.
This commit is contained in:
s1to 2026-08-19 10:30:37 +02:00
parent 5bfbe2d6c8
commit 48cafbbe59
3 changed files with 107 additions and 16 deletions

View file

@ -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<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
@ -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<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 }
@ -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, """
<html><head><meta name='viewport' content='width=device-width,initial-scale=1'>
<style>body{background:#121212;color:#FFB400;font-family:sans-serif;display:flex;
height:100vh;margin:0;align-items:center;justify-content:center;text-align:center}
div{padding:24px}p{opacity:.7;font-size:14px;line-height:1.5}</style></head>
<body><div><h2>Oasis</h2>
<p>Preparando la aplicacion&hellip;<br>El primer arranque descomprime los datos y
puede tardar unos minutos.</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
/**
* 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()
}

View file

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

View file

@ -1,11 +1,11 @@
<?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 APK actual declara usesCleartextTraffic=true a secas, que abre cualquier host. -->
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>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>