diff --git a/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx
index 0bdb88b5f..1cca6573a 100644
--- a/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx
+++ b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx
@@ -581,6 +581,9 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane
+
{t(
diff --git a/openless-all/app/android/frontend/lib/androidTypes.ts b/openless-all/app/android/frontend/lib/androidTypes.ts
index ac108bc53..1faf28f4f 100644
--- a/openless-all/app/android/frontend/lib/androidTypes.ts
+++ b/openless-all/app/android/frontend/lib/androidTypes.ts
@@ -1,7 +1,7 @@
/** Android-specific preference and status types (mirrors Rust IPC payloads). */
export type AndroidInsertStrategy = 'accessibility' | 'clipboard';
-export type AndroidOverlayTrigger = 'background' | 'keyboard' | 'always';
+export type AndroidOverlayTrigger = 'background' | 'keyboard' | 'always' | 'never';
export type AndroidOverlayActivationMode = 'tap' | 'long_press';
export type AndroidOverlayLeftSwipeAction = 'translation' | 'style_pack';
export type AndroidOverlayCancelSwipeDirection = 'up' | 'down';
diff --git a/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt b/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt
index 5d490db01..c7603489a 100644
--- a/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt
+++ b/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt
@@ -64,6 +64,10 @@ class OpenLessAccessibilityService : AccessibilityService() {
private fun refreshVocabularyCard() {
val request = ++vocabularyCardRequest
+ if (!OpenLessAndroidPreferences.overlayDisplayAllowed(this)) {
+ hideVocabularyCard()
+ return
+ }
requestVocabulary("pending") { ok, response ->
if (request == vocabularyCardRequest) showVocabularyCard(if (ok) response?.getString("json") ?: "[]" else "[]")
}
@@ -172,6 +176,7 @@ class OpenLessAccessibilityService : AccessibilityService() {
private fun showVocabularyCard(json: String) {
hideVocabularyCard()
+ if (!OpenLessAndroidPreferences.overlayDisplayAllowed(this)) return
val entries = try { org.json.JSONArray(json) } catch (_: Exception) { return }
val now = System.currentTimeMillis()
val pending = (0 until entries.length()).map { entries.getJSONObject(it) }.filter { it.optLong("expiresAtMs") > now }
@@ -752,7 +757,7 @@ class OpenLessAccessibilityService : AccessibilityService() {
}
companion object {
- private fun sendVocabularyCommand(operation: String, generation: Long = 0L) {
+ internal fun sendVocabularyCommand(operation: String, generation: Long = 0L) {
val context = OpenLessAppContext.context ?: return
val intent = Intent(context, OpenLessAccessibilityCommandReceiver::class.java).apply {
action = OpenLessAccessibilityCommandReceiver.ACTION_VOCABULARY
diff --git a/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt b/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt
index 5cbcd8c91..18ac36dd9 100644
--- a/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt
+++ b/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt
@@ -20,7 +20,7 @@ object OpenLessAndroidPreferences {
private const val DEFAULT_OVERLAY_SIZE_DP = 72
private const val MIN_OVERLAY_SIZE_DP = 48
private const val MAX_OVERLAY_SIZE_DP = 120
- private val VALID_OVERLAY_TRIGGERS = setOf("background", "always")
+ private val VALID_OVERLAY_TRIGGERS = setOf("background", "always", "never")
private val VALID_OVERLAY_ACTIVATION_MODES = setOf("tap", "long_press")
private val VALID_OVERLAY_LEFT_SWIPE_ACTIONS = setOf("translation", "style_pack")
private val VALID_OVERLAY_CANCEL_SWIPE_DIRECTIONS = setOf("up", "down")
@@ -35,6 +35,13 @@ object OpenLessAndroidPreferences {
return value.takeIf { it in VALID_OVERLAY_TRIGGERS }
}
+ fun overlayDisplayAllowed(context: Context): Boolean {
+ return shouldDisplayOverlay(overlayTriggerMode(context))
+ }
+
+ /** The `never` mode blocks every automatic overlay creation path. */
+ internal fun shouldDisplayOverlay(trigger: String?): Boolean = trigger != "never"
+
/** True when preferences still store legacy `"keyboard"` (before migration). */
fun isKeyboardOverlayTrigger(context: Context): Boolean {
return readPreferenceString(context, KEY_OVERLAY_TRIGGER) == "keyboard"
diff --git a/openless-all/app/android/kotlin/OpenLessOverlayService.kt b/openless-all/app/android/kotlin/OpenLessOverlayService.kt
index 3488da49b..2efd878f9 100644
--- a/openless-all/app/android/kotlin/OpenLessOverlayService.kt
+++ b/openless-all/app/android/kotlin/OpenLessOverlayService.kt
@@ -106,7 +106,10 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
when (intent?.action) {
null, ACTION_SHOW -> showOverlay()
ACTION_START_RECORDING -> {
- showOverlay()
+ if (!showOverlay()) {
+ stopSelf(startId)
+ return START_NOT_STICKY
+ }
if (!tryPromoteRecordingForeground()) {
abortRecordingStart(startId)
return START_NOT_STICKY
@@ -119,7 +122,13 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
stopSelf(startId)
}
ACTION_REPLACE_OVERLAY -> replaceOverlay()
- ACTION_TOGGLE_EXPAND -> handleIconClick()
+ ACTION_TOGGLE_EXPAND -> {
+ if (overlayDisplayAllowed()) {
+ handleIconClick()
+ } else {
+ hideOverlay()
+ }
+ }
ACTION_KEYBOARD_CHANGED -> handleKeyboardChanged(intent)
ACTION_REFRESH_LAYOUT -> refreshOverlayLayout()
}
@@ -191,7 +200,12 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
}
}
- private fun showOverlay() = withOverlayLock {
+ private fun showOverlay(): Boolean = withOverlayLock {
+ if (!overlayDisplayAllowed()) {
+ clearAllOverlayRoots()
+ Log.i(TAG, "overlay show skipped by preference")
+ return@withOverlayLock false
+ }
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
reconcileOverlayRoots()
overlayRoots.lastOrNull()?.let { existing ->
@@ -199,7 +213,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
if (params != null) {
refreshExistingOverlayLayout(existing, params)
Log.i(TAG, "overlay already shown roots=${overlayRoots.size}")
- return@withOverlayLock
+ return@withOverlayLock true
}
removeOverlayRoot(existing)
synchronized(overlayRoots) {
@@ -220,6 +234,10 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
if (removed > 0) {
Log.i(TAG, "overlay replace cleared removed=$removed")
}
+ if (!overlayDisplayAllowed()) {
+ Log.i(TAG, "overlay replace skipped by preference")
+ return@withOverlayLock
+ }
if (!canDrawOverlays()) {
Log.i(TAG, "overlay replace skipped no overlay permission")
return@withOverlayLock
@@ -235,6 +253,9 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
private fun hideOverlay() = withOverlayLock {
val removed = clearAllOverlayRoots()
+ if (!overlayDisplayAllowed()) {
+ OpenLessAccessibilityService.sendVocabularyCommand("refresh")
+ }
if (removed > 0) {
Log.i(TAG, "overlay hidden removed=$removed")
}
@@ -321,6 +342,10 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
return OpenLessPermissionBridge.canDrawOverlaysSafely(this)
}
+ private fun overlayDisplayAllowed(): Boolean {
+ return OpenLessAndroidPreferences.overlayDisplayAllowed(this)
+ }
+
private fun refreshExistingOverlayLayout(
root: FrameLayout,
params: WindowManager.LayoutParams,
@@ -338,6 +363,11 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
private fun refreshOverlayLayout() = withOverlayLock {
windowManager = windowManager ?: getSystemService(WINDOW_SERVICE) as WindowManager
reconcileOverlayRoots()
+ if (!overlayDisplayAllowed()) {
+ clearAllOverlayRoots()
+ Log.i(TAG, "overlay refresh skipped by preference")
+ return@withOverlayLock
+ }
val root = overlayRoots.lastOrNull() ?: rootView
if (root == null || !root.isAttachedToWindow) {
Log.i(TAG, "overlay refresh skipped roots=0")
@@ -752,7 +782,9 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
}
private fun startRecordingFromOverlay(translation: Boolean = false) {
- showOverlay()
+ if (!showOverlay()) {
+ return
+ }
if (tryPromoteRecordingForeground()) {
beginDictationFromOverlay(translation)
return
diff --git a/openless-all/app/android/kotlin/test/OpenLessOverlayStateTest.kt b/openless-all/app/android/kotlin/test/OpenLessOverlayStateTest.kt
index 432332987..108d84336 100644
--- a/openless-all/app/android/kotlin/test/OpenLessOverlayStateTest.kt
+++ b/openless-all/app/android/kotlin/test/OpenLessOverlayStateTest.kt
@@ -13,4 +13,12 @@ class OpenLessOverlayStateTest {
assertFalse(shouldIgnoreOverlayRecordingFrame(true, "voice edit capture failed"))
assertFalse(shouldIgnoreOverlayRecordingFrame(false, null))
}
+
+ @Test
+ fun neverTriggerBlocksOverlayDisplayButOtherModesRemainAvailable() {
+ assertFalse(OpenLessAndroidPreferences.shouldDisplayOverlay("never"))
+ assertTrue(OpenLessAndroidPreferences.shouldDisplayOverlay("background"))
+ assertTrue(OpenLessAndroidPreferences.shouldDisplayOverlay("always"))
+ assertTrue(OpenLessAndroidPreferences.shouldDisplayOverlay(null))
+ }
}
diff --git a/openless-all/app/crates/openless-core/src/android_types.rs b/openless-all/app/crates/openless-core/src/android_types.rs
index 4ba4716a8..27f6f274c 100644
--- a/openless-all/app/crates/openless-core/src/android_types.rs
+++ b/openless-all/app/crates/openless-core/src/android_types.rs
@@ -17,6 +17,7 @@ pub enum AndroidOverlayTrigger {
Background,
Keyboard,
Always,
+ Never,
}
impl AndroidOverlayTrigger {
@@ -399,6 +400,56 @@ mod android_overlay_tests {
},
);
}
+
+ #[test]
+ fn never_trigger_is_preserved_by_normalization_and_json() {
+ assert_eq!(
+ AndroidOverlayTrigger::Never.normalized(),
+ AndroidOverlayTrigger::Never,
+ );
+ assert_eq!(
+ serde_json::to_string(&AndroidOverlayTrigger::Never).unwrap(),
+ "\"never\"",
+ );
+ assert_eq!(
+ serde_json::from_str::("\"never\"").unwrap(),
+ AndroidOverlayTrigger::Never,
+ );
+ }
+
+ #[test]
+ fn default_overlay_trigger_remains_background() {
+ assert_eq!(
+ default_android_overlay_trigger(),
+ AndroidOverlayTrigger::Background,
+ );
+ }
+
+ #[test]
+ fn every_distinct_trigger_transition_is_classified() {
+ let triggers = [
+ AndroidOverlayTrigger::Background,
+ AndroidOverlayTrigger::Always,
+ AndroidOverlayTrigger::Never,
+ ];
+ for from in triggers {
+ for to in triggers {
+ let expected = if from == to {
+ AndroidOverlaySettingsAction::None
+ } else {
+ AndroidOverlaySettingsAction::Transition { from, to }
+ };
+ assert_eq!(
+ classify_android_overlay_settings_change(
+ &overlay_prefs(from, 72, AndroidOverlayActivationMode::Tap),
+ &overlay_prefs(to, 72, AndroidOverlayActivationMode::Tap),
+ ),
+ expected,
+ "unexpected transition from {from:?} to {to:?}",
+ );
+ }
+ }
+ }
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
diff --git a/openless-all/app/scripts/android-overlay-never-contract.test.mjs b/openless-all/app/scripts/android-overlay-never-contract.test.mjs
new file mode 100644
index 000000000..3e652db6a
--- /dev/null
+++ b/openless-all/app/scripts/android-overlay-never-contract.test.mjs
@@ -0,0 +1,35 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+
+const source = (name) =>
+ readFileSync(new URL(`../android/kotlin/${name}.kt`, import.meta.url), 'utf8');
+const accessibility = source('OpenLessAccessibilityService');
+const overlay = source('OpenLessOverlayService');
+
+assert.match(
+ accessibility,
+ /private fun refreshVocabularyCard\(\)\s*\{\s*val request = \+\+vocabularyCardRequest\s*if \(!OpenLessAndroidPreferences\.overlayDisplayAllowed\(this\)\)\s*\{\s*hideVocabularyCard\(\)\s*return\s*\}\s*requestVocabulary\("pending"\)/,
+ 'Disabling overlays must invalidate old replies and hide the card without waiting for pending-suggestion IPC',
+);
+assert.match(
+ accessibility,
+ /private fun showVocabularyCard\(json: String\)\s*\{\s*hideVocabularyCard\(\)\s*if \(!OpenLessAndroidPreferences\.overlayDisplayAllowed\(this\)\) return/,
+ 'Every vocabulary card render, including delayed IPC replies, must hide the old card and honor never before creating a new one',
+);
+assert.match(
+ overlay,
+ /private fun hideOverlay\(\)[\s\S]*?clearAllOverlayRoots\(\)[\s\S]*?if \(!overlayDisplayAllowed\(\)\)\s*\{\s*OpenLessAccessibilityService\.sendVocabularyCommand\("refresh"\)/,
+ 'Hiding overlays in never mode must also refresh cards in the accessibility process',
+);
+assert.match(
+ accessibility,
+ /internal fun sendVocabularyCommand\([\s\S]*?ACTION_VOCABULARY[\s\S]*?context\.sendBroadcast\(intent\)/,
+ 'The overlay service must reuse the cross-process vocabulary command',
+);
+assert.match(
+ accessibility,
+ /"refresh" -> service\.refreshVocabularyCard\(\)/,
+ 'The accessibility process must refresh the card after a preference change',
+);
+
+console.log('android overlay never contract tests passed');
diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs
index 2d68b67f9..0bc5d0c20 100644
--- a/openless-all/app/src-tauri/src/coordinator.rs
+++ b/openless-all/app/src-tauri/src/coordinator.rs
@@ -1062,8 +1062,11 @@ impl Coordinator {
AndroidOverlayTrigger::Background => "background",
AndroidOverlayTrigger::Keyboard => "keyboard",
AndroidOverlayTrigger::Always => "always",
+ AndroidOverlayTrigger::Never => "never",
}
}
+ let from = from.normalized();
+ let to = to.normalized();
if from == to {
return;
}
@@ -1072,20 +1075,15 @@ impl Coordinator {
overlay_trigger_log_name(from),
overlay_trigger_log_name(to),
);
- match (from, to) {
- (
- AndroidOverlayTrigger::Background | AndroidOverlayTrigger::Keyboard,
- AndroidOverlayTrigger::Always,
- ) => {
+ match to {
+ AndroidOverlayTrigger::Always => {
let _ = crate::android::replace_android_overlay();
}
- (
- AndroidOverlayTrigger::Always,
- AndroidOverlayTrigger::Background | AndroidOverlayTrigger::Keyboard,
- ) => {
+ AndroidOverlayTrigger::Background
+ | AndroidOverlayTrigger::Keyboard
+ | AndroidOverlayTrigger::Never => {
let _ = crate::android::hide_android_overlay();
}
- _ => {}
}
}
let _ = (from, to);
@@ -1099,7 +1097,9 @@ impl Coordinator {
AndroidOverlayTrigger::Always => {
let _ = crate::android::replace_android_overlay();
}
- AndroidOverlayTrigger::Background | AndroidOverlayTrigger::Keyboard => {
+ AndroidOverlayTrigger::Background
+ | AndroidOverlayTrigger::Keyboard
+ | AndroidOverlayTrigger::Never => {
let _ = crate::android::hide_android_overlay();
}
}
diff --git a/openless-all/app/src-tauri/src/coordinator/restore_runtime.rs b/openless-all/app/src-tauri/src/coordinator/restore_runtime.rs
index 04767889e..3129a69f0 100644
--- a/openless-all/app/src-tauri/src/coordinator/restore_runtime.rs
+++ b/openless-all/app/src-tauri/src/coordinator/restore_runtime.rs
@@ -73,7 +73,8 @@ impl openless_core::config::RestoreRuntimeEffects for RestoreHost {
crate::android::replace_android_overlay()
}
crate::types::AndroidOverlayTrigger::Background
- | crate::types::AndroidOverlayTrigger::Keyboard => {
+ | crate::types::AndroidOverlayTrigger::Keyboard
+ | crate::types::AndroidOverlayTrigger::Never => {
crate::android::hide_android_overlay()
}
}
diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts
index d11649b97..6f5f9d116 100644
--- a/openless-all/app/src/i18n/de.ts
+++ b/openless-all/app/src/i18n/de.ts
@@ -2150,6 +2150,7 @@ export const de: typeof zhCN = {
background: 'Wenn die App im Hintergrund ist',
keyboard: 'Wenn die Tastatur erscheint',
always: 'Immer sichtbar',
+ never: 'Nie anzeigen',
},
androidOverlayTriggerHint: {
background:
@@ -2157,6 +2158,7 @@ export const de: typeof zhCN = {
keyboard:
'Dieser Modus wird nicht weitergeführt. Bestehende Einstellungen werden auf „Im Hintergrund“ zurückgesetzt.',
always: 'Jederzeit verfügbar, aber dauerhaft auf dem Bildschirm.',
+ never: 'Wird nie automatisch angezeigt; die Tastatur bleibt nutzbar.',
},
androidOverlayTriggerDisabled: {
keyboard:
diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts
index 71e7e7b33..5b56571c2 100644
--- a/openless-all/app/src/i18n/en.ts
+++ b/openless-all/app/src/i18n/en.ts
@@ -2098,11 +2098,13 @@ export const en: typeof zhCN = {
background: 'When app is backgrounded',
keyboard: 'When keyboard appears',
always: 'Always visible',
+ never: 'Never show',
},
androidOverlayTriggerHint: {
background: 'Simple and battery-friendly; no overlay while typing in other apps.',
keyboard: 'This mode is shelved. Existing settings are moved back to background.',
always: 'Always available, but permanently on screen.',
+ never: 'Never appears automatically; the keyboard remains available.',
},
androidOverlayTriggerDisabled: {
keyboard:
diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts
index a955dfd16..e0d378fc9 100644
--- a/openless-all/app/src/i18n/es.ts
+++ b/openless-all/app/src/i18n/es.ts
@@ -2134,6 +2134,7 @@ export const es: typeof zhCN = {
background: 'Con la aplicación en segundo plano',
keyboard: 'Cuando aparece el teclado',
always: 'Siempre visible',
+ never: 'Nunca mostrar',
},
androidOverlayTriggerHint: {
background:
@@ -2141,6 +2142,7 @@ export const es: typeof zhCN = {
keyboard:
'Este modo se ha retirado. Los ajustes existentes vuelven al modo de segundo plano.',
always: 'Siempre disponible, pero permanece en pantalla.',
+ never: 'Nunca aparece automáticamente; el teclado sigue disponible.',
},
androidOverlayTriggerDisabled: {
keyboard:
diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts
index 187401398..615516ae7 100644
--- a/openless-all/app/src/i18n/fr.ts
+++ b/openless-all/app/src/i18n/fr.ts
@@ -2163,12 +2163,14 @@ export const fr: typeof zhCN = {
background: 'Lorsque l’application est en arrière-plan',
keyboard: 'À l’apparition du clavier',
always: 'Toujours visible',
+ never: 'Ne jamais afficher',
},
androidOverlayTriggerHint: {
background:
'Simple et économe en batterie ; aucune fenêtre flottante pendant la saisie dans d’autres applications.',
keyboard: 'Ce mode a été retiré. Les réglages existants repassent en mode arrière-plan.',
always: 'Toujours disponible, mais reste affichée en permanence.',
+ never: 'N’apparaît jamais automatiquement ; le clavier reste utilisable.',
},
androidOverlayTriggerDisabled: {
keyboard:
diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts
index cccdcfc1c..3bcbc1a90 100644
--- a/openless-all/app/src/i18n/ja.ts
+++ b/openless-all/app/src/i18n/ja.ts
@@ -2077,11 +2077,13 @@ export const ja: typeof zhCN = {
background: 'バックグラウンド',
keyboard: 'キーボード表示時',
always: '常時',
+ never: '表示しない',
},
androidOverlayTriggerHint: {
background: 'シンプル',
keyboard: 'このモードは保留中です。既存設定はバックグラウンドに戻します。',
always: '常に表示',
+ never: '自動表示しません。キーボードは引き続き使用できます。',
},
androidOverlayTriggerDisabled: {
keyboard:
diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts
index 6269138e3..267524e26 100644
--- a/openless-all/app/src/i18n/ko.ts
+++ b/openless-all/app/src/i18n/ko.ts
@@ -2062,11 +2062,13 @@ export const ko: typeof zhCN = {
background: '백그라운드',
keyboard: '키보드 표시 시',
always: '항상',
+ never: '표시 안 함',
},
androidOverlayTriggerHint: {
background: '단순',
keyboard: '이 모드는 보류되었습니다. 기존 설정은 백그라운드로 되돌립니다.',
always: '항상 표시',
+ never: '자동으로 표시되지 않으며 키보드는 계속 사용할 수 있습니다.',
},
androidOverlayTriggerDisabled: {
keyboard: '키보드 표시 감지는 보류되었습니다. 이후 오버레이 제스처로 대체합니다.',
diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts
index 10d0eb488..63f5c2a06 100644
--- a/openless-all/app/src/i18n/zh-CN.ts
+++ b/openless-all/app/src/i18n/zh-CN.ts
@@ -1978,11 +1978,13 @@ export const zhCN = {
background: '应用退到后台',
keyboard: '弹出键盘时',
always: '始终显示',
+ never: '从不显示',
},
androidOverlayTriggerHint: {
background: '省电、实现简单;其他 App 输入时不会自动出现。',
keyboard: '该模式已暂缓,历史配置会自动改为“应用退到后台”。',
always: '入口始终可见,但会一直占屏。',
+ never: '永不自动出现,键盘仍可正常使用。',
},
androidOverlayTriggerDisabled: {
keyboard: '“弹出键盘时”暂缓开放,后续将以悬浮窗手势替代键盘检测。',
diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts
index 65895ad86..449b2d0f8 100644
--- a/openless-all/app/src/i18n/zh-TW.ts
+++ b/openless-all/app/src/i18n/zh-TW.ts
@@ -1979,11 +1979,13 @@ export const zhTW: typeof zhCN = {
background: '退到背景',
keyboard: '鍵盤彈出時',
always: '常駐',
+ never: '永不顯示',
},
androidOverlayTriggerHint: {
background: '省電',
keyboard: '此模式已暫緩,既有設定會改回退到背景。',
always: '一直佔屏',
+ never: '不會自動出現,鍵盤仍可正常使用。',
},
androidOverlayTriggerDisabled: {
keyboard: '「鍵盤彈出時」暫緩開放,後續將以懸浮窗手勢取代鍵盤偵測。',
diff --git a/openless-all/app/src/lib/androidOverlayTrigger.test.ts b/openless-all/app/src/lib/androidOverlayTrigger.test.ts
new file mode 100644
index 000000000..c50723f53
--- /dev/null
+++ b/openless-all/app/src/lib/androidOverlayTrigger.test.ts
@@ -0,0 +1,20 @@
+import { normalizeAndroidOverlayTrigger } from '../../android/frontend/lib/androidTypes';
+
+function assert(condition: boolean, message: string) {
+ if (!condition) throw new Error(message);
+}
+
+assert(
+ normalizeAndroidOverlayTrigger('never') === 'never',
+ 'never must remain disabled instead of being migrated to another trigger',
+);
+assert(
+ normalizeAndroidOverlayTrigger('keyboard') === 'background',
+ 'legacy keyboard trigger must continue migrating to background',
+);
+assert(
+ normalizeAndroidOverlayTrigger('always') === 'always',
+ 'always trigger must remain unchanged',
+);
+
+console.log('android overlay trigger tests passed');