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.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log
import android.webkit.PermissionRequest import android.webkit.PermissionRequest
import android.webkit.ValueCallback
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
@ -17,13 +20,19 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.solarnethub.oasis.BuildConfig import com.solarnethub.oasis.BuildConfig
import java.net.HttpURLConnection
import java.net.URL
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var web: WebView private lateinit var web: WebView
private var pending: PermissionRequest? = null 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 -> private val askAndroid = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted ->
val req = pending val req = pending
@ -42,6 +51,9 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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)) startService(Intent(this, NodeService::class.java))
web = WebView(this) web = WebView(this)
@ -70,6 +82,22 @@ class MainActivity : AppCompatActivity() {
// toda peticion de getUserMedia y no hay videollamada posible, aunque los // toda peticion de getUserMedia y no hay videollamada posible, aunque los
// permisos esten declarados en el manifest. Es la puerta que faltaba. // permisos esten declarados en el manifest. Es la puerta que faltaba.
web.webChromeClient = object : WebChromeClient() { 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) { override fun onPermissionRequest(request: PermissionRequest) {
runOnUiThread { runOnUiThread {
if (!isLocal(request.origin)) { request.deny(); return@runOnUiThread } if (!isLocal(request.origin)) { request.deny(); return@runOnUiThread }
@ -94,7 +122,7 @@ class MainActivity : AppCompatActivity() {
} }
} }
waitForBackend() waitAndLoad()
} }
private fun has(p: String) = private fun has(p: String) =
@ -105,17 +133,76 @@ class MainActivity : AppCompatActivity() {
return (h == "127.0.0.1" || h == "localhost") && u.port == 3000 return (h == "127.0.0.1" || h == "localhost") && u.port == 3000
} }
/** Sondea el backend y carga la interfaz en cuanto responde. */ private fun showStatus(attempt: Int) {
private fun waitForBackend(attempt: Int = 0) { 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 { Thread {
val up = try { var i = 0
val c = URL("http://127.0.0.1:3000/").openConnection() as HttpURLConnection while (i < 600 && !loaded) { // hasta 5 minutos
c.connectTimeout = 1500; c.readTimeout = 1500; c.requestMethod = "HEAD" val ok = try {
c.responseCode in 200..399 java.net.Socket().use { it.connect(java.net.InetSocketAddress("127.0.0.1", 3000), 1000); true }
} catch (_: Exception) { false } } catch (e: Exception) {
Handler(Looper.getMainLooper()).post { if (i % 10 == 0) Log.w("OasisNode", "socket #" + i + ": " + e.javaClass.simpleName + ": " + e.message)
if (up) web.loadUrl("http://127.0.0.1:3000/") false
else if (attempt < 120) Handler(Looper.getMainLooper()).postDelayed({ waitForBackend(attempt + 1) }, 500) }
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() }.start()
} }

View file

@ -84,7 +84,11 @@ class NodeService : Service() {
/** Descomprime assets/nodejs-project.zip la primera vez y tras cada actualizacion. */ /** Descomprime assets/nodejs-project.zip la primera vez y tras cada actualizacion. */
private fun extractProjectIfNeeded(root: File) { private fun extractProjectIfNeeded(root: File) {
val stamp = File(root, ".version") 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 (stamp.exists() && stamp.readText() == current) return
if (root.exists()) root.deleteRecursively() if (root.exists()) root.deleteRecursively()
root.mkdirs() root.mkdirs()

View file

@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?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 <!-- 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. 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> <network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true"> <domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain> <domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain> <domain includeSubdomains="false">localhost</domain>
</domain-config> </domain-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config> </network-security-config>