diff --git a/CLAUDE.md b/CLAUDE.md
index 0b3bf06..35456fc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -25,6 +25,9 @@ app/src/main/java/mx/joykoo/scanner/
NoSoftKeyboardWebView.kt — IME suppression via null InputConnection
ScannerIntegration.kt — Scanner lifecycle interface
BroadcastScannerIntegration.kt — Intent-output scans → JS injection (React-safe value setter)
+ KioskController.kt — Lock Task kiosk toggle (persisted); device-owner full lockdown + HOME alias, else screen pinning
+ KioskAdminReceiver.kt — DeviceAdminReceiver for `dpm set-device-owner`
+ StatusStrip.kt — App-drawn status strip (Wi-Fi + battery only); system bars stay hidden
```
## Build
@@ -64,17 +67,25 @@ All configurable values are in `AppConfig.kt`:
- `BACK_PRESS_EXIT_COUNT` / `BACK_PRESS_WINDOW_MS` — back-button exit guard
- `ADMIN_VOLUME_HOLD_MS` — hold Volume Up + Volume Down this long to open the admin dialog
- `ADMIN_BARCODE` — scanning this value (Intent mode) opens the admin dialog
+- `KIOSK_BARCODE` — scanning this value (Intent mode) toggles kiosk lockdown
+- `RENDERER_CRASH_LIMIT` / `RENDERER_CRASH_WINDOW_MS` — WebView crash auto-recovery limit
## Navigation Rules
- URLs on `joykoo.mx` or `smart.joykoo.mx` (and subdomains) load inside the WebView
- All other URLs are silently blocked
- Back button goes back in WebView history; 3 rapid presses exits when no history
-- Admin dialog (exit / toggle soft keyboard): hold both volume keys 5 s, or scan `ADMIN_BARCODE`. Volume keys are consumed and never reach the page. No touch gestures — corner taps leaked through to site UI
+- Admin dialog (exit / toggle soft keyboard): hold both volume keys 3 s, or scan `ADMIN_BARCODE`. Volume keys are consumed and never reach the page. No touch gestures — corner taps leaked through to site UI
+
+## Kiosk & Errors
+
+- Kiosk mode (`KioskController`): when on, admin dialog hides "Salir" and Back never exits. Device owner required for real lockdown; the app can't be uninstalled while device owner, so set it only with the release-signed APK.
+- Error overlay: per-cause messages (no Wi-Fi with auto-retry on reconnect, unreachable, 5xx, SSL, repeated renderer crash). 4xx responses are left to the site.
+- Renderer crash (`onRenderProcessGone`): the WebView is replaced and the last URL (tracked via `doUpdateVisitedHistory`) reloaded.
## Security
-- HTTPS only, no cleartext, no SSL error bypass
+- HTTPS only, no cleartext, mixed content never allowed, no SSL error bypass
- No JavaScript interfaces exposed
- No file:// or content:// access
- Network security config enforces system trust anchors only
diff --git a/README.md b/README.md
index d0459bd..5abadfe 100644
--- a/README.md
+++ b/README.md
@@ -8,8 +8,10 @@ Aplicación nativa de Android que funciona como navegador dedicado de pantalla c
- **Supresión del teclado virtual** — El teclado en pantalla de Android nunca aparece, mientras que la entrada del escáner de códigos de barras (modo HID/teclado) funciona normalmente
- **Modo kiosco** — Pantalla siempre encendida, modo inmersivo, navegación restringida al dominio de la empresa
- **Persistencia de sesión** — Cookies y almacenamiento local persisten entre reinicios de la aplicación
-- **Manejo de errores de red** — Pantalla de reintento cuando no hay conexión Wi-Fi
-- **Salida protegida** — Se requieren 3 pulsaciones rápidas del botón Atrás para salir; menú de administrador (salir / mostrar u ocultar teclado) al mantener presionados Subir y Bajar volumen 5 segundos, o al escanear el código de administrador
+- **Manejo de errores** — Mensajes distintos para sin Wi-Fi (recarga automática al reconectar), servidor sin respuesta, error del servidor (5xx) y certificado inválido; recuperación automática si el motor de la página se cierra
+- **Barra de estado propia** — Solo muestra Wi-Fi (intensidad / sin internet) y porcentaje de batería
+- **Modo kiosco (bloqueo)** — Se activa/desactiva escaneando el código de kiosco (ver abajo)
+- **Salida protegida** — Se requieren 3 pulsaciones rápidas del botón Atrás para salir; menú de administrador (salir / mostrar u ocultar teclado) al mantener presionados Subir y Bajar volumen 3 segundos, o al escanear el código de administrador
## Requisitos
@@ -54,7 +56,8 @@ Todos los valores configurables están en un solo archivo:
| `HOME_URL` | URL que se carga al iniciar | `https://smart.joykoo.mx/` |
| `ALLOWED_HOSTS` | Dominios permitidos dentro del WebView | `smart.joykoo.mx`, `joykoo.mx` |
| `BACK_PRESS_EXIT_COUNT` | Pulsaciones rápidas de Atrás para salir | 3 |
-| `ADMIN_VOLUME_HOLD_MS` | Tiempo (ms) con ambos botones de volumen presionados para abrir el menú de administrador | 5000 |
+| `ADMIN_VOLUME_HOLD_MS` | Tiempo (ms) con ambos botones de volumen presionados para abrir el menú de administrador | 3000 |
+| `KIOSK_BARCODE` | Código que, al escanearse (modo Intent), activa/desactiva el modo kiosco | `JOYKOO-KIOSK-…` |
| `ADMIN_BARCODE` | Código que, al escanearse (modo Intent), abre el menú de administrador | `JOYKOO-ADMIN-…` |
## Uso del escáner
@@ -65,6 +68,22 @@ Todos los valores configurables están en un solo archivo:
4. El texto escaneado aparece en el campo
5. Si el escáner envía Enter al final, la página lo procesa normalmente
+## Modo kiosco
+
+Escanear `KIOSK_BARCODE` activa o desactiva el bloqueo (Lock Task de Android). El estado se guarda y se vuelve a aplicar al abrir la app. Mientras está activo, el menú de administrador no ofrece "Salir" y el botón Atrás no cierra la app.
+
+Para un bloqueo completo (sin botones Inicio/Recientes, sin panel de notificaciones, sin aviso del sistema y regreso automático a la app tras reiniciar), la app debe ser **propietaria del dispositivo** (*device owner*). En un dispositivo recién restablecido y **sin cuentas** (Google, etc.):
+
+```bash
+adb install app-release.apk
+adb shell dpm set-device-owner mx.joykoo.scanner/.KioskAdminReceiver
+```
+
+Importante:
+- Instale el APK **firmado de producción** antes de este paso. Una app propietaria no se puede desinstalar (solo con restablecimiento de fábrica), así que no se podrá cambiar a un APK firmado con otra clave.
+- Sin *device owner*, el modo kiosco usa la "fijación de pantalla" de Android: el sistema pide confirmación y el usuario puede salir manteniendo Atrás + Recientes.
+- Desactive el atajo de accesibilidad de volumen (Ajustes → Accesibilidad) para que mantener ambos botones de volumen no active TalkBack.
+
## Estructura del proyecto
```
@@ -73,7 +92,10 @@ app/src/main/java/mx/joykoo/scanner/
├── MainActivity.kt — Actividad principal (kiosco, WebView, navegación)
├── NoSoftKeyboardWebView.kt — WebView que suprime el teclado virtual
├── ScannerIntegration.kt — Interfaz para integración con el escáner
-└── BroadcastScannerIntegration.kt — Recepción de códigos en modo Intent
+├── BroadcastScannerIntegration.kt — Recepción de códigos en modo Intent
+├── KioskController.kt — Modo kiosco (Lock Task, pantalla de inicio)
+├── KioskAdminReceiver.kt — Componente de propietario del dispositivo
+└── StatusStrip.kt — Barra de estado propia (Wi-Fi y batería)
```
## Modo de salida por Intent (broadcast)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0cefc0b..c2cbaf9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/mx/joykoo/scanner/AppConfig.kt b/app/src/main/java/mx/joykoo/scanner/AppConfig.kt
index b4b86b2..acdda26 100644
--- a/app/src/main/java/mx/joykoo/scanner/AppConfig.kt
+++ b/app/src/main/java/mx/joykoo/scanner/AppConfig.kt
@@ -19,12 +19,21 @@ object AppConfig {
const val BACK_PRESS_WINDOW_MS = 2000L
/** How long (ms) Volume Up + Volume Down must be held together to open the admin dialog. */
- const val ADMIN_VOLUME_HOLD_MS = 5000L
+ const val ADMIN_VOLUME_HOLD_MS = 3000L
/** Scanning a barcode with exactly this value opens the admin dialog instead of
* being typed into the page (Intent output mode only). Print it as a QR/Code128. */
const val ADMIN_BARCODE = "JOYKOO-ADMIN-O4306IFH9AUJ"
+ /** Scanning this value toggles kiosk lockdown (Lock Task mode) on/off. Intent output
+ * mode only. Full lockdown needs the app set as device owner — see README. */
+ const val KIOSK_BARCODE = "JOYKOO-KIOSK-8PRMVBA5WSL5"
+
+ /** WebView renderer crashes allowed within RENDERER_CRASH_WINDOW_MS before the app
+ * stops auto-reloading and shows the error screen instead. */
+ const val RENDERER_CRASH_LIMIT = 3
+ const val RENDERER_CRASH_WINDOW_MS = 60_000L
+
/** Broadcast actions sent by scanners in "Intent output" mode (Urovo first, then
* other common Chinese vendors). Add the device's action here if it differs. */
val SCANNER_BROADCAST_ACTIONS = listOf(
diff --git a/app/src/main/java/mx/joykoo/scanner/KioskAdminReceiver.kt b/app/src/main/java/mx/joykoo/scanner/KioskAdminReceiver.kt
new file mode 100644
index 0000000..f634730
--- /dev/null
+++ b/app/src/main/java/mx/joykoo/scanner/KioskAdminReceiver.kt
@@ -0,0 +1,11 @@
+package mx.joykoo.scanner
+
+import android.app.admin.DeviceAdminReceiver
+
+/**
+ * Device admin component. Making the app device owner lets KioskController use
+ * full Lock Task mode (no Home/Recents, no notification shade, no prompt):
+ *
+ * adb shell dpm set-device-owner mx.joykoo.scanner/.KioskAdminReceiver
+ */
+class KioskAdminReceiver : DeviceAdminReceiver()
diff --git a/app/src/main/java/mx/joykoo/scanner/KioskController.kt b/app/src/main/java/mx/joykoo/scanner/KioskController.kt
new file mode 100644
index 0000000..8455068
--- /dev/null
+++ b/app/src/main/java/mx/joykoo/scanner/KioskController.kt
@@ -0,0 +1,99 @@
+package mx.joykoo.scanner
+
+import android.app.Activity
+import android.app.ActivityManager
+import android.app.admin.DevicePolicyManager
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageManager
+import android.os.Build
+import android.util.Log
+
+/**
+ * Kiosk lockdown via Android Lock Task mode, toggled by scanning AppConfig.KIOSK_BARCODE.
+ * The on/off setting is persisted and re-applied on every resume.
+ *
+ * - Device owner: true lockdown. Home/Recents and the notification shade are blocked,
+ * only the power menu stays available, and the app becomes the persistent home
+ * screen so it comes back after a reboot.
+ * - Not device owner: falls back to screen pinning, which shows a system prompt and
+ * can be undone by the user (hold Back + Recents).
+ */
+class KioskController(private val activity: Activity) {
+
+ private val dpm = activity.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
+ private val am = activity.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
+ private val admin = ComponentName(activity, KioskAdminReceiver::class.java)
+ private val homeAlias = ComponentName(activity, "${activity.packageName}.KioskHome")
+ private val prefs = activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+
+ val isDeviceOwner: Boolean
+ get() = dpm.isDeviceOwnerApp(activity.packageName)
+
+ var isEnabled: Boolean
+ get() = prefs.getBoolean(KEY_ENABLED, false)
+ private set(value) = prefs.edit().putBoolean(KEY_ENABLED, value).apply()
+
+ private val isInLockTask: Boolean
+ get() = am.lockTaskModeState != ActivityManager.LOCK_TASK_MODE_NONE
+
+ fun toggle(): Boolean {
+ isEnabled = !isEnabled
+ apply()
+ return isEnabled
+ }
+
+ /** Brings the device in line with the saved setting. Call from onResume. */
+ fun apply() {
+ try {
+ if (isEnabled) enter() else exit()
+ } catch (e: Exception) {
+ Log.e(TAG, "Kiosk ${if (isEnabled) "enter" else "exit"} failed", e)
+ }
+ }
+
+ private fun enter() {
+ if (isDeviceOwner) {
+ dpm.setLockTaskPackages(admin, arrayOf(activity.packageName))
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ dpm.setLockTaskFeatures(admin, DevicePolicyManager.LOCK_TASK_FEATURE_GLOBAL_ACTIONS)
+ }
+ setHomeScreen(true)
+ }
+ if (!isInLockTask) activity.startLockTask()
+ }
+
+ private fun exit() {
+ if (isInLockTask) activity.stopLockTask()
+ if (isDeviceOwner) {
+ setHomeScreen(false)
+ dpm.setLockTaskPackages(admin, emptyArray())
+ }
+ }
+
+ private fun setHomeScreen(enabled: Boolean) {
+ activity.packageManager.setComponentEnabledSetting(
+ homeAlias,
+ if (enabled) PackageManager.COMPONENT_ENABLED_STATE_ENABLED
+ else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
+ PackageManager.DONT_KILL_APP
+ )
+ if (enabled) {
+ val filter = IntentFilter(Intent.ACTION_MAIN).apply {
+ addCategory(Intent.CATEGORY_HOME)
+ addCategory(Intent.CATEGORY_DEFAULT)
+ }
+ dpm.addPersistentPreferredActivity(admin, filter, homeAlias)
+ } else {
+ dpm.clearPackagePersistentPreferredActivities(admin, activity.packageName)
+ }
+ }
+
+ companion object {
+ private const val TAG = "JoykooScanner"
+ private const val PREFS = "kiosk"
+ private const val KEY_ENABLED = "enabled"
+ }
+}
diff --git a/app/src/main/java/mx/joykoo/scanner/MainActivity.kt b/app/src/main/java/mx/joykoo/scanner/MainActivity.kt
index 304e542..39eed23 100644
--- a/app/src/main/java/mx/joykoo/scanner/MainActivity.kt
+++ b/app/src/main/java/mx/joykoo/scanner/MainActivity.kt
@@ -9,15 +9,20 @@ import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build
import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.os.SystemClock
import android.util.Log
import android.view.KeyEvent
import android.view.View
+import android.view.ViewGroup
import android.view.WindowInsets
import android.view.WindowInsetsController
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.webkit.ConsoleMessage
import android.webkit.CookieManager
+import android.webkit.RenderProcessGoneDetail
import android.webkit.SslErrorHandler
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
@@ -27,7 +32,9 @@ import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
+import android.widget.TextView
import android.widget.Toast
+import androidx.annotation.RequiresApi
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import org.json.JSONObject
@@ -37,8 +44,17 @@ class MainActivity : AppCompatActivity() {
private lateinit var webView: NoSoftKeyboardWebView
private lateinit var errorOverlay: View
private lateinit var scanner: ScannerIntegration
+ private lateinit var kiosk: KioskController
+ private lateinit var statusStrip: StatusStrip
+ private val handler = Handler(Looper.getMainLooper())
private var fileUploadCallback: ValueCallback>? = null
- private var isShowingError = false
+
+ // Error overlay state: what is shown and which URL Retry should load
+ private var currentError: ErrorKind? = null
+ private val isShowingError get() = currentError != null
+ private var failedUrl: String? = null
+ private var lastLoadedUrl: String? = null
+ private val rendererCrashTimestamps = mutableListOf()
// Admin dialog: hold Volume Up + Volume Down, or scan AppConfig.ADMIN_BARCODE
private val volumeKeysDown = mutableSetOf()
@@ -58,6 +74,13 @@ class MainActivity : AppCompatActivity() {
setupErrorOverlay()
setupBackNavigation()
scanner = BroadcastScannerIntegration(this, ::onBarcodeScanned)
+ kiosk = KioskController(this)
+ statusStrip = StatusStrip(
+ this,
+ findViewById(R.id.status_wifi),
+ findViewById(R.id.status_battery),
+ ::onWifiChanged
+ )
if (savedInstanceState == null) {
webView.loadUrl(AppConfig.HOME_URL)
@@ -105,13 +128,17 @@ class MainActivity : AppCompatActivity() {
@SuppressLint("SetJavaScriptEnabled")
private fun setupWebView() {
webView = findViewById(R.id.webview)
+ configureWebView()
+ }
+ @SuppressLint("SetJavaScriptEnabled")
+ private fun configureWebView() {
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
- mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
+ mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
// Cache and storage for session persistence
cacheMode = WebSettings.LOAD_DEFAULT
@@ -148,28 +175,50 @@ class MainActivity : AppCompatActivity() {
// ── Error Overlay ───────────────────────────────────────────────────
+ private enum class ErrorKind(val title: Int, val message: Int) {
+ NO_WIFI(R.string.error_no_wifi_title, R.string.error_no_wifi_message),
+ UNREACHABLE(R.string.error_unreachable_title, R.string.error_unreachable_message),
+ SERVER(R.string.error_server_title, R.string.error_server_message),
+ SSL(R.string.error_ssl_title, R.string.error_ssl_message),
+ RENDERER_CRASH(R.string.error_crash_title, R.string.error_crash_message),
+ }
+
private fun setupErrorOverlay() {
errorOverlay = findViewById(R.id.error_overlay)
- findViewById(R.id.btn_retry).setOnClickListener {
- hideError()
- if (isNetworkAvailable()) {
- webView.reload()
- } else {
- showError()
- }
+ findViewById(R.id.btn_retry).setOnClickListener { retryFailedLoad() }
+ findViewById(R.id.btn_home).setOnClickListener {
+ failedUrl = AppConfig.HOME_URL
+ retryFailedLoad()
}
}
- private fun showError() {
- isShowingError = true
+ private fun showError(kind: ErrorKind, url: String?, httpStatus: Int = 0) {
+ currentError = kind
+ if (url != null) failedUrl = url
+ findViewById(R.id.error_title).setText(kind.title)
+ findViewById(R.id.error_message).text = getString(kind.message, httpStatus)
errorOverlay.visibility = View.VISIBLE
}
private fun hideError() {
- isShowingError = false
+ currentError = null
errorOverlay.visibility = View.GONE
}
+ private fun retryFailedLoad() {
+ if (!isNetworkAvailable()) {
+ showError(ErrorKind.NO_WIFI, null)
+ return
+ }
+ hideError()
+ webView.loadUrl(failedUrl ?: AppConfig.HOME_URL)
+ }
+
+ /** Called by the status strip; reloads automatically when Wi-Fi comes back. */
+ private fun onWifiChanged(online: Boolean) {
+ if (online && currentError == ErrorKind.NO_WIFI) retryFailedLoad()
+ }
+
private fun isNetworkAvailable(): Boolean {
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork ?: return false
@@ -198,6 +247,7 @@ class MainActivity : AppCompatActivity() {
}
private fun handleExitBackPress() {
+ if (kiosk.isEnabled) return
val now = System.currentTimeMillis()
backPressTimestamps.add(now)
backPressTimestamps.removeAll { now - it > AppConfig.BACK_PRESS_WINDOW_MS }
@@ -223,12 +273,12 @@ class MainActivity : AppCompatActivity() {
KeyEvent.ACTION_DOWN -> if (event.repeatCount == 0) {
volumeKeysDown.add(event.keyCode)
if (volumeKeysDown.size == 2) {
- webView.postDelayed(adminHoldRunnable, AppConfig.ADMIN_VOLUME_HOLD_MS)
+ handler.postDelayed(adminHoldRunnable, AppConfig.ADMIN_VOLUME_HOLD_MS)
}
}
KeyEvent.ACTION_UP -> {
volumeKeysDown.remove(event.keyCode)
- webView.removeCallbacks(adminHoldRunnable)
+ handler.removeCallbacks(adminHoldRunnable)
}
}
}
@@ -236,16 +286,31 @@ class MainActivity : AppCompatActivity() {
private fun showAdminDialog() {
// The dialog takes focus, so the volume key-ups never reach us.
volumeKeysDown.clear()
- webView.removeCallbacks(adminHoldRunnable)
+ handler.removeCallbacks(adminHoldRunnable)
val keyboardLabel = if (isKeyboardSuppressed)
getString(R.string.keyboard_show) else getString(R.string.keyboard_hide)
- AlertDialog.Builder(this)
+ val builder = AlertDialog.Builder(this)
.setTitle(R.string.admin_title)
- .setMessage(R.string.admin_message)
- .setPositiveButton(R.string.admin_exit) { _, _ -> finish() }
.setNeutralButton(keyboardLabel) { _, _ -> toggleKeyboard() }
.setNegativeButton(R.string.admin_cancel, null)
- .show()
+ // Exiting is only offered outside kiosk mode; the kiosk barcode must be scanned first.
+ if (kiosk.isEnabled) {
+ builder.setMessage(R.string.admin_message_kiosk)
+ } else {
+ builder.setMessage(R.string.admin_message)
+ .setPositiveButton(R.string.admin_exit) { _, _ -> finish() }
+ }
+ builder.show()
+ }
+
+ private fun toggleKiosk() {
+ val enabled = kiosk.toggle()
+ val message = when {
+ !enabled -> R.string.kiosk_disabled
+ kiosk.isDeviceOwner -> R.string.kiosk_enabled
+ else -> R.string.kiosk_enabled_pinning
+ }
+ Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
private fun toggleKeyboard() {
@@ -268,6 +333,8 @@ class MainActivity : AppCompatActivity() {
super.onResume()
webView.onResume()
scanner.onResume()
+ statusStrip.start()
+ kiosk.apply()
enterImmersiveMode()
ensureWebViewFocus()
CookieManager.getInstance().flush()
@@ -277,6 +344,7 @@ class MainActivity : AppCompatActivity() {
super.onPause()
webView.onPause()
scanner.onPause()
+ statusStrip.stop()
CookieManager.getInstance().flush()
}
@@ -292,6 +360,7 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() {
scanner.onDestroy()
+ handler.removeCallbacksAndMessages(null)
webView.destroy()
super.onDestroy()
}
@@ -325,7 +394,11 @@ class MainActivity : AppCompatActivity() {
// assigning el.value directly is silently discarded by React.
private fun onBarcodeScanned(text: String) {
- if (text == AppConfig.ADMIN_BARCODE) showAdminDialog() else injectScannedText(text)
+ when (text) {
+ AppConfig.ADMIN_BARCODE -> showAdminDialog()
+ AppConfig.KIOSK_BARCODE -> toggleKiosk()
+ else -> injectScannedText(text)
+ }
}
private fun injectScannedText(text: String) {
@@ -427,6 +500,44 @@ class MainActivity : AppCompatActivity() {
""".trimIndent(), null)
}
+ // ── Renderer Crash Recovery ─────────────────────────────────────────
+ // A dead renderer leaves the WebView unusable, so it is replaced with a new
+ // instance and the last page reloaded. Repeated crashes stop the reload loop
+ // and show the error screen instead.
+
+ private fun recoverFromRendererCrash() {
+ val now = SystemClock.elapsedRealtime()
+ rendererCrashTimestamps.add(now)
+ rendererCrashTimestamps.removeAll { now - it > AppConfig.RENDERER_CRASH_WINDOW_MS }
+
+ val url = lastLoadedUrl ?: AppConfig.HOME_URL
+ replaceWebView()
+
+ if (rendererCrashTimestamps.size >= AppConfig.RENDERER_CRASH_LIMIT) {
+ rendererCrashTimestamps.clear()
+ showError(ErrorKind.RENDERER_CRASH, url)
+ } else {
+ webView.loadUrl(url)
+ }
+ }
+
+ private fun replaceWebView() {
+ val old = webView
+ val parent = old.parent as ViewGroup
+ val index = parent.indexOfChild(old)
+ val params = old.layoutParams
+ parent.removeView(old)
+ old.destroy()
+
+ webView = NoSoftKeyboardWebView(this).apply {
+ id = R.id.webview
+ keyboardSuppressed = isKeyboardSuppressed
+ }
+ parent.addView(webView, index, params)
+ configureWebView()
+ ensureWebViewFocus()
+ }
+
// ── WebViewClient ───────────────────────────────────────────────────
private inner class ScannerWebViewClient : WebViewClient() {
@@ -449,6 +560,12 @@ class MainActivity : AppCompatActivity() {
if (isShowingError) hideError()
}
+ // Also fires for client-side (pushState) navigation, unlike onPageFinished.
+ override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) {
+ super.doUpdateVisitedHistory(view, url, isReload)
+ if (url != null && url.startsWith("https://")) lastLoadedUrl = url
+ }
+
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
CookieManager.getInstance().flush()
@@ -464,7 +581,14 @@ class MainActivity : AppCompatActivity() {
) {
if (request.isForMainFrame) {
Log.e(TAG, "WebView error: ${error.description} (${error.errorCode})")
- showError()
+ // Keep the more specific message from onReceivedSslError for this load.
+ if (currentError == ErrorKind.SSL) return
+ val kind = when {
+ error.errorCode == ERROR_FAILED_SSL_HANDSHAKE -> ErrorKind.SSL
+ !isNetworkAvailable() -> ErrorKind.NO_WIFI
+ else -> ErrorKind.UNREACHABLE
+ }
+ showError(kind, request.url.toString())
}
}
@@ -473,9 +597,10 @@ class MainActivity : AppCompatActivity() {
request: WebResourceRequest,
errorResponse: WebResourceResponse
) {
- if (request.isForMainFrame) {
+ // 4xx pages (not found, session expired…) are left to the site to render.
+ if (request.isForMainFrame && errorResponse.statusCode >= 500) {
Log.e(TAG, "HTTP error ${errorResponse.statusCode} for ${request.url}")
- showError()
+ showError(ErrorKind.SERVER, request.url.toString(), errorResponse.statusCode)
}
}
@@ -486,7 +611,15 @@ class MainActivity : AppCompatActivity() {
) {
Log.e(TAG, "SSL error: ${error.primaryError} for ${error.url}")
handler.cancel()
- showError()
+ showError(ErrorKind.SSL, error.url)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean {
+ Log.e(TAG, "WebView renderer gone (crashed=${detail.didCrash()})")
+ // Recover on the next loop iteration; the dead view must not be touched here.
+ this@MainActivity.handler.post { if (!isFinishing) recoverFromRendererCrash() }
+ return true
}
}
diff --git a/app/src/main/java/mx/joykoo/scanner/StatusStrip.kt b/app/src/main/java/mx/joykoo/scanner/StatusStrip.kt
new file mode 100644
index 0000000..f482fa3
--- /dev/null
+++ b/app/src/main/java/mx/joykoo/scanner/StatusStrip.kt
@@ -0,0 +1,134 @@
+package mx.joykoo.scanner
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.graphics.Color
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.net.wifi.WifiManager
+import android.os.BatteryManager
+import android.os.Build
+import android.text.SpannableStringBuilder
+import android.text.style.ForegroundColorSpan
+import android.widget.TextView
+import androidx.core.content.ContextCompat
+
+/**
+ * App-drawn replacement for the system status bar (which stays hidden by immersive
+ * mode): shows only Wi-Fi status and battery percentage.
+ *
+ * [onWifiChanged] is called on the main thread with whether Wi-Fi has working internet.
+ */
+class StatusStrip(
+ private val context: Context,
+ private val wifiText: TextView,
+ private val batteryText: TextView,
+ private val onWifiChanged: (Boolean) -> Unit,
+) {
+ private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ private val wifiManager =
+ context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
+ private var started = false
+
+ private val batteryReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) = showBattery(intent)
+ }
+
+ private val wifiCallback = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) = post(cm.getNetworkCapabilities(network))
+ override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) = post(caps)
+ override fun onLost(network: Network) = post(null)
+ }
+
+ fun start() {
+ if (started) return
+ started = true
+ showWifi(null)
+ ContextCompat.registerReceiver(
+ context, batteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED),
+ ContextCompat.RECEIVER_NOT_EXPORTED
+ )
+ val request = NetworkRequest.Builder()
+ .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
+ .build()
+ cm.registerNetworkCallback(request, wifiCallback)
+ }
+
+ fun stop() {
+ if (!started) return
+ started = false
+ context.unregisterReceiver(batteryReceiver)
+ cm.unregisterNetworkCallback(wifiCallback)
+ }
+
+ // Network callbacks arrive on a background thread.
+ private fun post(caps: NetworkCapabilities?) {
+ wifiText.post { if (started) showWifi(caps) }
+ }
+
+ private fun showWifi(caps: NetworkCapabilities?) {
+ if (caps == null) {
+ wifiText.text = context.getString(R.string.status_no_wifi)
+ wifiText.setTextColor(COLOR_ALERT)
+ onWifiChanged(false)
+ return
+ }
+ val online = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
+ val bars = signalBars(caps)
+ val text = SpannableStringBuilder("Wi-Fi ")
+ BAR_GLYPHS.forEachIndexed { i, glyph ->
+ val start = text.length
+ text.append(glyph)
+ text.setSpan(
+ ForegroundColorSpan(if (i < bars) COLOR_TEXT else COLOR_DIM),
+ start, text.length, 0
+ )
+ }
+ if (!online) text.append(" ").append(context.getString(R.string.status_no_internet))
+ wifiText.text = text
+ wifiText.setTextColor(if (online) COLOR_TEXT else COLOR_WARN)
+ onWifiChanged(online)
+ }
+
+ @Suppress("DEPRECATION")
+ private fun signalBars(caps: NetworkCapabilities): Int {
+ val rssi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) caps.signalStrength
+ else wifiManager.connectionInfo.rssi
+ return when {
+ rssi == Int.MIN_VALUE -> 0
+ rssi >= -55 -> 4
+ rssi >= -66 -> 3
+ rssi >= -77 -> 2
+ rssi >= -88 -> 1
+ else -> 0
+ }
+ }
+
+ private fun showBattery(intent: Intent) {
+ val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
+ val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100)
+ if (level < 0 || scale <= 0) return
+ val percent = level * 100 / scale
+ val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
+ val charging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
+ status == BatteryManager.BATTERY_STATUS_FULL
+ batteryText.text = if (charging)
+ context.getString(R.string.status_battery_charging, percent)
+ else
+ context.getString(R.string.status_battery, percent)
+ batteryText.setTextColor(if (!charging && percent <= LOW_BATTERY) COLOR_ALERT else COLOR_TEXT)
+ }
+
+ companion object {
+ private const val BAR_GLYPHS = "▂▄▆█"
+ private const val LOW_BATTERY = 15
+ private val COLOR_TEXT = Color.WHITE
+ private val COLOR_DIM = Color.parseColor("#6B7280")
+ private val COLOR_WARN = Color.parseColor("#FBBF24")
+ private val COLOR_ALERT = Color.parseColor("#F87171")
+ }
+}
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index c95a38a..2940769 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,49 +1,96 @@
-
+ android:layout_height="match_parent"
+ android:orientation="vertical">
-
-
-
+
+ android:layout_height="24dp"
+ android:background="#1F2937"
+ android:gravity="center_vertical"
+ android:orientation="horizontal"
+ android:paddingHorizontal="12dp">
+ android:layout_weight="1"
+ android:maxLines="1"
+ android:textColor="#FFFFFF"
+ android:textSize="13sp" />
-
-
+ android:maxLines="1"
+ android:textColor="#FFFFFF"
+ android:textSize="13sp" />
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index fbe2580..1d329fb 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,9 +1,18 @@
Joykoo Scanner
- No se puede conectar
- Verifique la conexión Wi-Fi e intente de nuevo.Reintentar
+ Ir al inicio
+ Sin conexión Wi-Fi
+ Verifique la conexión Wi-Fi. La página se recargará automáticamente al reconectarse.
+ No se puede conectar con el servidor
+ Hay conexión a la red, pero el servidor no responde. Intente de nuevo en unos momentos.
+ El servidor tuvo un problema
+ Error %1$d. Intente de nuevo en unos minutos; si continúa, avise a sistemas.
+ Conexión no segura
+ No se pudo verificar el certificado del servidor. Revise que la fecha y hora del dispositivo sean correctas o avise a sistemas.
+ La página dejó de funcionar
+ La página se cerró inesperadamente varias veces. Toque Reintentar para cargarla de nuevo.Administración¿Qué desea hacer?Salir
@@ -12,4 +21,12 @@
Ocultar tecladoTeclado en pantalla activadoTeclado en pantalla desactivado
+ Modo kiosco activo. Para salir, primero desactívelo escaneando el código de kiosco.
+ Modo kiosco activado
+ Modo kiosco activado (fijación de pantalla: el dispositivo no está configurado como propietario)
+ Modo kiosco desactivado
+ Sin Wi-Fi
+ sin internet
+ %1$d%%
+ Cargando %1$d%%
diff --git a/app/src/main/res/xml/device_admin.xml b/app/src/main/res/xml/device_admin.xml
new file mode 100644
index 0000000..8b4cc42
--- /dev/null
+++ b/app/src/main/res/xml/device_admin.xml
@@ -0,0 +1,5 @@
+
+
+
+
+