Add intent-output scanner support and key event logging
All checks were successful
APK-BUILD/pipeline/head This commit looks good
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:
parent
b03d4507c1
commit
2f88432776
3
.gitignore
vendored
3
.gitignore
vendored
@ -30,3 +30,6 @@ Thumbs.db
|
|||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Other
|
||||||
|
SamplePage/
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
19
README.md
19
README.md
@ -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.
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user