Allow the soft keyboard only on fields a person taps
Every field keeps inputmode="none" and the forced hide. A finger tap (pointerdown, also through its label) gives that field the site's own inputmode back before Chromium focuses it, so numeric fields get a numeric keypad; it is suppressed again on blur or a cancelled tap, so scans and programmatic focus never open the keyboard. 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:
parent
d22af8f256
commit
28518e229f
15
CLAUDE.md
15
CLAUDE.md
@ -6,23 +6,30 @@ Native Android app (Kotlin) that wraps `https://smart.joykoo.mx/` in a fullscree
|
|||||||
|
|
||||||
## Critical Design Decision
|
## 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. The only exception is a field a person taps with a finger (see Keyboard Policy below). Keyboard suppression uses multiple layers — do not remove any of them:
|
||||||
|
|
||||||
1. `NoSoftKeyboardWebView.onCheckIsTextEditor()` → `false`
|
1. `NoSoftKeyboardWebView.onCheckIsTextEditor()` → `false`
|
||||||
2. `EditorInfo` flags `IME_FLAG_NO_EXTRACT_UI | IME_FLAG_NO_FULLSCREEN` in `onCreateInputConnection()`
|
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
|
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 setting `inputmode="none"` on all `<input>`, `<textarea>`, and `contenteditable` elements via `MutationObserver`
|
4. JavaScript injection (`KeyboardPolicy`) setting `inputmode="none"` on every `<input>`, `<textarea>`, and `contenteditable` via `MutationObserver`, except the field currently tapped
|
||||||
5. Manifest `windowSoftInputMode="stateAlwaysHidden|adjustNothing"`
|
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.
|
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`: every field is suppressed (`inputmode="none"`) until a finger taps it. On `pointerdown` (also via its `<label>`) the field gets the site's own `inputmode` back — numeric fields get a numeric keypad — and `data-jk-kb="1"`, before Chromium focuses it. On focus loss, or a tap that never focuses it (scroll / `pointercancel`), it is suppressed again, so programmatic focus (scans, the site's own `.focus()`) never shows the keyboard. A field whose own `inputmode` is `none` stays suppressed even when tapped.
|
||||||
|
|
||||||
|
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
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
app/src/main/java/mx/joykoo/scanner/
|
app/src/main/java/mx/joykoo/scanner/
|
||||||
AppConfig.kt — URL, allowed hosts, admin triggers, scanner broadcasts (single config point)
|
AppConfig.kt — URL, allowed hosts, admin triggers, scanner broadcasts (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 layers 1–3 (real InputConnection is returned)
|
||||||
|
KeyboardPolicy.kt — Injected rule: all fields inputmode="none" except the one a finger tapped
|
||||||
ScannerIntegration.kt — Scanner lifecycle interface
|
ScannerIntegration.kt — Scanner lifecycle interface
|
||||||
BroadcastScannerIntegration.kt — Intent-output scans → JS injection (React-safe value setter)
|
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
|
KioskController.kt — Lock Task kiosk toggle (session-only until KIOSK_PERSISTED); device-owner full lockdown + HOME alias, else screen pinning
|
||||||
|
|||||||
136
app/src/main/java/mx/joykoo/scanner/KeyboardPolicy.kt
Normal file
136
app/src/main/java/mx/joykoo/scanner/KeyboardPolicy.kt
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
package mx.joykoo.scanner
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-field soft keyboard policy, injected into the page.
|
||||||
|
*
|
||||||
|
* Every field gets `inputmode="none"` (scanner input). A field a finger taps is the
|
||||||
|
* exception: on `pointerdown` it gets the site's own `inputmode` back (so numeric fields
|
||||||
|
* get a numeric keypad) and is marked `data-jk-kb="1"`, before Chromium focuses it and
|
||||||
|
* decides whether to show the keyboard. When the field loses focus it is suppressed again,
|
||||||
|
* so a later programmatic focus (e.g. the site focusing a field after a scan) never shows
|
||||||
|
* the keyboard. Native code reads the mark on the focused field ([ALLOWED_JS]) and hides
|
||||||
|
* the keyboard when it is missing.
|
||||||
|
*
|
||||||
|
* A field whose own `inputmode` is "none" stays keyboard-free even when tapped.
|
||||||
|
*/
|
||||||
|
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 enabled = false;
|
||||||
|
var tapped = null;
|
||||||
|
|
||||||
|
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 suppress(el) {
|
||||||
|
if (!isEditable(el)) return;
|
||||||
|
siteInputMode(el);
|
||||||
|
el.setAttribute('inputmode', 'none');
|
||||||
|
el.setAttribute('autocomplete', 'off');
|
||||||
|
el.removeAttribute('data-jk-kb');
|
||||||
|
}
|
||||||
|
function allow(el) {
|
||||||
|
var im = siteInputMode(el);
|
||||||
|
if (im === 'none') return;
|
||||||
|
if (im) el.setAttribute('inputmode', im); else el.removeAttribute('inputmode');
|
||||||
|
el.setAttribute('data-jk-kb', '1');
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
// The field a tap lands on, including through its <label>.
|
||||||
|
function fieldFor(target) {
|
||||||
|
if (!target || target.nodeType !== 1) return null;
|
||||||
|
var el = target.closest(EDITABLE);
|
||||||
|
if (!el) {
|
||||||
|
var label = target.closest('label');
|
||||||
|
el = label && (label.control || label.querySelector(EDITABLE));
|
||||||
|
}
|
||||||
|
return el && isEditable(el) ? el : null;
|
||||||
|
}
|
||||||
|
// A tap that never focused its field (scroll, cancelled press) must not
|
||||||
|
// leave it able to show the keyboard on a later programmatic focus.
|
||||||
|
function dropUnfocusedTap() {
|
||||||
|
if (tapped && document.activeElement !== tapped) {
|
||||||
|
suppress(tapped);
|
||||||
|
tapped = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('pointerdown', function(e) {
|
||||||
|
if (!enabled) return;
|
||||||
|
var el = fieldFor(e.target);
|
||||||
|
if (el !== tapped) dropUnfocusedTap();
|
||||||
|
if (el) {
|
||||||
|
allow(el);
|
||||||
|
tapped = el;
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
document.addEventListener('pointercancel', function() {
|
||||||
|
if (enabled) dropUnfocusedTap();
|
||||||
|
}, true);
|
||||||
|
// Any allowed field that loses focus is suppressed again — not only the
|
||||||
|
// latest tap, since tapping field B while A is focused moves `tapped` first.
|
||||||
|
document.addEventListener('focusout', function(e) {
|
||||||
|
var el = e.target;
|
||||||
|
if (!enabled || el.nodeType !== 1 || el.getAttribute('data-jk-kb') !== '1') return;
|
||||||
|
suppress(el);
|
||||||
|
if (el === tapped) tapped = null;
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
var observer = new MutationObserver(function(muts) {
|
||||||
|
muts.forEach(function(m) {
|
||||||
|
m.addedNodes.forEach(function(n) { each(n, suppress); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
kb = window.__jkKb = {
|
||||||
|
enable: function() {
|
||||||
|
enabled = true;
|
||||||
|
each(document.documentElement, function(el) {
|
||||||
|
if (el !== tapped) suppress(el);
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
},
|
||||||
|
disable: function() {
|
||||||
|
enabled = false;
|
||||||
|
tapped = null;
|
||||||
|
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';
|
||||||
|
})()"""
|
||||||
|
}
|
||||||
@ -126,7 +126,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
super.onWindowFocusChanged(hasFocus)
|
super.onWindowFocusChanged(hasFocus)
|
||||||
if (hasFocus) {
|
if (hasFocus) {
|
||||||
enterImmersiveMode()
|
enterImmersiveMode()
|
||||||
hideKeyboard()
|
enforceKeyboardPolicy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,6 +176,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
WebView.setWebContentsDebuggingEnabled(true)
|
WebView.setWebContentsDebuggingEnabled(true)
|
||||||
|
|
||||||
|
webView.onInputConnectionRequested = ::enforceKeyboardPolicy
|
||||||
webView.webViewClient = ScannerWebViewClient()
|
webView.webViewClient = ScannerWebViewClient()
|
||||||
webView.webChromeClient = ScannerWebChromeClient()
|
webView.webChromeClient = ScannerWebChromeClient()
|
||||||
webView.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ ->
|
webView.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ ->
|
||||||
@ -334,11 +335,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
webView.keyboardSuppressed = isKeyboardSuppressed
|
webView.keyboardSuppressed = isKeyboardSuppressed
|
||||||
|
|
||||||
if (isKeyboardSuppressed) {
|
if (isKeyboardSuppressed) {
|
||||||
injectKeyboardSuppression(webView)
|
applyKeyboardPolicy(webView)
|
||||||
hideKeyboard()
|
enforceKeyboardPolicy()
|
||||||
Toast.makeText(this, R.string.keyboard_disabled, Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, R.string.keyboard_disabled, Toast.LENGTH_SHORT).show()
|
||||||
} else {
|
} else {
|
||||||
removeKeyboardSuppression(webView)
|
webView.evaluateJavascript(KeyboardPolicy.DISABLE_JS, null)
|
||||||
Toast.makeText(this, R.string.keyboard_enabled, Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, R.string.keyboard_enabled, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -471,50 +472,18 @@ class MainActivity : AppCompatActivity() {
|
|||||||
imm.hideSoftInputFromWindow(webView.windowToken, 0)
|
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
|
if (!isKeyboardSuppressed) return
|
||||||
view.evaluateJavascript("""
|
view.evaluateJavascript(KeyboardPolicy.APPLY_JS, null)
|
||||||
(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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun removeKeyboardSuppression(view: WebView) {
|
/** Hides the soft keyboard unless the focused field is one the policy lets a person type in. */
|
||||||
view.evaluateJavascript("""
|
private fun enforceKeyboardPolicy() {
|
||||||
(function() {
|
if (!isKeyboardSuppressed) return
|
||||||
if (window.__jkKbObserver) {
|
webView.evaluateJavascript(KeyboardPolicy.ALLOWED_JS) { allowed ->
|
||||||
window.__jkKbObserver.disconnect();
|
if (allowed != "true") hideKeyboard()
|
||||||
window.__jkKbObserver = null;
|
}
|
||||||
}
|
|
||||||
document.querySelectorAll('[inputmode="none"]').forEach(function(el) {
|
|
||||||
el.removeAttribute('inputmode');
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
""".trimIndent(), null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Renderer Crash Recovery ─────────────────────────────────────────
|
// ── Renderer Crash Recovery ─────────────────────────────────────────
|
||||||
@ -586,9 +555,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
override fun onPageFinished(view: WebView, url: String?) {
|
override fun onPageFinished(view: WebView, url: String?) {
|
||||||
super.onPageFinished(view, url)
|
super.onPageFinished(view, url)
|
||||||
CookieManager.getInstance().flush()
|
CookieManager.getInstance().flush()
|
||||||
injectKeyboardSuppression(view)
|
applyKeyboardPolicy(view)
|
||||||
view.evaluateJavascript(FileTransfers.BLOB_HOOK_JS, null)
|
view.evaluateJavascript(FileTransfers.BLOB_HOOK_JS, null)
|
||||||
hideKeyboard()
|
enforceKeyboardPolicy()
|
||||||
ensureWebViewFocus()
|
ensureWebViewFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,9 +15,10 @@ import android.webkit.WebView
|
|||||||
* 1. onCheckIsTextEditor() → false: first signal to the IME not to show.
|
* 1. onCheckIsTextEditor() → false: first signal to the IME not to show.
|
||||||
* 2. EditorInfo flags (NO_EXTRACT_UI, NO_FULLSCREEN): prevent fullscreen IME.
|
* 2. EditorInfo flags (NO_EXTRACT_UI, NO_FULLSCREEN): prevent fullscreen IME.
|
||||||
* 3. InputMethodManager.hideSoftInputFromWindow posted after each
|
* 3. InputMethodManager.hideSoftInputFromWindow posted after each
|
||||||
* InputConnection request: force-hides if the IME appears anyway.
|
* InputConnection request: force-hides if the IME appears anyway, unless
|
||||||
* 4. JavaScript inputmode="none" injection (in MainActivity): tells the
|
* [onInputConnectionRequested] decides the focused field allows typing.
|
||||||
* Chromium renderer not to request the keyboard for HTML inputs.
|
* 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.
|
* 5. Manifest stateAlwaysHidden|adjustNothing: system-level default.
|
||||||
*
|
*
|
||||||
* The real InputConnection from super is returned so that hardware key
|
* The real InputConnection from super is returned so that hardware key
|
||||||
@ -28,6 +29,9 @@ class NoSoftKeyboardWebView : WebView {
|
|||||||
|
|
||||||
var keyboardSuppressed = true
|
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) : super(context)
|
||||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
||||||
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) :
|
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) :
|
||||||
@ -40,9 +44,11 @@ class NoSoftKeyboardWebView : WebView {
|
|||||||
EditorInfo.IME_FLAG_NO_EXTRACT_UI or
|
EditorInfo.IME_FLAG_NO_EXTRACT_UI or
|
||||||
EditorInfo.IME_FLAG_NO_FULLSCREEN
|
EditorInfo.IME_FLAG_NO_FULLSCREEN
|
||||||
post {
|
post {
|
||||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE)
|
onInputConnectionRequested?.invoke() ?: run {
|
||||||
as InputMethodManager
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE)
|
||||||
imm.hideSoftInputFromWindow(windowToken, 0)
|
as InputMethodManager
|
||||||
|
imm.hideSoftInputFromWindow(windowToken, 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ic
|
return ic
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user