Add intent-output scanner support and key event logging
All checks were successful
APK-BUILD/pipeline/head This commit looks good

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 <noreply@anthropic.com>
This commit is contained in:
caspar 2026-10-08 12:39:16 -04:00
parent b03d4507c1
commit 2f88432776
6 changed files with 160 additions and 17 deletions

3
.gitignore vendored
View File

@ -30,3 +30,6 @@ Thumbs.db
# Logs # Logs
*.log *.log
# Other
SamplePage/

View File

@ -23,7 +23,8 @@ app/src/main/java/mx/joykoo/scanner/
AppConfig.kt — URL, allowed hosts, gesture thresholds (single config point) AppConfig.kt — URL, allowed hosts, gesture thresholds (single config point)
MainActivity.kt — Fullscreen kiosk activity, WebView setup, navigation, lifecycle MainActivity.kt — Fullscreen kiosk activity, WebView setup, navigation, lifecycle
NoSoftKeyboardWebView.kt — IME suppression via null InputConnection 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 ## Build

View File

@ -71,21 +71,10 @@ app/src/main/java/mx/joykoo/scanner/
├── AppConfig.kt — Configuración centralizada ├── AppConfig.kt — Configuración centralizada
├── 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 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. 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í.
## 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.

View File

@ -27,4 +27,25 @@ object AppConfig {
/** Time window (ms) for admin tap sequence. */ /** Time window (ms) for admin tap sequence. */
const val ADMIN_TAP_WINDOW_MS = 3000L 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
} }

View File

@ -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"
}
}

View File

@ -10,6 +10,7 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.KeyEvent
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.WindowInsets import android.view.WindowInsets
@ -30,11 +31,13 @@ import android.webkit.WebViewClient
import android.widget.Toast import android.widget.Toast
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import org.json.JSONObject
class MainActivity : AppCompatActivity() { 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 var fileUploadCallback: ValueCallback<Array<Uri>>? = null private var fileUploadCallback: ValueCallback<Array<Uri>>? = null
private var isShowingError = false private var isShowingError = false
@ -54,6 +57,7 @@ class MainActivity : AppCompatActivity() {
setupWebView() setupWebView()
setupErrorOverlay() setupErrorOverlay()
setupBackNavigation() setupBackNavigation()
scanner = BroadcastScannerIntegration(this, ::injectScannedText)
if (savedInstanceState == null) { if (savedInstanceState == null) {
webView.loadUrl(AppConfig.HOME_URL) webView.loadUrl(AppConfig.HOME_URL)
@ -264,6 +268,7 @@ class MainActivity : AppCompatActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
webView.onResume() webView.onResume()
scanner.onResume()
enterImmersiveMode() enterImmersiveMode()
ensureWebViewFocus() ensureWebViewFocus()
CookieManager.getInstance().flush() CookieManager.getInstance().flush()
@ -272,6 +277,7 @@ class MainActivity : AppCompatActivity() {
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
webView.onPause() webView.onPause()
scanner.onPause()
CookieManager.getInstance().flush() CookieManager.getInstance().flush()
} }
@ -286,6 +292,7 @@ class MainActivity : AppCompatActivity() {
} }
override fun onDestroy() { override fun onDestroy() {
scanner.onDestroy()
webView.destroy() webView.destroy()
super.onDestroy() super.onDestroy()
} }
@ -298,7 +305,61 @@ class MainActivity : AppCompatActivity() {
// We ensure the WebView has focus so it receives key events directly. // We ensure the WebView has focus so it receives key events directly.
// No override of dispatchKeyEvent is needed — the default chain works // No override of dispatchKeyEvent is needed — the default chain works
// correctly when the WebView is focused. Overriding it with a manual // 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() { private fun ensureWebViewFocus() {
if (::webView.isInitialized && !webView.hasFocus()) { if (::webView.isInitialized && !webView.hasFocus()) {