From 2f88432776eec6b7ea8c45c48d5c350561ce571e Mon Sep 17 00:00:00 2001 From: caspar Date: Thu, 8 Oct 2026 12:39:16 -0400 Subject: [PATCH] Add intent-output scanner support and key event logging Scanners in "Intent output" mode deliver barcodes via broadcast, which nothing in the app received, so scans never reached any field. Add BroadcastScannerIntegration (Urovo, Newland and other common actions, configurable in AppConfig) and inject the text into the focused field, or the first visible search/text field, using the native value setter plus an input event so React-controlled inputs accept it. Optionally sends Enter afterwards (SCANNER_SEND_ENTER). Add a logging-only dispatchKeyEvent pass-through to diagnose why keystroke-mode scans don't reach the orders search field. Ignore SamplePage/ and update README and CLAUDE.md. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + CLAUDE.md | 3 +- README.md | 19 ++---- .../main/java/mx/joykoo/scanner/AppConfig.kt | 21 ++++++ .../scanner/BroadcastScannerIntegration.kt | 68 +++++++++++++++++++ .../java/mx/joykoo/scanner/MainActivity.kt | 63 ++++++++++++++++- 6 files changed, 160 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/mx/joykoo/scanner/BroadcastScannerIntegration.kt diff --git a/.gitignore b/.gitignore index d023210..c3584b0 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ Thumbs.db # Logs *.log + +# Other +SamplePage/ diff --git a/CLAUDE.md b/CLAUDE.md index 58c5512..dc153fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,8 @@ app/src/main/java/mx/joykoo/scanner/ AppConfig.kt — URL, allowed hosts, gesture thresholds (single config point) MainActivity.kt — Fullscreen kiosk activity, WebView setup, navigation, lifecycle NoSoftKeyboardWebView.kt — IME suppression via null InputConnection - ScannerIntegration.kt — Interface stub for future vendor scanner SDK + ScannerIntegration.kt — Scanner lifecycle interface + BroadcastScannerIntegration.kt — Intent-output scans → JS injection (React-safe value setter) ``` ## Build diff --git a/README.md b/README.md index 208ced8..4e9bf93 100644 --- a/README.md +++ b/README.md @@ -71,21 +71,10 @@ app/src/main/java/mx/joykoo/scanner/ ├── AppConfig.kt — Configuración centralizada ├── MainActivity.kt — Actividad principal (kiosco, WebView, navegación) ├── NoSoftKeyboardWebView.kt — WebView que suprime el teclado virtual -└── ScannerIntegration.kt — Interfaz para futura integración con SDK de escáner +├── ScannerIntegration.kt — Interfaz para integración con el escáner +└── BroadcastScannerIntegration.kt — Recepción de códigos en modo Intent ``` -## Integración futura con SDK de escáner +## Modo de salida por Intent (broadcast) -La aplicación funciona inmediatamente con escáneres en modo HID/teclado. Para escáneres que requieren integración con SDK propietario (broadcast intents), implementar la interfaz `ScannerIntegration` y registrar el BroadcastReceiver correspondiente. - -## Seguridad - -- Solo conexiones HTTPS -- No se omiten errores de certificado TLS -- No se exponen interfaces JavaScript -- Acceso a archivos y contenido deshabilitado -- Navegación restringida a dominios autorizados - -## Licencia - -Uso interno — Joykoo. +Además del modo teclado (keystroke), la aplicación acepta escáneres configurados en **modo Intent**. El código recibido se escribe en el campo con foco (o, si no hay ninguno, en el primer campo de búsqueda/texto visible) y se envía Enter al final. Las acciones y claves extra soportadas (Urovo, Newland y otros) están en `SCANNER_BROADCAST_ACTIONS` / `SCANNER_BROADCAST_EXTRAS` en `AppConfig.kt`; si el dispositivo usa otra acción, agréguela ahí. diff --git a/app/src/main/java/mx/joykoo/scanner/AppConfig.kt b/app/src/main/java/mx/joykoo/scanner/AppConfig.kt index 43a9487..00e10b6 100644 --- a/app/src/main/java/mx/joykoo/scanner/AppConfig.kt +++ b/app/src/main/java/mx/joykoo/scanner/AppConfig.kt @@ -27,4 +27,25 @@ object AppConfig { /** Time window (ms) for admin tap sequence. */ const val ADMIN_TAP_WINDOW_MS = 3000L + /** 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( + "android.intent.ACTION_DECODE_DATA", // Urovo (default) + "urovo.rcv.message", // Urovo (legacy) + "scan.rcv.message", + "android.intent.action.SCANRESULT", + "nlscan.action.SCANNER_RESULT", // Newland + "com.android.server.scannerservice.broadcast", + "com.scanner.broadcast", + ) + + /** Extra keys checked, in order, for the barcode (String or byte[] + "length"). */ + val SCANNER_BROADCAST_EXTRAS = listOf( + "barcode_string", "barocode", "barcode", "value", + "SCAN_BARCODE1", "scannerdata", "data", + ) + + /** Press Enter after a broadcast scan is inserted, like the keystroke-mode suffix. */ + const val SCANNER_SEND_ENTER = true + } diff --git a/app/src/main/java/mx/joykoo/scanner/BroadcastScannerIntegration.kt b/app/src/main/java/mx/joykoo/scanner/BroadcastScannerIntegration.kt new file mode 100644 index 0000000..4b756de --- /dev/null +++ b/app/src/main/java/mx/joykoo/scanner/BroadcastScannerIntegration.kt @@ -0,0 +1,68 @@ +package mx.joykoo.scanner + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.util.Log +import androidx.core.content.ContextCompat + +/** + * Receives barcodes from scanners configured in "Intent output" (broadcast) mode + * and hands the decoded text to [onBarcode] on the main thread. + * + * Actions and extra keys come from AppConfig so other vendors can be added + * without touching this class. + */ +class BroadcastScannerIntegration( + private val context: Context, + private val onBarcode: (String) -> Unit, +) : ScannerIntegration { + + private var registered = false + + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val barcode = extractBarcode(intent) + Log.d(TAG, "Scan broadcast ${intent.action}: ${barcode?.length ?: "no data"}") + if (!barcode.isNullOrEmpty()) onBarcode(barcode) + } + } + + override fun onResume() { + if (registered) return + val filter = IntentFilter().apply { + AppConfig.SCANNER_BROADCAST_ACTIONS.forEach { addAction(it) } + } + // The scanner service is another app, so the receiver must be exported. + ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_EXPORTED) + registered = true + } + + override fun onPause() { + if (!registered) return + context.unregisterReceiver(receiver) + registered = false + } + + override fun onDestroy() = onPause() + + @Suppress("DEPRECATION") + private fun extractBarcode(intent: Intent): String? { + val extras = intent.extras ?: return null + for (key in AppConfig.SCANNER_BROADCAST_EXTRAS) { + when (val value = extras.get(key)) { + is String -> return value.trimEnd('\r', '\n') + is ByteArray -> { + val length = extras.getInt("length", value.size).coerceIn(0, value.size) + return String(value, 0, length, Charsets.UTF_8).trimEnd('\r', '\n') + } + } + } + return null + } + + companion object { + private const val TAG = "JoykooScanner" + } +} diff --git a/app/src/main/java/mx/joykoo/scanner/MainActivity.kt b/app/src/main/java/mx/joykoo/scanner/MainActivity.kt index 98f45e3..efadf0f 100644 --- a/app/src/main/java/mx/joykoo/scanner/MainActivity.kt +++ b/app/src/main/java/mx/joykoo/scanner/MainActivity.kt @@ -10,6 +10,7 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.util.Log +import android.view.KeyEvent import android.view.MotionEvent import android.view.View import android.view.WindowInsets @@ -30,11 +31,13 @@ import android.webkit.WebViewClient import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AppCompatActivity +import org.json.JSONObject class MainActivity : AppCompatActivity() { private lateinit var webView: NoSoftKeyboardWebView private lateinit var errorOverlay: View + private lateinit var scanner: ScannerIntegration private var fileUploadCallback: ValueCallback>? = null private var isShowingError = false @@ -54,6 +57,7 @@ class MainActivity : AppCompatActivity() { setupWebView() setupErrorOverlay() setupBackNavigation() + scanner = BroadcastScannerIntegration(this, ::injectScannedText) if (savedInstanceState == null) { webView.loadUrl(AppConfig.HOME_URL) @@ -264,6 +268,7 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() webView.onResume() + scanner.onResume() enterImmersiveMode() ensureWebViewFocus() CookieManager.getInstance().flush() @@ -272,6 +277,7 @@ class MainActivity : AppCompatActivity() { override fun onPause() { super.onPause() webView.onPause() + scanner.onPause() CookieManager.getInstance().flush() } @@ -286,6 +292,7 @@ class MainActivity : AppCompatActivity() { } override fun onDestroy() { + scanner.onDestroy() webView.destroy() super.onDestroy() } @@ -298,7 +305,61 @@ class MainActivity : AppCompatActivity() { // We ensure the WebView has focus so it receives key events directly. // No override of dispatchKeyEvent is needed — the default chain works // correctly when the WebView is focused. Overriding it with a manual - // forward would cause double dispatch. + // forward would cause double dispatch. The override below only logs + // (adb logcat -s JoykooScanner) to diagnose keystroke-mode scanners. + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + Log.d(TAG, "Key ${event.action} code=${event.keyCode} char=${event.unicodeChar} " + + "chars=${event.characters} dev=${event.deviceId} focus=${currentFocus?.javaClass?.simpleName}") + return super.dispatchKeyEvent(event) + } + + // ── Broadcast (Intent output) scans ───────────────────────────────── + // Writes the barcode into the focused field (or the first visible + // search/text field) using the native value setter + an `input` event, + // which is what React-controlled inputs need to pick up the change — + // assigning el.value directly is silently discarded by React. + + private fun injectScannedText(text: String) { + if (!::webView.isInitialized) return + ensureWebViewFocus() + webView.evaluateJavascript(""" + (function(text, sendEnter) { + var TEXT_TYPES = /^(text|search|tel|number|email|url|password)?${'$'}/i; + function editable(el) { + return el && !el.disabled && !el.readOnly && (el.isContentEditable + || el.tagName === 'TEXTAREA' + || (el.tagName === 'INPUT' && TEXT_TYPES.test(el.getAttribute('type') || ''))); + } + var el = document.activeElement; + if (!editable(el)) { + el = Array.prototype.find.call( + document.querySelectorAll('input[type=search], input[type=text], input:not([type])'), + function(e) { return editable(e) && e.offsetParent !== null; }); + } + if (!el) return false; + el.focus(); + if (el.isContentEditable) { + document.execCommand('insertText', false, text); + } else { + var proto = el.tagName === 'TEXTAREA' + ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, text); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + } + if (sendEnter) { + ['keydown', 'keypress', 'keyup'].forEach(function(type) { + el.dispatchEvent(new KeyboardEvent(type, { key: 'Enter', code: 'Enter', + keyCode: 13, which: 13, bubbles: true, cancelable: true })); + }); + } + return true; + })(${JSONObject.quote(text)}, ${AppConfig.SCANNER_SEND_ENTER}); + """.trimIndent()) { result -> + if (result != "true") Log.w(TAG, "Scan received but no editable field found") + } + } private fun ensureWebViewFocus() { if (::webView.isInitialized && !webView.hasFocus()) {