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()) {