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.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…<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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue