Add hidden on-screen keyboard toggle (5 taps top-right corner)
All checks were successful
APK-BUILD/pipeline/head This commit looks good

Tap the top-right 60dp corner 5 times within 3 seconds to toggle
the soft keyboard on or off. Default is off (suppressed). The toggle
controls all suppression layers: JS inputmode injection, the
MutationObserver, InputMethodManager hide calls, and the WebView's
EditorInfo flags. A toast confirms the current state in Spanish.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
caspar 2026-10-08 00:39:53 -04:00
parent b7cf7256ac
commit 619fa4f048
4 changed files with 81 additions and 14 deletions

View File

@ -12,6 +12,9 @@ object AppConfig {
"joykoo.mx", "joykoo.mx",
) )
/** Size in dp of the corner regions used for hidden tap gestures. */
const val GESTURE_CORNER_DP = 60
/** Number of rapid Back presses required to exit the app when there is no WebView history. */ /** Number of rapid Back presses required to exit the app when there is no WebView history. */
const val BACK_PRESS_EXIT_COUNT = 3 const val BACK_PRESS_EXIT_COUNT = 3
@ -23,4 +26,10 @@ object AppConfig {
/** Time window (ms) for admin tap sequence. */ /** Time window (ms) for admin tap sequence. */
const val ADMIN_TAP_WINDOW_MS = 3000L const val ADMIN_TAP_WINDOW_MS = 3000L
/** Number of taps on the top-right corner to toggle the on-screen keyboard. */
const val KEYBOARD_TOGGLE_TAP_COUNT = 5
/** Time window (ms) for keyboard toggle tap sequence. */
const val KEYBOARD_TOGGLE_TAP_WINDOW_MS = 3000L
} }

View File

