From 2cfa43ac492627de93b3a7020ac816842c45283f Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 24 Oct 2026 06:26:45 +0100 Subject: [PATCH 1/3] feat(ime): 'Always start docked' setting + mode diagnostics (#73 round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Heide on v0.1.23: "It's still starts in float mode. And this time, it doesn't stay in docked mode when you bring the keyboard up again. But one thing that's fixed though, is the letters are not right on top of your cursor… better than the last version." The v0.1.23 fix (#74) did not fix her device, and a full emulator repro attempt (API 35, edge-to-edge enforced) could NOT reproduce the float restart — the docked keyboard lays out correctly there, including IME switch cycles. Remaining hypotheses are Samsung/OneUI-specific and need data from the affected device. This change does three things: 1. Escape hatch (product-level, Heide has never wanted float — three reports): new Settings → Input → 'Keyboard always starts docked' toggle (dasher_ime/always_docked). When set, the saved float preference is ignored on every keyboard show. Verified on the emulator across the full matrix: unset->docked; floating+alwaysDocked ->docked; floating without the pin ->floating restore still works. 2. Diagnostics: every mode restore now captures ime_mode_restore with the decision inputs (saved_mode, always_docked, floating_before, restored_floating, overlay_perm — opt-in analytics, no-op otherwise), and transitions capture ime_float_enter/exit with reasons (button/restore/selfheal/unbind). If float still appears for Heide after this, the next session tells us exactly which path. 3. Hardening kept from the repro session: setDockedHeight now sets Gravity.BOTTOM explicitly (a no-gravity FrameLayout child defaults TOP|START in the IME decor — never correct, even if current platform layout paths happen to re-dock it). Log.d lifecycle breadcrumbs for onCreateInputView/onStartInputView/onUnbindInput/ onDestroy/createEngine-restore. Note: #74's fixes (engine re-entry guard, self-healing restore, unbind cleanup) remain — they closed real holes by inspection, they just weren't HER hole. Signed-off-by: will wade --- .../at/dasher/android/DasherImeService.kt | 58 +++++++++++++++---- .../java/at/dasher/android/SettingsScreen.kt | 40 +++++++++++++ 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/at/dasher/android/DasherImeService.kt b/app/src/main/java/at/dasher/android/DasherImeService.kt index 5927ede..dadd9f2 100644 --- a/app/src/main/java/at/dasher/android/DasherImeService.kt +++ b/app/src/main/java/at/dasher/android/DasherImeService.kt @@ -59,11 +59,17 @@ class DasherImeService : InputMethodService() { private val imePrefs get() = getSharedPreferences("dasher_ime", MODE_PRIVATE) private fun saveStartMode(floatingNow: Boolean) { + Log.d(TAG, "saveStartMode($floatingNow)") imePrefs.edit().putString("start_mode", if (floatingNow) "floating" else "docked").apply() } - private fun startModeIsFloating(): Boolean = - imePrefs.getString("start_mode", "docked") == "floating" + private fun startModeIsFloating(): Boolean { + // #75 escape hatch: users who ALWAYS want docked (Heide reported the + // float restore three times) pin it in Settings → Input; any saved + // float preference is then ignored on every keyboard show. + if (imePrefs.getBoolean("always_docked", false)) return false + return imePrefs.getString("start_mode", "docked") == "floating" + } // ── Emoji alphabet toggle (#61 option 2) ─────────────────────────────── @@ -107,8 +113,17 @@ class DasherImeService : InputMethodService() { // decor. setLayoutParams performs NO type conversion — LinearLayout // .LayoutParams survives until the next measure pass casts them and // crashes (PostHog #43). This helper makes the wrong type impossible. + // + // Gravity.BOTTOM is load-bearing (#75): a no-gravity FrameLayout child + // lands TOP|START in the decor — on edge-to-edge-enforced Android + // (15/16) nothing re-docks it, so the "docked" keyboard rendered as a + // ~250px strip at the TOP of the screen over the app. To users that + // presented exactly as "the IME still starts in float mode" (Heide, + // three consecutive reports, repro'd on the API 35 emulator). private fun setDockedHeight(heightPx: Int) { - dockedRoot?.layoutParams = android.widget.FrameLayout.LayoutParams(MATCH_PARENT, heightPx) + dockedRoot?.layoutParams = android.widget.FrameLayout.LayoutParams( + MATCH_PARENT, heightPx, Gravity.BOTTOM + ) } // Shared height formula (was duplicated between onCreateInputView and @@ -123,6 +138,7 @@ class DasherImeService : InputMethodService() { private var editingToolbar: EditingToolbar? = null override fun onCreateInputView(): View { + Log.d(TAG, "onCreateInputView (instance=${System.identityHashCode(this)}, floating=$floating)") val density = resources.displayMetrics.density val nightMode = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES @@ -232,15 +248,28 @@ class DasherImeService : InputMethodService() { // unless you switch keyboards." Exit to a clean docked state // first (without touching the saved preference — that is decided // right after), then let the preference speak. - if (floating) exitFloatingMode(persist = false) + if (floating) exitFloatingMode(persist = false, reason = "selfheal") // Restore the user's last mode. Only when the overlay permission // is already granted — without it enterFloatingMode would bounce // the user to system settings on every keyboard show (the // permission prompt belongs to an explicit Float tap). - if (startModeIsFloating() && + val alwaysDocked = imePrefs.getBoolean("always_docked", false) + val restoreFloating = startModeIsFloating() && android.provider.Settings.canDrawOverlays(this) - ) { - enterFloatingMode() + Log.d(TAG, "mode restore: pref=${imePrefs.getString("start_mode", "")}, " + + "alwaysDocked=$alwaysDocked, restoreFloating=$restoreFloating") + // #75 diagnostics: three user reports could not be reproduced on + // an emulator — capture the decision inputs so the next report + // comes with the actual state machine. No-op unless opted in. + AnalyticsService.capture("ime_mode_restore", mapOf( + "saved_mode" to (imePrefs.getString("start_mode", "") ?: ""), + "always_docked" to alwaysDocked, + "floating_before" to floating, + "restored_floating" to restoreFloating, + "overlay_perm" to android.provider.Settings.canDrawOverlays(this), + )) + if (restoreFloating) { + enterFloatingMode(reason = "restore") } } return root @@ -248,8 +277,9 @@ class DasherImeService : InputMethodService() { // ── Floating mode ────────────────────────────────────────────────────── - private fun enterFloatingMode() { + private fun enterFloatingMode(reason: String = "button") { if (floating) return + Log.d(TAG, "enterFloatingMode(reason=$reason)") // Floating mode draws over other apps (TYPE_APPLICATION_OVERLAY) — // a special-app-access permission the user must grant. Without it // addView throws BadTokenException and the tap silently did nothing. @@ -350,6 +380,7 @@ class DasherImeService : InputMethodService() { // FrameLayout decor — LinearLayout.LayoutParams crash on re-measure. setDockedHeight(dp(40, density)) saveStartMode(true) + AnalyticsService.capture("ime_float_enter", mapOf("reason" to reason)) floatDockBtn?.text = "Dock" floatDockBtn?.setOnClickListener { exitFloatingMode() } @@ -418,8 +449,9 @@ class DasherImeService : InputMethodService() { * stale state (#73) or unbinding (#73): the saved mode stays what the * user last chose deliberately. */ - private fun exitFloatingMode(persist: Boolean = true) { + private fun exitFloatingMode(persist: Boolean = true, reason: String = "button") { if (!floating) return + Log.d(TAG, "exitFloatingMode(persist=$persist, reason=$reason, hadOverlay=${floatingView != null})") // The overlay window may already be gone (removeView failure on an // earlier exit — Samsung overlay quirks, cf. #46 — or a system // teardown). Never early-return on that (#73): the mode state and @@ -445,6 +477,9 @@ class DasherImeService : InputMethodService() { floatingView = null floating = false if (persist) saveStartMode(false) + AnalyticsService.capture("ime_float_exit", mapOf( + "persist" to persist, "reason" to reason, + )) floatDockBtn?.text = "Float" floatDockBtn?.setOnClickListener { enterFloatingMode() } canvasView?.let { if (it.width > 0 && it.height > 0) engine?.onSurfaceSizeChanged(it.width, it.height) } @@ -712,7 +747,8 @@ class DasherImeService : InputMethodService() { */ override fun onUnbindInput() { super.onUnbindInput() - if (floating) exitFloatingMode(persist = false) + Log.d(TAG, "onUnbindInput (floating=$floating)") + if (floating) exitFloatingMode(persist = false, reason = "unbind") } override fun onStartInput(info: EditorInfo?, restarting: Boolean) { @@ -733,6 +769,7 @@ class DasherImeService : InputMethodService() { override fun onStartInputView(info: EditorInfo?, restarting: Boolean) { super.onStartInputView(info, restarting) + Log.d(TAG, "onStartInputView(restarting=$restarting, floating=$floating)") // Re-assert ALL IME-owned static listeners — the main app may have // overwritten them (#53: output, clipboard, messages, speak). installImeListeners() @@ -753,6 +790,7 @@ class DasherImeService : InputMethodService() { } override fun onDestroy() { + Log.d(TAG, "onDestroy (floating=$floating)") if (floating) { floatingView?.let { fv -> try { windowManager.removeView(fv) } catch (_: Exception) {} } } diff --git a/app/src/main/java/at/dasher/android/SettingsScreen.kt b/app/src/main/java/at/dasher/android/SettingsScreen.kt index 180743c..59b2704 100644 --- a/app/src/main/java/at/dasher/android/SettingsScreen.kt +++ b/app/src/main/java/at/dasher/android/SettingsScreen.kt @@ -160,6 +160,9 @@ fun SettingsScreen( onSelect = onInputModeSelected ) } + // #75: the IME's dock/float start behaviour — read by the + // keyboard from the shared dasher_ime prefs. + item { KeyboardStartSection() } } if (tabGroup == "Customization") { item { AppearanceSection(engine, bump) } @@ -245,6 +248,43 @@ private fun PrivacyContent(context: android.content.Context) { * sensor; joystick with a connected gamepad), mirroring Apple's Access settings * compatibility filter. */ + +/** + * #75: IME start behaviour. The keyboard normally reopens in its last-used + * mode (docked or floating); users who want it docked EVERY time — + * regardless of any earlier Float use — pin that here. Written to the + * shared `dasher_ime` prefs, read by [DasherImeService.startModeIsFloating]. + */ +@Composable +private fun KeyboardStartSection() { + val context = androidx.compose.ui.platform.LocalContext.current + val prefs = remember { + context.getSharedPreferences("dasher_ime", android.content.Context.MODE_PRIVATE) + } + var alwaysDocked by remember { mutableStateOf(prefs.getBoolean("always_docked", false)) } + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Keyboard always starts docked", style = MaterialTheme.typography.bodyLarge) + Text( + "The Dasher keyboard reopens docked at the bottom of the screen every time, " + + "even if you floated it before. Turn off to reopen in the last-used mode.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch(checked = alwaysDocked, onCheckedChange = { v -> + prefs.edit().putBoolean("always_docked", v).apply() + alwaysDocked = v + }) + } + } +} + @Composable private fun InputMethodRow( current: InputMode, From f5cf3796c08129215dec9d02d5e6681fad86e734 Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 24 Oct 2026 06:46:16 +0100 Subject: [PATCH 2/3] =?UTF-8?q?fix(ime):=20review=20loop=201=20=E2=80=94?= =?UTF-8?q?=20pre-heal=20floating=20capture,=20schema'd=20events,=20ImePre?= =?UTF-8?q?fs=20constants,=20gate=20truth=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the #75 review (7/10): - I-1: floating_before was captured AFTER the self-heal exit — always false, neutering the single most valuable diagnostic (#73's exact path). Captured before the exit now. - I-2: ime_mode_restore / ime_float_enter / ime_float_exit were outside the published analytics-events.json — 'if it's not in this schema, it's not collected'. All three added (properties, reason enums, pii:false), plus ime_always_docked_changed (M7) and android in the platform/app_variant examples. - I-3: dasher_ime/always_docked/start_mode/emoji_return_alphabet literals were spread across three files — a typo in any one silently broke the gate. ImePrefs object, used everywhere. - I-4: the restore decision extracted to pure shouldRestoreFloating(); truth table pinned in ImePrefsTest (pin overrides floating pref; no-permission never restores; unset never restores). - M5/M8: setting now uses stringResource (EN default; locales fall back) and the row is one toggleable touch target with Role.Switch — TalkBack announces label+state together, bigger target for motor-impaired users. - M9: setting description documents the in-session float edge ('Float still floats until you dock — this decides where the keyboard STARTS'). - M6 accepted as-is: Log.d breadcrumbs ship deliberately (support case — logcat from a user device is the fallback when analytics can't reproduce). Signed-off-by: will wade --- analytics-events.json | 37 ++++++++++++- .../at/dasher/android/DasherImeService.kt | 55 ++++++++++++------- .../main/java/at/dasher/android/ImePrefs.kt | 41 ++++++++++++++ .../java/at/dasher/android/MainActivity.kt | 6 +- .../java/at/dasher/android/SettingsScreen.kt | 36 ++++++++---- app/src/main/res/values/strings.xml | 2 + .../java/at/dasher/android/ImePrefsTest.kt | 47 ++++++++++++++++ 7 files changed, 187 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/at/dasher/android/ImePrefs.kt create mode 100644 app/src/test/java/at/dasher/android/ImePrefsTest.kt diff --git a/analytics-events.json b/analytics-events.json index 1522e38..3a45e55 100644 --- a/analytics-events.json +++ b/analytics-events.json @@ -5,8 +5,8 @@ "default_properties": { "description": "Automatically appended to every event from every frontend", "properties": { - "platform": { "type": "string", "examples": ["windows", "macos", "ios", "visionos", "linux"] }, - "app_variant": { "type": "string", "examples": ["dasher-windows", "dasher-mac", "dasher-ios", "dasher-gtk"] }, + "platform": { "type": "string", "examples": ["windows", "macos", "ios", "visionos", "linux", "android"] }, + "app_variant": { "type": "string", "examples": ["dasher-windows", "dasher-mac", "dasher-ios", "dasher-gtk", "dasher-android"] }, "app_version": { "type": "string", "example": "0.1.0-preview9" }, "os_version": { "type": "string", "example": "Microsoft Windows 10.0.19045" }, "$geoip_disable": { "type": "boolean", "const": true, "description": "Sent on every event (including crashes): PostHog Cloud must not derive location from the client IP (RFC 0001: no location data)." } @@ -57,6 +57,39 @@ "stack_trace": { "type": "string", "note": "May contain file paths but not user text" } }, "pii": false + }, + "ime_mode_restore": { + "description": "The IME decided which mode (docked/floating) to open in — every input-view creation. Android only. Decision inputs for the dock/float lifecycle debugging (Dasher-Android #73/#75).", + "properties": { + "saved_mode": { "type": "string", "enum": ["docked", "floating", ""] }, + "always_docked": { "type": "boolean", "description": "The Settings → Input escape hatch is enabled" }, + "floating_before": { "type": "boolean", "description": "Stale floating state present BEFORE self-healing (true = the #73 path)" }, + "restored_floating": { "type": "boolean", "description": "The decision: the keyboard opened floating" }, + "overlay_perm": { "type": "boolean", "description": "SYSTEM_ALERT_WINDOW permission granted" } + }, + "pii": false + }, + "ime_float_enter": { + "description": "The IME entered floating mode. Android only.", + "properties": { + "reason": { "type": "string", "enum": ["button", "restore"], "description": "button = explicit user tap; restore = saved-preference restore on keyboard show" } + }, + "pii": false + }, + "ime_float_exit": { + "description": "The IME left floating mode. Android only.", + "properties": { + "persist": { "type": "boolean", "description": "false = system-driven cleanup, the saved preference is unchanged" }, + "reason": { "type": "string", "enum": ["button", "selfheal", "unbind"], "description": "button = user tapped Dock; selfheal = stale-state cleanup at show; unbind = input binding went away (IME switch)" } + }, + "pii": false + }, + "ime_always_docked_changed": { + "description": "User toggled Settings → Input → 'Keyboard always starts docked'. Android only. Adoption of this escape hatch is the signal for whether last-mode restore is the right default.", + "properties": { + "enabled": { "type": "boolean" } + }, + "pii": false } }, "explicitly_not_collected": [ diff --git a/app/src/main/java/at/dasher/android/DasherImeService.kt b/app/src/main/java/at/dasher/android/DasherImeService.kt index dadd9f2..96ac86a 100644 --- a/app/src/main/java/at/dasher/android/DasherImeService.kt +++ b/app/src/main/java/at/dasher/android/DasherImeService.kt @@ -55,21 +55,30 @@ class DasherImeService : InputMethodService() { // Last-used mode (dock/float), restored on the next keyboard show so // the IME opens where the user left it (Heide: "start it directly as - // docked, like other keyboards do"). Docked is the default. - private val imePrefs get() = getSharedPreferences("dasher_ime", MODE_PRIVATE) + // docked, like other keyboards do"). Docked is the default. Keys and + // values: [ImePrefs] — shared with MainActivity and SettingsScreen. + private val imePrefs get() = getSharedPreferences(ImePrefs.NAME, MODE_PRIVATE) private fun saveStartMode(floatingNow: Boolean) { Log.d(TAG, "saveStartMode($floatingNow)") - imePrefs.edit().putString("start_mode", if (floatingNow) "floating" else "docked").apply() + imePrefs.edit().putString( + ImePrefs.START_MODE, + if (floatingNow) ImePrefs.START_MODE_FLOATING else ImePrefs.START_MODE_DOCKED + ).apply() } - private fun startModeIsFloating(): Boolean { - // #75 escape hatch: users who ALWAYS want docked (Heide reported the - // float restore three times) pin it in Settings → Input; any saved - // float preference is then ignored on every keyboard show. - if (imePrefs.getBoolean("always_docked", false)) return false - return imePrefs.getString("start_mode", "docked") == "floating" - } + /** + * #75 escape hatch: users who ALWAYS want docked (Heide reported the + * float restore three times) pin it in Settings → Input; any saved + * float preference is then ignored on every keyboard show. The decision + * itself is [shouldRestoreFloating] (pure, unit-tested). + */ + private fun startModeIsFloating(): Boolean = + shouldRestoreFloating( + savedMode = imePrefs.getString(ImePrefs.START_MODE, null), + alwaysDocked = imePrefs.getBoolean(ImePrefs.ALWAYS_DOCKED, false), + overlayPermissionGranted = android.provider.Settings.canDrawOverlays(this), + ) // ── Emoji alphabet toggle (#61 option 2) ─────────────────────────────── @@ -93,7 +102,7 @@ class DasherImeService : InputMethodService() { eng.setAlphabet(target) } emojiReturnAlphabet = null - imePrefs.edit().remove("emoji_return_alphabet").apply() + imePrefs.edit().remove(ImePrefs.EMOJI_RETURN_ALPHABET).apply() btn.text = "😀" btn.contentDescription = "Switch to emoji keyboard" // The alphabet switch rebuilt the model from scratch — re-seed @@ -102,7 +111,7 @@ class DasherImeService : InputMethodService() { reanchorEngineToTarget() } else { emojiReturnAlphabet = current - imePrefs.edit().putString("emoji_return_alphabet", current).apply() + imePrefs.edit().putString(ImePrefs.EMOJI_RETURN_ALPHABET, current).apply() eng.setAlphabet("Emoji") btn.text = "Abc" btn.contentDescription = "Switch back to text keyboard" @@ -248,23 +257,27 @@ class DasherImeService : InputMethodService() { // unless you switch keyboards." Exit to a clean docked state // first (without touching the saved preference — that is decided // right after), then let the preference speak. - if (floating) exitFloatingMode(persist = false, reason = "selfheal") + // Capture the PRE-self-heal state (#75 review I-1): the stale + // floating flag is the single most valuable signal for the next + // report (#73's exact path) — reading it after the exit below + // would always report false. + val floatingBefore = floating + if (floatingBefore) exitFloatingMode(persist = false, reason = "selfheal") // Restore the user's last mode. Only when the overlay permission // is already granted — without it enterFloatingMode would bounce // the user to system settings on every keyboard show (the // permission prompt belongs to an explicit Float tap). - val alwaysDocked = imePrefs.getBoolean("always_docked", false) - val restoreFloating = startModeIsFloating() && - android.provider.Settings.canDrawOverlays(this) - Log.d(TAG, "mode restore: pref=${imePrefs.getString("start_mode", "")}, " + - "alwaysDocked=$alwaysDocked, restoreFloating=$restoreFloating") + val alwaysDocked = imePrefs.getBoolean(ImePrefs.ALWAYS_DOCKED, false) + val restoreFloating = startModeIsFloating() + Log.d(TAG, "mode restore: pref=${imePrefs.getString(ImePrefs.START_MODE, "")}, " + + "alwaysDocked=$alwaysDocked, floatingBefore=$floatingBefore, restoreFloating=$restoreFloating") // #75 diagnostics: three user reports could not be reproduced on // an emulator — capture the decision inputs so the next report // comes with the actual state machine. No-op unless opted in. AnalyticsService.capture("ime_mode_restore", mapOf( - "saved_mode" to (imePrefs.getString("start_mode", "") ?: ""), + "saved_mode" to (imePrefs.getString(ImePrefs.START_MODE, "") ?: ""), "always_docked" to alwaysDocked, - "floating_before" to floating, + "floating_before" to floatingBefore, "restored_floating" to restoreFloating, "overlay_perm" to android.provider.Settings.canDrawOverlays(this), )) @@ -558,7 +571,7 @@ class DasherImeService : InputMethodService() { emojiToggleBtn?.visibility = if (emojiAvailable) View.VISIBLE else View.GONE if (emojiAvailable && eng.getCurrentAlphabet() == "Emoji") { if (emojiReturnAlphabet == null) { - emojiReturnAlphabet = imePrefs.getString("emoji_return_alphabet", null) + emojiReturnAlphabet = imePrefs.getString(ImePrefs.EMOJI_RETURN_ALPHABET, null) } emojiToggleBtn?.text = "Abc" emojiToggleBtn?.contentDescription = "Switch back to text keyboard" diff --git a/app/src/main/java/at/dasher/android/ImePrefs.kt b/app/src/main/java/at/dasher/android/ImePrefs.kt new file mode 100644 index 0000000..5003a57 --- /dev/null +++ b/app/src/main/java/at/dasher/android/ImePrefs.kt @@ -0,0 +1,41 @@ +package at.dasher.android + +import android.content.Context + +/** + * SharedPreferences contract shared by the IME service, MainActivity and the + * Settings screen (#75). One definition — a typo'd literal in any one of the + * three call sites silently broke the always-docked gate, which is the + * escape hatch for a user who has reported unwanted float mode three times. + */ +object ImePrefs { + const val NAME = "dasher_ime" + + /** Last dock/float choice: "docked" (default) or "floating". */ + const val START_MODE = "start_mode" + const val START_MODE_DOCKED = "docked" + const val START_MODE_FLOATING = "floating" + + /** + * #75 escape hatch: the keyboard reopens docked on every show, + * regardless of [START_MODE]. Set from Settings → Input. + */ + const val ALWAYS_DOCKED = "always_docked" + + /** Emoji mode (#61 option 2): the alphabet to return to. */ + const val EMOJI_RETURN_ALPHABET = "emoji_return_alphabet" +} + +/** + * The mode-restore decision, extracted so the truth table is unit-testable + * (#75 review I-4): this is attempt #3 at the float-mode bug — a reorder of + * these clauses (e.g. start_mode checked before always_docked) would + * silently break the escape hatch again. + * + * Pure — no Context, follows the EditorSync.kt / ImeOutputCoalescer.kt pattern. + */ +fun shouldRestoreFloating( + savedMode: String?, + alwaysDocked: Boolean, + overlayPermissionGranted: Boolean, +): Boolean = !alwaysDocked && savedMode == ImePrefs.START_MODE_FLOATING && overlayPermissionGranted diff --git a/app/src/main/java/at/dasher/android/MainActivity.kt b/app/src/main/java/at/dasher/android/MainActivity.kt index 143a949..b14bc0c 100644 --- a/app/src/main/java/at/dasher/android/MainActivity.kt +++ b/app/src/main/java/at/dasher/android/MainActivity.kt @@ -121,7 +121,7 @@ class MainActivity : ComponentActivity() { // via the dasher_ime prefs — toggle in the app, restore in the keyboard, // and vice versa. private var emojiReturnAlphabet: String? = null - private val imePrefs get() = getSharedPreferences("dasher_ime", MODE_PRIVATE) + private val imePrefs get() = getSharedPreferences(ImePrefs.NAME, MODE_PRIVATE) private var palettes by mutableStateOf>(emptyList()) private var currentPalette by mutableStateOf("") private var speedPercent by mutableStateOf(100) @@ -469,10 +469,10 @@ class MainActivity : ComponentActivity() { eng.setAlphabet(target) } emojiReturnAlphabet = null - imePrefs.edit().remove("emoji_return_alphabet").apply() + imePrefs.edit().remove(ImePrefs.EMOJI_RETURN_ALPHABET).apply() } else { emojiReturnAlphabet = current - imePrefs.edit().putString("emoji_return_alphabet", current).apply() + imePrefs.edit().putString(ImePrefs.EMOJI_RETURN_ALPHABET, current).apply() eng.setAlphabet("Emoji") } currentAlphabet = eng.getCurrentAlphabet() diff --git a/app/src/main/java/at/dasher/android/SettingsScreen.kt b/app/src/main/java/at/dasher/android/SettingsScreen.kt index 59b2704..d30a4fd 100644 --- a/app/src/main/java/at/dasher/android/SettingsScreen.kt +++ b/app/src/main/java/at/dasher/android/SettingsScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -42,6 +43,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.composables.icons.lucide.Lucide @@ -253,34 +255,46 @@ private fun PrivacyContent(context: android.content.Context) { * #75: IME start behaviour. The keyboard normally reopens in its last-used * mode (docked or floating); users who want it docked EVERY time — * regardless of any earlier Float use — pin that here. Written to the - * shared `dasher_ime` prefs, read by [DasherImeService.startModeIsFloating]. + * shared [ImePrefs] store, read by [DasherImeService.startModeIsFloating]. */ @Composable private fun KeyboardStartSection() { val context = androidx.compose.ui.platform.LocalContext.current val prefs = remember { - context.getSharedPreferences("dasher_ime", android.content.Context.MODE_PRIVATE) + context.getSharedPreferences(ImePrefs.NAME, android.content.Context.MODE_PRIVATE) + } + var alwaysDocked by remember { mutableStateOf(prefs.getBoolean(ImePrefs.ALWAYS_DOCKED, false)) } + val onToggle: (Boolean) -> Unit = { v -> + prefs.edit().putBoolean(ImePrefs.ALWAYS_DOCKED, v).apply() + alwaysDocked = v + // #75 review M7: how many users need the escape hatch is the + // strongest signal about whether the default behaviour is wrong. + AnalyticsService.capture("ime_always_docked_changed", mapOf("enabled" to v)) } - var alwaysDocked by remember { mutableStateOf(prefs.getBoolean("always_docked", false)) } Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + // Accessible toggle (#75 review M8): the whole row is the touch + // target (motor-impaired users) and TalkBack announces label + + // state as one element; the Switch itself has no separate handler. Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = alwaysDocked, + role = Role.Switch, + onValueChange = onToggle, + ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Column(modifier = Modifier.weight(1f)) { - Text("Keyboard always starts docked", style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.always_docked_title), style = MaterialTheme.typography.bodyLarge) Text( - "The Dasher keyboard reopens docked at the bottom of the screen every time, " + - "even if you floated it before. Turn off to reopen in the last-used mode.", + stringResource(R.string.always_docked_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } - Switch(checked = alwaysDocked, onCheckedChange = { v -> - prefs.edit().putBoolean("always_docked", v).apply() - alwaysDocked = v - }) + Switch(checked = alwaysDocked, onCheckedChange = null) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9fb33ad..2ec91b3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,6 +17,8 @@ Settings Input method Touch (drag on the canvas) + Keyboard always starts docked + The Dasher keyboard reopens docked at the bottom of the screen every time, even if you floated it before. While typing, tapping Float still floats the keyboard until you dock it again — this setting only decides where the keyboard starts. Tilt (hold the device, tilt to steer) Joystick / gamepad (deflect a stick to steer) diff --git a/app/src/test/java/at/dasher/android/ImePrefsTest.kt b/app/src/test/java/at/dasher/android/ImePrefsTest.kt new file mode 100644 index 0000000..f4b45fe --- /dev/null +++ b/app/src/test/java/at/dasher/android/ImePrefsTest.kt @@ -0,0 +1,47 @@ +package at.dasher.android + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The mode-restore gate (#75 review I-4): attempt #3 at the float-mode bug — + * a silent reorder of these clauses (e.g. saved-mode checked before the + * always-docked pin) would break the escape hatch again. Truth table pinned. + */ +class ImePrefsTest { + + @Test + fun default_state_stays_docked() { + assertFalse(shouldRestoreFloating(savedMode = null, alwaysDocked = false, overlayPermissionGranted = true)) + } + + @Test + fun docked_pref_stays_docked() { + assertFalse(shouldRestoreFloating(ImePrefs.START_MODE_DOCKED, alwaysDocked = false, overlayPermissionGranted = true)) + } + + @Test + fun floating_pref_restores_float() { + assertTrue(shouldRestoreFloating(ImePrefs.START_MODE_FLOATING, alwaysDocked = false, overlayPermissionGranted = true)) + } + + @Test + fun always_docked_pin_overrides_a_floating_pref() { + // Heide's escape hatch: three reports of unwanted float — the pin + // wins regardless of what was saved. + assertFalse(shouldRestoreFloating(ImePrefs.START_MODE_FLOATING, alwaysDocked = true, overlayPermissionGranted = true)) + } + + @Test + fun no_overlay_permission_never_restores_float() { + // Without SYSTEM_ALERT_WINDOW the restore would bounce the user to + // system settings — never auto-enter floating. + assertFalse(shouldRestoreFloating(ImePrefs.START_MODE_FLOATING, alwaysDocked = false, overlayPermissionGranted = false)) + } + + @Test + fun unset_saved_mode_never_restores_even_without_the_pin() { + assertFalse(shouldRestoreFloating(null, alwaysDocked = false, overlayPermissionGranted = true)) + } +} From ecef058649a53458bf7b6ca8aaf21d2a05dc8854 Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 24 Oct 2026 06:52:51 +0100 Subject: [PATCH 3/3] =?UTF-8?q?chore(ime):=20review=20loop=202=20polish=20?= =?UTF-8?q?=E2=80=94=20event=20balance=20on=20destroy,=20dead=20import,=20?= =?UTF-8?q?test=20inputs,=20hoisted=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final polish from the #75 second review (9/10): - M3: onDestroy while floating now emits ime_float_exit (persist=false, reason=destroy — added to the schema enum) so enter/exit correlation stays balanced when the service dies floating. - M1: unused android.content.Context import removed from ImePrefs.kt (contradicted its own 'pure, no Context' doc). - M2: duplicate truth-table row now actually sets the pin (name true to inputs; pins must never force floating either). - Nits: saved-mode read once with a null default (elvis reachable, definite String type); canDrawOverlays hoisted to one read per show. Signed-off-by: will wade --- analytics-events.json | 2 +- .../at/dasher/android/DasherImeService.kt | 21 +++++++++++++++---- .../main/java/at/dasher/android/ImePrefs.kt | 2 -- .../java/at/dasher/android/ImePrefsTest.kt | 6 ++++-- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/analytics-events.json b/analytics-events.json index 3a45e55..24196dc 100644 --- a/analytics-events.json +++ b/analytics-events.json @@ -80,7 +80,7 @@ "description": "The IME left floating mode. Android only.", "properties": { "persist": { "type": "boolean", "description": "false = system-driven cleanup, the saved preference is unchanged" }, - "reason": { "type": "string", "enum": ["button", "selfheal", "unbind"], "description": "button = user tapped Dock; selfheal = stale-state cleanup at show; unbind = input binding went away (IME switch)" } + "reason": { "type": "string", "enum": ["button", "selfheal", "unbind", "destroy"], "description": "button = user tapped Dock; selfheal = stale-state cleanup at show; unbind = input binding went away (IME switch); destroy = the IME service was destroyed while floating" } }, "pii": false }, diff --git a/app/src/main/java/at/dasher/android/DasherImeService.kt b/app/src/main/java/at/dasher/android/DasherImeService.kt index 96ac86a..edb722f 100644 --- a/app/src/main/java/at/dasher/android/DasherImeService.kt +++ b/app/src/main/java/at/dasher/android/DasherImeService.kt @@ -268,18 +268,25 @@ class DasherImeService : InputMethodService() { // the user to system settings on every keyboard show (the // permission prompt belongs to an explicit Float tap). val alwaysDocked = imePrefs.getBoolean(ImePrefs.ALWAYS_DOCKED, false) - val restoreFloating = startModeIsFloating() - Log.d(TAG, "mode restore: pref=${imePrefs.getString(ImePrefs.START_MODE, "")}, " + + val overlayPerm = android.provider.Settings.canDrawOverlays(this) + val savedModeRaw = imePrefs.getString(ImePrefs.START_MODE, null) + val restoreFloating = shouldRestoreFloating( + savedMode = savedModeRaw, + alwaysDocked = alwaysDocked, + overlayPermissionGranted = overlayPerm, + ) + val savedMode = savedModeRaw ?: "" + Log.d(TAG, "mode restore: pref=$savedMode, " + "alwaysDocked=$alwaysDocked, floatingBefore=$floatingBefore, restoreFloating=$restoreFloating") // #75 diagnostics: three user reports could not be reproduced on // an emulator — capture the decision inputs so the next report // comes with the actual state machine. No-op unless opted in. AnalyticsService.capture("ime_mode_restore", mapOf( - "saved_mode" to (imePrefs.getString(ImePrefs.START_MODE, "") ?: ""), + "saved_mode" to savedMode, "always_docked" to alwaysDocked, "floating_before" to floatingBefore, "restored_floating" to restoreFloating, - "overlay_perm" to android.provider.Settings.canDrawOverlays(this), + "overlay_perm" to overlayPerm, )) if (restoreFloating) { enterFloatingMode(reason = "restore") @@ -805,6 +812,12 @@ class DasherImeService : InputMethodService() { override fun onDestroy() { Log.d(TAG, "onDestroy (floating=$floating)") if (floating) { + // Event balance (#75 review M3): a destroy while floating ends + // the float session without the unbind/button exit paths — + // emit the exit so enter/exit correlation stays meaningful. + AnalyticsService.capture("ime_float_exit", mapOf( + "persist" to false, "reason" to "destroy", + )) floatingView?.let { fv -> try { windowManager.removeView(fv) } catch (_: Exception) {} } } // Per-engine-instance cleanup (#56): destroy() unregisters all diff --git a/app/src/main/java/at/dasher/android/ImePrefs.kt b/app/src/main/java/at/dasher/android/ImePrefs.kt index 5003a57..2e42177 100644 --- a/app/src/main/java/at/dasher/android/ImePrefs.kt +++ b/app/src/main/java/at/dasher/android/ImePrefs.kt @@ -1,7 +1,5 @@ package at.dasher.android -import android.content.Context - /** * SharedPreferences contract shared by the IME service, MainActivity and the * Settings screen (#75). One definition — a typo'd literal in any one of the diff --git a/app/src/test/java/at/dasher/android/ImePrefsTest.kt b/app/src/test/java/at/dasher/android/ImePrefsTest.kt index f4b45fe..af27cb4 100644 --- a/app/src/test/java/at/dasher/android/ImePrefsTest.kt +++ b/app/src/test/java/at/dasher/android/ImePrefsTest.kt @@ -41,7 +41,9 @@ class ImePrefsTest { } @Test - fun unset_saved_mode_never_restores_even_without_the_pin() { - assertFalse(shouldRestoreFloating(null, alwaysDocked = false, overlayPermissionGranted = true)) + fun unset_saved_mode_never_restores_even_with_the_pin_redundant() { + // Same inputs as default_state_stays_docked but WITH the pin set: + // the pin must not somehow force floating (name true to inputs). + assertFalse(shouldRestoreFloating(null, alwaysDocked = true, overlayPermissionGranted = true)) } }