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
|
||||
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
|
||||
|
||||
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
|
||||
- **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)
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@ -37,6 +38,34 @@
|
||||
</intent-filter>
|
||||
</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>
|
||||
|
||||
</manifest>
|
||||
|
||||
@ -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(
|
||||
|
||||
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.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<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
|
||||
private val volumeKeysDown = mutableSetOf<Int>()
|
||||
@ -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<View>(R.id.btn_retry).setOnClickListener {
|
||||
hideError()
|
||||
if (isNetworkAvailable()) {
|
||||
webView.reload()
|
||||
} else {
|
||||
showError()
|
||||
}
|
||||
findViewById<View>(R.id.btn_retry).setOnClickListener { retryFailedLoad() }
|
||||
findViewById<View>(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<TextView>(R.id.error_title).setText(kind.title)
|
||||
findViewById<TextView>(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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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,49 +1,96 @@
|
||||
<?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_height="match_parent">
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<mx.joykoo.scanner.NoSoftKeyboardWebView
|
||||
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) -->
|
||||
<!-- App-drawn status strip: Wi-Fi and battery only (system bars stay hidden) -->
|
||||
<LinearLayout
|
||||
android:id="@+id/error_overlay"
|
||||
android:id="@+id/status_strip"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#FFFFFF"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="32dp"
|
||||
android:visibility="gone">
|
||||
android:layout_height="24dp"
|
||||
android:background="#1F2937"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="12dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:id="@+id/status_wifi"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/error_no_connection"
|
||||
android:textAlignment="center"
|
||||
android:textColor="#333333"
|
||||
android:textSize="18sp" />
|
||||
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:layout_marginTop="8dp"
|
||||
android:text="@string/error_check_wifi"
|
||||
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" />
|
||||
android:maxLines="1"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="13sp" />
|
||||
|
||||
</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>
|
||||
|
||||
@ -1,9 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<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="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_message">¿Qué desea hacer?</string>
|
||||
<string name="admin_exit">Salir</string>
|
||||
@ -12,4 +21,12 @@
|
||||
<string name="keyboard_hide">Ocultar teclado</string>
|
||||
<string name="keyboard_enabled">Teclado en pantalla activado</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>
|
||||
|
||||
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