Add kiosk lockdown, custom status strip, crash recovery and error screens
All checks were successful
APK-BUILD/pipeline/head This commit looks good
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:
parent
84035e7107
commit
455f8498f7
15
CLAUDE.md
15
CLAUDE.md
@ -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
|
||||||
|
|||||||
30
README.md
30
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
|
- **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)
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
11
app/src/main/java/mx/joykoo/scanner/KioskAdminReceiver.kt
Normal file
11
app/src/main/java/mx/joykoo/scanner/KioskAdminReceiver.kt
Normal 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()
|
||||||
99
app/src/main/java/mx/joykoo/scanner/KioskController.kt
Normal file
99
app/src/main/java/mx/joykoo/scanner/KioskController.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
134
app/src/main/java/mx/joykoo/scanner/StatusStrip.kt
Normal file
134
app/src/main/java/mx/joykoo/scanner/StatusStrip.kt
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,37 +1,74 @@
|
|||||||
<?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">
|
||||||
|
|
||||||
|
<!-- App-drawn status strip: Wi-Fi and battery only (system bars stay hidden) -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/status_strip"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:background="#1F2937"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="12dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/status_wifi"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/status_battery"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
<mx.joykoo.scanner.NoSoftKeyboardWebView
|
<mx.joykoo.scanner.NoSoftKeyboardWebView
|
||||||
android:id="@+id/webview"
|
android:id="@+id/webview"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent" />
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
<!-- Error overlay shown when the page cannot load (e.g. no network) -->
|
<!-- Error overlay; title/message are set per error kind in MainActivity -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/error_overlay"
|
android:id="@+id/error_overlay"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="#FFFFFF"
|
android:background="#FFFFFF"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="32dp"
|
android:padding="32dp"
|
||||||
android:visibility="gone">
|
android:visibility="gone">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
android:id="@+id/error_title"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/error_no_connection"
|
|
||||||
android:textAlignment="center"
|
android:textAlignment="center"
|
||||||
android:textColor="#333333"
|
android:textColor="#333333"
|
||||||
android:textSize="18sp" />
|
android:textSize="18sp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
android:id="@+id/error_message"
|
||||||
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:layout_marginTop="8dp"
|
||||||
android:text="@string/error_check_wifi"
|
|
||||||
android:textAlignment="center"
|
android:textAlignment="center"
|
||||||
android:textColor="#666666"
|
android:textColor="#666666"
|
||||||
android:textSize="14sp" />
|
android:textSize="14sp" />
|
||||||
@ -44,6 +81,16 @@
|
|||||||
android:paddingHorizontal="32dp"
|
android:paddingHorizontal="32dp"
|
||||||
android:text="@string/retry" />
|
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>
|
</LinearLayout>
|
||||||
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
5
app/src/main/res/xml/device_admin.xml
Normal file
5
app/src/main/res/xml/device_admin.xml
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user