Skip to content

Add voice overlay: dictate into any app from a floating button - #68

Open
hagope wants to merge 12 commits into
soniqo:mainfrom
hagope:feat/voice-overlay
Open

Add voice overlay: dictate into any app from a floating button#68
hagope wants to merge 12 commits into
soniqo:mainfrom
hagope:feat/voice-overlay

Conversation

@hagope

@hagope hagope commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a voice overlay to the demo app: a draggable mic button drawn over other apps. Tap it and it becomes ■ stop / ✕ cancel — stop types the transcript into whatever text field currently has input focus, cancel discards it.

Reaching another app's text field requires an accessibility service; an overlay alone cannot. Three grants are needed (microphone, display-over-other-apps, accessibility), each behind its own system screen, and VoiceOverlayActivity shows which are still missing. That screen also carries a scratch text field, so the overlay can be tried without leaving the app — useful on first run, where a failure otherwise gives no clue whether the overlay, the grant, or the target app is at fault.

Manually tested on a Samsung S26 (Android 16), dictating into third-party apps.

Insertion: two paths, chosen per field

Worth review attention, because the obvious approach is the wrong one in a way that is not obvious.

ACTION_SET_TEXT has to rebuild the field's entire value, so it must be told what is already there. Apps whose empty field reports a placeholder as its text poison that read — Telegram's composer returns "Message", so every dictation arrived as "Message <what you said>". Two rounds of placeholder detection (isShowingHintText, then hintText / contentDescription matching) both failed against it, because nothing the node exposed distinguished placeholder from content.

ACTION_PASTE lets the app insert at its own cursor and is never told the contents, so a placeholder structurally cannot reach the result. But every clipboard write raises the system's "copied" chip, which is noise on every single dictation.

So the method is chosen per field:

  • Contents are known — no text, or a declared hint — ACTION_SET_TEXT writes directly, with cursor-aware splicing. No clipboard, no chip.
  • Text with no placeholder signal to explain itACTION_PASTE, which cannot be poisoned. SET_TEXT remains the fallback if a field rejects paste.

On the paste path the clip is marked sensitive so Android 13+ cannot preview the dictation, and afterwards the user's previous clip is restored, or the clipboard cleared if there was none — dictation should not linger there any more than anything else typed.

Trade-off: on the paste path the app controls placement, so the joining-space logic does not apply. A missing space is a smaller problem than a corrupted prefix.

Other design notes

  • The overlay window is non-focusable on purpose. If it took focus, the field being dictated into would lose it and there would be nowhere to type.
  • Nothing dictated is ever dropped. With no editable field focused, the text goes to the clipboard with a toast — the one path that deliberately leaves it there.
  • Field detection tries findFocus(FOCUS_INPUT) on the service, rootInActiveWindow, and each window, then falls back to a depth-bounded walk for a focused editable node — which catches Compose fields and web views that never report input focus the standard way.
  • The bubble docks to an edge and snaps back to it, so growing into stop/cancel and shrinking back lands it exactly where it started. Right edge by default.

Engine state between dictations

Reaches into speech-core behaviour, so flagging it: VoicePipeline never clears pending_utterances_ on stop()/start(), and the turn detector holds mid-utterance audio until VAD sees end-of-speech. The overlay stops the mic mid-sentence and reuses one long-lived pipeline, so leftover audio surfaced inside the next dictation — including audio the user had cancelled. Filed upstream as soniqo/speech-core#120.

Handled app-side: a silence flush closes the open utterance (also capturing the sentence tail), a drain waits for the engine to fall quiet before a new dictation can start, and a session gate drops results arriving outside their own dictation. An explicit reset() upstream would let the drain go away.

DictationActivity looks structurally exposed to the same leak (one pipeline, mic toggled on and off). Left untouched rather than changed speculatively.

Commits

  1. Add voice overlay — the feature.
  2. Fix dictations lost to a race in the finalize wait — the wait after Stop returned as soon as speechActive and partialText were both clear, but both are clear between SpeechEnded and TranscriptionCompleted, so it could return before the result existed and report "Nothing heard". Now waits for the engine to fall quiet.
  3. Add a scratch text field — the try-it box.
  4. Drop its placeholder text — the instructions already sit above it.
  5. Treat a displayed hint as an empty field — first attempt at the Telegram bug.
  6. Catch placeholders exposed only via contentDescription — second attempt, plus a node-report diagnostic on the setup screen.
  7. Insert via paste — the fix that worked.
  8. Dock to the right edge by default — under the thumb, clear of the back gesture.
  9. Clear the dictation from the clipboard after pasting.
  10. Only use the clipboard when the field's contents are in doubt — removes the "copied" chip from the common case.

Test plan

  • ./gradlew :app:testDebugUnitTest — 15 pass, including 13 TextInsertionTest cases covering cursor clamping, selection replacement, unknown/out-of-range offsets, joining-space rules, and placeholder handling
  • ./gradlew :sdk:test — passes
  • ./gradlew :app:assembleDebug — passes
  • Manually on a Samsung S26 (Android 16): dictation into Telegram and other apps, clipboard fallback with no field focused, edge snapping and docking, cancel-then-record leaving no stale text, short phrases committing correctly
  • :sdk:connectedAndroidTest not run — no device available in this environment; this change does not touch the SDK

Docs

README.md plus all nine translations (zh, ja, ko, es, de, fr, hi, pt, ru).

hagope added 3 commits July 25, 2026 07:44
A draggable mic button drawn over other apps. Tap it and it becomes
stop/cancel: stop types the transcript into whatever text field has
input focus, cancel discards it.

Reaching another app's text field requires an accessibility service —
an overlay alone cannot. The overlay window is deliberately
non-focusable so the target field keeps input focus while the buttons
are tapped. Text is spliced in at the cursor with ACTION_SET_TEXT,
falling back to clipboard paste for fields that reject it (some web
views), and to the clipboard entirely when no editable field is
focused, so a dictation is never silently lost.

Engine state has to be flushed between dictations. speech-core's
VoicePipeline never clears pending_utterances_ on stop()/start(), and
the turn detector holds mid-utterance audio until VAD sees
end-of-speech. Since the overlay stops the mic mid-sentence and reuses
one long-lived pipeline, that leftover audio surfaced inside the *next*
dictation. Each session now pushes silence to close the open utterance
(which also captures the sentence tail) and drains the engine before
going idle. A session gate drops results arriving outside their
dictation. An explicit reset() upstream would let the drain go away.

- app/.../overlay/OverlayBubbleService.kt — bubble, mic, session state
- app/.../overlay/DictationAccessibilityService.kt — text insertion
- app/.../overlay/TextInsertion.kt — cursor/selection splicing, tested
- app/.../overlay/VoiceOverlayActivity.kt — permission setup screen

Documented in README.md and all nine translations.
The wait after Stop returned as soon as speechActive and partialText
were both clear — but both are clear in the window between SpeechEnded
and TranscriptionCompleted, so it could return before the result
existed, commit an empty transcript, and report "Nothing heard". Short
phrases that finish before the first partial is emitted fall in the
same hole.

Flushing silence to close the utterance made this fire far more often:
it makes SpeechEnded arrive promptly and reliably, which is exactly the
moment the old condition bailed out.

Wait for the engine to fall quiet instead. Every event it emits pushes
the quiet window back, so the wait cannot return mid-flight, and a
genuine no-speech case still resolves after the settle window rather
than hanging to the timeout.
Trying the overlay meant leaving the app for a third-party one, which
is a poor first run: if nothing appears, it is unclear whether the
overlay, the accessibility grant, or that particular app is at fault.

The setup screen now carries a plain EditText to dictate into. Being an
ordinary text field is the point — it exercises the same focused-field
path as any other app, so a successful dictation here confirms both the
accessibility service and the non-focusable overlay window.

Showing the overlay no longer sends the app to the background, since
the field to test against is on this screen; Home still dismisses it.
The screen scrolls now that the keyboard covers part of it.
hagope added 9 commits July 25, 2026 09:26
The instructions already sit above the box, so the in-field hint was
repeating them inside the thing it was describing.
An empty text field often reports its placeholder through the node's
text — Telegram's composer returns "Message" — so dictation was being
appended to the placeholder and every message started with "Message".

