From 63909abf6d4bc2dcc201528e9cca0da3794ac74a Mon Sep 17 00:00:00 2001 From: caspar Date: Mon, 28 Sep 2026 15:41:55 -0400 Subject: [PATCH] Initial commit. Basic app. --- .gitignore | 32 ++ CLAUDE.md | 75 ++++ README.md | 91 +++++ app/build.gradle.kts | 44 +++ app/proguard-rules.pro | 11 + app/src/main/AndroidManifest.xml | 42 ++ .../main/java/mx/joykoo/scanner/AppConfig.kt | 26 ++ .../java/mx/joykoo/scanner/MainActivity.kt | 371 ++++++++++++++++++ .../joykoo/scanner/NoSoftKeyboardWebView.kt | 68 ++++ .../mx/joykoo/scanner/ScannerIntegration.kt | 30 ++ .../res/drawable/ic_launcher_background.xml | 10 + .../res/drawable/ic_launcher_foreground.xml | 51 +++ .../main/res/drawable/ic_launcher_legacy.xml | 5 + app/src/main/res/layout/activity_main.xml | 49 +++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 166 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 123 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 221 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 538 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 735 bytes app/src/main/res/values/strings.xml | 7 + app/src/main/res/values/themes.xml | 9 + .../main/res/xml/network_security_config.xml | 9 + build.gradle.kts | 4 + gradle.properties | 4 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43453 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 133 +++++++ settings.gradle.kts | 19 + 29 files changed, 1102 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/mx/joykoo/scanner/AppConfig.kt create mode 100644 app/src/main/java/mx/joykoo/scanner/MainActivity.kt create mode 100644 app/src/main/java/mx/joykoo/scanner/NoSoftKeyboardWebView.kt create mode 100644 app/src/main/java/mx/joykoo/scanner/ScannerIntegration.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_launcher_legacy.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d023210 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar + +# Android Studio / IntelliJ +.idea/ +*.iml +local.properties +captures/ + +# Android build artifacts +*.apk +*.aab +*.ap_ +*.dex + +# Native build +.externalNativeBuild/ +.cxx/ + +# Keystore (never commit signing keys) +*.jks +*.keystore +release.properties + +# OS files +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2a7c5de --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,75 @@ +# 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. The custom `NoSoftKeyboardWebView` returns `null` from `onCreateInputConnection()` to prevent the IME from ever being invoked, while hardware key events from the scanner still flow through the standard View key-event dispatch chain. Do not revert this or add an InputConnection — it is the core of the app. + +## Project Structure + +``` +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 +``` + +## 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_TAP_COUNT` / `ADMIN_TAP_WINDOW_MS` — hidden admin exit gesture (7 taps top-left corner) + +## 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 exit: 7 rapid taps in top-left 60dp corner → confirmation dialog (PIN-ready stub) + +## Security + +- HTTPS only, no cleartext, 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..208ced8 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Joykoo Scanner + +Aplicación nativa de Android que funciona como navegador dedicado de pantalla completa para el sistema web de Joykoo (`smart.joykoo.mx`), diseñada para escáneres industriales de códigos de barras y QR. + +## Características + +- **WebView en pantalla completa** — Carga el sitio web de la empresa sin barra de direcciones ni controles del navegador +- **Supresión del teclado virtual** — El teclado en pantalla de Android nunca aparece, mientras que la entrada del escáner de códigos de barras (modo HID/teclado) funciona normalmente +- **Modo kiosco** — Pantalla siempre encendida, modo inmersivo, navegación restringida al dominio de la empresa +- **Persistencia de sesión** — Cookies y almacenamiento local persisten entre reinicios de la aplicación +- **Manejo de errores de red** — Pantalla de reintento cuando no hay conexión Wi-Fi +- **Salida protegida** — Se requieren 3 pulsaciones rápidas del botón Atrás para salir; gesto oculto de administrador (7 toques en esquina superior izquierda) con diálogo de confirmación + +## Requisitos + +- Android 7.0+ (API 24) +- Dispositivo con escáner de códigos de barras integrado en modo HID/teclado +- Conexión Wi-Fi al servidor de la empresa + +## Compilación + +### Prerrequisitos + +- JDK 17 +- Android SDK con: + - `platform-tools` + - `platforms;android-34` + - `build-tools;34.0.0` + +### Usando Android Studio + +Abrir el proyecto y compilar normalmente. + +### Línea de comandos + +```bash +export JAVA_HOME=/ruta/a/jdk-17 +export ANDROID_HOME=/ruta/a/android-sdk + +./gradlew assembleDebug # APK de depuración +./gradlew assembleRelease # APK de producción (requiere configurar firma) +``` + +El APK resultante se encuentra en `app/build/outputs/apk/`. + +## Configuración + +Todos los valores configurables están en un solo archivo: + +**`app/src/main/java/mx/joykoo/scanner/AppConfig.kt`** + +| Parámetro | Descripción | Valor por defecto | +|---|---|---| +| `HOME_URL` | URL que se carga al iniciar | `https://smart.joykoo.mx/` | +| `ALLOWED_HOSTS` | Dominios permitidos dentro del WebView | `smart.joykoo.mx`, `joykoo.mx` | +| `BACK_PRESS_EXIT_COUNT` | Pulsaciones rápidas de Atrás para salir | 3 | +| `ADMIN_TAP_COUNT` | Toques en esquina superior izquierda para menú de administrador | 7 | + +## Uso del escáner + +1. El usuario toca un campo de texto en la página web +2. El campo recibe el foco — **el teclado virtual no aparece** +3. El usuario escanea un código de barras o QR +4. El texto escaneado aparece en el campo +5. Si el escáner envía Enter al final, la página lo procesa normalmente + +## Estructura del proyecto + +``` +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 +``` + +## Integración futura con SDK de escáner + +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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..f03ce88 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "mx.joykoo.scanner" + compileSdk = 34 + + defaultConfig { + applicationId = "mx.joykoo.scanner" + minSdk = 24 + targetSdk = 34 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("androidx.webkit:webkit:1.11.0") + implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f7c0991 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,11 @@ +# Keep WebView JavaScript interface methods +-keepclassmembers class * { + @android.webkit.JavascriptInterface ; +} + +# Keep WebViewClient and WebChromeClient callbacks +-keepclassmembers class * extends android.webkit.WebViewClient { + public void *(android.webkit.WebView, java.lang.String); + public void *(android.webkit.WebView, java.lang.String, android.graphics.Bitmap); + public boolean *(android.webkit.WebView, java.lang.String); +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0cefc0b --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/mx/joykoo/scanner/AppConfig.kt b/app/src/main/java/mx/joykoo/scanner/AppConfig.kt new file mode 100644 index 0000000..f72ecda --- /dev/null +++ b/app/src/main/java/mx/joykoo/scanner/AppConfig.kt @@ -0,0 +1,26 @@ +package mx.joykoo.scanner + +object AppConfig { + /** The URL loaded on app startup. Change this single value to point to a different site. */ + const val HOME_URL = "https://smart.joykoo.mx/" + + /** Hostnames that are considered part of the company application. + * Navigation to URLs on these hosts stays inside the WebView. + * All other hosts are blocked (or optionally opened externally). */ + val ALLOWED_HOSTS = setOf( + "smart.joykoo.mx", + "joykoo.mx", + ) + + /** Number of rapid Back presses required to exit the app when there is no WebView history. */ + const val BACK_PRESS_EXIT_COUNT = 3 + + /** Time window (ms) in which BACK_PRESS_EXIT_COUNT presses must occur to exit. */ + const val BACK_PRESS_WINDOW_MS = 2000L + + /** Number of taps on the hidden admin area required to trigger the admin exit. */ + const val ADMIN_TAP_COUNT = 7 + + /** Time window (ms) for admin tap sequence. */ + const val ADMIN_TAP_WINDOW_MS = 3000L +} diff --git a/app/src/main/java/mx/joykoo/scanner/MainActivity.kt b/app/src/main/java/mx/joykoo/scanner/MainActivity.kt new file mode 100644 index 0000000..8d1ddb1 --- /dev/null +++ b/app/src/main/java/mx/joykoo/scanner/MainActivity.kt @@ -0,0 +1,371 @@ +package mx.joykoo.scanner + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.content.Context +import android.graphics.Bitmap +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.view.MotionEvent +import android.view.View +import android.view.WindowInsets +import android.view.WindowInsetsController +import android.view.WindowManager +import android.webkit.CookieManager +import android.webkit.ValueCallback +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AppCompatActivity + +class MainActivity : AppCompatActivity() { + + private lateinit var webView: NoSoftKeyboardWebView + private lateinit var errorOverlay: View + private var fileUploadCallback: ValueCallback>? = null + private var isShowingError = false + + // Admin exit: rapid taps in the top-left corner + private var adminTapTimestamps = mutableListOf() + + // Back-press exit: rapid presses when no WebView history + private var backPressTimestamps = mutableListOf() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + keepScreenAwake() + enterImmersiveMode() + setupWebView() + setupErrorOverlay() + setupBackNavigation() + + if (savedInstanceState == null) { + webView.loadUrl(AppConfig.HOME_URL) + } + + ensureWebViewFocus() + } + + // ── Fullscreen / Immersive ────────────────────────────────────────── + + private fun keepScreenAwake() { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + private fun enterImmersiveMode() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.insetsController?.let { controller -> + controller.hide(WindowInsets.Type.systemBars()) + controller.systemBarsBehavior = + WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } else { + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + or View.SYSTEM_UI_FLAG_FULLSCREEN + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + ) + } + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) enterImmersiveMode() + } + + // ── WebView Setup ─────────────────────────────────────────────────── + + @SuppressLint("SetJavaScriptEnabled") + private fun setupWebView() { + webView = findViewById(R.id.webview) + + webView.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + databaseEnabled = true + + // Allow mixed content only if absolutely necessary for the target site. + // Default to the safest option. + mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + + // Cache and storage for session persistence + cacheMode = WebSettings.LOAD_DEFAULT + setSupportMultipleWindows(false) + + // Reasonable user-agent so the target site serves a normal mobile page + userAgentString = "$userAgentString JoykooScanner/1.0" + + // Allow file access from file: URLs only (not content:// or broad access) + allowFileAccess = false + allowContentAccess = false + + // Viewport and zoom for industrial screen sizes + useWideViewPort = true + loadWithOverviewMode = true + builtInZoomControls = true + displayZoomControls = false + + // Text encoding + defaultTextEncodingName = "UTF-8" + } + + // Persistent cookies across app restarts + CookieManager.getInstance().apply { + setAcceptCookie(true) + setAcceptThirdPartyCookies(webView, true) + } + + webView.webViewClient = ScannerWebViewClient() + webView.webChromeClient = ScannerWebChromeClient() + } + + // ── Error Overlay ─────────────────────────────────────────────────── + + private fun setupErrorOverlay() { + errorOverlay = findViewById(R.id.error_overlay) + findViewById(R.id.btn_retry).setOnClickListener { + hideError() + if (isNetworkAvailable()) { + webView.reload() + } else { + showError() + } + } + } + + private fun showError() { + isShowingError = true + errorOverlay.visibility = View.VISIBLE + } + + private fun hideError() { + isShowingError = false + errorOverlay.visibility = View.GONE + } + + private fun isNetworkAvailable(): Boolean { + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val network = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(network) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } + + // ── Back Navigation ───────────────────────────────────────────────── + + private fun setupBackNavigation() { + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (isShowingError) { + // Don't navigate away from error; just retry + return + } + + if (webView.canGoBack()) { + webView.goBack() + backPressTimestamps.clear() + } else { + handleExitBackPress() + } + } + }) + } + + private fun handleExitBackPress() { + val now = System.currentTimeMillis() + backPressTimestamps.add(now) + backPressTimestamps.removeAll { now - it > AppConfig.BACK_PRESS_WINDOW_MS } + + if (backPressTimestamps.size >= AppConfig.BACK_PRESS_EXIT_COUNT) { + finish() + } else { + val remaining = AppConfig.BACK_PRESS_EXIT_COUNT - backPressTimestamps.size + Toast.makeText( + this, + "Presione Atrás $remaining vez/veces más para salir", + Toast.LENGTH_SHORT + ).show() + } + } + + // ── Admin Exit (hidden tap gesture) ───────────────────────────────── + + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + if (ev.actionMasked == MotionEvent.ACTION_DOWN) { + // Top-left 60x60 dp region as the hidden admin area + val threshold = (60 * resources.displayMetrics.density).toInt() + if (ev.x < threshold && ev.y < threshold) { + handleAdminTap() + } + } + return super.dispatchTouchEvent(ev) + } + + private fun handleAdminTap() { + val now = System.currentTimeMillis() + adminTapTimestamps.add(now) + adminTapTimestamps.removeAll { now - it > AppConfig.ADMIN_TAP_WINDOW_MS } + + if (adminTapTimestamps.size >= AppConfig.ADMIN_TAP_COUNT) { + adminTapTimestamps.clear() + showAdminExitDialog() + } + } + + private fun showAdminExitDialog() { + // Structured so a PIN check can be added here before calling finish() + AlertDialog.Builder(this) + .setTitle("Salir de la aplicación") + .setMessage("¿Desea cerrar la aplicación del escáner?") + .setPositiveButton("Salir") { _, _ -> finish() } + .setNegativeButton("Cancelar", null) + .show() + } + + // ── Lifecycle ─────────────────────────────────────────────────────── + + override fun onResume() { + super.onResume() + webView.onResume() + enterImmersiveMode() + ensureWebViewFocus() + CookieManager.getInstance().flush() + } + + override fun onPause() { + super.onPause() + webView.onPause() + CookieManager.getInstance().flush() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + webView.saveState(outState) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + webView.restoreState(savedInstanceState) + } + + override fun onDestroy() { + webView.destroy() + super.onDestroy() + } + + // ── Hardware Key Events ───────────────────────────────────────────── + // Hardware key events from the barcode scanner flow through the + // standard Android dispatch: Activity → ViewGroup → focused View. + // The WebView handles them in its own dispatchKeyEvent/onKeyDown. + // + // 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. + + private fun ensureWebViewFocus() { + if (::webView.isInitialized && !webView.hasFocus()) { + webView.requestFocus() + } + } + + // ── WebViewClient ─────────────────────────────────────────────────── + + private inner class ScannerWebViewClient : WebViewClient() { + + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest + ): Boolean { + val host = request.url.host ?: return true + // Allow navigation within the company's domains + if (AppConfig.ALLOWED_HOSTS.any { host == it || host.endsWith(".$it") }) { + return false // let WebView handle it + } + // Block navigation to external sites + return true + } + + override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + if (isShowingError) hideError() + } + + override fun onPageFinished(view: WebView, url: String?) { + super.onPageFinished(view, url) + CookieManager.getInstance().flush() + ensureWebViewFocus() + } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError + ) { + // Only show error overlay for main-frame navigation failures + if (request.isForMainFrame) { + showError() + } + } + + // Do NOT override onReceivedSslError to proceed — let TLS + // errors fail as they should for security. + } + + // ── WebChromeClient ───────────────────────────────────────────────── + + private inner class ScannerWebChromeClient : WebChromeClient() { + + // File upload support: in the webpage + override fun onShowFileChooser( + webView: WebView, + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserParams + ): Boolean { + fileUploadCallback?.onReceiveValue(null) + fileUploadCallback = filePathCallback + + val intent = fileChooserParams.createIntent() + try { + startActivityForResult(intent, FILE_CHOOSER_REQUEST) + } catch (e: Exception) { + fileUploadCallback?.onReceiveValue(null) + fileUploadCallback = null + return false + } + return true + } + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: android.content.Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == FILE_CHOOSER_REQUEST) { + fileUploadCallback?.onReceiveValue( + if (resultCode == RESULT_OK && data != null) { + WebChromeClient.FileChooserParams.parseResult(resultCode, data) + } else { + null + } + ) + fileUploadCallback = null + } + } + + companion object { + private const val FILE_CHOOSER_REQUEST = 1001 + } +} diff --git a/app/src/main/java/mx/joykoo/scanner/NoSoftKeyboardWebView.kt b/app/src/main/java/mx/joykoo/scanner/NoSoftKeyboardWebView.kt new file mode 100644 index 0000000..f41bfa2 --- /dev/null +++ b/app/src/main/java/mx/joykoo/scanner/NoSoftKeyboardWebView.kt @@ -0,0 +1,68 @@ +package mx.joykoo.scanner + +import android.content.Context +import android.util.AttributeSet +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import android.webkit.WebView + +/** + * WebView subclass that prevents the Android software keyboard (IME) from + * appearing while preserving hardware keyboard / barcode-scanner key events. + * + * HOW IT WORKS + * ============ + * When an HTML inside a WebView gains focus, the framework calls + * onCreateInputConnection() on the focused View. If this method returns a + * non-null InputConnection, the InputMethodManager (IMM) has a channel to + * the IME and will show the software keyboard. + * + * By returning null, we tell the IMM that this View does not participate in + * the input-method protocol. The IMM therefore never shows the soft keyboard + * for this View — it is prevented from opening in the first place rather + * than being hidden reactively after it appears. + * + * WHY HARDWARE/SCANNER INPUT STILL WORKS + * ======================================= + * Hardware key events (from a physical keyboard or a barcode scanner in + * HID/keyboard-wedge mode) are delivered through the Android View key-event + * dispatch pipeline: Activity.dispatchKeyEvent → ViewGroup.dispatchKeyEvent + * → View.dispatchKeyEvent → View.onKeyDown/onKeyUp. This pipeline is + * completely independent of InputConnection/IME. WebView's internal + * key-event handling (which forwards events to the Chromium renderer and + * into focused HTML inputs) works through this pipeline, so hardware key + * events continue to reach whichever HTML input has focus inside the + * WebView. + * + * The only thing lost by returning null is the ability to receive composed + * text from a software IME (e.g. predictive text, swipe typing, CJK + * composition). Since this device is a dedicated barcode scanner with no + * need for software keyboard input, this is the desired behavior. + * + * ADDITIONAL SAFETY: onCheckIsTextEditor() + * ======================================== + * Some Android IME implementations also check onCheckIsTextEditor() to + * decide whether to show. Returning false provides a second signal that + * this View does not want IME interaction. + */ +class NoSoftKeyboardWebView : WebView { + + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : + super(context, attrs, defStyleAttr) + + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? { + // Returning null prevents the IMM from establishing a connection to + // any software IME. The soft keyboard cannot appear because there is + // no InputConnection for it to communicate through. Hardware key + // events still flow via the View key-event dispatch chain. + return null + } + + override fun onCheckIsTextEditor(): Boolean { + // Signal to the framework that this View is not a text editor, + // preventing IME-related behaviors from triggering. + return false + } +} diff --git a/app/src/main/java/mx/joykoo/scanner/ScannerIntegration.kt b/app/src/main/java/mx/joykoo/scanner/ScannerIntegration.kt new file mode 100644 index 0000000..4402eba --- /dev/null +++ b/app/src/main/java/mx/joykoo/scanner/ScannerIntegration.kt @@ -0,0 +1,30 @@ +package mx.joykoo.scanner + +/** + * Extension point for vendor-specific scanner SDK integration. + * + * Generic Chinese industrial scanners often provide a broadcast-based API + * (e.g. via Intent actions like "com.scanner.broadcast" or similar) in + * addition to or instead of HID keyboard-wedge mode. + * + * To integrate a vendor SDK: + * 1. Add the vendor library to app/build.gradle.kts dependencies. + * 2. Implement a concrete subclass of this interface. + * 3. Register/unregister the BroadcastReceiver in onResume/onPause. + * 4. When a barcode is received, call the provided callback to inject the + * scanned text into the WebView's focused input. + * + * The HID keyboard-wedge mode (hardware key events) works without any of + * this — it is handled automatically by Android's View key dispatch. + */ +interface ScannerIntegration { + + /** Called when the activity resumes. Register broadcast receivers here. */ + fun onResume() + + /** Called when the activity pauses. Unregister broadcast receivers here. */ + fun onPause() + + /** Called when the activity is destroyed. Clean up resources. */ + fun onDestroy() +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..bf081ba --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..7d3fa1a --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_legacy.xml b/app/src/main/res/drawable/ic_launcher_legacy.xml new file mode 100644 index 0000000..8d0a890 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_legacy.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..c95a38a --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + +