Single source of truth for all planned work. Items above the --- are existing plans; items below are research conducted 2026-06-03.
Current release: v1.8.246 (versionCode 2046). Local verification: docs-only module build-cache hygiene note verified with git diff --check, bash scripts/check-fastlane-metadata.sh, and bash scripts/check-repo-hygiene.sh; APK assembly was intentionally not run per operator request to avoid repeated heavy Android builds.
Hard rules still apply (see AGENTS.md): no INTERNET permission in :app; Apache-2.0 ceiling on :app; no closed-source blobs; one logical change per commit; every shipped release bumps gradle.properties version, writes a CHANGELOG.md section, and adds a fastlane/metadata/android/en-US/changelogs/<versionCode>.txt (draft <=480 chars for headroom).
Item IDs trace to their origin research: F#/EI# from the archived 2026-05-25 research feature plan; R#/O# from the 2026-05-25 second-pass findings; WS# from the archived improvement-plan workstreams; N#/Next-#/L# from the archived roadmap tiers. Shipped items and reframed/rejected items live in COMPLETED.md; full release detail in CHANGELOG.md. Historical strategy (tiered NOW/NEXT/LATER, sourced appendix) is preserved at docs/archive/ROADMAP_v5.67_2026-05-18.md.
Last researched: Cycle 16 - 2026-06-04.
This roadmap is fed continuously by an automated research machine. On every pass, the implementing machine should:
git pull --rebaseto get the latest researched items before starting.- Work the open 🤖 items top-down by priority (P0 -> P3). Build them properly: multi-file structure, real error handling, no runtime auto-install hacks, version strings synced, docs/CHANGELOG updated in the same commit.
- In addition to building items, run a full UX audit each pass. Walk every screen / page / dialog / form / table / empty-loading-error-disabled state across light/dark/high-contrast themes. Check onboarding, navigation clarity, spacing/contrast/alignment, clipping/overflow, hierarchy, microcopy, destructive-action guards, keyboard + screen-reader accessibility, and trust signals. Fix what you find, or file it back as a new 🤖 roadmap item if it is larger than a pass.
- Check off ✅ each item you complete (leave it in place with the checkmark), commit per logical change with a "why" message, and push.
- Never edit this Implementer Instructions block or the 🔬 Researcher Queue headings. Never force-push.
Keep the :app invariant strict: no internet/network permissions, Apache-2.0
ceiling, no closed-source blobs, and network or incompatible features only in
isolated addon APKs. Shipped work belongs in CHANGELOG.md; completed roadmap
items belong in COMPLETED.md.
- P1 — Keyboard surface polish + manual-override verification (WS11)
- Why: Candidate-row, smartbar, and software-key states plus the full layout matrix need real-field verification; cannot fully close without a device.
- Touches: candidate-row selection/pressed/disabled/correction states; smartbar ordering + overflow + long-label resilience; software-key pressed/held/disabled/gesture states; one-handed/floating/split/compact/landscape/tablet layouts.
- Acceptance: each state and layout verified in real input fields on a device.
- Source: docs/archive/TODO_2026-06-03.md B / improvement-plan WS11.
- P1 — Glide-trail theme baselines + low-end perf evidence (F9)
- Why: Glide-trail themes lack Roborazzi baselines and low-end (<=4 GB) performance evidence.
- Touches: Roborazzi baselines (device/emulator); trace
swiftfloris.glide.trailDrawMson Pixel 4a / Galaxy A12-class. - Acceptance: baselines recorded; trail-draw timing captured on low-end hardware.
- Source: docs/archive/TODO_2026-06-03.md B / research feature plan F9.
- P2 — F40 Roborazzi capture phase (F40 capture)
- Why: Screen-level Roborazzi test classes ship baseline-pending (v1.8.201); the baseline PNGs still need on-device capture.
- Touches:
:app:recordRoborazziDebugfor the A1 test classes, then remove the class-level@Ignorefrom the pending F40 screenshot classes. - Acceptance: baseline PNGs captured;
@Ignoreremoved; gate green. - Source: docs/archive/TODO_2026-06-03.md A1/B / research feature plan F40.
- P2 — Glide-trail reduced-animation + tooltip verification (EI4 residual)
- Why: Confirm Rainbow/Aurora/Neon glide trails honour
ANIMATOR_DURATION_SCALE == 0fon-device; the doc disclosure already shipped (v1.8.182). - Touches: GesturesScreen "i" tooltip + on-device animation-scale check.
- Acceptance: trails respect zero animation scale; tooltip present.
- Source: docs/archive/TODO_2026-06-03.md B / second-pass EI4.
- Why: Confirm Rainbow/Aurora/Neon glide trails honour
- P1 — Backup/restore + import path-safety device confirmation (WS13 device portions)
- Why: Unit tests for these paths are Tier A and done; the on-device confirmation is still required.
- Touches: backup/restore overwrite-vs-merge; clipboard media missing-file/path-safety; extension-import path-traversal;
StickerMediaProvider.openFileSAF-grant allow-list validation for imported stickers. - Acceptance: overwrite/merge, missing-media, traversal, and imported-sticker open-file behaviors are confirmed on-device; forged encoded sticker URIs are rejected without breaking legitimate user-picked sticker folders.
- Source: docs/archive/TODO_2026-06-03.md B / improvement-plan WS13;
docs/AUDIT_2026-06-02.md:159-164.
- P3 — API 37 / Kotlin 2.4 dependency compatibility follow-up
- Why: The v1.8.216 freshness pass verified Kotlin
2.4.0and AndroidX Core1.19.0as current, but Kotlin has no matching KSP2.4.0plugin artifact yet and AndroidX Core1.19.0requirescompileSdk 37. - 2026-06-04 recheck: Maven metadata still reports Kotlin
2.4.0as current and KSP2.3.9as the latest KSP Gradle plugin; AndroidX Core1.19.0AAR metadata declaresminCompileSdk=37. This row stays open. - Touches:
gradle/libs.versions.toml,gradle/tools.versions.toml, API 37 behavior-gate docs. - Acceptance: bump Kotlin only after a compatible KSP plugin is published; bump AndroidX Core only with the compileSdk 37 behavior-gate plan and full Gradle/Roborazzi verification.
- Source: v1.8.216 dependency freshness pass.
- Why: The v1.8.216 freshness pass verified Kotlin
- P2 — Localization content-quality pass (WS12)
- Why: Turkish repeated-word lint, vague/abrupt English source labels, and inconsistent failure/destructive copy need cleanup.
- Touches: native-safe Turkish repeated-word review; tighten English source labels; standardize backup/restore/import/export failure + destructive-confirmation copy; document translation-safe cleanup rules.
- Acceptance: lint warnings reviewed; copy standardized; rules documented.
- Source: docs/archive/TODO_2026-06-03.md A5 / improvement-plan WS12.
- Shipped: v1.8.243 (2026-06-04) with native-safe Turkish repeated-word
cleanup, clearer theme/import source labels, standardized backup/restore/
dictionary/extension failure and generic destructive-confirmation copy,
repo-hygiene translation-safe cleanup rules, and focused
LocalizationCopyTestresource coverage.
- P2 — Visual-QA + manual-QA + release-evidence checklists (WS10 / WS15)
- Why: No standing checklists for the portrait/landscape/compact/floating/dark/high-font-scale matrix, manual QA, or release evidence.
- Touches: docs for visual-QA matrix, manual-QA flow, and release-evidence capture.
- Acceptance: three checklists exist and are referenced from the verification docs.
- Source: docs/archive/TODO_2026-06-03.md A5 / improvement-plan WS10/WS15.
- Shipped: v1.8.244 (2026-06-04) with
docs/QA_CHECKLISTS.mdcovering the visual-QA matrix, manual-QA flow, and release-evidence checklist, plus links from local verification, contributing, accessibility, and README docs.
- P3 — Fastlane changelog drafting guide (R5)
- Why: No documented guidance on drafting the <=480-char fastlane changelog.
- Touches: add the guide to
docs/LOCAL_VERIFICATION.md/docs/REPO_HYGIENE.md. - Acceptance: guide present with the character-budget rule.
- Source: docs/archive/TODO_2026-06-03.md A5 / second-pass R5.
- Shipped: v1.8.245 (2026-06-04) with Fastlane changelog drafting rules in repo hygiene, local verification, contributor, and agent-facing release docs.
- P3 — Document module build-cache survival (O1)
- Why:
lib/<module>/build/cache survivesgit rm --cached; this surprises contributors. - Touches: note in
docs/REPO_HYGIENE.md. - Acceptance: behavior documented.
- Source: docs/archive/TODO_2026-06-03.md A5 / second-pass O1.
- Shipped: v1.8.246 (2026-06-04) with repo-hygiene guidance for
git rm --cached, surviving ignored modulebuild/directories, and safe local cleanup expectations.
- Why:
These are genuine blockers — each needs an account, key, sibling repo, ML infra, or a product decision the code cannot make.
- P0 — Crowdin sync of v1.8.179 + v1.8.186 string drops (R1)
- Why: 44 stale translated entries across 22 locales; Crowdin web console is source of truth (lint
UnusedResourcesuntil done). - Touches: server-side Crowdin sync/pull.
- Acceptance: translations synced; stale-entry lint clears.
- Source: docs/archive/TODO_2026-06-03.md C / second-pass R1.
- Why: 44 stale translated entries across 22 locales; Crowdin web console is source of truth (lint
- P1 — FlorisBoard
0.6.0-alpha02cherry-picks (F22)- Why: Upstream CLDR 48, Emoji 17, number-field fix, and floating-window foundation are worth picking up; conflict resolution needs iterative on-device builds and risks regressing shipped features.
- Touches: cherry-pick + conflict resolution across input/emoji/layout.
- Acceptance: picks merged without regressing shipped features; on-device verified.
- Source: docs/archive/TODO_2026-06-03.md C / research feature plan F22.
- P1 — Apache-2.0 glide model trained on the MIT FUTO swipe dataset (F21)
- Why: A licensed in-tree glide model needs off-device ML training infra (XL, out-of-tree). FUTO Keyboard v0.1.29 now publishes FUTO Swipe, a public 1M-swipe QWERTY English dataset, top-1/top-4 benchmark framing, and an open-source swipe system, sharpening this from "train a model" into "evaluate against a public test set before integrating."
- Touches: external training pipeline + model integration; candidate-row top-4 display policy if the model exposes alternatives.
- Acceptance: Apache-2.0-clean model trained and integrated; before merge, report top-1 and top-4 error on the public FUTO filtered test-set framing and document whether SwiftFloris should expose accepted word + 3 alternatives after glide completion.
- Source: docs/archive/TODO_2026-06-03.md C / research feature plan F21; https://github.com/futo-org/android-keyboard/releases/tag/0.1.29.
- P2 — Bundled Vosk small-en-us recognizer addon (F8)
- Why: Needs a sibling addon repo + JNI;
RECORD_AUDIOonly in the addon, never:app. - Touches: sibling addon repo, JNI binding.
- Acceptance: recognizer ships as a signed addon;
:appstays permission-clean. - Source: docs/archive/TODO_2026-06-03.md C / research feature plan F8.
- Why: Needs a sibling addon repo + JNI;
- P2 — CycloneDX SBOM + SLSA provenance on release (F10)
- Why: Needs GitHub Attestations onboarding + release-tag dispatch.
- Touches: release workflow attestation step.
- Acceptance: SBOM + provenance attached to releases.
- Source: docs/archive/TODO_2026-06-03.md C / research feature plan F10.
- P2 — GPG-signed release tags (F11)
- Why: Needs a maintainer GPG key.
- Touches: release-tag signing.
- Acceptance: tags are GPG-signed and verifiable.
- Source: docs/archive/TODO_2026-06-03.md C / research feature plan F11.
- P2 — F-Droid
fdroiddatasubmission (F12)- Why:
dev.patrickgold.florisboard(.beta)package-id collides with upstream; needs a rename/coexistence decision plus a multi-month review queue. - Touches: fdroiddata metadata + package-id decision.
- Acceptance: submission accepted into the F-Droid queue.
- Source: docs/archive/TODO_2026-06-03.md C / research feature plan F12.
- Why:
- P2 — FunctionGemma 270M MCP-bridge addon (F30)
- Why: Needs a sibling addon repo.
- Touches: sibling addon repo + MCP bridge.
- Acceptance: addon bridges FunctionGemma over MCP without linking into
:app. - Source: docs/archive/TODO_2026-06-03.md C / research feature plan F30.
- P3 — Cross-platform desktop dictionary-export CLI (F13)
- Why: Needs a sibling repo.
- Touches: standalone CLI project.
- Acceptance: CLI exports the dictionary format cross-platform.
- Source: docs/archive/TODO_2026-06-03.md C / research feature plan F13.
- F-Droid package-id: coexist with upstream FlorisBoard
.betaor rename? (blocks F12) - Vosk 40 MB addon in 2026, or voice stays FUTO-handoff-only? (affects F8 + EI7 copy)
- Maintainer GPG key (Yubikey-backed?) for signed tags? (affects F11)
- F-Droid submission timing — during the migration spike or a quiet week? (affects F12)
- 🔬
mcp-tool-name-scope-recheck-2026-06-04- syncedmasterafter the upstream Cycle 16 docs push, rechecked the deferred MCP tool-name audit against live daemon discovery, registry, dispatch router, and tests. This cycle adds one focused row for constraining advertised tool names and removing cross-daemon first-match ambiguity before tool dispatch.
- 🤖 P3 — Scope MCP daemon tool dispatch by daemon and constrain tool names (R17-1)
- Why: MCP daemon discovery trims tool names and rejects only blank strings.
The registry then resolves
findTool(toolName)by returning the first daemon that advertises that name, andMcpDispatchRouterdispatches by that global string. Two installed daemons can therefore collide on the same tool name, and malformed names can enter Settings summaries, disable keys, and dispatch logs without a shared shape contract. MCP calls already carry a resolvedDaemonKeyat the client boundary, so the ambiguity should be removed before dispatch. - Evidence:
McpDaemonDiscoverer.kt:91-109accepts any nonblank parsedname;McpToolDescriptoronly requires nonblank names inMcpBridgeContract.kt:84-94;McpDaemonRegistry.kt:85-93scans all active daemons and returns the first matching tool;McpDispatchRouter.kt:53-83accepts onlyRequest.toolName, resolves it throughRegistryView.findTool, then callsclient.callTool(daemonKey = resolved.daemon, toolName = request.toolName, ...);McpDaemonDiscovererTest.kt:85-100only covers blank name skipping;McpDaemonRegistryTest.kt:53-70covers multi-daemon lookup with distinct names, not duplicate-name behavior; the deferred audit records the collision/shadowing risk indocs/AUDIT_2026-05-28.md:80-82. - Touches:
McpDaemonDiscoverer.kt,McpDaemonRegistry.kt,McpDispatchRouter.kt, MCP Settings/disable-key surfaces if they depend on a flat name, and focused MCP discoverer/registry/router tests. Preserve the no-network, signature-permission, payload-size, consent, and sensitive-field gates. - Acceptance: discovery rejects tool names outside a documented bounded
charset/length; duplicate names across daemons cannot be dispatched by
first-match global lookup; dispatch either carries a
DaemonKeyplus tool name or uses a generated stable scoped tool id; Settings summaries and per-tool enable/disable keys use the same scoped identity; tests fail for malformed names and for two daemons advertising the same name where the old first daemon would silently shadow the second. - Verify:
./gradlew.bat :app:testDebugUnitTest --tests "dev.patrickgold.florisboard.ime.mcp.*" - Complexity: M
- Why: MCP daemon discovery trims tool names and rejects only blank strings.
The registry then resolves
- 🔬
subtype-switch-by-id-double-read-recheck-2026-06-04- syncedmasterafter the Cycle 15 docs push, rechecked the deferred subtype switch-by-id audit against live subtype manager code, the subtype chooser caller, and the closed next/previous subtype fallback audit. This cycle adds one focused row for collapsing the switch-by-id path to a single nullable subtype lookup before activation.
- 🤖 P2 — Collapse subtype switch-by-id to a single nullable lookup (R16-1)
- Why:
switchToSubtypeById(id)validates the id against onesubtypessnapshot, then callsgetSubtypeById(id)!!, which snapshots the list again and can throw if the subtype list changes between the two reads. Subtype edits, restore flows, or localization updates should turn a stale id into a no-op, not a crash from a forced null assertion. - Evidence:
SubtypeManager.kt:276-278snapshotssubtypesingetSubtypeById(id);SubtypeManager.kt:402-404performs the separate existence check and forced!!lookup;SelectSubtypePanel.kt:83reaches this path from the subtype chooser;docs/AUDIT_2026-05-28.md:61-63records the TOCTOU/NPE finding;docs/AUDIT_2026-06-02.md:37closes only the next/previous fallback path, leaving switch-by-id open. KotlinStateFlowdocs describe thread-safe access, but Kotlin null-safety docs still make!!an NPE boundary when the second lookup returns null: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/, https://kotlinlang.org/docs/null-safety.html. - Touches:
SubtypeManager.kt; add a focusedSubtypeManagerswitch-by-id regression test or a narrow source-level guard test underapp/src/test/kotlin/dev/patrickgold/florisboard/ime/core/. - Acceptance:
switchToSubtypeById(id)stores one nullable lookup result in a local value and returns when absent; valid ids still activate through the manual subtype path; stale/mutated ids do not throw; existingswitchToNextSubtype/switchToPrevSubtypefallback behavior is unchanged. - Verify:
./gradlew.bat :app:testDebugUnitTest --tests "dev.patrickgold.florisboard.ime.core.*Subtype*" - Complexity: XS
- Why:
- 🔬
honeycomb-parse-diagnostics-recheck-2026-06-04- syncedmasterafter the Cycle 14 docs push, rechecked the deferred keyboard-layout parse diagnostics audit against live Honeycomb parser and tests. This cycle adds one focused row for logging malformed honeycomb layout JSON before the fail-safe empty-layout fallback.
- 🤖 P2 — Log Honeycomb layout parse failures before fail-safe empty layout (R15-1)
- Why:
HoneycombLayoutLoader.parse(...)intentionally returnsemptyList()for malformed layout JSON so a corrupt disk/addon layout does not crash the IME, but the catch block drops the exception with no diagnostic. The result can be an empty character keyboard with no support signal, while sibling parser/degradation paths such asZipfFrequencyTable.parse(...)already log before returning a safe empty/default result. - Evidence:
HoneycombLayoutLoader.kt:39-43documents the fail-safe malformed input policy;HoneycombLayoutLoader.kt:59-79catchesExceptionand returnsemptyList()withoutflogWarning/flogError;HoneycombLayoutLoaderTest.kt:152-156pins the malformed-JSON empty-list fallback but not diagnostic emission;ZipfFrequencyTable.kt:109-117catches parse failures and logs them withflogError; the deferred audit records the silent empty-keyboard path indocs/AUDIT_2026-05-28.md:72-74. - Touches:
HoneycombLayoutLoader.ktandHoneycombLayoutLoaderTest.ktor a small source-level logging contract test. Preserve the fail-safe return value and do not turn malformed layouts into crashes. - Acceptance: malformed JSON still returns
emptyList(), but the catch path emits one actionable diagnostic through the project logging stack namingHoneycombLayoutLoaderand the parse failure; valid layouts and modifier filtering are unchanged; tests fail if the catch block silently returnsemptyList()again. - Verify:
./gradlew.bat :app:testDebugUnitTest --tests "dev.patrickgold.florisboard.ime.text.keyboard.HoneycombLayoutLoaderTest" - Complexity: XS
- Why:
- 🔬
personal-ngram-control-token-recheck-2026-06-04- syncedmasterafter the Cycle 13 docs push, rechecked the deferred TSV control-character audit against live bigram/trigram normalization, load, and flush paths. This cycle adds one focused row for rejecting personal n-gram tokens that cannot be represented safely in the current TSV format.
- 🤖 P2 — Reject control separators before personal n-gram TSV persistence (R14-1)
- Why: Both personal n-gram stores normalize learned words by trimming edge
punctuation, rejecting digits, and requiring at least one letter, but they do
not reject interior tab, newline, carriage-return, NUL, or other ISO control
characters. Flush then writes tokens directly into tab-separated,
newline-delimited files. A pasted or imported token like
foo\tbaror a token containing\u0000can corrupt the next load by shifting TSV fields, splitting rows, or colliding with the trigram context delimiter. - Evidence:
PersonalBigramStore.kt:82-88andPersonalTrigramStore.kt:86-92return lowercased trimmed words without any control-character rejection;PersonalBigramStore.kt:101-111andPersonalTrigramStore.kt:107-119parse persisted rows withsplit('\t');PersonalBigramStore.kt:303-315andPersonalTrigramStore.kt:305-319write raw token strings separated by tabs and newlines;PersonalTrigramStore.kt:51reserves\u0000as the in-memory context delimiter; the current dictionary source tests cover locale-scoped flush/reset contracts but not write-time token safety; the deferred audit records the corruption path indocs/AUDIT_2026-05-28.md:66-68. - Touches:
PersonalBigramStore.kt,PersonalTrigramStore.kt, and a focused JVM/source contract test for normalization or learn/flush rejection. Keep the current simple TSV format; this row is about rejecting unrepresentable tokens, not introducing an escaping migration. - Acceptance: after trimming and before lowercasing, both stores reject any
normalized token containing
'\t','\n','\r','\u0000', orChar.isISOControl(); learned control-character tokens do not reach in-memory maps or persisted TSV rows; existing apostrophe/hyphen real-word cases continue to work; tests fail if either store can persist a token that changes TSV field count or trigram context splitting on reload. - Verify:
./gradlew.bat :app:testDebugUnitTest --tests "dev.patrickgold.florisboard.ime.dictionary.PersonalNgramFlushIsolationTest"or a new focused personal n-gram token-safety test class. - Complexity: S
- Why: Both personal n-gram stores normalize learned words by trimming edge
punctuation, rejecting digits, and requiring at least one letter, but they do
not reject interior tab, newline, carriage-return, NUL, or other ISO control
characters. Flush then writes tokens directly into tab-separated,
newline-delimited files. A pasted or imported token like
- 🔬
personal-ngram-stats-reset-race-recheck-2026-06-04- syncedmasterafter the Cycle 12 docs push, rechecked the deferredtotalEntryCount()/resetAndAwait()audit against live bigram/trigram stores and the typing-stats screen. This cycle adds one focused row for serializing personal n-gram stats counting with reset cleanup.
- 🤖 P2 — Serialize personal n-gram stats counting with reset cleanup (R13-1)
- Why: Settings -> Typing stats refreshes personal bigram/trigram totals from
totalEntryCount(), but each store builds its persisted-locale set outsideloadGuardand then callsensureLoaded(localeTag).resetAndAwait()clears in-memory tables and deletes matching files underloadGuard, so a stats refresh can observe stale filenames or reload a locale around a reset and make a just-cleared learning store appear non-empty. The bug is distinct from R12-1's file replacement durability: this is the read/reset interleaving visible to the Settings stats UI. - Evidence:
PersonalBigramStore.kt:224-242listspersonal_bigrams_*.tsv, mergestablesByLocale.keys, and then callsensureLoaded(localeTag)for each tag without holdingloadGuard;PersonalBigramStore.kt:367-376clears bigram tables and deletes matching files underloadGuard;PersonalTrigramStore.kt:229-245andPersonalTrigramStore.kt:368-375repeat the same count/reset shape for trigrams;TypingStatsScreen.kt:137-143displays both counts immediately in Settings;PersonalNgramFlushIsolationTest.kt:64-68only checks that reset is the broad cleanup path, not that stats counting is serialized with it; the deferred audit records the race indocs/AUDIT_2026-05-28.md:58-60. - Touches:
PersonalBigramStore.kt,PersonalTrigramStore.kt, and a focused JVM/source contract test such asPersonalNgramFlushIsolationTestor a new personal n-gram stats/reset test. Keep the R12-1 atomic-replace work and the v1.8.234 per-locale flush isolation intact. - Acceptance: both stores compute
totalEntryCount()under the same serialization boundary asresetAndAwait()or from a reset-safe snapshot; file enumeration,tablesByLocalekey collection, andensureLoaded()do not interleave with reset deletion; a reset followed by or racing with a stats refresh returns zero rather than resurrecting deleted locales; tests fail if either store reintroduces unlocked file enumeration plusensureLoaded()intotalEntryCount(). - Verify:
./gradlew.bat :app:testDebugUnitTest --tests "dev.patrickgold.florisboard.ime.dictionary.PersonalNgramFlushIsolationTest"or the new focused stats/reset test class. - Complexity: S
- Why: Settings -> Typing stats refreshes personal bigram/trigram totals from
- 🔬
personal-ngram-atomic-replace-recheck-2026-06-04- syncedmasterafter the Cycle 11 docs push, rechecked the deferred personal n-gram persistence data-loss audit against live bigram/trigram stores and the current locale-isolation tests. This cycle adds one focused row for atomic replacement of the personal n-gram TSV files.
- 🤖 P2 — Replace personal n-gram files atomically without deleting live data first (R12-1)
- Why: Both personal n-gram stores write a
.tmpfile, attemptrenameTo(dst), then delete the live destination before a second rename if the first rename fails. That fallback breaks the atomicity the temp-write pattern is meant to provide: a transient rename failure, destination-exists edge case, or second rename failure can remove the last known-good personal prediction data for that locale. The current tests pin per-locale flush isolation, but not safe replacement semantics. - Evidence:
PersonalBigramStore.kt:303-320writes a temp file and falls back tofileFor(localeTag).delete(); tmp.renameTo(fileFor(localeTag));PersonalTrigramStore.kt:305-324uses the same fallback; both stores have minSdk-compatible access tojava.nio.file.Files.movevia Android API 26+;PersonalNgramFlushIsolationTest.kt:28-50covers bigram/trigram locale flush targeting;PersonalNgramFlushIsolationTest.kt:53-69inspects the flush body for locale scope and broad reset cleanup, but does not reject destination deletion or require atomic/replace-existing moves; the deferred audit records the data-loss window indocs/AUDIT_2026-05-28.md:31-34. - Touches:
PersonalBigramStore.kt,PersonalTrigramStore.kt, andPersonalNgramFlushIsolationTest.ktor a small file-replacement helper test. Keep the v1.8.234 per-locale flush isolation and concurrency guards intact. - Acceptance: the stores never delete the destination before a successful
replacement exists; the preferred path uses
Files.move(tmp, dst, REPLACE_EXISTING, ATOMIC_MOVE)where supported; fallback handlesAtomicMoveNotSupportedExceptionwithout removing the live file until a replacement is durable;.tmpcleanup is best-effort and never sacrifices the destination; tests fail if either store reintroducesdst.delete(); tmp.renameTo(dst)or equivalent live-file deletion before success. - Verify:
./gradlew.bat :app:testDebugUnitTest --tests "dev.patrickgold.florisboard.ime.dictionary.PersonalNgramFlushIsolationTest"plus a small manual/debug file-write smoke if the implementation extracts a helper for replace failure injection. - Complexity: S
- Why: Both personal n-gram stores write a
- 🔬
prefs-init-splash-recovery-recheck-2026-06-04- syncedmasterafter the Cycle 10 docs push, rechecked the older async preference initialization audit against live startup code after v1.8.218 closed only the synchronous staged-exception path. This cycle adds one focused row for preference-store failures that happen insideFlorisApplication.init()'s launched coroutine.
- 🤖 P2 — Guard async preference-store init failures before the splash wait (R11-1)
- Why:
FlorisApplication.onCreate()now stages and surfaces synchronous startup exceptions, butinit()still launchesFlorisPreferenceStoreinitialization on a plain background scope and flipspreferenceStoreLoadedonly after that suspend call succeeds. IfinitAndroid(...)throws or the plain parent job fails, the Settings activity keeps the splash screen because its keep condition waits on the same false flow value, and the existing staged-startup crash redirect has already run. This is separate from R2-1: the failure happens afteronCreate()returns and outside the synchronoustry/catch. - Evidence:
FlorisApplication.kt:82createsCoroutineScope(Dispatchers.Default)without aSupervisorJob;FlorisApplication.kt:161-170launchesFlorisPreferenceStore.initAndroidand setspreferenceStoreLoaded.value = trueonly after logging the successful result; there is notry/catch,finally, or failure state in that coroutine;FlorisAppActivity.kt:102-115checks only already-staged startup exceptions before installing the splash keep condition!appContext.preferenceStoreLoaded.value;FlorisAppActivity.kt:155-167deferssetContentuntil that flow becomes true;FlorisAppActivity.kt:313-319consumes only pre-existing staged exceptions;StartupCrashRecoveryTest.kt:50-78covers staged exception persistence/redirects but not async preference initialization failure; the deferred audit tracks this distinct path indocs/AUDIT_2026-05-28.md:127-129. - Touches:
FlorisApplication.ktand focused startup tests, likely by extracting a small preference-init helper or injectable initializer so tests can force aninitAndroidfailure without corrupting real datastore files. Keep the existing R2-1 staged-crash behavior intact. - Acceptance: the preference-init coroutine uses a supervised/error-guarded
scope; failures are logged and routed to an existing crash/recovery surface
or a deliberately degraded startup state;
preferenceStoreLoadedcannot remain false indefinitely after init failure; Settings does not keep the splash forever; tests simulate a failing preference initializer and verify crash/recovery visibility plus splash unblock behavior. - Verify:
./gradlew.bat :app:testDebugUnitTest --tests "dev.patrickgold.florisboard.app.*Startup*"plus a manual debug smoke with a forced preference-init failure confirming Settings leaves the splash and shows the intended recovery path. - Complexity: M
- Shipped: v1.8.240 (2026-06-04) with a supervised application scope,
testable preference-init helper, guarded async init failure staging,
preferenceStoreLoadedfinally-unblock behavior, loaded-time crash-dialog redirect before normal Settings content, and focused startup recovery tests. Manual forced-failure smoke remains pending.
- Why:
- 🔬
editor-content-job-lifecycle-recheck-2026-06-04- syncedmasterafter the Cycle 9 docs push, rechecked the deferred editor content-generation lifecycle audit against liveAbstractEditorInstanceafter v1.8.233 closed only the synchronous batch-edit critical sections. This cycle adds one focused row for stale async content jobs that can still publish against a finished input session.
- 🤖 P2 — Cancel stale editor content-generation jobs on reset/finishInput (R10-1)
- Why:
handleStartInputView(...)andhandleSelectionUpdate(...)launch background content generation using a capturedInputConnection, then publishactiveCursorCapsMode,activeContent, shift-state reevaluation, and composing-region updates when the coroutine resumes.reset(),handleFinishInputView(), andhandleFinishInput()clear editor state, but they do not cancel those launched jobs or gate publication by a session generation. A delayed job can therefore repopulate editor state and callsetComposingRegion/finishComposingTexton an old connection after the IME has switched fields or finished input. v1.8.233 closed the selected synchronous batch critical sections; this is the remaining async lifecycle boundary. - Evidence:
AbstractEditorInstance.kt:69owns aMainScope;AbstractEditorInstance.kt:141-153launches fromhandleStartInputViewwith a capturedicand publishes state plusic.setComposingRegion(...);AbstractEditorInstance.kt:165-209repeats the pattern for selection updates;AbstractEditorInstance.kt:212-227resets active info, caps, content, expected-content queue, and last-commit position without cancelling launched jobs or incrementing a request/session generation;AbstractEditorInstance.kt:303-308maps invalid composing ranges tofinishComposingText(), so stale jobs can still mutate the old editor;EditorInputConnectionBatchTest.kt:27-189covers synchronous batch helper call ordering but no delayed content-generation lifecycle; the deferred audit already isolates this as distinct from the v1.8.233 batch fix (docs/AUDIT_2026-05-28.md:51-57). - Touches:
AbstractEditorInstance.ktplus focused JVM tests underapp/src/test/.../ime/editor; introduce a small pending-contentJob, generation token, or request object as needed. Keep the v1.8.233EditorInputConnectionBatchhelper behavior unchanged. - Acceptance: launching a new start-input-view or selection-update content
pass cancels or supersedes any previous pending pass;
reset(),handleFinishInputView(), andhandleFinishInput()prevent pending jobs from publishing state or touching their capturedInputConnection; resumed jobs re-check the active generation and current connection identity before setting caps/content, reevaluating shift state, or setting/finishing a composing region; tests simulate a delayed first job, then reset or switch sessions, and prove only the current session can publish. - Verify:
./gradlew.bat :app:testDebugUnitTest --tests "dev.patrickgold.florisboard.ime.editor.*"plus a manual smoke switching fields while composing text and confirming no stale composing region appears in the previous field. - Complexity: M
- Shipped: v1.8.239 (2026-06-04) with a pending content-generation
Job, generation token, current-connection identity check before generation and publication, reset/finishInput cancellation, start/selection supersession, and focused delayed-job JVM/Robolectric coverage. Manual field-switch smoke remains pending.
- Why:
- 🔬
nlp-request-privacy-snapshot-recheck-2026-06-04- syncedmasterat the Cycle 8 pushed tip, rechecked the remaining suggestion privacy/state audit against liveNlpManager.suggest, field-start incognito resolution, smart-compose sensitive-field guards, and AndroidEditorInfoprivacy docs. ExistingSuggestionPrivacyPolicytests cover the policy decisions, but not the async request boundary. This cycle adds one focused privacy row for request-scoped candidate generation inputs.
- 🤖 P2 — Snapshot suggestion privacy inputs before async candidate generation (R9-1)
- Why:
NlpManager.suggestcaptures theEditorContentandSubtypefor a request, then launches background work that re-reads live preference, incognito, and editor-info state before calling suggestion providers, recording typing traces, and deciding whether smart-compose ghost text is allowed. If the user toggles incognito or the IME switches fields before that coroutine reaches those gates, candidate generation can use privacy facts from a different field/session than the text snapshot it is processing. The request-id guard prevents older results from overwriting newer candidates, but it does not freeze provider inputs or side effects that already ran inside the stale request. - Evidence:
NlpManager.kt:211-214capturescontent,subtype, and arequestIdbeforescope.launch;NlpManager.kt:216-244readsprefs.emoji.suggestionEnabled,prefs.suggestion.blockPossiblyOffensive,prefs.suggestion.enabled, andkeyboardManager.activeState.isIncognitoModeinside the coroutine;NlpManager.kt:277-286records typing-trace evidence using the live incognito flag;NlpManager.kt:295andNlpManager.kt:322-324let ghost text consulteditorInstance.activeInfoafter the async boundary;NlpManager.kt:299-306orders publication by request id but only after providers and trace logging have run;EditorInstance.kt:151-155resolvesactiveState.isIncognitoModefrom each field'sIME_FLAG_NO_PERSONALIZED_LEARNINGand incognito preference;KeyboardManager.kt:739-741mutates the same live state for dynamic incognito toggles;SuggestionPrivacyPolicyTest.kt:24-113covers the policy functions but not request-scoped propagation throughNlpManager. AndroidEditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNINGdocs define the host-app privacy request for typing history / personalized language models (https://developer.android.com/reference/android/view/inputmethod/EditorInfo#IME_FLAG_NO_PERSONALIZED_LEARNING). - Touches:
NlpManager.kt, a small immutable request/context data class if helpful,SuggestionPrivacyPolicyonly if request-snapshot helpers belong there, and focused fake-provider tests underapp/src/test/.../ime/nlp. - Acceptance:
suggest(...)snapshots privacy-relevant inputs before launching provider work; emoji/word providers, typing-trace recording, and ghost-text gating all consume the same request-scopedisPrivateSession, offensive-content flag, enabled flags, and active editor sensitivity; no content-scoped async path readskeyboardManager.activeState.isIncognitoModeoreditorInstance.activeInfoafter the launch boundary; existing request ordering still prevents stale candidate publication; tests simulate incognito/field changes while a fake provider is delayed and prove the original request's privacy decision is preserved. - Verify:
./gradlew.bat :app:testDebugUnitTest --tests "dev.patrickgold.florisboard.ime.nlp.*"plus a manual smoke where dynamic incognito is toggled during typing and suggestions/typing-trace output stay aligned with the request that produced the candidates. - Shipped: v1.8.236 (2026-06-04) with immutable
SuggestionRequestPrivacySnapshotpropagation through emoji/word providers, trace gating, and ghost-text sensitivity checks. Focused source/request contract tests passed; manual dynamic-incognito smoke remains pending. - Complexity: M
- Why:
- 🔬
user-dictionary-back-feedback-recheck-2026-06-04- syncedmasterat the Cycle 7 pushed tip, rechecked the user-dictionary operation gating audit against live Compose back handling, nearby policy tests, and AndroidXBackHandlerdocs, and avoided duplicating the already-shipped dictionary transfer progress cards. This cycle adds one focused navigation feedback row for blocked back gestures during active dictionary work.
- 🤖 P3 — Give blocked user-dictionary back gestures explicit feedback (R8-1)
- Why: save/delete/import/export operations deliberately block leaving the
user-dictionary screen so mutable dictionary work is not interrupted. The
toolbar back button is disabled and progress cards explain that work is in
flight, but the system back gesture is still intercepted by an enabled
BackHandlerand then silently does nothing while an operation is active. That makes a protected in-progress state look like a frozen settings screen. - Evidence:
UserDictionaryScreen.kt:171-179derivesisEntryOperationInProgress,isDictionaryTransferInProgress, andcanLeaveDictionaryScreen;UserDictionaryScreen.kt:626-650disables the visible navigation button whilecanLeaveDictionaryScreenis false;UserDictionaryScreen.kt:722-727enablesBackHandlerfor current-locale or active-operation state but only handles the current-locale escape whencanLeaveDictionaryScreenis true;UserDictionaryScreen.kt:734-758andstrings.xml:942-945/strings.xml:997-1005already expose progress-card copy for import/export/save/delete;UserDictionaryEntryPolicy.kt:45-65andUserDictionaryEntryPolicyTest.kt:23-51pin the block-leaving policy;docs/AUDIT_2026-05-28.md:160-162records the same swallowed-back gap. AndroidXBackHandlerdocs define theenabledflag as the control for whether the handler is active, so an enabled no-op handler consumes the gesture (https://developer.android.com/reference/kotlin/androidx/activity/compose/BackHandler.composable). - Touches:
UserDictionaryScreen.kt,UserDictionaryEntryPolicy.ktif a small operation-to-feedback helper is useful, localized strings for the back-blocked message, and focused JVM/Compose tests around the policy or screen behavior. - Acceptance: system back during save, delete, import, and export keeps the user on the dictionary screen but surfaces clear feedback using existing in-progress wording or a dedicated toast/snackbar/live announcement; system back still closes the selected locale when no operation is active; with no selected locale and no active operation, this screen does not consume back; the toolbar button disabled state and existing progress cards remain unchanged; TalkBack users receive equivalent feedback.
- Verify: focused unit tests for the extracted feedback decision plus manual Settings -> Dictionary smoke for save/delete/import/export in progress, hardware/gesture back, toolbar back, and TalkBack announcement behavior.
- Complexity: S
- Shipped: v1.8.232 (2026-06-04) with operation-specific blocked-back toasts,
a pure
UserDictionaryBlockedBackNoticeresolver, and focused policy coverage for save/delete/import/export/idle decisions. Manual system-back and TalkBack proof remains device-gated.
- Why: save/delete/import/export operations deliberately block leaving the
user-dictionary screen so mutable dictionary work is not interrupted. The
toolbar back button is disabled and progress cards explain that work is in
flight, but the system back gesture is still intercepted by an enabled
- 🔬
flag-secure-incognito-toggle-recheck-2026-06-04- syncedmasterat the Cycle 6 pushed tip, rechecked the remainingFLAG_SECUREaudit note against live IME startup and smartbar incognito-toggle code, and avoided duplicating older password-fieldFLAG_SECUREcoverage. This cycle adds one focused privacy row for mid-session incognito toggles.
- 🤖 P2 — Re-apply
FLAG_SECUREwhen incognito mode toggles mid-session (R7-1)- Why:
FLAG_SECUREnow covers password fields and incognito state when a field starts, but the user can toggle incognito from the smartbar while staying in the same ordinary text field. In that path, the IME updates privacy state and toasts, but does not re-run the secure-window policy until the nextonStartInputView, leaving the keyboard screenshot-/record-able during the current private session. - Evidence:
FlorisImeService.kt:599callsapplyFlagSecureForCurrentField(editorInfo)only during input-view start;FlorisImeService.kt:617-636addsFLAG_SECUREfor password fields oractiveState.isIncognitoModeand explicitly notes the missing mid-session toggle callback;KeyboardManager.kt:738-755flipsactiveState.isIncognitoModeforKeyCode.TOGGLE_INCOGNITO_MODEand shows a toast without notifying the service/window;docs/AUDIT_2026-06-02.md:89-92records the same follow-up. AndroidWindowManager.LayoutParams.FLAG_SECUREdocs define the flag as preventing the window content from appearing in screenshots or on non-secure displays (https://developer.android.com/reference/android/view/WindowManager.LayoutParams). - Touches:
FlorisImeService.kt,KeyboardManager.ktor a smallFlagSecurePolicyhelper, focused unit tests around password/incognito/off combinations, anddocs/THREAT_MODEL.md/docs/PRIVACY_AND_AI.mdif the runtime guarantee is documented there. - Acceptance: toggling dynamic incognito on in a non-password field applies
FLAG_SECUREimmediately; toggling it off clears the flag only when the active field is not password/no-personalized-learning protected; password fields remain secure across toggle attempts; existing field-start behavior is unchanged; manual screenshot or screen-recording evidence confirms the current IME window blocks capture after the toggle. - Verify: focused JVM tests for the extracted policy/callback plus manual IME smoke with a normal field, password field, dynamic incognito on/off, and screenshot/screen-recording attempt.
- Complexity: S-M
- Shipped: v1.8.231 (2026-06-04) with a lifecycle-cleared
KeyboardManager->FlorisImeServiceincognito callback, main-thread secure-window reapplication, pureFlagSecurePolicycoverage, and updated privacy/threat-model docs. Device screenshot/screen-recording proof remains hardware-gated.
- Why:
- 🔬
editor-batch-critical-section-recheck-2026-06-04- syncedmasterat the Cycle 5 pushed tip, rechecked the editor hot-path audit against liveAbstractEditorInstancecode and AndroidInputConnectioncontracts, and avoided duplicating already-closed unbalanced-batch fixes. This cycle adds one focused reliability row for batch-edit critical sections.
- 🤖 P2 — Keep
InputConnectionbatch edits free ofrunBlockingand queue-lock work (R6-1)- Why:
beginBatchEdit()/endBatchEdit()should bracket the smallest synchronous editor mutation set. Several IME hot paths currently open a batch edit before doingrunBlocking, generating expected content, and acquiring theExpectedContentQueuelock. That keeps the target editor in a nested batch while coroutine/queue work runs on the UI path and also leavesendBatchEdit()outsidetry/finallyin fragile code paths. - Evidence:
AbstractEditorInstance.kt:311-325opens a batch insetSelection, then entersrunBlockingto computenewContent, push the expected-content queue, and callsetSelection/setComposingRegion;AbstractEditorInstance.kt:396-414andAbstractEditorInstance.kt:429-445show the same pattern in text commit/finalize paths;ExpectedContentQueueuses suspendingwithLockhelpers atAbstractEditorInstance.kt:679-708;docs/AUDIT_2026-05-28.md:54-56records this exactsetSelectionrisk. AndroidInputConnectiondocs definebeginBatchEdit,endBatchEdit,setSelection, and composing region/text mutations as editor-facing operations (https://developer.android.com/reference/android/view/inputmethod/InputConnection). - Touches:
AbstractEditorInstance.kt, a small fake/stubInputConnectiontest harness underapp/src/test/.../ime/editor, and comments only where a hot path must intentionally keep a batch open. - Acceptance:
setSelection,commitTextInternal,finalizeComposingText, and thecommitCharreplacement branch have no suspending/blocking queue work betweenbeginBatchEdit()andendBatchEdit(); batch pairs usetry/finally; expected-content ordering remains correct foronUpdateSelection; tests assert batch depth returns to zero and that the order ofInputConnectioncalls is unchanged for representative cursor, commit, and composing-finalization cases. - Verify:
./gradlew.bat :app:testDebugUnitTest --tests "dev.patrickgold.florisboard.ime.editor.*"plus manual sustained-typing smoke on a low-end device if available. - Complexity: M
- Shipped: v1.8.233 (2026-06-04) with expected-content generation and queue
pushes moved before the selection, text-commit, composing-finalize, and
composing-region replacement batches;
EditorInputConnectionBatchpairs begin/end withtry/finally, andEditorInputConnectionBatchTestpins representative call order and batch depth. Low-end sustained-typing smoke remains device-gated.
- Why:
- 🔬
addon-trust-boundary-recheck-2026-06-04- syncedmasterat the v1.8.226 release-ledger baseline, checked the older trust-boundary audit backlog against live addon code and current Android package-visibility/signature-permission docs, and avoided duplicating the already-fixed signing-history and Tasker extras-bounds findings. This cycle adds one implementation-ready addon trust row.
- 🤖 P1 — Require explicit first-run trust for non-co-signed addon APK enrollment (R5-1)
- Why: SwiftFloris' addon contract says addon packages must be co-signed with
the IME or explicitly trusted by the user in Settings, but the current
first-seen registry path auto-pins any package that passes manifest,
no-network, and descriptor screening. Because Android package visibility
<queries>intentionally lets the IME discover packages matching addon intent signatures, discovery should not be treated as user consent. - Evidence:
AddonContract.kt:108-110andAndroidManifest.xml:15-19promise co-signed addons or explicit Settings opt-in;AddonEnumerator.kt:118-183accepts any package with addon metadata, no banned network permission, required descriptor/version/license fields, and a readable signing fingerprint;AddonRegistry.kt:55-60auto-pins first-seen signing certificates whenpinnedFingerprint == null;AddonRegistryTest.kt:48-72andAddonRegistryStartupTest.kt:46-56currently assert first-seen auto-enrollment from an empty pin set;AddonsSettingsScreen.kt:104-145only exposes rescan/reset flows, whileAddonsSettingsScreen.kt:252-292can trust a changed certificate only after an existing pin rejected it;docs/AUDIT_2026-05-28.md:84-86records the same contract mismatch. Android's<permission>docs definesignaturepermissions as granted only to apps signed with the same certificate (https://developer.android.com/guide/topics/manifest/permission-element); Android package-visibility docs say apps can use<queries>intent signatures to discover matching packages, and that package visibility is a privacy-sensitive capability (https://developer.android.com/training/package-visibility,https://developer.android.com/training/package-visibility/declaring). - Touches:
AddonRegistry.kt,AddonRegistryStartup.kt,AddonsSettingsScreen.kt, addon strings,docs/addons/dictionary-pack-spec.md,docs/THREAT_MODEL.md,AddonRegistryTest,AddonRegistryStartupTest, and focused Settings tests if the pending-trust UI state is extracted. - Acceptance: first-seen packages whose signing fingerprint does not match
SigningFingerprint.sha256(context)are shown as pending/rejected until the user explicitly trusts that fingerprint; co-signed packages still enroll without extra prompts; changed-certificate packages remain rejected until a separate explicit trust action; reset-all trust clears both accepted and pending decisions; docs describe the exact trust states. - Verify:
./gradlew.bat :app:testDebugUnitTest --tests "dev.patrickgold.florisboard.ime.addon.*"plus a manual Settings -> Addons pass with co-signed, unsigned-untrusted, unsigned-trusted, changed-cert, and reset-all cases. - Shipped: v1.8.229 (2026-06-04) with focused addon registry/startup/pin-set coverage under the broad debug JVM unit-test task. Manual Settings -> Addons package-state smoke remains device-gated.
- Complexity: M
- Why: SwiftFloris' addon contract says addon packages must be co-signed with
the IME or explicitly trusted by the user in Settings, but the current
first-seen registry path auto-pins any package that passes manifest,
no-network, and descriptor screening. Because Android package visibility
- 🔬
locale-a11y-mime-native-audit-2026-06-04- syncedmaster, confirmed the Cycle 3 docs push is now atdc72e32, refreshed the post-v1.8.225 release-ledger evidence to the rewritten pushed hashes, checked current audit carry-forwards for duplicates, and widened into language-tag, Compose semantics, MIME-filter, and ByteBuffer platform contracts. Existing RA-9 and device-gated work remain valid; R3-1/R3-4 and RA-4 have since closed. This cycle adds four small, implementation-ready correctness/a11y/contract items and sharpens WS13 with the deferred sticker-provider SAF validation.
- 🤖 P1 — Correct Japanese locale capability gates and pin them with tests (R4-1)
- Why:
FlorisLocale.supportsAutoSpacedisables auto-space for"jp", but Android/BCP-47 Japanese locales use language subtag"ja";"JP"is only a region subtag. Japanese therefore falls through as an auto-space language, and the adjacent capitalization table has no regression coverage for the same class of language-tag mistakes. - Evidence:
FlorisLocale.kt:219-231hard-codes no-capitalization and no-auto-space language lists, including"jp"but not"ja";EditorInstance.kt:701andKeyboardManager.kt:678,728consumeprimaryLocale.supportsAutoSpace;LayoutScriptClassifier.kt:139already classifies"ja"as Japanese; IANA Language Subtag Registry listsSubtag: ja/Description: Japaneseand regionSubtag: JP/Description: Japan(https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry); AndroidLocaledocs recommend BCP 47forLanguageTag/toLanguageTagfor conforming locale strings (https://developer.android.com/reference/java/util/Locale). - Touches:
FlorisLocale.kt, newFlorisLocaleTestor equivalent JVM test,docs/AUTOCORRECT_LIFECYCLE.mdif the locale capability contract is documented there. - Acceptance:
FlorisLocale.from("ja").supportsAutoSpace == falseandFlorisLocale.from("ja").supportsCapitalization == false; existingzh,ko,th,bn,hi, and a Latin control locale are pinned; no regression tolanguageTag()/localeTag()serialization. - Verify:
./gradlew.bat :app:testDebugUnitTest --tests "dev.patrickgold.florisboard.lib.FlorisLocaleTest"plus the existing editor spacing policy tests. - Shipped: v1.8.227 (2026-06-04) with
FlorisLocaleTestcoverage.
- Why:
- 🤖 P3 — Add TalkBack descriptions for clipboard image/video history tiles (R4-2)
- Why: Text clips can surface URL/email/phone descriptions, but image and video history tiles expose only visual thumbnails. A screen-reader user can focus and activate the tile without hearing whether it is an image, video, pinned/recent item, or the copied timestamp. The prior audit deferred this only to avoid Crowdin churn, not because the gap was invalid.
- Evidence:
ClipboardInputLayout.kt:282-357renders image/videoImagethumbnails and the video overlay icon withcontentDescription = null;docs/AUDIT_2026-05-29.md:163-164records the missing clipboard image/videocontentDescription; Compose semantics docs say semantic properties give accessibility services additional context and thatcontentDescriptionconveys an icon/image's meaning (https://developer.android.com/develop/ui/compose/accessibility/semantics). - Touches:
ClipboardInputLayout.kt,strings.xml/ translations,docs/ACCESSIBILITY.md, optional semantics or screenshot test if the helper can be extracted without brittle IME rendering. - Acceptance: clipboard image and video tiles expose localized, non-sensitive labels such as "Clipboard image" / "Clipboard video" plus pinned/recent and copied-time context where available; decorative overlay icons remain hidden; sensitive text-description protections remain unchanged.
- Verify:
./gradlew.bat :app:testDebugUnitTestplus manual TalkBack pass over text/image/video clipboard history, long-press popup, and paste/delete actions. - Shipped: v1.8.238 (2026-06-04) with localized image/video media labels, Pinned/Recent/Other group context, copied-time context, decorative thumbnail overlay semantics preserved, focused JUnit/resource/source coverage, and manual TalkBack smoke still pending.
- 🤖 P3 — Pin
MimeTypeFilteraggregate semantics and remove constructor stdout (R4-3)- Why: The shared MIME helper is used by extension-file import and
copy-to-clipboard image routing, but its aggregate helpers still carry
"document and test" TODOs and the constructor prints compiled regex filters
to stdout. It also deliberately permits wildcard fragments like
application/font-*, which differs from AndroidXMimeTypeFilter; that divergence should be explicit and tested before more provider/import code depends on it. - Evidence:
MimeTypeFilter.kt:31-127documents wildcard-at-any-position behavior, hasprintln(filters)in the constructor, and leavesmatchesAll,matchesAny, andmatchesOneundocumented/test TODOs;MimeTypeFilterTest.kt:23-124covers only single-MIMEmatches; AndroidXMimeTypeFilterallows wildcards only as the whole type/subtype and notes Android framework MIME matching is case-sensitive (https://developer.android.com/reference/androidx/core/content/MimeTypeFilter); AndroidClipDescription.compareMimeTypesdocuments the platform pattern comparison used elsewhere in the IME (https://developer.android.com/reference/android/content/ClipDescription#compareMimeTypes(java.lang.String,java.lang.String)). - Touches:
lib/kotlin/src/main/kotlin/org/florisboard/lib/kotlin/MimeTypeFilter.kt,lib/kotlin/src/test/kotlin/org/florisboard/lib/kotlin/MimeTypeFilterTest.kt, call-site comments if any behavior is intentionally broader than AndroidX. - Acceptance: no constructor stdout; aggregate helpers have KDoc and tests for null/empty lists, exactly-one vs many matches, case-sensitive behavior, and the intentional fragment-wildcard cases used by font/image import filters.
- Verify:
./gradlew.bat :lib:kotlin:testDebugUnitTest --tests "org.florisboard.lib.kotlin.MimeTypeFilterTest". - Shipped: v1.8.241 (2026-06-04) with constructor stdout removed,
aggregate KDoc, null/empty/all/any/exactly-one/case-sensitive focused
coverage, and explicit legacy fragment-wildcard coverage for font/import
MIME strings. Verified with the pure-JVM
:lib:kotlin:test --tests "org.florisboard.lib.kotlin.MimeTypeFilterTest"task becauselib/kotlinis not an Android unit-test module.
- Why: The shared MIME helper is used by extension-file import and
copy-to-clipboard image routing, but its aggregate helpers still carry
"document and test" TODOs and the constructor prints compiled regex filters
to stdout. It also deliberately permits wildcard fragments like
- 🤖 P3 — Make
NativeStr.toJavaString()honor ByteBuffer position/limit/arrayOffset (R4-4)- Why: The native string bridge currently returns the whole backing array
whenever
hasArray()is true, ignoringposition(),limit(), andarrayOffset(). The current caller surface is small, but CJK/native addon work will make this bridge harder to reason about if sliced heap buffers decode stale prefix/suffix bytes. - Evidence:
Native.kt:39-46usesarray()directly on heap-backed buffers but copies onlyremaining()bytes for direct buffers;docs/AUDIT_2026-05-29.md:165-166records the latent offset/position bug; AndroidByteBufferdocs statehasArray()permitsarray()/arrayOffset(), and buffer content-sensitive operations depend on remaining elements fromposition()tolimit() - 1(https://developer.android.com/reference/java/nio/ByteBuffer). - Touches:
Native.kt, new focused JVM test for heap, sliced heap, direct, read-only/direct-equivalent, and non-zero-position buffers. - Acceptance:
toJavaString()decodes exactly the remaining bytes without mutating the caller-visible position, or documents and tests the mutation if preserving position is not feasible; direct and heap-backed buffers behave the same. - Verify:
./gradlew.bat :app:testDebugUnitTest --tests "dev.patrickgold.florisboard.lib.NativeStrTest". - Shipped: v1.8.242 (2026-06-04) with duplicate-view decoding that respects
position(),limit(), and sliced heaparrayOffset()without consuming the caller-visible buffer. Focused coverage pins heap, sliced heap, direct, read-only, non-zero-position, andtoNativeStrround-trip behavior.
- Why: The native string bridge currently returns the whole backing array
whenever
- 🔬
post-1.8.225-sync-and-futo-swipe-refresh-2026-06-04- syncedmaster, reconciled the post-v1.8.225 local fixes against the current roadmap, later confirmed the pushed docs state atdc72e32, and checked current competitor/standards sources. Existing RA-9, device-gated visual work, and maintainer-gated release items remain valid; this cycle adds only net-new release-ledger, clipboard-search, and sync-crypto-contract work, plus sharper evidence on F21 from the FUTO Keyboard v0.1.29 swipe release.
- 🤖 P0 — Reconcile post-v1.8.225 local fixes into a versioned release ledger (R3-1)
- Why: The branch is
v1.8.223-6-gdc72e32,HEADis untagged, and three local code-fix commits after the v1.8.225 docs marker change privacy, crypto, i18n, and theme-engine behavior without a matching new version, fastlane changelog, or top-of-README release entry. That breaks the repo's own "one shipped release = version + changelog + fastlane metadata + tag" contract and makes it hard for Obtainium/F-Droid/reproducible-build readers to know which APK contains the fixes. - Evidence:
git describe --tags --dirty --always->v1.8.223-6-gdc72e32;git tag --points-at HEADis empty;CHANGELOG.md:5-78documents v1.8.225 but not commits4fda240,86c9885, or76a74c2;gradle.properties:18-19still reports versionCode 2025 / versionName 1.8.225 after those commits. - Touches:
CHANGELOG.md,README.md,PROJECT_CONTEXT.md,gradle.properties,fastlane/metadata/android/en-US/changelogs/2026.txt,COMPLETED.mdif any roadmap/audit rows are closed, and the release tag. - Acceptance: a new release marker (or explicitly documented untagged-dev
marker) covers the n-gram/thread-safety/crypto/privacy, shared-secret
scrubbing/Arabic-shaping, and Snygg selector/contentScale fixes; fastlane
metadata exists for the new versionCode;
git describeresolves to the new tag once released. - Verify:
git describe --tags --dirty --always;bash scripts/check-fastlane-metadata.sh; full release gate after the build machine performs the version bump. - Shipped: v1.8.226 (2026-06-04) with versionCode 2026, fastlane changelog
2026.txt, release tagv1.8.226, and a local verification caveat inCHANGELOG.md#v1.8.226. - Complexity: S-M
- Why: The branch is
- 🤖 P1 — Wire clipboard-history text search into the in-keyboard clipboard palette (R3-2)
- Why: SwiftFloris already has a tested pure
ClipboardHistoryFilterand ahistorySearchEnabledpreference, but the liveClipboardInputLayoutexposes only type filters. FUTO Keyboard v0.1.29 added clipboard-history search in its latest release, reinforcing this as table-stakes for long local clipboard histories. - Evidence:
ClipboardHistoryFilter.kt:22-68defines the query contract and says the search wire-up is missing;ClipboardPrefs.kt:152-162defines the default-on UI-density toggle;ClipboardInputLayout.kt:151-163filters only by activeItemType; FUTO Keyboard v0.1.29 release notes list clipboard history search: https://github.com/futo-org/android-keyboard/releases/tag/0.1.29. - Touches:
ClipboardInputLayout.kt, clipboard strings,ClipboardScreen.ktif the existing toggle needs a visible settings row, and focused Compose or policy tests around query + type-filter composition. - Acceptance: when history search is enabled, the clipboard palette offers a
compact search affordance, filters text clips through
ClipboardHistoryFilter.filterByQuery, composes correctly with image/video type filters, shows a clear/no-results state, preserves sensitive-field and lock-screen redaction behavior, and resets scroll to the first match on query/type changes. - Verify:
:app:testDebugUnitTest --tests "dev.patrickgold.florisboard.ime.clipboard.*"; manual keyboard clipboard palette smoke with text, sensitive text, image, video, no-results, and device-locked states. - Shipped: v1.8.228 (2026-06-04) with
ClipboardHistoryFilterTestcoverage for query plus type-filter composition and a local verification caveat for manual TalkBack/device smoke. - Complexity: M
- Why: SwiftFloris already has a tested pure
- 🤖 P1 — Freeze the sealed-box envelope/KDF contract with vectors before sync transport ships (R3-3)
- Why: The local sealed-box scaffold now uses X25519 + AES-GCM with an RFC-5869-style HMAC KDF and scrubs the derived shared secret, but tests only cover generated-key round-trips. Before CRDT sync starts persisting or exchanging envelopes, the byte format and derivation constants need deterministic vectors so a future KDF tweak does not silently strand paired devices.
- Evidence:
SealedBoxCrypto.kt:96-143emitsephemeralPub || nonce || ciphertext+tagand opens the same shape;SealedBoxCrypto.kt:166-170derives key material from X25519 output;SealedBoxCryptoTest.kt:24-68lacks deterministic vectors or schema/version assertions; libsodium's sealed-box docs define the same ephemeral-public-key-prefixed shape and erase the ephemeral secret after encryption (https://doc.libsodium.org/public-key_cryptography/sealed_boxes); RFC 5869 defines HKDF's extract-then-expand construction and publishes SHA-256 test vectors (https://datatracker.ietf.org/doc/html/rfc5869). - Touches:
SealedBoxCrypto.kt,SealedBoxCryptoTest.kt,docs/THREAT_MODEL.mdordocs/SECURITY.mdfor the sync-envelope contract. - Acceptance: tests pin at least one deterministic vector for nonce/key or a fixed test-key envelope; docs state the envelope schema/version and compatibility policy; raw X25519 output and temporary nonce/KDF buffers are scrubbed where practical; malformed-envelope diagnostics remain non-leaky.
- Verify:
:app:testDebugUnitTest --tests "dev.patrickgold.florisboard.ime.sync.SealedBoxCryptoTest"; manual review that no network permission or native dependency was introduced. - Shipped: v1.8.230 (2026-06-04) with explicit v1 envelope constants, fixed-key deterministic vector coverage, malformed-envelope null-return coverage, temporary-buffer scrubbing, and threat-model compatibility docs.
- Complexity: M
- 🤖 P2 — Backfill focused regression tests for the untested post-v1.8.225 hotfix surfaces (R3-4)
- Why: The newest local fixes cover fragile crash/privacy/i18n paths, but the changed behavior is not fully pinned by tests. Without focused tests, the same regressions can reappear while the release ledger says the fixes are shipped.
- Evidence:
ArabicShaperTest.kt:24-58lacks a combining-mark case even though86c9885changed mark-skipping join context;SnyggRuleTest.ktcovers valid/invalid selectors but not unknown-selector fallback after76a74c2;rg "contentScale|SnyggContentScaleValue" lib/snygg/src/testfinds no serializer-id test;SwiftKeyTypingTraceRecorder.ktgained private-session gates in4fda240without a focused recorder test. - Touches:
ArabicShaperTest.kt,SnyggRuleTest.kt/ Snygg value tests,SwiftKeyTypingTraceRecordertests, and n-gram per-locale flush tests if the stores already expose a tractable test seam. - Acceptance: combining-mark Arabic shaping, unknown Snygg selector import,
contentScaleserialization, private-session trace suppression, and per-locale n-gram flush behavior each have focused regression coverage or a documented reason they require an extraction seam first. - Verify: focused test packages plus full Gradle gate.
- Shipped: v1.8.234 (2026-06-04) with combining-mark Arabic shaping
coverage, unknown-selector rejection coverage, full
contentScaleserialization/default coverage, private-session recorder suppression coverage, and source-level locale-scoped bigram/trigram flush guards. - Complexity: M
- 🔬
startup-diagnostics-and-docs-refresh-2026-06-04- re-read the current v1.8.218 repo state, the committed audit docs, the last 15 shipped releases, and current upstream/standards sources. Existing settings-search, dependency, upstream FlorisBoard, CLDR/Emoji, F-Droid, device-gated, and maintainer-gated rows remain correctly represented below; this cycle adds only the net-new startup diagnostics and source-of-truth documentation gaps.
- 🤖 P1 — Persist or surface staged startup exceptions before Settings opens (R2-1)
- Shipped v1.8.218:
CrashUtility.consumeStagedException(...)persists staged init exceptions without invoking the process-killing uncaught handler, andFlorisAppActivityopensCrashDialogActivitybefore the splash keep condition can hang onpreferenceStoreLoaded. - Why: A synchronous
FlorisApplication.onCreate()failure is staged and the application returns, but no production call drains the staged exception into the existing crash-file / notification path. On a privacy keyboard, a silent startup failure is a trust problem even if the failure is rare. - Evidence:
FlorisApplication.kt:100-156installs Flog/CrashUtility, then catchesExceptionwithCrashUtility.stageException(e); return;CrashUtility.kt:159-170stores and drains staged exceptions;rg "handleStagedButUnhandledExceptions" app/src/main/kotlin app/src/test/kotlinfinds no production/test call site;FlorisAppActivity.kt:100-170opens the Settings activity without readingCrashUtility;docs/AUDIT_2026-05-28.md:16-17independently verified the same path. - Touches:
FlorisApplication.kt,CrashUtility.kt,FlorisAppActivity.kt, and a focused JVM/Robolectric test around the chosen staging/drain policy. - Acceptance: an injected synchronous app-init failure creates a persisted stacktrace or visible crash/recovery surface; the splash screen does not hang silently; the implementation documents whether it intentionally calls the existing uncaught handler (process-killing) or writes a recoverable staged-init stacktrace without killing the Settings activity.
- Verify:
:app:testDebugUnitTest; manual debug build with a temporary injected pre-init()failure before removing the injection. - Complexity: M
- Shipped v1.8.218:
- 🤖 P2 — Replace remaining restore/crash diagnostic
printStackTrace()paths with project logging plus user-safe fallback copy (R2-2)- Shipped v1.8.219: restore archive-load, per-section restore, restore
launcher, and top-level restore failures now route diagnostics through
flogError, restore cards/toasts useBackupRestorePolicy.restoreErrorMessage(...)to avoid null/blank user copy, and crash stacktrace write failures use theCRASH_UTILITYlogging topic instead of rawprintStackTrace(). - Why: The restore flow and crash-file write helper still fall back to raw
printStackTrace()on exceptional diagnostic paths, while adjacent code already usesflogError. The fix should improve consistency and user-facing failure text without overstating release-build file-log coverage, becauseFlogis debug-gated andfileLog()is still a stub. - Evidence:
RestoreScreen.ktfailure paths are called out indocs/AUDIT_2026-05-28.md:19-22; siblingBackupScreen.kt:205andBackupScreen.kt:338useflogError;CrashUtility.kt:366-370still catches crash-file write failures withe.printStackTrace();Flog.kt:326tracks the file-logging TODO. - Touches:
RestoreScreen.kt,CrashUtility.kt, possiblyFlog.ktonly if a minimal release-safe sink is added; add focused tests for non-null restore failure messages where practical. - Acceptance: restore failure cards/toasts use stable fallback text when
localizedMessageis null; diagnostic exceptions route through the project logging idiom; docs/changelog do not claim persisted release logs unless a real persisted sink is implemented. - Verify:
:app:testDebugUnitTest; manual restore-failure smoke with a bad archive on the Android SDK host. - Complexity: S-M
- Shipped v1.8.219: restore archive-load, per-section restore, restore
launcher, and top-level restore failures now route diagnostics through
- 🤖 P2 — Refresh root onboarding docs to the v1.8.220 source of truth (R2-3)
- Shipped v1.8.220: root onboarding docs now route open work to
ROADMAP.md, shipped state toCOMPLETED.md, release notes toCHANGELOG.mdplus fastlane metadata, and archived parity/improvement plans are clearly historical context. - Why: The live roadmap is current, but the fast onboarding docs still mixed older stack facts, archived-plan routes, and retired release-note instructions. Future build passes use these docs first, so stale routing increases the chance of wrong release or planning edits.
- Evidence: the pre-fix stale scan found outdated stack/version facts and
root release-note/planning routes in
PROJECT_CONTEXT.md,ARCHITECTURE.md,CONTRIBUTING.md,README.md,docs/REPO_HYGIENE.md, andAGENTS.md. - Touches:
PROJECT_CONTEXT.md,ARCHITECTURE.md,CONTRIBUTING.md,README.md,docs/REPO_HYGIENE.md,AGENTS.md, and release ledgers. - Acceptance: root docs agree that
ROADMAP.mdis the open-work source,COMPLETED.mdis shipped-state summary,CHANGELOG.mdis the only release note stream, and current stack/release facts match v1.8.220. - Verify: stale reference scan;
:app:verifyNoInternetPermission;:app:testDebugUnitTest;:app:lintDebug;:app:assembleDebug; fastlane metadata check; repo hygiene check. - Complexity: M
- Shipped v1.8.220: root onboarding docs now route open work to
- 🔬
voice-copy-dependency-refresh-2026-06-04- rechecked the v1.8.207 voice-copy slice and public dependency metadata without running Gradle on this VM. FUTO Voice Input remains the correct privacy-preserving handoff for voice copy, Android 17/API 37 remains future behavior-gate work, and dependency drift is low-risk maintenance rather than a security item. The new P3 dependency freshness row is the build-lane handoff.
Research conducted 2026-06-03. Items below are new — not duplicates of Existing Planned Work.
This pass focused on the v1.8.204 settings search drop (the newest feature, shipped this release) and a few cross-cutting gaps the three deep audits (docs/AUDIT_2026-05-28/29 + 2026-06-02) and the existing roadmap do not already cover. The search subsystem is a hand-maintained static catalog that mirrors the navigation graph; v1.8.221 adds a drift guard, v1.8.222 adds a no-results escape hatch, v1.8.223 adds high-traffic synonym coverage, v1.8.224 resets result scrolling when the query changes, and v1.8.235 adds the TalkBack/accessibility pass, while the remaining search work is highlight-lifecycle polish.
All current quick wins shipped through v1.8.215. Remaining settings-search work is listed under Larger Bets.
- P1 — Drift guard test: every
SettingsSearchDestinationis navigable + every entry resId resolves (RA-1)- Shipped v1.8.221:
SettingsSearchIndexIntegrityTestnow fails on duplicate entry IDs, missing/blank real string resources, fake resolver fallback text, and destination-route mapping drift. The screen navigation path uses the sameSettingsSearchDestination.toSearchRoute()helper the test pins. - Why: The search catalog is a 33-value enum + ~100 hand-curated entries that mirror the navigation graph and reference real string resIds. Nothing fails the build when a Settings screen is added without a search entry, an entry points at a deleted/renamed pref label, or a
destinationloses itsRoutes.*arm. The only existing test (SettingsSearchIndexTest.kt) uses a fakeresolvemap and asserts ranking, not integrity. This is the same registry-drift failure mode the project already hit elsewhere (see the partitioned-prefs golden test). - Evidence: pre-fix
SettingsSearchIndex.ktheld enum/catalog rows directly andSettingsSearchScreen.ktkept destination routing inside a private navigation function; the existingSettingsSearchIndexTestresolved strings through a fake map. - Touches:
SettingsSearchScreen.kt;SettingsSearchIndexIntegrityTest.kt. - Acceptance: deleting a referenced string res or adding an unmapped destination fails the test; passes today.
- Verify:
:app:testDebugUnitTest --tests "dev.patrickgold.florisboard.app.settings.search.*"; full Gradle gate. - Complexity: M
- Shipped v1.8.221:
- P2 — No-results fallback action in settings search (RA-2)
- Shipped v1.8.222: zero-result searches now show a centered
Browse all settingstext button that navigates toRoutes.Settings.Home. - Why: An empty result set renders only gray "no results for X" text — a dead-end. There's no escape hatch (browse-all / jump to Settings home) and, notably, no link into the Android system keyboard settings, which is where a missing pref often actually lives (the search index is app-internal only).
- Evidence: pre-fix
SettingsSearchScreen.ktrendered only a no-resultsText; the shipped branch now renders the message plus action. - Touches:
SettingsSearchScreen; defaultsettings__search__browse_allstring. - Acceptance: from a zero-result query the user can reach Settings home in one tap; copy is translation-safe.
- Verify: focused search tests plus
:app:assembleDebug; full Gradle gate. - Complexity: S
- Shipped v1.8.222: zero-result searches now show a centered
- P2 — Reset settings-search result scroll when the query changes (RA-10)
- Shipped v1.8.224:
SettingsSearchScreennow scrolls populated non-blank result sets back to item 0 whenever the query changes, while blank and no-result states stay untouched.SettingsSearchScreenStateTestpins the reset guard. - Why: settings search ranks results per query, but the list keeps one
LazyListStateacross every edit. A user who scrolls down one query and then types a different query can land mid-list for the new result set, hiding the highest-ranked destination until they manually scroll back up. - Evidence: pre-fix
SettingsSearchScreenrecomputedresultsfromsearchQuerybut created one unkeyedrememberLazyListState()for the lifetime of the screen; only the initial-focusLaunchedEffectexisted. - Touches:
SettingsSearchScreen;SettingsSearchScreenStateTest. - Acceptance: after changing a non-blank query, the result list starts at the first/highest-ranked result; clearing or entering a no-results query does not leave the next populated query scrolled into the middle.
- Verify: focused search package tests plus full Gradle gate.
- Complexity: S
- Shipped v1.8.224:
- P2 — Keyword/synonym coverage audit for high-traffic settings terms (RA-3)
- Shipped v1.8.223:
SettingsSearchIndexnow adds targeted keyword synonyms for theme mode, haptic feedback, trace/shape-writing gestures, punctuation spacing, and privacy audit rows.SettingsSearchIndexTestpins the requested "dark theme", "haptic", "trace", "punctuation", and "privacy" queries to the expected destinations, with exact target-row checks for dark mode, punctuation, and privacy. - Why: Search matches title/summary/screen-title/keywords substrings, but many discoverable prefs have sparse
keywords(e.g. "haptic" only on input-feedback, "dark"/"light" not on theme.mode, "swipe" present on gestures but "shape writing"/"trace" partial). Users search by capability words, not the exact shipped label. - Evidence: pre-fix
SettingsSearchIndexrows had no targeted synonyms for theme mode, glide trace/shape-writing, punctuation spacing, or privacy audit capability queries; the shipped rows now carry those keywords. - Touches:
SettingsSearchIndex.entrieskeyword strings only (no code path change);SettingsSearchIndexTest. - Acceptance: a documented set of capability synonyms each resolve to the right destination; test pins them.
- Verify: focused search package tests plus full Gradle gate.
- Complexity: M
- Shipped v1.8.223:
- P2 — Accessibility/TalkBack pass over the search screen + result list (RA-4)
- Why:
ACCESSIBILITY.mddoes not yet cover the new search surface. The resultJetPrefListItems areclickablewith norole/merged-semantics announcement of "result N of M", the leading icon is correctlycontentDescription = null(decorative) but the field itself has no labelled state, and the empty/no-results text isn't a live region — a TalkBack user won't hear result-count changes as they type. - Evidence:
SettingsSearchScreen.kt:82-143— noModifier.semantics{}/liveRegion/roleon the field, results, or the count-changing branches;docs/ACCESSIBILITY.md"Manual QA checklist" has no search entry. - Touches:
SettingsSearchScreensemantics (field label, resultsrole = Role.Button/merged,liveRegion = Politeon the result-count container); add a search row to thedocs/ACCESSIBILITY.mdmanual-QA checklist. - Acceptance: TalkBack announces a labelled search field, reads each result's screen + title, and reports result-count changes; checklist documents the flow.
- Verify: manual TalkBack on-device;
:app:assembleDebug. - Shipped: v1.8.235 (2026-06-04) with labelled/stateful search-field semantics, polite live-region status updates, merged button-role result-row labels with result position/screen/title/summary context, a focused accessibility contract test, and manual checklist coverage. Manual TalkBack smoke remains device-gated.
- Complexity: M
- Why:
- P2 — Consume or dismiss Settings search highlight state after the target screen is reached (RA-9)
- Why: the search-result highlight card is stored in a process-wide Compose singleton and rendered by the shared settings scaffold. Because production code never clears it, the same "Search result" card can reappear whenever the user later visits the matching settings screen, pushing content down after the original search context is gone.
- Evidence:
SettingsSearchScreen.kt:158-164callsSettingsSearchHighlightStore.mark(...);FlorisScreen.kt:234-247renders aFlorisInfoCardwheneveractiveTarget.screenTitle == title;SettingsSearchIndex.kt:84-99stores already-resolved display strings and exposesclear(), butrg "SettingsSearchHighlightStore.clear"finds only the JVM test caller. - Touches:
SettingsSearchHighlightStoreplus theFlorisScreensearch-card rendering path. Prefer a one-shotconsumeTargetFor(screenTitle)API or a local displayed-target copy with a dismiss action, so the card survives the first target-screen composition but does not persist across later visits. If practical, match by a stable destination/screen key instead of localized title text. - Acceptance: selecting a search result still shows the destination card once; leaving and returning to that screen without a new search does not show the stale card; users can dismiss the card explicitly if it remains visible.
- Verify: focused JVM test for the consume/clear contract;
:app:assembleDebug; optional manual Settings search -> destination -> back -> destination smoke. - Shipped: v1.8.237 (2026-06-04) with one-shot
SettingsSearchHighlightStore.consumeTargetFor(...), localFlorisScreendisplayed-target state, an explicit close action on the highlight card, and focused JUnit coverage. Manual navigation smoke remains pending. - Complexity: M
- P3 — Surface settings search from Settings home (entry-point discoverability) (RA-8)
- Confirmed 2026-06-04: Settings Home already exposes the search route as a
top app-bar action with
settings__search__titlecontent description, so search is reachable from the first Settings screen without scrolling. - Why: Search is a registered route but reaching it depends on the home-screen wiring; a top-of-home search affordance (or app-bar icon) is the conventional discovery point and matches how Gboard/SwiftKey expose their settings search.
- Evidence:
HomeScreen.kt:68-75definesactions { FlorisIconButton(...) },onClick = { navController.navigate(Routes.Settings.Search) }, iconIcons.Default.Search, and content descriptionR.string.settings__search__title. - Touches: none; source already satisfies the row.
- Acceptance: search is reachable from the first screen of Settings without scrolling.
- Verify: source inspection; optional manual on-device smoke.
- Complexity: S
- Confirmed 2026-06-04: Settings Home already exposes the search route as a
top app-bar action with