Initial commit. Basic app.

This commit is contained in:
caspar 2026-09-28 15:41:55 -04:00
commit 63909abf6d
29 changed files with 1102 additions and 0 deletions

32
.gitignore vendored Normal file
View File

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

75
CLAUDE.md Normal file
View File

@ -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.

91
README.md Normal file
View File

@ -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.

44
app/build.gradle.kts Normal file
View File

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

11
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,11 @@
# Keep WebView JavaScript interface methods
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
# 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);
}

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.JoykooScanner"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="false">
<!--
windowSoftInputMode="stateAlwaysHidden" is a first layer of defense:
it tells the system not to show the IME when the activity starts or
resumes. However, WebView can still explicitly request the IME when
an HTML input is focused, so this alone is NOT sufficient.
The real suppression happens in NoSoftKeyboardWebView which overrides
onCreateInputConnection() to return null, preventing the IME from
ever being invoked while still allowing hardware key events to flow
through the standard View key-event dispatch.
-->
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden|keyboard|screenLayout|smallestScreenSize"
android:exported="true"
android:launchMode="singleTask"
android:screenOrientation="portrait"
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

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

View File

@ -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<Array<Uri>>? = null
private var isShowingError = false
// Admin exit: rapid taps in the top-left corner
private var adminTapTimestamps = mutableListOf<Long>()
// Back-press exit: rapid presses when no WebView history
private var backPressTimestamps = mutableListOf<Long>()
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<View>(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: <input type="file"> in the webpage
override fun onShowFileChooser(
webView: WebView,
filePathCallback: ValueCallback<Array<Uri>>,
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
}
}

View File

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

View File

@ -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()
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1565C0"
android:pathData="M0,0h108v108h-108z" />
</vector>

View File

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- QR code icon outline -->
<path
android:fillColor="#FFFFFF"
android:pathData="M30,30h18v18h-18z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M60,30h18v18h-18z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M30,60h18v18h-18z" />
<path
android:fillColor="#1A237E"
android:pathData="M33,33h12v12h-12z" />
<path
android:fillColor="#1A237E"
android:pathData="M63,33h12v12h-12z" />
<path
android:fillColor="#1A237E"
android:pathData="M33,63h12v12h-12z" />
<path
android:fillColor="#1A237E"
android:pathData="M36,36h6v6h-6z" />
<path
android:fillColor="#1A237E"
android:pathData="M66,36h6v6h-6z" />
<path
android:fillColor="#1A237E"
android:pathData="M36,66h6v6h-6z" />
<!-- Data dots -->
<path
android:fillColor="#1A237E"
android:pathData="M54,54h6v6h-6z" />
<path
android:fillColor="#1A237E"
android:pathData="M60,60h6v6h-6z" />
<path
android:fillColor="#1A237E"
android:pathData="M66,60h6v6h-6z" />
<path
android:fillColor="#1A237E"
android:pathData="M54,66h6v6h-6z" />
<path
android:fillColor="#1A237E"
android:pathData="M72,66h6v6h-6z" />
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_launcher_background" />
<item android:drawable="@drawable/ic_launcher_foreground" />
</layer-list>

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<mx.joykoo.scanner.NoSoftKeyboardWebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<!-- Error overlay shown when the page cannot load (e.g. no network) -->
<LinearLayout
android:id="@+id/error_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FFFFFF"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/error_no_connection"
android:textAlignment="center"
android:textColor="#333333"
android:textSize="18sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/error_check_wifi"
android:textAlignment="center"
android:textColor="#666666"
android:textSize="14sp" />
<Button
android:id="@+id/btn_retry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:paddingHorizontal="32dp"
android:text="@string/retry" />
</LinearLayout>
</FrameLayout>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 735 B

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Joykoo Scanner</string>
<string name="error_no_connection">No se puede conectar</string>
<string name="error_check_wifi">Verifique la conexión Wi-Fi e intente de nuevo.</string>
<string name="retry">Reintentar</string>
</resources>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.JoykooScanner" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowFullscreen">true</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowBackground">@android:color/white</item>
</style>
</resources>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Only allow HTTPS connections. No cleartext traffic. -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>

4
build.gradle.kts Normal file
View File

@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}

4
gradle.properties Normal file
View File

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

133
gradlew vendored Executable file
View File

@ -0,0 +1,133 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld -- "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NonStop* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1 ; then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is://undefined. That's://why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
;;
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is://undefined. That's://why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
;;
esac
fi
# Collect all arguments for the java command, stracks://the precedence://of values.
# For this to work, currentIFS://should remain default.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xeli" mode is set and the command fails
if "$cygwin" || "$msys" ; then
stty -icanon min 1 time 0 2>/dev/null || true
fi
exec "$JAVACMD" "$@"

19
settings.gradle.kts Normal file
View File

@ -0,0 +1,19 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
@Suppress("UnstableApiUsage")
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "JoykooScanner"
include(":app")