Compare commits

..

1 Commits

Author SHA1 Message Date
caspar
e63de716cd Keep the site's download file name and offer to open saved files
All checks were successful
APK-BUILD/pipeline/head This commit looks good
WebView drops the <a download> name of page-made files, so downloads such as
the Verificar entrega Odoo CSV were saved as downloadfile.csv. The download
hook now records the name when the link is clicked and the app asks the page
for it. After saving, a dialog shows the file name and offers Abrir, which
uses the registered viewer or, for types nothing opens (CSV on the
scanners), launches the app set in AppConfig.FILE_OPEN_FALLBACK_APPS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:45:32 -04:00
8 changed files with 186 additions and 190 deletions

View File

@ -6,30 +6,23 @@ 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 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: The Android soft keyboard must NEVER appear. 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 — skipped only when the focused field carries `data-jk-kb="1"` 3. `InputMethodManager.hideSoftInputFromWindow()` posted after each InputConnection request and on page load / window focus
4. JavaScript injection (`KeyboardPolicy`) setting `inputmode="none"` on every `<input>`, `<textarea>`, and `contenteditable` via `MutationObserver`, except the field currently tapped 4. JavaScript injection setting `inputmode="none"` on all `<input>`, `<textarea>`, and `contenteditable` elements 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`: 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 layers 1–3 (real InputConnection is returned) NoSoftKeyboardWebView.kt — IME suppression via null InputConnection
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
@ -78,6 +71,7 @@ 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
@ -95,7 +89,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. Saved to Downloads, with a toast on completion. - 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).
- 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,6 +13,12 @@
<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,6 +60,14 @@ 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,6 +30,7 @@ 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
@ -90,22 +91,44 @@ 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 fileName = fileNameFor(url, contentDisposition, mimeType) val save: (String) -> Unit = when {
val save: () -> Unit = when { url.startsWith("blob:") -> { name -> readBlob(url, name, mimeType) }
url.startsWith("blob:") -> { { readBlob(url, fileName, mimeType) } } url.startsWith("data:") -> { name -> saveDataUrl(url, name) }
url.startsWith("data:") -> { { saveDataUrl(url, fileName) } } isAllowedHttps(url) -> { name -> enqueueDownload(url, userAgent, name, mimeType) }
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)) save() if (!permissions.allGranted(Permissions.STORAGE)) {
else toast(R.string.download_permission_denied) 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 {
@ -130,7 +153,11 @@ 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) {
toast(activity.getString(R.string.download_done, title)) showSaved(
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)
@ -230,21 +257,67 @@ 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), null) MediaScannerConnection.scanFile(activity, arrayOf(file.path), arrayOf(mimeType)) { _, uri ->
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
@ -260,9 +333,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
).replace(Regex("[\\\\/:*?\"<>|]"), "_") ).let(::sanitizeFileName)
if (name.contains('.')) return name if (name.contains('.')) return name
val ext = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } val ext = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it.substringBefore(';')) }
return if (ext != null) "$name.$ext" else name return if (ext != null) "$name.$ext" else name
} }
@ -405,7 +478,8 @@ 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;
@ -419,6 +493,26 @@ 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

@ -1,136 +0,0 @@
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';
})()"""
}

View File

@ -126,7 +126,7 @@ class MainActivity : AppCompatActivity() {
super.onWindowFocusChanged(hasFocus) super.onWindowFocusChanged(hasFocus)
if (hasFocus) { if (hasFocus) {
enterImmersiveMode() enterImmersiveMode()
enforceKeyboardPolicy() hideKeyboard()
} }
} }
@ -176,7 +176,6 @@ 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, _ ->
@ -335,11 +334,11 @@ class MainActivity : AppCompatActivity() {
webView.keyboardSuppressed = isKeyboardSuppressed webView.keyboardSuppressed = isKeyboardSuppressed
if (isKeyboardSuppressed) { if (isKeyboardSuppressed) {
applyKeyboardPolicy(webView) injectKeyboardSuppression(webView)
enforceKeyboardPolicy() hideKeyboard()
Toast.makeText(this, R.string.keyboard_disabled, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.keyboard_disabled, Toast.LENGTH_SHORT).show()
} else { } else {
webView.evaluateJavascript(KeyboardPolicy.DISABLE_JS, null) removeKeyboardSuppression(webView)
Toast.makeText(this, R.string.keyboard_enabled, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.keyboard_enabled, Toast.LENGTH_SHORT).show()
} }
} }
@ -472,18 +471,50 @@ class MainActivity : AppCompatActivity() {
imm.hideSoftInputFromWindow(webView.windowToken, 0) imm.hideSoftInputFromWindow(webView.windowToken, 0)
} }
/** Marks which fields may show the soft keyboard (see KeyboardPolicy). */ private fun injectKeyboardSuppression(view: WebView) {
private fun applyKeyboardPolicy(view: WebView) {
if (!isKeyboardSuppressed) return if (!isKeyboardSuppressed) return
view.evaluateJavascript(KeyboardPolicy.APPLY_JS, null) 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)
} }
/** Hides the soft keyboard unless the focused field is one the policy lets a person type in. */ private fun removeKeyboardSuppression(view: WebView) {
private fun enforceKeyboardPolicy() { view.evaluateJavascript("""
if (!isKeyboardSuppressed) return (function() {
webView.evaluateJavascript(KeyboardPolicy.ALLOWED_JS) { allowed -> if (window.__jkKbObserver) {
if (allowed != "true") hideKeyboard() window.__jkKbObserver.disconnect();
} window.__jkKbObserver = null;
}
document.querySelectorAll('[inputmode="none"]').forEach(function(el) {
el.removeAttribute('inputmode');
});
})();
""".trimIndent(), null)
} }
// ── Renderer Crash Recovery ───────────────────────────────────────── // ── Renderer Crash Recovery ─────────────────────────────────────────
@ -555,9 +586,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()
applyKeyboardPolicy(view) injectKeyboardSuppression(view)
view.evaluateJavascript(FileTransfers.BLOB_HOOK_JS, null) view.evaluateJavascript(FileTransfers.BLOB_HOOK_JS, null)
enforceKeyboardPolicy() hideKeyboard()
ensureWebViewFocus() ensureWebViewFocus()
} }

View File

@ -15,10 +15,9 @@ 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, unless * InputConnection request: force-hides if the IME appears anyway.
* [onInputConnectionRequested] decides the focused field allows typing. * 4. JavaScript inputmode="none" injection (in MainActivity): tells the
* 4. JavaScript inputmode="none" injection (KeyboardPolicy): tells the * Chromium renderer not to request the keyboard for HTML inputs.
* 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
@ -29,9 +28,6 @@ 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) :
@ -44,11 +40,9 @@ 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 {
onInputConnectionRequested?.invoke() ?: run { val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE)
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
as InputMethodManager imm.hideSoftInputFromWindow(windowToken, 0)
imm.hideSoftInputFromWindow(windowToken, 0)
}
} }
} }
return ic return ic

View File

@ -26,7 +26,12 @@
<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_done">Guardado en Descargas: %1$s</string> <string name="download_saved_title">Archivo guardado</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>