@ -41,6 +41,10 @@ class MainActivity : AppCompatActivity() {
// Admin exit: rapid taps in the top-left corner // Admin exit: rapid taps in the top-left corner
private var adminTapTimestamps = mutableListOf<Long>() private var adminTapTimestamps = mutableListOf<Long>()
// Keyboard toggle: rapid taps in the top-right corner
private var keyboardToggleTapTimestamps = mutableListOf<Long>()
private var isKeyboardSuppressed = true
// Back-press exit: rapid presses when no WebView history // Back-press exit: rapid presses when no WebView history
private var backPressTimestamps = mutableListOf<Long>() private var backPressTimestamps = mutableListOf<Long>()
@ -213,11 +217,14 @@ class MainActivity : AppCompatActivity() {
override fun dispatchTouchEvent(ev: MotionEvent): Boolean { override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
if (ev.actionMasked == MotionEvent.ACTION_DOWN) { if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
// Top-left 60x60 dp region as the hidden admin area val threshold = (AppConfig.GESTURE_CORNER_DP * resources.displayMetrics.density).toInt()
val threshold = (60 * resources.displayMetrics.density).toInt()
if (ev.x < threshold && ev.y < threshold) { if (ev.x < threshold && ev.y < threshold) {
handleAdminTap() handleAdminTap()
} }
val screenWidth = resources.displayMetrics.widthPixels
if (ev.x > screenWidth - threshold && ev.y < threshold) {
handleKeyboardToggleTap()
}
} }
return super.dispatchTouchEvent(ev) return super.dispatchTouchEvent(ev)
} }
@ -234,7 +241,6 @@ class MainActivity : AppCompatActivity() {
} }
private fun showAdminExitDialog() { private fun showAdminExitDialog() {
// Structured so a PIN check can be added here before calling finish()
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle("Salir de la aplicación") .setTitle("Salir de la aplicación")
.setMessage("¿Desea cerrar la aplicación del escáner?") .setMessage("¿Desea cerrar la aplicación del escáner?")
@ -243,6 +249,35 @@ class MainActivity : AppCompatActivity() {
.show() .show()
} }
// ── Keyboard Toggle (hidden tap gesture, top-right corner) ──────────
private fun handleKeyboardToggleTap() {
val now = System.currentTimeMillis()
keyboardToggleTapTimestamps.add(now)
keyboardToggleTapTimestamps.removeAll {
now - it > AppConfig.KEYBOARD_TOGGLE_TAP_WINDOW_MS
}
if (keyboardToggleTapTimestamps.size >= AppConfig.KEYBOARD_TOGGLE_TAP_COUNT) {
keyboardToggleTapTimestamps.clear()
toggleKeyboard()
}
}
private fun toggleKeyboard() {
isKeyboardSuppressed = !isKeyboardSuppressed
webView.keyboardSuppressed = isKeyboardSuppressed
if (isKeyboardSuppressed) {
injectKeyboardSuppression(webView)
hideKeyboard()
Toast.makeText(this, R.string.keyboard_disabled, Toast.LENGTH_SHORT).show()
} else {
removeKeyboardSuppression(webView)
Toast.makeText(this, R.string.keyboard_enabled, Toast.LENGTH_SHORT).show()
}
}
// ── Lifecycle ─────────────────────────────────────────────────────── // ── Lifecycle ───────────────────────────────────────────────────────
override fun onResume() { override fun onResume() {
@ -291,15 +326,15 @@ class MainActivity : AppCompatActivity() {
} }
private fun hideKeyboard() { private fun hideKeyboard() {
if (!isKeyboardSuppressed) return
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(webView.windowToken, 0) imm.hideSoftInputFromWindow(webView.windowToken, 0)
} }
private fun injectKeyboardSuppression(view: WebView) { private fun injectKeyboardSuppression(view: WebView) {
if (!isKeyboardSuppressed) return
view.evaluateJavascript(""" view.evaluateJavascript("""
(function() { (function() {
if (window.__jkKbSuppressed) return;
window.__jkKbSuppressed = true;
function suppress(el) { function suppress(el) {
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'
|| el.isContentEditable) { || el.isContentEditable) {
@ -309,7 +344,8 @@ class MainActivity : AppCompatActivity() {
} }
document.querySelectorAll('input, textarea, [contenteditable]') document.querySelectorAll('input, textarea, [contenteditable]')
.forEach(suppress); .forEach(suppress);
new MutationObserver(function(muts) { if (window.__jkKbObserver) window.__jkKbObserver.disconnect();
window.__jkKbObserver = new MutationObserver(function(muts) {
muts.forEach(function(m) { muts.forEach(function(m) {
m.addedNodes.forEach(function(n) { m.addedNodes.forEach(function(n) {
if (n.nodeType === 1) { if (n.nodeType === 1) {
@ -320,12 +356,27 @@ class MainActivity : AppCompatActivity() {
} }
}); });
}); });
}).observe(document.body || document.documentElement, });
window.__jkKbObserver.observe(document.body || document.documentElement,
{ childList: true, subtree: true }); { childList: true, subtree: true });
})(); })();
""".trimIndent(), null) """.trimIndent(), null)
} }
private fun removeKeyboardSuppression(view: WebView) {
view.evaluateJavascript("""
(function() {
if (window.__jkKbObserver) {
window.__jkKbObserver.disconnect();
window.__jkKbObserver = null;
}
document.querySelectorAll('[inputmode="none"]').forEach(function(el) {
el.removeAttribute('inputmode');
});
})();
""".trimIndent(), null)
}
// ── WebViewClient ─────────────────────────────────────────────────── // ── WebViewClient ───────────────────────────────────────────────────
private inner class ScannerWebViewClient : WebViewClient() { private inner class ScannerWebViewClient : WebViewClient() {

View File

@ -26,6 +26,8 @@ import android.webkit.WebView
*/ */
class NoSoftKeyboardWebView : WebView { class NoSoftKeyboardWebView : WebView {
var keyboardSuppressed = true
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) :
@ -33,17 +35,20 @@ class NoSoftKeyboardWebView : WebView {
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? { override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
val ic = super.onCreateInputConnection(outAttrs) val ic = super.onCreateInputConnection(outAttrs)
if (keyboardSuppressed) {
outAttrs.imeOptions = outAttrs.imeOptions or outAttrs.imeOptions = outAttrs.imeOptions or
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) as InputMethodManager val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE)
as InputMethodManager
imm.hideSoftInputFromWindow(windowToken, 0) imm.hideSoftInputFromWindow(windowToken, 0)
} }
}
return ic return ic
} }
override fun onCheckIsTextEditor(): Boolean { override fun onCheckIsTextEditor(): Boolean {
return false return !keyboardSuppressed
} }
} }

View File

@ -4,4 +4,6 @@
<string name="error_no_connection">No se puede conectar</string> <string name="error_no_connection">No se puede conectar</string>
<string name="error_check_wifi">Verifique la conexión Wi-Fi e intente de nuevo.</string> <string name="error_check_wifi">Verifique la conexión Wi-Fi e intente de nuevo.</string>
<string name="retry">Reintentar</string> <string name="retry">Reintentar</string>
<string name="keyboard_enabled">Teclado en pantalla activado</string>
<string name="keyboard_disabled">Teclado en pantalla desactivado</string>
</resources> </resources>