feat: add text snippets on iOS and Android - #223
Conversation
Ports the trigger/expansion snippet feature from VocaMac (VocaHQ/vocamac#150) to both mobile apps. A SnippetExpander runs a single combined regex over every trigger (longest first, word/punctuation-aware boundaries, case-insensitive, replaced in reverse match order) as the last stage after transcript formatting, so styling never rewrites a snippet's literal expansion text. Both platforms get a settings screen to add, edit, and delete snippets.
✅ Deploy Preview for vocaphone-web canceled.
|
|
| Filename | Overview |
|---|---|
| ios/VocaPhoneApp/App/SettingsView.swift | Adds snippet management with draft-based add/edit sheets, explicit Save validation, deletion, and shared-store persistence. |
| ios/VocaPhoneShared/DictatedTranscript.swift | Adds snippet expansion as the final transcript transformation after styling and number conversion. |
| ios/VocaPhoneShared/SnippetExpander.swift | Implements case-insensitive, boundary-aware, single-pass snippet replacement. |
| android/app/src/main/java/com/vocahq/vocaphone/core/SnippetExpander.kt | Implements Android-compatible Unicode-aware snippet matching and reverse-order replacement. |
| android/app/src/main/java/com/vocahq/vocaphone/dictation/DictationController.kt | Integrates snippet expansion into the common transcript delivery funnel. |
| android/app/src/main/java/com/vocahq/vocaphone/settings/SettingsRepository.kt | Adds DataStore-backed snippet creation, validation, updates, deletion, and loading. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
A[Raw transcript] --> B[Sanitize and repair]
B --> C[Apply writing style]
C --> D[Convert spoken numbers]
D --> E[Expand configured snippets]
E --> F[Insert final text]
Reviews (5): Last reviewed commit: "feat(android): give snippets its own set..." | Re-trigger Greptile
The inline edit row persisted on every keystroke, so clearing an existing trigger or expansion mid-edit wrote that blank straight to the store — a blank expansion then silently deleted the trigger from every future transcript. Only fully-valid rows are written now; a blank row stays editable in memory until it's valid again.
|
Addressed in da65325:
|
UNICODE_CHARACTER_CLASS compiles on the desktop JVM the unit tests run on
and throws on Android's ICU engine, so every dictation with a snippet saved
failed with "UNICODE_CHARACTER_CLASS flag not supported" while the suite
stayed green.
The flag is gone, and the word boundary it was there to widen is now spelled
out as [\p{L}\p{N}_] on both platforms instead of \b. \b is not portable
either: ASCII-only \w on the JVM, Unicode \w under ICU, so a non-ASCII
trigger could pass a test and still fail on a phone. Naming the class keeps
the two engines and the two platforms in step.
Expansion also no longer takes the transcript down with it — a trigger that
cannot compile costs the expansion, not the dictation — and triggers are
trimmed before matching. Blank expansions are rejected at the Android
repository as they already are in the iOS store, since one would silently
delete its trigger from every later transcript.
Adds an instrumented test so the expander is exercised on the real ICU
engine, which is the only place this class of bug is visible.
|
Device testing caught a real bug — pushed in 8f475dd. The crash
The underlying trap
Also fixed while reviewing
Verification
Note, unrelated to this PR: |
|
Want your agent to iterate on Greptile's feedback? Try greploops. |
Rows were bound straight to the store and persisted on every keystroke, so the halfway state of an edit was the saved state. Guarding that write by validity only moved the damage: clearing a field to retype it dropped the row from the store, and leaving the screen before finishing lost the snippet for good. Editing now opens the same form Add uses, and nothing reaches the store until Save, so there is no halfway state to write and persist() can save the list as it stands. Deleting a snippet stays an explicit swipe. Both expanders now also skip a blank expansion rather than applying it. Neither settings screen will save one, but anything already stored would quietly delete its trigger from every transcript, and an empty field has never meant that.
|
Good catch — fixed properly in fa4c896. You're right, and it was a regression from my own previous fix. Guarding the per-keystroke write by validity just moved the damage: clearing a field to retype it dropped the row from The real problem was per-keystroke persistence of a half-typed row — both earlier patches worked around that design instead of removing it. Editing now opens the same form Add uses, with Cancel and Save, so there is no halfway state to write:
This also lines the iOS editor up with Android's Defence in depth: both expanders now skip a blank expansion rather than applying it. Neither settings screen will save one any more, but anything already stored (from an intermediate build) would quietly delete its trigger from every transcript, and an empty field has never meant that. Covered by a test on each platform. Verification
|
Snippets sat at the bottom of the Keyboard page, below layout, typing and the personal dictionary — three scrolls into a page about key layout, for a feature that rewrites what dictation types and has nothing to do with keys. Nobody scrolls that far to find something they don't know exists, and iOS already lists it at the top level, so the two apps disagreed. It is now its own row on the settings menu, carrying a count the way the other rows carry their current value.
Summary
SnippetExpanderon both platforms builds one combined regex over all triggers (longest first, word/punctuation-aware boundaries, case-insensitive, matches replaced in reverse order so an expansion containing another trigger is never re-expanded).Me@example.com).Platform notes
Snippet/SnippetStorepersist to the shared App GroupUserDefaults(group.com.vocahq) so the keyboard extension and main app agree on the same list; wired as a 5th stage inDictatedTranscript.finished.Snippetpersists via DataStore as hand-rolled JSON (matching the codebase's existing convention, no serialization dependency); wired intoDictationController.deliver(), the single funnel all delivery paths (batch/streaming/local) converge on. Regex usesUNICODE_CASE/UNICODE_CHARACTER_CLASSso non-ASCII triggers match correctly.Test plan
xcodebuild test-without-building— full suite passes (604/604), including newSnippetExpanderTestsandDictatedTranscriptTestscases../gradlew testFullDebugUnitTest lintFullDebug— full suite passes (677 tests), lint clean.