Compare commits

..

1 Commits

Author SHA1 Message Date
caspar
4b23724639 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>
2026-10-12 15:02:45 -04:00
8 changed files with 162 additions and 186 deletions

View File

@ -6,23 +6,36 @@ 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. 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` 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 scan `<input>`, `<textarea>`, and `contenteditable` via `MutationObserver`
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` 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 ## 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 per-field rule: scan fields get inputmode="none", manual fields keep the keyboard
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
@ -71,7 +84,6 @@ All configurable values are in `AppConfig.kt`:
- `ADMIN_BARCODE` — scanning this value (Intent mode) opens the admin dialog - `ADMIN_BARCODE` — scanning this value (Intent mode) opens the admin dialog
- `KIOSK_BARCODE` — scanning this value (Intent mode) toggles kiosk lockdown - `KIOSK_BARCODE` — scanning this value (Intent mode) toggles kiosk lockdown
- `RENDERER_CRASH_LIMIT` / `RENDERER_CRASH_WINDOW_MS` — WebView crash auto-recovery limit - `RENDERER_CRASH_LIMIT` / `RENDERER_CRASH_WINDOW_MS` — WebView crash auto-recovery limit
- `FILE_OPEN_FALLBACK_APPS` — app launched by a download's "Abrir" when nothing opens that file type (also add it to `<queries>` in the manifest)
## Navigation Rules ## Navigation Rules
@ -89,7 +101,7 @@ All configurable values are in `AppConfig.kt`:
## Files & Permissions ## Files & Permissions
- Permissions are requested at first launch (camera; storage on Android ≤ 9) and again when a feature needs one. - Permissions are requested at first launch (camera; storage on Android ≤ 9) and again when a feature needs one.
- Downloads: https on allowed hosts via `DownloadManager` (session cookie forwarded); `data:` decoded natively; `blob:` read in-page and returned via `evaluateJavascript` polling (no JS interface). A document-start hook keeps blobs readable if the site revokes the URL immediately, and records the `<a download="name">` file name (WebView does not pass it on; without it files save as `downloadfile.csv`). Saved to Downloads, then an "Archivo guardado" dialog offers **Abrir**: the app registered for the type, else the app in `AppConfig.FILE_OPEN_FALLBACK_APPS` (CSV → CSV Editor from F-Droid, which has no open-with support) is launched. Fallback apps must also be declared under `<queries>` in the manifest (Android 11+ package visibility). - Downloads: https on allowed hosts via `DownloadManager` (session cookie forwarded); `data:` decoded natively; `blob:` read in-page and returned via `evaluateJavascript` polling (no JS interface). A document-start hook keeps blobs readable if the site revokes the URL immediately. Saved to Downloads, with a toast on completion.
- Uploads: `<input type=file>` image fields show an in-app "Tomar foto / Elegir de la galería" dialog (the system chooser hid the camera behind the gallery picker); `capture` opens the camera directly. If camera permission is denied, a dialog offers app Settings or the file picker. Photos are saved to Pictures/Joykoo. - Uploads: `<input type=file>` image fields show an in-app "Tomar foto / Elegir de la galería" dialog (the system chooser hid the camera behind the gallery picker); `capture` opens the camera directly. If camera permission is denied, a dialog offers app Settings or the file picker. Photos are saved to Pictures/Joykoo.
- In-page camera (`getUserMedia`) is granted to allowed hosts only, video only. - In-page camera (`getUserMedia`) is granted to allowed hosts only, video only.

View File

@ -13,12 +13,6 @@
<uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- Apps in AppConfig.FILE_OPEN_FALLBACK_APPS: Android 11+ hides other apps unless they
are declared here, so launching them would fail with ActivityNotFoundException. -->
<queries>
<package android:name="tech.brianrayburn.csv_editor" />
</queries>
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"

View File

@ -60,14 +60,6 @@ object AppConfig {
"SCAN_BARCODE1", "scannerdata", "data", "SCAN_BARCODE1", "scannerdata", "data",
) )
/** App launched by a saved download's "Abrir" button when no installed app registers to
* open that file type, keyed by extension; the user then opens the file from Descargas.
* CSV Editor (F-Droid) has no "open with" support, and nothing else opens .csv here.
* Each package must also be listed under <queries> in AndroidManifest.xml. */
val FILE_OPEN_FALLBACK_APPS = mapOf(
"csv" to "tech.brianrayburn.csv_editor",
)
/** Press Enter after a broadcast scan is inserted, like the keystroke-mode suffix. */ /** Press Enter after a broadcast scan is inserted, like the keystroke-mode suffix. */
const val SCANNER_SEND_ENTER = true const val SCANNER_SEND_ENTER = true

View File

@ -30,7 +30,6 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import org.json.JSONObject import org.json.JSONObject
import org.json.JSONTokener
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@ -91,44 +90,22 @@ class FileTransfers(
/** Wire to WebView.setDownloadListener. */ /** Wire to WebView.setDownloadListener. */
fun onDownloadStart(url: String, userAgent: String, contentDisposition: String?, mimeType: String?) { fun onDownloadStart(url: String, userAgent: String, contentDisposition: String?, mimeType: String?) {
val save: (String) -> Unit = when { val fileName = fileNameFor(url, contentDisposition, mimeType)
url.startsWith("blob:") -> { name -> readBlob(url, name, mimeType) } val save: () -> Unit = when {
url.startsWith("data:") -> { name -> saveDataUrl(url, name) } url.startsWith("blob:") -> { { readBlob(url, fileName, mimeType) } }
isAllowedHttps(url) -> { name -> enqueueDownload(url, userAgent, name, mimeType) } url.startsWith("data:") -> { { saveDataUrl(url, fileName) } }
isAllowedHttps(url) -> { { enqueueDownload(url, userAgent, fileName, mimeType) } }
else -> { else -> {
Log.w(TAG, "Blocked download from $url") Log.w(TAG, "Blocked download from $url")
return return
} }
} }
permissions.withPermissions(Permissions.STORAGE) { permissions.withPermissions(Permissions.STORAGE) {
if (!permissions.allGranted(Permissions.STORAGE)) { if (permissions.allGranted(Permissions.STORAGE)) save()
toast(R.string.download_permission_denied) else toast(R.string.download_permission_denied)
return@withPermissions
}
// WebView drops the <a download="name"> of page-made files, so ask the page
// (BLOB_HOOK_JS records it on click); the server's disposition is the fallback.
siteFileName(url) { siteName ->
save(siteName ?: fileNameFor(url, contentDisposition, mimeType))
}
} }
} }
private fun siteFileName(url: String, callback: (String?) -> Unit) {
webView().evaluateJavascript("""
(function(url) {
var names = window.__jkDownloadNames, name = names && names[url];
if (names) delete names[url];
return name || '';
})(${JSONObject.quote(url)});
""".trimIndent()) { json ->
val name = (try { JSONTokener(json).nextValue() as? String } catch (_: Exception) { null })
?.let(::sanitizeFileName)
callback(name?.takeIf { it.isNotBlank() })
}
}
private fun sanitizeFileName(name: String) = name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim()
private fun enqueueDownload(url: String, userAgent: String, fileName: String, mimeType: String?) { private fun enqueueDownload(url: String, userAgent: String, fileName: String, mimeType: String?) {
try { try {
val request = DownloadManager.Request(Uri.parse(url)).apply { val request = DownloadManager.Request(Uri.parse(url)).apply {
@ -153,11 +130,7 @@ class FileTransfers(
val status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) val status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
val title = c.getString(c.getColumnIndexOrThrow(DownloadManager.COLUMN_TITLE)) val title = c.getString(c.getColumnIndexOrThrow(DownloadManager.COLUMN_TITLE))
if (status == DownloadManager.STATUS_SUCCESSFUL) { if (status == DownloadManager.STATUS_SUCCESSFUL) {
showSaved( toast(activity.getString(R.string.download_done, title))
title,
downloadManager.getUriForDownloadedFile(id),
downloadManager.getMimeTypeForDownloadedFile(id) ?: "application/octet-stream"
)
} else { } else {
Log.e(TAG, "Download $id ended with status $status") Log.e(TAG, "Download $id ended with status $status")
toast(R.string.download_failed) toast(R.string.download_failed)
@ -257,67 +230,21 @@ class FileTransfers(
values.clear() values.clear()
values.put(MediaStore.Downloads.IS_PENDING, 0) values.put(MediaStore.Downloads.IS_PENDING, 0)
resolver.update(uri, values, null, null) resolver.update(uri, values, null, null)
// MediaStore renames duplicates ("name (1).csv"); show the name actually used.
showSaved(displayName(uri) ?: fileName, uri, mimeType)
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
dir.mkdirs() dir.mkdirs()
val file = uniqueFile(dir, fileName) val file = uniqueFile(dir, fileName)
file.writeBytes(bytes) file.writeBytes(bytes)
MediaScannerConnection.scanFile(activity, arrayOf(file.path), arrayOf(mimeType)) { _, uri -> MediaScannerConnection.scanFile(activity, arrayOf(file.path), arrayOf(mimeType), null)
handler.post { showSaved(file.name, uri, mimeType) }
}
} }
toast(activity.getString(R.string.download_done, fileName))
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Saving $fileName failed", e) Log.e(TAG, "Saving $fileName failed", e)
toast(R.string.download_failed) toast(R.string.download_failed)
} }
} }
private fun displayName(uri: Uri): String? =
activity.contentResolver.query(uri, arrayOf(MediaStore.Downloads.DISPLAY_NAME), null, null, null)
?.use { if (it.moveToFirst()) it.getString(0) else null }
/** Tells the user where the file went and offers to open it. */
private fun showSaved(name: String, uri: Uri?, mimeType: String) {
if (activity.isFinishing) return
val dialog = AlertDialog.Builder(activity)
.setTitle(R.string.download_saved_title)
.setMessage(activity.getString(R.string.download_saved_message, name))
.setNegativeButton(R.string.download_close, null)
if (uri != null) dialog.setPositiveButton(R.string.download_open) { _, _ -> openFile(name, uri, mimeType) }
dialog.show()
}
/**
* Opens the file in whatever app handles its type. Many file types (CSV on these
* scanners) have no such app, so AppConfig.FILE_OPEN_FALLBACK_APPS names an app to
* launch instead, where the user opens the file from Descargas themselves.
*/
private fun openFile(name: String, uri: Uri, mimeType: String) {
val view = Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, mimeType.substringBefore(';'))
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
try {
activity.startActivity(view)
return
} catch (_: ActivityNotFoundException) {
Log.i(TAG, "No viewer for $mimeType, trying fallback app")
}
// An implicit MAIN/LAUNCHER intent never matches: startActivity adds CATEGORY_DEFAULT.
val fallback = AppConfig.FILE_OPEN_FALLBACK_APPS[name.substringAfterLast('.', "").lowercase()]
val launch = fallback?.let { activity.packageManager.getLaunchIntentForPackage(it) }
if (launch != null) {
activity.startActivity(launch)
toast(activity.getString(R.string.download_open_manually, name))
return
}
Log.w(TAG, "No app opens $name (fallback ${fallback ?: "none"} not installed)")
toast(R.string.download_no_app)
}
private fun uniqueFile(dir: File, name: String): File { private fun uniqueFile(dir: File, name: String): File {
var file = File(dir, name) var file = File(dir, name)
var n = 1 var n = 1
@ -333,9 +260,9 @@ class FileTransfers(
// guessFileName only understands http(s); blob/data names come from the disposition. // guessFileName only understands http(s); blob/data names come from the disposition.
val name = URLUtil.guessFileName( val name = URLUtil.guessFileName(
if (url.startsWith("http")) url else "", contentDisposition, mimeType if (url.startsWith("http")) url else "", contentDisposition, mimeType
).let(::sanitizeFileName) ).replace(Regex("[\\\\/:*?\"<>|]"), "_")
if (name.contains('.')) return name if (name.contains('.')) return name
val ext = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it.substringBefore(';')) } val ext = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
return if (ext != null) "$name.$ext" else name return if (ext != null) "$name.$ext" else name
} }
@ -478,8 +405,7 @@ class FileTransfers(
private val IMAGE_EXTENSIONS = setOf(".jpg", ".jpeg", ".png", ".webp", ".heic", ".gif") private val IMAGE_EXTENSIONS = setOf(".jpg", ".jpeg", ".png", ".webp", ".heic", ".gif")
/** Injected at document start: remembers blobs behind object URLs and delays /** Injected at document start: remembers blobs behind object URLs and delays
* revocation, so a download whose URL the site revokes immediately can still be read, * revocation, so a download whose URL the site revokes immediately can still be read. */
* and records the `download` file name of clicked links. */
val BLOB_HOOK_JS = """ val BLOB_HOOK_JS = """
(function() { (function() {
if (window.__jkBlobs) return; if (window.__jkBlobs) return;
@ -493,26 +419,6 @@ class FileTransfers(
URL.revokeObjectURL = function(url) { URL.revokeObjectURL = function(url) {
setTimeout(function() { delete blobs[url]; revoke.call(URL, url); }, 60000); setTimeout(function() { delete blobs[url]; revoke.call(URL, url); }, 60000);
}; };
// File names from <a download="name">, which WebView does not pass on.
// Covers a.click() and dispatchEvent on detached links, and real taps.
var names = window.__jkDownloadNames = {};
function remember(a) {
if (a && a.hasAttribute('download') && a.href) names[a.href] = a.getAttribute('download');
}
var click = HTMLAnchorElement.prototype.click;
HTMLAnchorElement.prototype.click = function() {
remember(this);
return click.apply(this, arguments);
};
var dispatch = EventTarget.prototype.dispatchEvent;
HTMLAnchorElement.prototype.dispatchEvent = function(ev) {
if (ev && ev.type === 'click') remember(this);
return dispatch.apply(this, arguments);
};
document.addEventListener('click', function(e) {
var a = e.target && e.target.closest && e.target.closest('a[download]');
if (a) remember(a);
}, true);
})(); })();
""".trimIndent() """.trimIndent()
} }

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

View File

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

View File

@ -26,12 +26,7 @@
<string name="kiosk_enabled_pinning">Modo kiosco activado (fijación de pantalla: el dispositivo no está configurado como propietario)</string> <string name="kiosk_enabled_pinning">Modo kiosco activado (fijación de pantalla: el dispositivo no está configurado como propietario)</string>
<string name="kiosk_disabled">Modo kiosco desactivado</string> <string name="kiosk_disabled">Modo kiosco desactivado</string>
<string name="download_started">Descargando %1$s…</string> <string name="download_started">Descargando %1$s…</string>
<string name="download_saved_title">Archivo guardado</string> <string name="download_done">Guardado en Descargas: %1$s</string>
<string name="download_saved_message">Se guardó en la carpeta Descargas:\n%1$s</string>
<string name="download_open">Abrir</string>
<string name="download_close">Cerrar</string>
<string name="download_open_manually">Abra Descargas › %1$s</string>
<string name="download_no_app">No hay ninguna app para abrir este archivo</string>
<string name="download_failed">No se pudo descargar el archivo</string> <string name="download_failed">No se pudo descargar el archivo</string>
<string name="download_permission_denied">Se necesita permiso de almacenamiento para guardar archivos</string> <string name="download_permission_denied">Se necesita permiso de almacenamiento para guardar archivos</string>
<string name="upload_title">Agregar foto</string> <string name="upload_title">Agregar foto</string>