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" /> - -