Add kiosk lockdown, custom status strip, crash recovery and error screens
All checks were successful
APK-BUILD/pipeline/head This commit looks good

- Kiosk mode: scanning AppConfig.KIOSK_BARCODE toggles Android Lock Task
  mode (persisted). As device owner (KioskAdminReceiver) it is a full
  lockdown and the app becomes the persistent home screen; otherwise it
  falls back to screen pinning. "Salir" and Back-to-exit are disabled
  while kiosk mode is on.
- Status strip: app-drawn bar showing only Wi-Fi status and battery
  percentage; system bars stay hidden.
- Renderer crash: onRenderProcessGone replaces the WebView and reloads
  the last URL; repeated crashes show an error screen instead.
- Error screen: per-cause messages (no Wi-Fi with auto-retry on
  reconnect, server unreachable, 5xx, SSL) and an "Ir al inicio"
  button; 4xx pages are left to the site.
- Mixed content is never allowed.
- Admin volume-key hold reduced to 3 seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
caspar 2026-10-08 14:37:25 -04:00
parent 84035e7107
commit 455f8498f7
11 changed files with 584 additions and 67 deletions

View File

@ -25,6 +25,9 @@ app/src/main/java/mx/joykoo/scanner/
NoSoftKeyboardWebView.kt — IME suppression via null InputConnection NoSoftKeyboardWebView.kt — IME suppression via null InputConnection
ScannerIntegration.kt — Scanner lifecycle interface ScannerIntegration.kt — Scanner lifecycle interface
BroadcastScannerIntegration.kt — Intent-output scans → JS injection (React-safe value setter) 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 ## Build
@ -64,17 +67,25 @@ All configurable values are in `AppConfig.kt`:
- `BACK_PRESS_EXIT_COUNT` / `BACK_PRESS_WINDOW_MS` — back-button exit guard - `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_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 - `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 ## Navigation Rules
- URLs on `joykoo.mx` or `smart.joykoo.mx` (and subdomains) load inside the WebView - URLs on `joykoo.mx` or `smart.joykoo.mx` (and subdomains) load inside the WebView
- All other URLs are silently blocked - All other URLs are silently blocked
- Back button goes back in WebView history; 3 rapid presses exits when no history - 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 ## 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 JavaScript interfaces exposed
- No file:// or content:// access - No file:// or content:// access
- Network security config enforces system trust anchors only - Network security config enforces system trust anchors only

View File

