Allow the soft keyboard on fields the site marks as typed

Scan fields keep inputmode="none" and the forced hide; fields the site's markup
marks as manual (data-jk, its own inputmode, textarea, autocapitalize, or a
typing input type) keep the keyboard and the site's inputmode, so numeric
fields get a numeric keypad. Native code checks the focused field before
hiding the keyboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
caspar 2026-10-12 15:02:45 -04:00
parent d22af8f256
commit 4b23724639
4 changed files with 147 additions and 57 deletions

View File

@ -6,23 +6,36 @@ Native Android app (Kotlin) that wraps `https://smart.joykoo.mx/` in a fullscree
## Critical Design Decision
The Android soft keyboard must NEVER appear. Keyboard suppression uses multiple layers — do not remove any of them:
The Android soft keyboard must NEVER appear for scanner fields. Fields that the site's markup marks as typed by a person are the only exception (see Keyboard Policy below). Keyboard suppression uses multiple layers — do not remove any of them:
1. `NoSoftKeyboardWebView.onCheckIsTextEditor()` → `false`
2. `EditorInfo` flags `IME_FLAG_NO_EXTRACT_UI | IME_FLAG_NO_FULLSCREEN` in `onCreateInputConnection()`
3. `InputMethodManager.hideSoftInputFromWindow()` posted after each InputConnection request and on page load / window focus
4. JavaScript injection setting `inputmode="none"` on all `<input>`, `<textarea>`, and `contenteditable` elements via `MutationObserver`
3. `InputMethodManager.hideSoftInputFromWindow()` posted after each InputConnection request and on page load / window focus — skipped only when the focused field carries `data-jk-kb="1"`
4. JavaScript injection (`KeyboardPolicy`) setting `inputmode="none"` on every scan `<input>`, `<textarea>`, and `contenteditable` via `MutationObserver`
5. Manifest `windowSoftInputMode="stateAlwaysHidden|adjustNothing"`
The real `InputConnection` from `super.onCreateInputConnection()` **must be returned** — returning `null` breaks hardware key input from the barcode scanner on devices like the Urovo CT58C.
## Keyboard Policy
`KeyboardPolicy.kt` decides per field from the site's markup, first match wins:
1. `data-jk="manual"` / `data-jk="scan"` on the field or an ancestor (the site should use this for anything the hints get wrong)
2. the site's own `inputmode` (`none` → scan, anything else → manual, and it is kept, e.g. numeric keypad)
3. `<textarea>` / contenteditable → manual
4. `autocapitalize` other than off/none → manual
5. `type` tel / number / email / url / password → manual
6. otherwise → scan (keyboard suppressed)
The site reads scans with a page-wide `keydown` listener (bursts ≤ 60 ms apart), so scanning does not need a focused field. The admin "Mostrar teclado" toggle still turns all suppression off.
## Project Structure
```
app/src/main/java/mx/joykoo/scanner/
AppConfig.kt — URL, allowed hosts, admin triggers, scanner broadcasts (single config point)
MainActivity.kt — Fullscreen kiosk activity, WebView setup, navigation, lifecycle
NoSoftKeyboardWebView.kt — IME suppression via null InputConnection
NoSoftKeyboardWebView.kt — IME suppression layers 1–3 (real InputConnection is returned)
KeyboardPolicy.kt — Injected per-field rule: scan fields get inputmode="none", manual fields keep the keyboard
ScannerIntegration.kt — Scanner lifecycle interface
BroadcastScannerIntegration.kt — Intent-output scans → JS injection (React-safe value setter)
KioskController.kt — Lock Task kiosk toggle (session-only until KIOSK_PERSISTED); device-owner full lockdown + HOME alias, else screen pinning

View File

@ -0,0 +1,102 @@
package mx.joykoo.scanner
/**
* Per-field soft keyboard policy, injected into the page.
*
* Fields the policy says are typed by a person keep the site's own `inputmode` and are
* marked `data-jk-kb="1"`; every other field gets `inputmode="none"` (scanner input).
* Native code reads the mark on the focused field ([ALLOWED_JS]) and hides the keyboard
* when it is missing, so the IME still never shows for scan fields.
*
* Rule — the site's markup decides:
* 1. `data-jk="manual"` / `data-jk="scan"` on the field or an ancestor (explicit, wins)
* 2. the site's own `inputmode` (`none` → scan, anything else → manual)
* 3. `<textarea>` / contenteditable → manual
* 4. `autocapitalize` other than off/none → manual
* 5. `type` tel / number / email / url / password → manual
* otherwise → scan
*/
object KeyboardPolicy {
/** Applies the policy to all current and future fields. Safe to run repeatedly. */
val APPLY_JS = """
(function() {
var kb = window.__jkKb;
if (!kb) {
var EDITABLE = 'input, textarea, [contenteditable]';
var TEXT_TYPES = /^(text|search|tel|number|email|url|password)?${'$'}/i;
var TYPING_TYPES = /^(tel|number|email|url|password)${'$'}/i;
function isEditable(el) {
return el.isContentEditable || el.tagName === 'TEXTAREA'
|| (el.tagName === 'INPUT' && TEXT_TYPES.test(el.getAttribute('type') || ''));
}
// The site's inputmode, remembered before it is overwritten with "none".
function siteInputMode(el) {
if (!el.hasAttribute('data-jk-im'))
el.setAttribute('data-jk-im', el.getAttribute('inputmode') || '');
return el.getAttribute('data-jk-im');
}
function wantsKeyboard(el) {
var marked = el.closest('[data-jk]');
if (marked) return marked.getAttribute('data-jk') === 'manual';
var im = siteInputMode(el);
if (im) return im !== 'none';
if (el.tagName === 'TEXTAREA' || el.isContentEditable) return true;
var cap = (el.getAttribute('autocapitalize') || '').toLowerCase();
if (cap && cap !== 'off' && cap !== 'none') return true;
return TYPING_TYPES.test(el.getAttribute('type') || '');
}
function apply(el) {
if (!isEditable(el)) return;
var im = siteInputMode(el);
if (wantsKeyboard(el)) {
if (im) el.setAttribute('inputmode', im); else el.removeAttribute('inputmode');
el.setAttribute('data-jk-kb', '1');
} else {
el.setAttribute('inputmode', 'none');
el.setAttribute('autocomplete', 'off');
el.removeAttribute('data-jk-kb');
}
}
function restore(el) {
var im = el.getAttribute('data-jk-im');
if (im === null) return;
if (im) el.setAttribute('inputmode', im); else el.removeAttribute('inputmode');
el.removeAttribute('data-jk-im');
el.removeAttribute('data-jk-kb');
}
function each(root, fn) {
if (root.nodeType !== 1) return;
fn(root);
root.querySelectorAll(EDITABLE).forEach(fn);
}
var observer = new MutationObserver(function(muts) {
muts.forEach(function(m) {
m.addedNodes.forEach(function(n) { each(n, apply); });
});
});
kb = window.__jkKb = {
enable: function() {
each(document.documentElement, apply);
observer.observe(document.documentElement, { childList: true, subtree: true });
},
disable: function() {
observer.disconnect();
each(document.documentElement, restore);
}
};
}
kb.enable();
})();
""".trimIndent()
/** Removes the policy and gives every field back the site's own inputmode. */
const val DISABLE_JS = "window.__jkKb && window.__jkKb.disable();"
/** "true" when the focused field may show the soft keyboard. */
const val ALLOWED_JS = """(function() {
var el = document.activeElement;
return !!el && el.getAttribute('data-jk-kb') === '1';
})()"""
}

View File

@ -126,7 +126,7 @@ class MainActivity : AppCompatActivity() {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
enterImmersiveMode()
hideKeyboard()
enforceKeyboardPolicy()
}
}
@ -176,6 +176,7 @@ class MainActivity : AppCompatActivity() {
WebView.setWebContentsDebuggingEnabled(true)
webView.onInputConnectionRequested = ::enforceKeyboardPolicy
webView.webViewClient = ScannerWebViewClient()
webView.webChromeClient = ScannerWebChromeClient()
webView.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ ->
@ -334,11 +335,11 @@ class MainActivity : AppCompatActivity() {
webView.keyboardSuppressed = isKeyboardSuppressed
if (isKeyboardSuppressed) {
injectKeyboardSuppression(webView)
hideKeyboard()
applyKeyboardPolicy(webView)
enforceKeyboardPolicy()
Toast.makeText(this, R.string.keyboard_disabled, Toast.LENGTH_SHORT).show()
} else {
removeKeyboardSuppression(webView)
webView.evaluateJavascript(KeyboardPolicy.DISABLE_JS, null)
Toast.makeText(this, R.string.keyboard_enabled, Toast.LENGTH_SHORT).show()
}
}
@ -471,50 +472,18 @@ class MainActivity : AppCompatActivity() {
imm.hideSoftInputFromWindow(webView.windowToken, 0)
}
private fun injectKeyboardSuppression(view: WebView) {
/** Marks which fields may show the soft keyboard (see KeyboardPolicy). */
private fun applyKeyboardPolicy(view: WebView) {
if (!isKeyboardSuppressed) return
view.evaluateJavascript("""
(function() {
function suppress(el) {
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'
|| el.isContentEditable) {
el.setAttribute('inputmode', 'none');
el.setAttribute('autocomplete', 'off');
}
}
document.querySelectorAll('input, textarea, [contenteditable]')
.forEach(suppress);
if (window.__jkKbObserver) window.__jkKbObserver.disconnect();
window.__jkKbObserver = new MutationObserver(function(muts) {
muts.forEach(function(m) {
m.addedNodes.forEach(function(n) {
if (n.nodeType === 1) {
suppress(n);
if (n.querySelectorAll)
n.querySelectorAll('input, textarea, [contenteditable]')
.forEach(suppress);
}
});
});
});
window.__jkKbObserver.observe(document.body || document.documentElement,
{ childList: true, subtree: true });
})();
""".trimIndent(), null)
view.evaluateJavascript(KeyboardPolicy.APPLY_JS, null)
}
private fun removeKeyboardSuppression(view: WebView) {
view.evaluateJavascript("""
(function() {
if (window.__jkKbObserver) {
window.__jkKbObserver.disconnect();
window.__jkKbObserver = null;
/** Hides the soft keyboard unless the focused field is one the policy lets a person type in. */
private fun enforceKeyboardPolicy() {
if (!isKeyboardSuppressed) return
webView.evaluateJavascript(KeyboardPolicy.ALLOWED_JS) { allowed ->
if (allowed != "true") hideKeyboard()
}
document.querySelectorAll('[inputmode="none"]').forEach(function(el) {
el.removeAttribute('inputmode');
});
})();
""".trimIndent(), null)
}
// ── Renderer Crash Recovery ─────────────────────────────────────────
@ -586,9 +555,9 @@ class MainActivity : AppCompatActivity() {
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
CookieManager.getInstance().flush()
injectKeyboardSuppression(view)
applyKeyboardPolicy(view)
view.evaluateJavascript(FileTransfers.BLOB_HOOK_JS, null)
hideKeyboard()
enforceKeyboardPolicy()
ensureWebViewFocus()
}

View File

@ -15,9 +15,10 @@ import android.webkit.WebView
* 1. onCheckIsTextEditor() → false: first signal to the IME not to show.
* 2. EditorInfo flags (NO_EXTRACT_UI, NO_FULLSCREEN): prevent fullscreen IME.
* 3. InputMethodManager.hideSoftInputFromWindow posted after each
* InputConnection request: force-hides if the IME appears anyway.
* 4. JavaScript inputmode="none" injection (in MainActivity): tells the
* Chromium renderer not to request the keyboard for HTML inputs.
* InputConnection request: force-hides if the IME appears anyway, unless
* [onInputConnectionRequested] decides the focused field allows typing.
* 4. JavaScript inputmode="none" injection (KeyboardPolicy): tells the
* Chromium renderer not to request the keyboard for scan fields.
* 5. Manifest stateAlwaysHidden|adjustNothing: system-level default.
*
* The real InputConnection from super is returned so that hardware key
@ -28,6 +29,9 @@ class NoSoftKeyboardWebView : WebView {
var keyboardSuppressed = true
/** Replaces the unconditional hide in layer 3 with a per-field check (MainActivity). */
var onInputConnectionRequested: (() -> Unit)? = null
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) :
@ -40,11 +44,13 @@ class NoSoftKeyboardWebView : WebView {
EditorInfo.IME_FLAG_NO_EXTRACT_UI or
EditorInfo.IME_FLAG_NO_FULLSCREEN
post {
onInputConnectionRequested?.invoke() ?: run {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE)
as InputMethodManager
imm.hideSoftInputFromWindow(windowToken, 0)
}
}
}
return ic
}