From 2bff0f7aca3b21b8a9060b652d72d21b44051048 Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 28 Oct 2026 12:05:11 +0000 Subject: [PATCH] feat(telemetry): ui_action + setting_changed (RFC 0001 2026-10-28 amendment) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX-team question: 'what settings are the priority and what UX/UI is the focus on the main UI' — needs aggregate frequency of main-UI control use and settings changes. Two schema'd events, both opt-in gated, both with hard privacy rules (values only for bool/number/enum from fixed vocabularies; free-text string settings record the change without the value; no file paths — see the RFC amendment). Instrumented: - main toolbar: new/open/pause_play(+state)/copy/save/share/game_mode (+state)/settings — surface main_toolbar - status bar: speed %, auto-speed, emoji toggle (+state) — surface status_bar, committed via setting_changed/main_ui + ui_action - Settings: every parameter row commit (bool switch, slider on finish, enum dropdown, string dropdown from fixed vocabularies), input-method picker, colour-palette swatch, output font family/size (drag commits once on release), always-docked toggle — surface settings - IME: editing toolbar (backspace/cursor/copy/paste/select) — surface ime_toolbar; top bar (float/dock/hide/emoji) — surface ime_topbar Pre-existing derived events (alphabet_selected, input_method_changed, emoji_toggled, ime_always_docked_changed, ime_float_*) remain for continuity; setting_changed/ui_action are the cross-platform aggregates per the amendment. All commit-site instrumentation — programmatic engine changes emit nothing. Signed-off-by: will wade --- analytics-events.json | 19 +++++ .../at/dasher/android/DasherImeService.kt | 16 ++++- .../java/at/dasher/android/EditingToolbar.kt | 13 ++++ .../java/at/dasher/android/MainActivity.kt | 55 +++++++++++--- .../java/at/dasher/android/SettingsScreen.kt | 71 ++++++++++++++----- .../java/at/dasher/android/UiTelemetry.kt | 68 ++++++++++++++++++ 6 files changed, 215 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/at/dasher/android/UiTelemetry.kt diff --git a/analytics-events.json b/analytics-events.json index 24196dc..4b71d53 100644 --- a/analytics-events.json +++ b/analytics-events.json @@ -90,6 +90,25 @@ "enabled": { "type": "boolean" } }, "pii": false + }, + "ui_action": { + "description": "User activated a control in the main UI (outside settings windows) — RFC 0001 2026-10-28 amendment. Answers 'where should main-UI focus go'.", + "properties": { + "surface": { "type": "string", "enum": ["main_toolbar", "status_bar", "ime_toolbar", "ime_topbar"] }, + "action": { "type": "string", "description": "Fixed vocabulary; new identifiers require a schema update", "examples": ["new", "open", "save", "pause_play", "game_mode", "settings", "copy", "share", "emoji_toggle", "speed_up", "speed_down", "layout", "float", "dock", "hide", "cursor_left", "cursor_right", "backspace", "select_all", "paste"] }, + "state": { "type": "string", "enum": ["on", "off", "paused", "playing", "text", "emoji"], "description": "Optional — for toggles" } + }, + "pii": false + }, + "setting_changed": { + "description": "User committed a settings change — RFC 0001 2026-10-28 amendment. Answers 'which settings are the priority'. Fires at the widget commit site only (never for programmatic engine changes).", + "properties": { + "setting_id": { "type": "string", "description": "Canonical identifier: DasherCore parameter name or frontend setting key", "examples": ["Max Bitrate", "output_font_size"] }, + "value_type": { "type": "string", "enum": ["bool", "number", "enum", "string"] }, + "value": { "type": ["boolean", "number", "string"], "description": "Present ONLY for bool/number/enum (fixed vocabularies). NEVER for string settings — free text is not collected" }, + "surface": { "type": "string", "enum": ["settings", "main_ui", "ime"] } + }, + "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 ba346e3..11b513c 100644 --- a/app/src/main/java/at/dasher/android/DasherImeService.kt +++ b/app/src/main/java/at/dasher/android/DasherImeService.kt @@ -92,6 +92,9 @@ class DasherImeService : InputMethodService() { val eng = engine ?: return val btn = emojiToggleBtn ?: return val current = eng.getCurrentAlphabet() + // RFC 0001 amendment: the cross-platform aggregate (the IME's own + // alphabet events already capture the alphabet switch itself). + UiTelemetry.action("ime_topbar", "emoji_toggle", if (current == "Emoji") "text" else "emoji") if (current == "Emoji") { val target = emojiReturnAlphabet if (target.isNullOrEmpty() || target == "Emoji") { @@ -936,10 +939,21 @@ class DasherImeService : InputMethodService() { setImageDrawable(LucideIcons.tinted(context, iconRes, topBarTint())) background = pillBackground() tooltipTextCompat(description) - setOnClickListener { onClick() } + setOnClickListener { + UiTelemetry.action("ime_topbar", topbarActionIdFor(description)) + onClick() + } } } + /** RFC 0001 amendment vocabulary for the IME top bar. */ + private fun topbarActionIdFor(description: String): String = when (description) { + "Float the keyboard" -> "float" + "Dock the keyboard" -> "dock" + "Hide the keyboard" -> "hide" + else -> description.lowercase().replace(' ', '_') + } + /** * The toolbars' bordered pill with a real pressed state — rounded.md 8dp * and the highlight tokens for the press fill. The STROKE deliberately diff --git a/app/src/main/java/at/dasher/android/EditingToolbar.kt b/app/src/main/java/at/dasher/android/EditingToolbar.kt index 17205f1..8d8a623 100644 --- a/app/src/main/java/at/dasher/android/EditingToolbar.kt +++ b/app/src/main/java/at/dasher/android/EditingToolbar.kt @@ -91,12 +91,25 @@ class EditingToolbar( large: Boolean = false, mutates: Boolean = true, action: () -> Unit) { addView(toolButton(iconRes, tooltip, large).apply { setOnClickListener { + // RFC 0001 amendment: which IME editing tools are actually + // used (Heide's SwiftKey round-trips suggested these matter). + UiTelemetry.action("ime_toolbar", actionIdFor(tooltip)) action() if (mutates) onBufferChanged() } }) } + private fun actionIdFor(tooltip: String): String = when (tooltip) { + "Move cursor left" -> "cursor_left" + "Move cursor right" -> "cursor_right" + "Delete" -> "backspace" + "Select all" -> "select_all" + "Copy" -> "copy" + "Paste" -> "paste" + else -> tooltip.lowercase().replace(' ', '_') + } + private fun addSpacer(widthPx: Int) { addView(android.view.View(context).apply { layoutParams = LayoutParams(widthPx, 1) diff --git a/app/src/main/java/at/dasher/android/MainActivity.kt b/app/src/main/java/at/dasher/android/MainActivity.kt index b14bc0c..d3b567e 100644 --- a/app/src/main/java/at/dasher/android/MainActivity.kt +++ b/app/src/main/java/at/dasher/android/MainActivity.kt @@ -386,10 +386,14 @@ class MainActivity : ComponentActivity() { }, onSpeedChanged = { pct -> speedPercent = pct; engine?.setSpeedPercent(pct); engine?.saveSettings() + // RFC 0001 amendment: speed from the status bar vs. + // settings answers different UX questions. + UiTelemetry.setting("Speed Percent", pct.toLong(), "main_ui") }, onAutoSpeedChanged = { v -> autoSpeed = v if (autoSpeedKey >= 0) { engine?.setBoolValue(autoSpeedKey, v); engine?.saveSettings() } + UiTelemetry.setting("Auto Speed", v, "main_ui") }, onOpenSettings = { showSettings = true }, onOpen = { openOutput() }, @@ -479,6 +483,10 @@ class MainActivity : ComponentActivity() { eng.saveSettings() pinAlphabetChoice() AnalyticsService.capture("emoji_toggled", mapOf("active" to (currentAlphabet == "Emoji"))) + // RFC 0001 2026-10-28 amendment vocabulary (emoji_toggled stays for + // continuity; ui_action is the cross-platform aggregate). + UiTelemetry.action("status_bar", "emoji_toggle", + if (currentAlphabet == "Emoji") "emoji" else "text") } /** @@ -858,26 +866,53 @@ class MainActivity : ComponentActivity() { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { - ToolbarButton(Lucide.FilePlus, stringResource(R.string.toolbar_new), onClear) - ToolbarButton(Lucide.FolderOpen, stringResource(R.string.toolbar_open), onOpen) + ToolbarButton(Lucide.FilePlus, stringResource(R.string.toolbar_new), onClear) { + UiTelemetry.action("main_toolbar", "new") + } + ToolbarButton(Lucide.FolderOpen, stringResource(R.string.toolbar_open), onOpen) { + UiTelemetry.action("main_toolbar", "open") + } ToolbarButton(if (isPlaying) Lucide.Pause else Lucide.Play, stringResource(if (isPlaying) R.string.toolbar_pause else R.string.toolbar_play), - onTogglePlay) - ToolbarButton(Lucide.ClipboardCopy, stringResource(R.string.toolbar_copy), onCopyAll) - ToolbarButton(Lucide.Save, stringResource(R.string.toolbar_save), onSave) - ToolbarButton(Lucide.Share2, stringResource(R.string.toolbar_share), onShare) + onTogglePlay) { + // state before the toggle: 'playing' means the user just paused + UiTelemetry.action("main_toolbar", "pause_play", + if (isPlaying) "paused" else "playing") + } + ToolbarButton(Lucide.ClipboardCopy, stringResource(R.string.toolbar_copy), onCopyAll) { + UiTelemetry.action("main_toolbar", "copy") + } + ToolbarButton(Lucide.Save, stringResource(R.string.toolbar_save), onSave) { + UiTelemetry.action("main_toolbar", "save") + } + ToolbarButton(Lucide.Share2, stringResource(R.string.toolbar_share), onShare) { + UiTelemetry.action("main_toolbar", "share") + } Spacer(Modifier.weight(1f)) ToolbarButton(Lucide.Gamepad2, stringResource(if (gameMode) R.string.toolbar_game_leave else R.string.toolbar_game), - onToggleGame) - ToolbarButton(Lucide.Settings, stringResource(R.string.toolbar_settings), onOpenSettings) + onToggleGame) { + UiTelemetry.action("main_toolbar", "game_mode", + if (gameMode) "off" else "on") + } + ToolbarButton(Lucide.Settings, stringResource(R.string.toolbar_settings), onOpenSettings) { + UiTelemetry.action("main_toolbar", "settings") + } } } } @Composable - private fun ToolbarButton(icon: ImageVector, description: String, onClick: () -> Unit) { - IconButton(onClick = onClick) { + private fun ToolbarButton( + icon: ImageVector, + description: String, + onClick: () -> Unit, + onTelemetry: (() -> Unit)? = null, + ) { + IconButton(onClick = { + onTelemetry?.invoke() + onClick() + }) { Icon(icon, contentDescription = description, tint = MaterialTheme.colorScheme.onSurface) } } diff --git a/app/src/main/java/at/dasher/android/SettingsScreen.kt b/app/src/main/java/at/dasher/android/SettingsScreen.kt index d30a4fd..b572daa 100644 --- a/app/src/main/java/at/dasher/android/SettingsScreen.kt +++ b/app/src/main/java/at/dasher/android/SettingsScreen.kt @@ -270,6 +270,8 @@ private fun KeyboardStartSection() { // #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)) + // RFC 0001 amendment: the cross-platform aggregate for the UX team. + UiTelemetry.setting("Keyboard always starts docked", v, "settings") } Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { // Accessible toggle (#75 review M8): the whole row is the touch @@ -318,6 +320,8 @@ private fun InputMethodRow( ) { Column(modifier = Modifier.weight(1f)) { Text(stringResource(R.string.input_method_title), style = MaterialTheme.typography.bodyLarge) + // RFC 0001 amendment note: the aggregate lives in setting_changed; + // input_method_changed (v1 schema) stays for continuity. Text("How you steer the Dasher pointer. Other modes appear when the hardware supports them.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } @@ -346,6 +350,15 @@ private fun InputMethodRow( onClick = { onSelect(mode) expanded = false + UiTelemetry.setting( + "Input Method", + when (mode) { + InputMode.TOUCH -> "touch" + InputMode.TILT -> "tilt" + InputMode.JOYSTICK -> "joystick" + }, + "settings", + ) } ) } @@ -517,7 +530,10 @@ private fun PaletteSwatchRow( modifier = Modifier .height(40.dp) .width(56.dp) - .then(Modifier.clickable { onSelect(p.name) }) + .then(Modifier.clickable { + onSelect(p.name) + UiTelemetry.setting("Colour Palette", p.name, "settings") + }) .border( width = if (isSelected) 2.dp else 1.dp, color = if (isSelected) MaterialTheme.colorScheme.primary @@ -653,16 +669,24 @@ private fun OutputFontSection( options.forEach { (value, name) -> DropdownMenuItem( text = { Text(name) }, - onClick = { onChange(value, size); expanded = false } + onClick = { + onChange(value, size); expanded = false + UiTelemetry.setting("output_font_family", value.ifEmpty { "System" }, "settings") + } ) } } } } Text("Size: ${size.toInt()}", style = MaterialTheme.typography.bodyLarge) + var pendingSize by remember { mutableStateOf(size) } Slider( value = size, - onValueChange = { onChange(family, it) }, + onValueChange = { pendingSize = it; onChange(family, it) }, + onValueChangeFinished = { + // RFC 0001 amendment: one event per committed drag, not per frame. + UiTelemetry.setting("output_font_size", pendingSize.toInt().toLong(), "settings") + }, valueRange = 10f..48f, steps = 0 ) @@ -686,7 +710,7 @@ private fun ParameterRow(engine: DasherEngine, p: ParameterInfo, version: Int, o } } when { - p.isBool -> BoolControl(engine, p.key, onChange) + p.isBool -> BoolControl(engine, p, onChange) p.isLong -> LongControl(engine, p, onChange) p.isString -> StringControl(engine, p, onChange) } @@ -694,16 +718,19 @@ private fun ParameterRow(engine: DasherEngine, p: ParameterInfo, version: Int, o } @Composable -private fun BoolControl(engine: DasherEngine, key: Int, onChange: () -> Unit) { - var checked by remember { mutableStateOf(engine.boolValue(key)) } - Switch(checked = checked, onCheckedChange = { v -> checked = v; engine.setBoolValue(key, v); onChange() }) +private fun BoolControl(engine: DasherEngine, p: ParameterInfo, onChange: () -> Unit) { + var checked by remember { mutableStateOf(engine.boolValue(p.key)) } + Switch(checked = checked, onCheckedChange = { v -> + checked = v; engine.setBoolValue(p.key, v); onChange() + UiTelemetry.setting(p.name, v, "settings") + }) } @Composable private fun LongControl(engine: DasherEngine, p: ParameterInfo, onChange: () -> Unit) { val enums = remember(p.key) { engine.enumValues(p.key) } when { - enums.isNotEmpty() -> EnumDropdown(engine, p.key, enums, onChange) + enums.isNotEmpty() -> EnumDropdown(engine, p, enums, onChange) p.max > p.min -> LongSlider(engine, p, onChange) else -> Text("${engine.longValue(p.key)}", style = MaterialTheme.typography.bodyMedium) } @@ -717,7 +744,10 @@ private fun LongSlider(engine: DasherEngine, p: ParameterInfo, onChange: () -> U Slider( value = value, onValueChange = { value = it }, - onValueChangeFinished = { engine.setLongValue(p.key, value.toLong()); onChange() }, + onValueChangeFinished = { + engine.setLongValue(p.key, value.toLong()); onChange() + UiTelemetry.setting(p.name, value.toLong(), "settings") + }, valueRange = p.min.toFloat()..p.max.toFloat(), steps = if (p.step > 0) ((p.max - p.min) / p.step).coerceAtLeast(1).toInt() - 1 else 0 ) @@ -726,10 +756,10 @@ private fun LongSlider(engine: DasherEngine, p: ParameterInfo, onChange: () -> U @Composable private fun EnumDropdown( - engine: DasherEngine, key: Int, options: List>, onChange: () -> Unit + engine: DasherEngine, p: ParameterInfo, options: List>, onChange: () -> Unit ) { var expanded by remember { mutableStateOf(false) } - var current by remember { mutableStateOf(engine.longValue(key).toInt()) } + var current by remember { mutableStateOf(engine.longValue(p.key).toInt()) } val label = options.firstOrNull { it.second == current }?.first ?: "$current" Box { OutlinedButton(onClick = { expanded = true }) { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) } @@ -737,7 +767,11 @@ private fun EnumDropdown( options.forEach { (name, value) -> DropdownMenuItem( text = { Text(name) }, - onClick = { current = value; engine.setLongValue(key, value.toLong()); expanded = false; onChange() } + onClick = { + current = value; engine.setLongValue(p.key, value.toLong()); expanded = false; onChange() + // Engine enums are fixed vocabularies (permitted values) + UiTelemetry.setting(p.name, name, "settings") + } ) } } @@ -754,7 +788,7 @@ private fun StringControl(engine: DasherEngine, p: ParameterInfo, onChange: () - remember(p.key) { engine.stringValues(p.key) }.map { it to it } } if (options.isNotEmpty()) { - StringDropdown(engine, p.key, options, onChange) + StringDropdown(engine, p, options, onChange) } else { Text(engine.stringValue(p.key), style = MaterialTheme.typography.bodyMedium, maxLines = 1) } @@ -762,10 +796,10 @@ private fun StringControl(engine: DasherEngine, p: ParameterInfo, onChange: () - @Composable private fun StringDropdown( - engine: DasherEngine, key: Int, options: List>, onChange: () -> Unit + engine: DasherEngine, p: ParameterInfo, options: List>, onChange: () -> Unit ) { var expanded by remember { mutableStateOf(false) } - var current by remember { mutableStateOf(engine.stringValue(key)) } + var current by remember { mutableStateOf(engine.stringValue(p.key)) } val label = options.firstOrNull { it.first == current }?.second ?: current.ifEmpty { "Default" } Box { OutlinedButton(onClick = { expanded = true }) { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) } @@ -773,7 +807,12 @@ private fun StringDropdown( options.forEach { (value, name) -> DropdownMenuItem( text = { Text(name) }, - onClick = { current = value; engine.setStringValue(key, value); expanded = false; onChange() } + onClick = { + current = value; engine.setStringValue(p.key, value); expanded = false; onChange() + // Options come from fixed vocabularies (fonts, permitted + // values) — RFC 0001 amendment allows enum values. + UiTelemetry.setting(p.name, value.ifEmpty { "System" }, "settings") + } ) } } diff --git a/app/src/main/java/at/dasher/android/UiTelemetry.kt b/app/src/main/java/at/dasher/android/UiTelemetry.kt new file mode 100644 index 0000000..fb20f75 --- /dev/null +++ b/app/src/main/java/at/dasher/android/UiTelemetry.kt @@ -0,0 +1,68 @@ +package at.dasher.android + +/** + * RFC 0001 2026-10-28 amendment: UI-interaction telemetry. Two events, both + * opt-in gated by [AnalyticsService.capture]'s no-op-before-consent, both in + * the published schema: + * + * - `ui_action` — a main-UI control was activated (fixed action vocabulary) + * - `setting_changed` — a settings change was committed at its widget site + * (never for programmatic engine changes). String settings record the + * change but never the free-text value. + * + * Pure formatting helpers over AnalyticsService — no state. + */ +object UiTelemetry { + + fun action(surface: String, action: String, state: String? = null) { + val props = buildMap { + put("surface", surface) + put("action", action) + if (state != null) put("state", state) + } + AnalyticsService.capture("ui_action", props) + } + + /** Bool setting committed by the user. */ + fun setting(settingId: String, value: Boolean, surface: String) { + AnalyticsService.capture("setting_changed", mapOf( + "setting_id" to settingId, + "value_type" to "bool", + "value" to value, + "surface" to surface, + )) + } + + /** Numeric setting committed by the user (speeds, sizes — not PII). */ + fun setting(settingId: String, value: Long, surface: String) { + AnalyticsService.capture("setting_changed", mapOf( + "setting_id" to settingId, + "value_type" to "number", + "value" to value, + "surface" to surface, + )) + } + + /** Enum-valued setting committed by the user (fixed vocabularies: alphabet ids, palettes, fonts). */ + fun setting(settingId: String, value: String, surface: String) { + AnalyticsService.capture("setting_changed", mapOf( + "setting_id" to settingId, + "value_type" to "enum", + "value" to value, + "surface" to surface, + )) + } + + /** + * Free-text/string setting committed by the user — the VALUE is never + * collected (RFC 0001 amendment: file paths and custom text excluded); + * only the fact of the change and its surface. + */ + fun stringSettingChanged(settingId: String, surface: String) { + AnalyticsService.capture("setting_changed", mapOf( + "setting_id" to settingId, + "value_type" to "string", + "surface" to surface, + )) + } +}