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');