Hint detection uses the node's own isShowingHintText, falling back to
comparing text against hintText for apps that populate the hint but
never set the flag. Selection offsets describe the hint in that state,
so they are ignored once it is treated as empty.
…e node

isShowingHintText and hintText cover standard EditTexts but not custom
editors: Telegram's composer still prefixed every message with
"Message". Placeholder detection now also treats text matching
contentDescription as empty when the caret is unset, which is the shape
a custom editor reports for an empty field.

Since this has to be inferred per app, the service now records what the
targeted node actually reported — class, package, text, hintText,
isShowingHintText, contentDescription, selection, and what was treated
as existing text — and the setup screen can show it. Guessing twice was
one time too many.
Two attempts at detecting placeholders both failed on Telegram, whose
composer still prefixed every message with "Message". The approach was
the problem, not the heuristics: ACTION_SET_TEXT has to rebuild the
field's entire value, so it must be told what is already there, and an
app whose empty field reports a placeholder as its text will always
poison that read.

ACTION_PASTE lets the app insert at its own cursor. It never has to be
told the current contents, so an empty field stays empty underneath and
no placeholder can reach the result. Paste is now primary with SET_TEXT
as the fallback for fields that reject it, inverting the previous
order.

The user's clipboard is saved and restored around the paste, after a
delay — restoring synchronously races the target app reading the clip.

Trade-off: the app controls placement, so the joining-space logic no
longer applies when appending to existing text. A missing space is a
smaller problem than a corrupted prefix, and the SET_TEXT fallback
still applies spacing where it runs.
It sits under the thumb for most right-handed use, and keeps clear of
the left-edge back gesture. The initial position starts at the right
edge rather than sliding there on first layout.
Insertion goes through the clipboard, so the dictated text was left
sitting there whenever the user had nothing on it beforehand — as
readable to any other app as anything else they had copied.

It is now cleared after the paste, or the user's previous clip is
restored if there was one. The no-field-focused fallback still leaves
the text on the clipboard deliberately, since pasting it manually is
the entire point of that path.
Every clipboard write raises the system's "copied" chip, and routing
all insertion through paste meant one on every dictation.

Paste is only needed when an empty field might be reporting a
placeholder as its text. When the node says it is empty, or declares a
hint, the contents are known and ACTION_SET_TEXT can write directly —
no clipboard, no chip. Fields whose text no placeholder signal explains
still go through paste, keeping the Telegram fix intact.

The clip is also marked sensitive so Android 13+ cannot preview the
dictation in the chip on the paths that still use it.
Solid green and red circles shouted over whatever app was underneath.
The mark now carries the colour on the same dark circle as the idle
bubble, so the overlay stays quiet while still reading as stop and
cancel at a glance. Glyphs go up 18sp to 20sp to hold their weight
without the filled background behind them.
speech-core force-splits any utterance past 15 s
(agent_config.h max_utterance_duration, not overridden here), so a long
dictation arrives as several utterances queued behind each other. The
wait after Stop could not survive that.

Silence cannot distinguish "finished" from "busy": transcribing a
segment emits nothing for its whole duration, so a wait keyed only on
quiet gave up mid-decode, committed what it had, and closed the
session — after which the real result was dropped as belonging to a
finished dictation. The longer the audio, the more reliably it lost the
tail.

The wait now consults the pipeline's own state, and the timeout scales
with how long was recorded rather than sitting at a flat 4 s, capped at
30 s. The drain uses the same busy check.
@ivan-digital

Copy link
Copy Markdown
Member

@hagope Is this PR ready for review as a merge candidate? At a high level, I think a few design points may still need another pass:

  • fix: isolate voice pipeline input sessions speech-core#123 now provides deterministic session isolation, so the app-side timeout/drain workaround could likely be simplified.
  • Clipboard save/restore is unsafe from a non-focused service on Android 10+ and may overwrite the user’s clipboard.
  • Model loading bypasses the existing unique download worker, creating a possible concurrent-cache race.

Are these planned follow-ups, or should we review the current shape as final?

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.

2 participants