Every field keeps inputmode="none" and the forced hide. A finger tap (pointerdown, also through its label) gives that field the site's own inputmode back before Chromium focuses it, so numeric fields get a numeric keypad; it is suppressed again on blur or a cancelled tap, so scans and programmatic focus never open the keyboard. Native code checks the focused field before hiding the keyboard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
113 lines
7.1 KiB
Markdown
113 lines
7.1 KiB
Markdown
# JoykooScanner — Android Barcode Scanner Kiosk App
|
||
|
||
## What This Is
|
||
|
||
Native Android app (Kotlin) that wraps `https://smart.joykoo.mx/` in a fullscreen WebView for dedicated Chinese industrial handheld barcode scanners. The scanner operates in HID/keyboard-wedge mode — it sends Android key events like a physical keyboard.
|
||
|
||
## Critical Design Decision
|
||
|
||
The Android soft keyboard must NEVER appear for scanner fields. The only exception is a field a person taps with a finger (see Keyboard Policy below). Keyboard suppression uses multiple layers — do not remove any of them:
|
||
|
||
1. `NoSoftKeyboardWebView.onCheckIsTextEditor()` → `false`
|
||
2. `EditorInfo` flags `IME_FLAG_NO_EXTRACT_UI | IME_FLAG_NO_FULLSCREEN` in `onCreateInputConnection()`
|
||
3. `InputMethodManager.hideSoftInputFromWindow()` posted after each InputConnection request and on page load / window focus — skipped only when the focused field carries `data-jk-kb="1"`
|
||
4. JavaScript injection (`KeyboardPolicy`) setting `inputmode="none"` on every `<input>`, `<textarea>`, and `contenteditable` via `MutationObserver`, except the field currently tapped
|
||
5. Manifest `windowSoftInputMode="stateAlwaysHidden|adjustNothing"`
|
||
|
||
The real `InputConnection` from `super.onCreateInputConnection()` **must be returned** — returning `null` breaks hardware key input from the barcode scanner on devices like the Urovo CT58C.
|
||
|
||
## Keyboard Policy
|
||
|
||
`KeyboardPolicy.kt`: every field is suppressed (`inputmode="none"`) until a finger taps it. On `pointerdown` (also via its `<label>`) the field gets the site's own `inputmode` back — numeric fields get a numeric keypad — and `data-jk-kb="1"`, before Chromium focuses it. On focus loss, or a tap that never focuses it (scroll / `pointercancel`), it is suppressed again, so programmatic focus (scans, the site's own `.focus()`) never shows the keyboard. A field whose own `inputmode` is `none` stays suppressed even when tapped.
|
||
|
||
The site reads scans with a page-wide `keydown` listener (bursts ≤ 60 ms apart), so scanning does not need a focused field. The admin "Mostrar teclado" toggle still turns all suppression off.
|
||
|
||
## Project Structure
|
||
|
||
```
|
||
app/src/main/java/mx/joykoo/scanner/
|
||
AppConfig.kt — URL, allowed hosts, admin triggers, scanner broadcasts (single config point)
|
||
MainActivity.kt — Fullscreen kiosk activity, WebView setup, navigation, lifecycle
|
||
NoSoftKeyboardWebView.kt — IME suppression layers 1–3 (real InputConnection is returned)
|
||
KeyboardPolicy.kt — Injected rule: all fields inputmode="none" except the one a finger tapped
|
||
ScannerIntegration.kt — Scanner lifecycle interface
|
||
BroadcastScannerIntegration.kt — Intent-output scans → JS injection (React-safe value setter)
|
||
KioskController.kt — Lock Task kiosk toggle (session-only until KIOSK_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
|
||
Permissions.kt — Runtime permission prompts (camera, storage ≤ Android 9); asked at launch and on demand
|
||
FileTransfers.kt — Downloads → Downloads folder; file uploads with camera option; photos kept in Pictures/Joykoo
|
||
```
|
||
|
||
## Build
|
||
|
||
- **Language**: Kotlin, targeting JVM 1.8 bytecode
|
||
- **SDK**: compileSdk 34, minSdk 24, targetSdk 34
|
||
- **Dependencies**: androidx core-ktx, appcompat, webkit, swiperefreshlayout
|
||
- **Gradle**: 8.7 with Kotlin DSL, AGP 8.5.2, Kotlin 1.9.24
|
||
- **Package**: `mx.joykoo.scanner`
|
||
|
||
### Build prerequisites (not yet installed on this server)
|
||
|
||
1. `sudo dnf install java-17-openjdk-devel` — only the JRE is installed, need javac
|
||
2. Android SDK cmdline-tools — download to `~/Android/Sdk/`, then install:
|
||
- `platform-tools`
|
||
- `platforms;android-34`
|
||
- `build-tools;34.0.0`
|
||
3. Set env vars:
|
||
```
|
||
JAVA_HOME=/usr/lib/jvm/java-17-openjdk-17.0.20.1.1-1.2.el9.x86_64
|
||
ANDROID_HOME=~/Android/Sdk
|
||
```
|
||
4. `./gradlew assembleDebug`
|
||
|
||
### Server environment (RHEL 9.8)
|
||
|
||
- JRE 17 installed at `/usr/lib/jvm/java-17-openjdk-17.0.20.1.1-1.2.el9.x86_64`
|
||
- 323 GB free on /home, 5.5 GB free on /
|
||
- curl, wget, unzip, zip available
|
||
- No Android SDK present anywhere
|
||
|
||
## Configuration
|
||
|
||
All configurable values are in `AppConfig.kt`:
|
||
- `HOME_URL` — the webpage to load
|
||
- `ALLOWED_HOSTS` — domains that stay inside the WebView
|
||
- `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 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`): **off by default and not persisted** while it is being tested (`AppConfig.KIOSK_PERSISTED = false`) — each launch starts unlocked and clears any leftover lockdown; scanning `KIOSK_BARCODE` toggles it for the session. 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.
|
||
|
||
## Files & Permissions
|
||
|
||
- Permissions are requested at first launch (camera; storage on Android ≤ 9) and again when a feature needs one.
|
||
- Downloads: https on allowed hosts via `DownloadManager` (session cookie forwarded); `data:` decoded natively; `blob:` read in-page and returned via `evaluateJavascript` polling (no JS interface). A document-start hook keeps blobs readable if the site revokes the URL immediately. Saved to Downloads, with a toast on completion.
|
||
- Uploads: `<input type=file>` image fields show an in-app "Tomar foto / Elegir de la galería" dialog (the system chooser hid the camera behind the gallery picker); `capture` opens the camera directly. If camera permission is denied, a dialog offers app Settings or the file picker. Photos are saved to Pictures/Joykoo.
|
||
- In-page camera (`getUserMedia`) is granted to allowed hosts only, video only.
|
||
|
||
## Security
|
||
|
||
- 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
|
||
- Domain allowlist in WebViewClient
|
||
|
||
## UI Strings
|
||
|
||
Spanish (`es`) — this is for a Mexico-based company.
|