Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions analytics-events.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)." }
Expand Down Expand Up @@ -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", "<unset>"] },
"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": [
Expand Down
96 changes: 80 additions & 16 deletions app/src/main/java/at/dasher/android/DasherImeService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) ───────────────────────────────

Expand All @@ -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
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -232,24 +257,49 @@ 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 ?: "<unset>"
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
}

// ── 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.
Expand Down Expand Up @@ -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() }

Expand Down Expand Up @@ -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
Expand All @@ -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) }
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand All @@ -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()
Expand All @@ -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
Expand Down
39 changes: 39 additions & 0 deletions app/src/main/java/at/dasher/android/ImePrefs.kt
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions app/src/main/java/at/dasher/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<String>>(emptyList())
private var currentPalette by mutableStateOf("")
private var speedPercent by mutableStateOf(100)
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading