Skip to content

feat: add text snippets on iOS and Android - #223

Merged
Mr-Sunglasses merged 5 commits into
VocaHQ:mainfrom
Mr-Sunglasses:feat/text-snippets
Aug 29, 2026
Merged

feat: add text snippets on iOS and Android#223
Mr-Sunglasses merged 5 commits into
VocaHQ:mainfrom
Mr-Sunglasses:feat/text-snippets

Conversation

@Mr-Sunglasses

Copy link
Copy Markdown
Member

Summary

  • Ports the trigger/expansion text-snippets feature from VocaMac (feat: Add support for text snippets vocamac#150) to the iOS and Android apps.
  • SnippetExpander on 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).
  • Expansion runs as the final stage after transcript formatting/styling on each platform, so auto-capitalization/styling never rewrites a snippet's literal expansion text (e.g. an email trigger doesn't come out Me@example.com).
  • Adds a Snippets settings screen on each platform (add / edit / delete, trigger trimmed on save, expansion left as typed).

Platform notes

  • iOS: Snippet/SnippetStore persist to the shared App Group UserDefaults (group.com.vocahq) so the keyboard extension and main app agree on the same list; wired as a 5th stage in DictatedTranscript.finished.
  • Android: Snippet persists via DataStore as hand-rolled JSON (matching the codebase's existing convention, no serialization dependency); wired into DictationController.deliver(), the single funnel all delivery paths (batch/streaming/local) converge on. Regex uses UNICODE_CASE/UNICODE_CHARACTER_CLASS so non-ASCII triggers match correctly.

Test plan

  • iOS: xcodebuild test-without-building — full suite passes (604/604), including new SnippetExpanderTests and DictatedTranscriptTests cases.
  • Android: ./gradlew testFullDebugUnitTest lintFullDebug — full suite passes (677 tests), lint clean.

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.
@netlify

netlify Bot commented Aug 29, 2026

Copy link
Copy Markdown

Deploy Preview for vocaphone-web canceled.

Name Link
🔨 Latest commit 3a5a465
🔍 Latest deploy log https://app.netlify.com/projects/vocaphone-web/deploys/6a934446168a000008a7d18c

@greptile-apps

greptile-apps Bot commented Aug 29, 2026

Copy link
Copy Markdown

Greptile Summary

Adds configurable text snippets to iOS and Android, expanding triggers after transcript formatting while preserving literal replacement text.

  • Adds shared snippet models, persistence, matching, and platform-specific settings interfaces.
  • Integrates expansion into the final transcript-delivery stage on both platforms.
  • Adds unit, integration, and Android device regex coverage.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains.

No blocking failure remains.

Important Files Changed

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]
Loading

Reviews (5): Last reviewed commit: "feat(android): give snippets its own set..." | Re-trigger Greptile

Comment thread ios/VocaPhoneApp/App/SettingsView.swift Outdated
Comment thread ios/VocaPhoneShared/DictatedTranscript.swift
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.
@Mr-Sunglasses

Copy link
Copy Markdown
Member Author

Addressed in da65325:

  • Inline edits persist invalid snippets — fixed. persist() now only writes fully-valid rows (non-empty trimmed trigger, non-empty expansion) to the store; a row mid-edit stays visible and editable locally but is dropped from the persisted list until it's valid again, so a blank expansion can no longer delete a trigger's dictated text.
  • Physical-device insertion verification — no iOS or Android device is connected to this machine right now, so I can't run just ios device / just android run myself. This needs to be done before merge per AGENTS.md; will follow up with app/OS/sequence once I have a device connected, or a maintainer can verify.

Comment thread ios/VocaPhoneApp/App/SettingsView.swift Outdated
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.
@Mr-Sunglasses

Copy link
Copy Markdown
Member Author

Device testing caught a real bug — pushed in 8f475dd.

The crash

Pattern.UNICODE_CHARACTER_CLASS (added in an earlier round to make matching Unicode-aware) compiles on the desktop JVM and throws on Android: UnsupportedOperationException: UNICODE_CHARACTER_CLASS flag not supported. Every dictation with a snippet saved failed, and the whole unit suite stayed green because it runs on the desktop JVM, not on ICU.

The underlying trap

\b is not portable either — it resolves against ASCII-only \w on the JVM and Unicode \w under Android's ICU engine. So a non-ASCII trigger like büro could pass a unit test and still fail on a phone (or vice versa). Both platforms now spell the boundary out as [\p{L}\p{N}_], which means the same thing in both engines, so the tests actually describe device behaviour and iOS/Android stay in step with each other.

Also fixed while reviewing

  • Expansion could take the transcript down with it. Pattern.compile was unguarded inside DictationController.deliver(), so the throw above cost the user the entire dictation, not just the expansion. Now falls back to unexpanded text, matching what iOS already did.
  • first {}firstOrNull {} on the alternation group lookup; unreachable today, but it sat on the same delivery path where a throw loses the transcript.
  • Triggers are trimmed before matching on both platforms (previously only trimmed on save, so a trigger with padding compiled the padding into the pattern).
  • Blank expansions rejected at the Android repository, as they already are in the iOS store — one would silently delete its trigger from every later transcript.

Verification

  • Physical device: POCO F1, Android 14. New SnippetExpanderDeviceTest (instrumented) runs the expander on the real ICU engine — 6/6 pass. This is the only place this class of bug is visible, so it's now a permanent guard.
    ./gradlew :app:connectedFullDebugAndroidTest \
      -Pandroid.testInstrumentationRunnerArguments.class=com.vocahq.vocaphone.core.SnippetExpanderDeviceTest
    → Starting 6 tests on POCO F1 - 14 … BUILD SUCCESSFUL
    
    App installed and launched on the same device via just run; end-to-end dictation-with-snippet re-check in progress.
  • Android: ./gradlew testFullDebugUnitTest lintFullDebug — green, lint clean.
  • iOS: 608/608 unit tests pass (added matching non-ASCII / whitespace-trigger coverage).

Note, unrelated to this PR: app/src/androidTest/.../local/LocalEngineEndToEndTest.kt does not compile against the current WhisperLib.fullTranscribe signature (drifted since #112), which blocks the whole androidTest source set. I worked around it locally to run the above rather than fix it here — worth a separate issue.

@greptile-apps

greptile-apps Bot commented Aug 29, 2026

Copy link
Copy Markdown

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.
@Mr-Sunglasses

Copy link
Copy Markdown
Member Author

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 SnippetStore, and leaving the screen before finishing lost the snippet permanently. Worse than what it replaced.

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:

  • Nothing reaches the store until Save; persist() writes the list as it stands and no longer filters, so it cannot drop a row.
  • Save stays disabled while the trigger or expansion is blank, so an invalid snippet can't be created or saved over a valid one.
  • Cancel leaves the stored snippet untouched.
  • Deleting is an explicit swipe, the only path that removes anything.

This also lines the iOS editor up with Android's SnippetEditorDialog and the macOS row's Save/Cancel, so all three platforms now share one model.

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

  • iOS: 609/609 unit tests pass; preview isolation ok.
  • Android: testFullDebugUnitTest lintFullDebug green.
  • Physical device, POCO F1 / Android 14: instrumented SnippetExpanderDeviceTest 6/6 on the real ICU engine, app reinstalled via just run.

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.
@Mr-Sunglasses
Mr-Sunglasses merged commit cd8728f into VocaHQ:main Aug 29, 2026
9 checks passed
@Mr-Sunglasses
Mr-Sunglasses deleted the feat/text-snippets branch August 29, 2026 21:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant