diff --git a/analytics-events.json b/analytics-events.json index 1522e38..24196dc 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", "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 + }, + "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 5927ede..edb722f 100644 --- a/app/src/main/java/at/dasher/android/DasherImeService.kt +++ b/app/src/main/java/at/dasher/android/DasherImeService.kt @@ -55,15 +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) { - imePrefs.edit().putString("start_mode", if (floatingNow) "floating" else "docked").apply() + Log.d(TAG, "saveStartMode($floatingNow)") + imePrefs.edit().putString( + ImePrefs.START_MODE, + if (floatingNow) ImePrefs.START_MODE_FLOATING else ImePrefs.START_MODE_DOCKED + ).apply() } + /** + * #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 = - imePrefs.getString("start_mode", "docked") == "floating" + 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) ─────────────────────────────── @@ -87,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 @@ -96,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" @@ -107,8 +122,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 +147,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 +257,39 @@ 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) + // 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). - if (startModeIsFloating() && - android.provider.Settings.canDrawOverlays(this) - ) { - enterFloatingMode() + val alwaysDocked = imePrefs.getBoolean(ImePrefs.ALWAYS_DOCKED, false) + 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 savedMode, + "always_docked" to alwaysDocked, + "floating_before" to floatingBefore, + "restored_floating" to restoreFloating, + "overlay_perm" to overlayPerm, + )) + if (restoreFloating) { + enterFloatingMode(reason = "restore") } } return root @@ -248,8 +297,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 +400,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 +469,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 +497,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) } @@ -523,7 +578,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" @@ -712,7 +767,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 +789,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,7 +810,14 @@ 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 new file mode 100644 index 0000000..2e42177 --- /dev/null +++ b/app/src/main/java/at/dasher/android/ImePrefs.kt @@ -0,0 +1,39 @@ +package at.dasher.android + +/** + * 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 180743c..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 @@ -160,6 +162,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 +250,55 @@ 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 [ImePrefs] store, read by [DasherImeService.startModeIsFloating]. + */ +@Composable +private fun KeyboardStartSection() { + val context = androidx.compose.ui.platform.LocalContext.current + val prefs = remember { + 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)) + } + 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() + .toggleable( + value = alwaysDocked, + role = Role.Switch, + onValueChange = onToggle, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.always_docked_title), style = MaterialTheme.typography.bodyLarge) + Text( + stringResource(R.string.always_docked_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch(checked = alwaysDocked, onCheckedChange = null) + } + } +} + @Composable private fun InputMethodRow( current: InputMode, 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..af27cb4 --- /dev/null +++ b/app/src/test/java/at/dasher/android/ImePrefsTest.kt @@ -0,0 +1,49 @@ +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_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)) + } +}