@ -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 - **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 - **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 - **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 - **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
- **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 - **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 ## 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/` | | `HOME_URL` | URL que se carga al iniciar | `https://smart.joykoo.mx/` |
| `ALLOWED_HOSTS` | Dominios permitidos dentro del WebView | `smart.joykoo.mx`, `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 | | `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-…` | | `ADMIN_BARCODE` | Código que, al escanearse (modo Intent), abre el menú de administrador | `JOYKOO-ADMIN-…` |
## Uso del escáner ## 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 4. El texto escaneado aparece en el campo
5. Si el escáner envía Enter al final, la página lo procesa normalmente 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 ## Estructura del proyecto
``` ```
@ -73,7 +92,10 @@ app/src/main/java/mx/joykoo/scanner/
├── MainActivity.kt — Actividad principal (kiosco, WebView, navegación) ├── MainActivity.kt — Actividad principal (kiosco, WebView, navegación)
├── NoSoftKeyboardWebView.kt — WebView que suprime el teclado virtual ├── NoSoftKeyboardWebView.kt — WebView que suprime el teclado virtual
├── ScannerIntegration.kt — Interfaz para integración con el escáner ├── 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) ## Modo de salida por Intent (broadcast)

View File

@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@ -37,6 +38,34 @@
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Home-screen entry, enabled only while kiosk mode is on (KioskController)
so the app returns after a reboot instead of the normal launcher. -->
<activity-alias
android:name=".KioskHome"
android:enabled="false"
android:exported="true"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity-alias>
<!-- Device owner component for full Lock Task kiosk mode:
adb shell dpm set-device-owner mx.joykoo.scanner/.KioskAdminReceiver -->
<receiver
android:name=".KioskAdminReceiver"
android:exported="true"
android:permission="android.permission.BIND_DEVICE_ADMIN">
<meta-data
android:name="android.app.device_admin"
android:resource="@xml/device_admin" />
<intent-filter>
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
</intent-filter>
</receiver>
</application> </application>
</manifest> </manifest>

View File

@ -19,12 +19,21 @@ object AppConfig {
const val BACK_PRESS_WINDOW_MS = 2000L const val BACK_PRESS_WINDOW_MS = 2000L
/** How long (ms) Volume Up + Volume Down must be held together to open the admin dialog. */ /** 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 /** 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. */ * being typed into the page (Intent output mode only). Print it as a QR/Code128. */
const val ADMIN_BARCODE = "JOYKOO-ADMIN-O4306IFH9AUJ" 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 /** 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. */ * other common Chinese vendors). Add the device's action here if it differs. */
val SCANNER_BROADCAST_ACTIONS = listOf( val SCANNER_BROADCAST_ACTIONS = listOf(

View File

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

View File

@ -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"
}
}

View File

@ -9,15 +9,20 @@ import android.net.NetworkCapabilities
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import android.view.View import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets import android.view.WindowInsets
import android.view.WindowInsetsController import android.view.WindowInsetsController
import android.view.WindowManager import android.view.WindowManager
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.webkit.ConsoleMessage import android.webkit.ConsoleMessage
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.RenderProcessGoneDetail
import android.webkit.SslErrorHandler import android.webkit.SslErrorHandler
import android.webkit.ValueCallback import android.webkit.ValueCallback
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
@ -27,7 +32,9 @@ import android.webkit.WebResourceResponse
import android.webkit.WebSettings import android.webkit.WebSettings
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import org.json.JSONObject import org.json.JSONObject
@ -37,8 +44,17 @@ class MainActivity : AppCompatActivity() {
private lateinit var webView: NoSoftKeyboardWebView private lateinit var webView: NoSoftKeyboardWebView
private lateinit var errorOverlay: View private lateinit var errorOverlay: View
private lateinit var scanner: ScannerIntegration 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<Array<Uri>>? = null private var fileUploadCallback: ValueCallback<Array<Uri>>? = 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<Long>()
// Admin dialog: hold Volume Up + Volume Down, or scan AppConfig.ADMIN_BARCODE // Admin dialog: hold Volume Up + Volume Down, or scan AppConfig.ADMIN_BARCODE
private val volumeKeysDown = mutableSetOf<Int>() private val volumeKeysDown = mutableSetOf<Int>()
@ -58,6 +74,13 @@ class MainActivity : AppCompatActivity() {
setupErrorOverlay() setupErrorOverlay()
setupBackNavigation() setupBackNavigation()
scanner = BroadcastScannerIntegration(this, ::onBarcodeScanned) scanner = BroadcastScannerIntegration(this, ::onBarcodeScanned)
kiosk = KioskController(this)
statusStrip = StatusStrip(
this,
findViewById(R.id.status_wifi),
findViewById(R.id.status_battery),
::onWifiChanged
)
if (savedInstanceState == null) { if (savedInstanceState == null) {
webView.loadUrl(AppConfig.HOME_URL) webView.loadUrl(AppConfig.HOME_URL)
@ -105,13 +128,17 @@ class MainActivity : AppCompatActivity() {
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
private fun setupWebView() { private fun setupWebView() {
webView = findViewById(R.id.webview) webView = findViewById(R.id.webview)
configureWebView()
}
@SuppressLint("SetJavaScriptEnabled")
private fun configureWebView() {
webView.settings.apply { webView.settings.apply {
javaScriptEnabled = true javaScriptEnabled = true
domStorageEnabled = true domStorageEnabled = true
databaseEnabled = true databaseEnabled = true
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
// Cache and storage for session persistence // Cache and storage for session persistence
cacheMode = WebSettings.LOAD_DEFAULT cacheMode = WebSettings.LOAD_DEFAULT
@ -148,28 +175,50 @@ class MainActivity : AppCompatActivity() {
// ── Error Overlay ─────────────────────────────────────────────────── // ── 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() { private fun setupErrorOverlay() {
errorOverlay = findViewById(R.id.error_overlay) errorOverlay = findViewById(R.id.error_overlay)
findViewById<View>(R.id.btn_retry).setOnClickListener { findViewById<View>(R.id.btn_retry).setOnClickListener { retryFailedLoad() }
hideError() findViewById<View>(R.id.btn_home).setOnClickListener {
if (isNetworkAvailable()) { failedUrl = AppConfig.HOME_URL
webView.reload() retryFailedLoad()
} else {
showError()
}
} }
} }
private fun showError() { private fun showError(kind: ErrorKind, url: String?, httpStatus: Int = 0) {
isShowingError = true currentError = kind
if (url != null) failedUrl = url
findViewById<TextView>(R.id.error_title).setText(kind.title)
findViewById<TextView>(R.id.error_message).text = getString(kind.message, httpStatus)
errorOverlay.visibility = View.VISIBLE errorOverlay.visibility = View.VISIBLE
} }
private fun hideError() { private fun hideError() {
isShowingError = false currentError = null
errorOverlay.visibility = View.GONE 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 { private fun isNetworkAvailable(): Boolean {
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork ?: return false val network = cm.activeNetwork ?: return false
@ -198,6 +247,7 @@ class MainActivity : AppCompatActivity() {
} }
private fun handleExitBackPress() { private fun handleExitBackPress() {
if (kiosk.isEnabled) return
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
backPressTimestamps.add(now) backPressTimestamps.add(now)
backPressTimestamps.removeAll { now - it > AppConfig.BACK_PRESS_WINDOW_MS } backPressTimestamps.removeAll { now - it > AppConfig.BACK_PRESS_WINDOW_MS }
@ -223,12 +273,12 @@ class MainActivity : AppCompatActivity() {
KeyEvent.ACTION_DOWN -> if (event.repeatCount == 0) { KeyEvent.ACTION_DOWN -> if (event.repeatCount == 0) {
volumeKeysDown.add(event.keyCode) volumeKeysDown.add(event.keyCode)
if (volumeKeysDown.size == 2) { if (volumeKeysDown.size == 2) {
webView.postDelayed(adminHoldRunnable, AppConfig.ADMIN_VOLUME_HOLD_MS) handler.postDelayed(adminHoldRunnable, AppConfig.ADMIN_VOLUME_HOLD_MS)
} }
} }
KeyEvent.ACTION_UP -> { KeyEvent.ACTION_UP -> {
volumeKeysDown.remove(event.keyCode) volumeKeysDown.remove(event.keyCode)
webView.removeCallbacks(adminHoldRunnable) handler.removeCallbacks(adminHoldRunnable)
} }
} }
} }
@ -236,16 +286,31 @@ class MainActivity : AppCompatActivity() {
private fun showAdminDialog() { private fun showAdminDialog() {
// The dialog takes focus, so the volume key-ups never reach us. // The dialog takes focus, so the volume key-ups never reach us.
volumeKeysDown.clear() volumeKeysDown.clear()
webView.removeCallbacks(adminHoldRunnable) handler.removeCallbacks(adminHoldRunnable)
val keyboardLabel = if (isKeyboardSuppressed) val keyboardLabel = if (isKeyboardSuppressed)
getString(R.string.keyboard_show) else getString(R.string.keyboard_hide) getString(R.string.keyboard_show) else getString(R.string.keyboard_hide)
AlertDialog.Builder(this) val builder = AlertDialog.Builder(this)
.setTitle(R.string.admin_title) .setTitle(R.string.admin_title)
.setMessage(R.string.admin_message)
.setPositiveButton(R.string.admin_exit) { _, _ -> finish() }
.setNeutralButton(keyboardLabel) { _, _ -> toggleKeyboard() } .setNeutralButton(keyboardLabel) { _, _ -> toggleKeyboard() }
.setNegativeButton(R.string.admin_cancel, null) .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() { private fun toggleKeyboard() {
@ -268,6 +333,8 @@ class MainActivity : AppCompatActivity() {
super.onResume() super.onResume()
webView.onResume() webView.onResume()
scanner.onResume() scanner.onResume()
statusStrip.start()
kiosk.apply()
enterImmersiveMode() enterImmersiveMode()
ensureWebViewFocus() ensureWebViewFocus()
CookieManager.getInstance().flush() CookieManager.getInstance().flush()
@ -277,6 +344,7 @@ class MainActivity : AppCompatActivity() {
super.onPause() super.onPause()
webView.onPause() webView.onPause()
scanner.onPause() scanner.onPause()
statusStrip.stop()
CookieManager.getInstance().flush() CookieManager.getInstance().flush()
} }
@ -292,6 +360,7 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() { override fun onDestroy() {
scanner.onDestroy() scanner.onDestroy()
handler.removeCallbacksAndMessages(null)
webView.destroy() webView.destroy()
super.onDestroy() super.onDestroy()
} }
@ -325,7 +394,11 @@ class MainActivity : AppCompatActivity() {
// assigning el.value directly is silently discarded by React. // assigning el.value directly is silently discarded by React.
private fun onBarcodeScanned(text: String) { 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) { private fun injectScannedText(text: String) {
@ -427,6 +500,44 @@ class MainActivity : AppCompatActivity() {
""".trimIndent(), null) """.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 ─────────────────────────────────────────────────── // ── WebViewClient ───────────────────────────────────────────────────
private inner class ScannerWebViewClient : WebViewClient() { private inner class ScannerWebViewClient : WebViewClient() {
@ -449,6 +560,12 @@ class MainActivity : AppCompatActivity() {
if (isShowingError) hideError() 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?) { override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url) super.onPageFinished(view, url)
CookieManager.getInstance().flush() CookieManager.getInstance().flush()
@ -464,7 +581,14 @@ class MainActivity : AppCompatActivity() {
) { ) {
if (request.isForMainFrame) { if (request.isForMainFrame) {
Log.e(TAG, "WebView error: ${error.description} (${error.errorCode})") 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, request: WebResourceRequest,
errorResponse: WebResourceResponse 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}") 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}") Log.e(TAG, "SSL error: ${error.primaryError} for ${error.url}")
handler.cancel() 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
} }
} }

View File

@ -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")
}
}

View File

@ -1,49 +1,96 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent"
android:orientation="vertical">
<mx.joykoo.scanner.NoSoftKeyboardWebView <!-- App-drawn status strip: Wi-Fi and battery only (system bars stay hidden) -->
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<!-- Error overlay shown when the page cannot load (e.g. no network) -->
<LinearLayout <LinearLayout
android:id="@+id/error_overlay" android:id="@+id/status_strip"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="24dp"
android:background="#FFFFFF" android:background="#1F2937"
android:gravity="center" android:gravity="center_vertical"
android:orientation="vertical" android:orientation="horizontal"
android:padding="32dp" android:paddingHorizontal="12dp">
android:visibility="gone">
<TextView <TextView
android:layout_width="wrap_content" android:id="@+id/status_wifi"
android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/error_no_connection" android:layout_weight="1"
android:textAlignment="center" android:maxLines="1"
android:textColor="#333333" android:textColor="#FFFFFF"
android:textSize="18sp" /> android:textSize="13sp" />
<TextView <TextView
android:id="@+id/status_battery"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:maxLines="1"
android:text="@string/error_check_wifi" android:textColor="#FFFFFF"
android:textAlignment="center" android:textSize="13sp" />
android:textColor="#666666"
android:textSize="14sp" />
<Button
android:id="@+id/btn_retry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:paddingHorizontal="32dp"
android:text="@string/retry" />
</LinearLayout> </LinearLayout>
</FrameLayout> <FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<mx.joykoo.scanner.NoSoftKeyboardWebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<!-- Error overlay; title/message are set per error kind in MainActivity -->
<LinearLayout
android:id="@+id/error_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FFFFFF"
android:clickable="true"
android:focusable="true"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone">
<TextView
android:id="@+id/error_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAlignment="center"
android:textColor="#333333"
android:textSize="18sp" />
<TextView
android:id="@+id/error_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAlignment="center"
android:textColor="#666666"
android:textSize="14sp" />
<Button
android:id="@+id/btn_retry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:paddingHorizontal="32dp"
android:text="@string/retry" />
<Button
android:id="@+id/btn_home"
style="?android:attr/borderlessButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/go_home" />
</LinearLayout>
</FrameLayout>
</LinearLayout>

View File

@ -1,9 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Joykoo Scanner</string> <string name="app_name">Joykoo Scanner</string>
<string name="error_no_connection">No se puede conectar</string>
<string name="error_check_wifi">Verifique la conexión Wi-Fi e intente de nuevo.</string>
<string name="retry">Reintentar</string> <string name="retry">Reintentar</string>
<string name="go_home">Ir al inicio</string>
<string name="error_no_wifi_title">Sin conexión Wi-Fi</string>
<string name="error_no_wifi_message">Verifique la conexión Wi-Fi. La página se recargará automáticamente al reconectarse.</string>
<string name="error_unreachable_title">No se puede conectar con el servidor</string>
<string name="error_unreachable_message">Hay conexión a la red, pero el servidor no responde. Intente de nuevo en unos momentos.</string>
<string name="error_server_title">El servidor tuvo un problema</string>
<string name="error_server_message">Error %1$d. Intente de nuevo en unos minutos; si continúa, avise a sistemas.</string>
<string name="error_ssl_title">Conexión no segura</string>
<string name="error_ssl_message">No se pudo verificar el certificado del servidor. Revise que la fecha y hora del dispositivo sean correctas o avise a sistemas.</string>
<string name="error_crash_title">La página dejó de funcionar</string>
<string name="error_crash_message">La página se cerró inesperadamente varias veces. Toque Reintentar para cargarla de nuevo.</string>
<string name="admin_title">Administración</string> <string name="admin_title">Administración</string>
<string name="admin_message">¿Qué desea hacer?</string> <string name="admin_message">¿Qué desea hacer?</string>
<string name="admin_exit">Salir</string> <string name="admin_exit">Salir</string>
@ -12,4 +21,12 @@
<string name="keyboard_hide">Ocultar teclado</string> <string name="keyboard_hide">Ocultar teclado</string>
<string name="keyboard_enabled">Teclado en pantalla activado</string> <string name="keyboard_enabled">Teclado en pantalla activado</string>
<string name="keyboard_disabled">Teclado en pantalla desactivado</string> <string name="keyboard_disabled">Teclado en pantalla desactivado</string>
<string name="admin_message_kiosk">Modo kiosco activo. Para salir, primero desactívelo escaneando el código de kiosco.</string>
<string name="kiosk_enabled">Modo kiosco activado</string>
<string name="kiosk_enabled_pinning">Modo kiosco activado (fijación de pantalla: el dispositivo no está configurado como propietario)</string>
<string name="kiosk_disabled">Modo kiosco desactivado</string>
<string name="status_no_wifi">Sin Wi-Fi</string>
<string name="status_no_internet">sin internet</string>
<string name="status_battery">%1$d%%</string>
<string name="status_battery_charging">Cargando %1$d%%</string>
</resources> </resources>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- No admin policies needed; device owner status alone enables Lock Task mode. -->
<device-admin>
<uses-policies />
</device-admin>