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:
parent
5bfbe2d6c8
commit
48cafbbe59
3 changed files with 107 additions and 16 deletions
|
|
@ -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…<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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue