conduit/android
cogwheel 3e7e3c0aaa
Fix voice call speech and default new calls to the speakerphone (#650)
* fix(voice): keep reasoning blocks out of speech and stop skipping the answer

Voice mode speaks the assistant content as it streams, and since the
streaming merge started preserving local <details> wrappers, TTS only
stripped blocks that were already closed. An open reasoning or tool_calls
wrapper was read aloud, and once its </details> landed the block vanished
from the split, shifting every later chunk left past the monotonic chunk
cursor. The answer itself was then never spoken.

TTS now sanitizes through the shared semantic_details helpers and treats an
unterminated semantic opener as a hard stop, so the speakable text only ever
grows. The streaming feed re-anchors on the text of the last chunk it handed
to playback instead of trusting a bare index.

* feat(voice): start calls on the speakerphone when nothing is plugged in

A voice call runs the audio session in communication mode, so a phone with
no accessory attached routes playback to the earpiece. Held like a call it
is fine, held like a speakerphone it is barely audible.

The coordinator now scans the attached audio devices at call start and
engages the loudspeaker only when it finds nothing to play through. A failed
scan leaves the route alone, since blaring an answer over someone's headset
is worse than a quiet earpiece. It keeps watching for the rest of the call,
so pulling headphones out moves playback to the speaker and connecting a
headset takes it back off. Pressing the speaker button ends the automatic
switching for that call.

* fix(voice): serialize audio route and TTS feed changes

Route changes are several platform calls deep, so two of them running at
once interleaved and the slower one got the last word. A headset pulled
out during a reroute could leave the call on the earpiece, and an
automatic reroute could land after the user pressed the speaker button or
after the call ended.

Queue route changes one at a time and re-read the state each one assumed
before it applies, so a stale reroute stands down instead of overwriting
a newer decision. Deactivation drains the queue before tearing the route
down.

Streaming TTS feeds had the same shape of problem: a second feed could
append its chunks in between the ones an earlier feed was still handing
to playback, so sentences could be spoken out of order. Chain the feeds.

* fix(voice): catch uppercase semantic details wrappers

The complete-block pattern in the TTS sanitizer already ignored case, but
the shared opener and block patterns did not, so an uppercase
`<DETAILS TYPE="reasoning">` that had not closed yet was read aloud.
Tag and attribute names are case-insensitive in HTML and this is model
output, so match either case everywhere.

* fix(voice): ignore a default route scan that came back too late

The accessory scan at the start of a call can still be out when the user
presses the speaker button or hangs up. Its result would then resubscribe
to device changes and push the call onto the loudspeaker after teardown.
Stamp the scan with a call generation and drop the answer if the call it
belongs to is over or the user has since chosen a route.

* fix(voice): only treat routes a call can actually use as accessories

A voice call runs the session in communication mode, which cannot route
to A2DP or AirPlay, so a device offering only those is not somewhere the
call can play and should not suppress the loudspeaker default. Headsets
that also speak HFP still register as bluetoothSco.

Accessory detection also required only a matching device type, so a
plugged-in microphone counted as somewhere to play. Require an output.

Manual speaker toggles are rejected once teardown has started, where
honouring them would put communication mode back after the route was
handed back.

* fix(voice): anchor the speech cursor on spoken text, not one chunk

The cursor was re-anchored by searching the fresh split for the text of
the last chunk handed to playback. When an answer repeats a sentence, that
search can match the wrong copy and skip everything in between, and when
the server rewrites the answer the anchor disappears and the cursor stops
moving at all.

Carry the text already spoken instead and walk the new split against it.
The match is positional, so repeated sentences are unambiguous, and a
rewrite resumes at the point the two versions stop agreeing rather than
replaying or skipping.

* fix(tts): strip nested details blocks before speaking

A non-greedy `</details>` match stops at the first close tag, so a
wrapper nested inside another left the outer block's tail in the text
handed to TTS: `<details><details>x</details>secret</details>` spoke
`secret`. Replace the pattern with a depth-counting walk shared from
`semantic_details.dart`, which also keeps the existing behaviour of
withholding the tail of a semantic wrapper that has not closed yet.

* fix(voice): re-check the route owner before publishing a reroute

The speaker button can be pressed, or the call can end, while the
platform calls behind an automatic reroute are still in flight.
Publishing afterwards left the speaker control showing a route nobody
chose.

* fix(tts): keep scanning inside an unclosed ordinary details

An ordinary `<details>` still waiting for its close tag kept the whole
suffix, so a reasoning wrapper opened inside it reached TTS. Keep the
tag and carry on from just past it: nested complete blocks are stripped
and a nested open wrapper still truncates.

* fix(voice): let a newer device event own the published route

A device event that lands while an earlier reroute is mid-flight claims
_accessoryAttached before queueing its own work. The earlier reroute
then published its now-stale value, so the speaker control showed the
old route until the newer operation caught up.

* fix(tts): speak on the call route during a voice call

Android routes voice-communication and media output separately. Device
TTS spoke as USAGE_MEDIA while the call held focus as
USAGE_VOICE_COMMUNICATION in MODE_IN_COMMUNICATION, so it ignored the
call's speakerphone choice and went silent once the app was backgrounded
mid-call.

The engine now gets voice-communication audio attributes (and the
matching legacy stream param) while a call is up, and goes back to the
media stream for read-aloud.

* fix(voice): only report a route change the platform took

A refused reroute still published to speakerphoneRouteChanges and stuck
in _speakerphoneEnabled, so the speaker button pointed at a route nobody
was hearing and the enabled == _speakerphoneEnabled guard dropped the
next identical device event as already handled.

The route calls now answer whether they landed, the flag rolls back when
they did not, and neither the device-change path nor the user toggle
publishes without a successful move.

* fix(tts): hand the engine back to read aloud after a call

Disposing the voice-mode provider mid-call left TtsManager in voice-call
mode, so a later read-aloud spoke on the call route: earpiece, at call
volume. Provider disposal now clears the flag, and so does reset().

reset() also stops rewinding _sessionCounter. A feed queued on the
previous chain only checks the active session's id, so reusing an id let
it append its old text to the next session, and the id names the server
chunk temp dir and background lease that the same stale work tears
down.

* fix(voice): light the speaker button only on a confirmed route

The default route is picked before the audio session exists, so the move
to the loudspeaker happens on the next configureFor* pass. The snapshot
was set from the pick, not the move, so a refused reroute still lit the
speaker button while the call stayed on the earpiece.

applyDefaultSpeakerphoneRoute now only sets the preference. The
configure pass reports whether the platform took it and announces it on
speakerphoneRouteChanges, which is already the one path the snapshot
follows. A refused move puts the flag back so the next device event can
try again.

* fix(voice): queue configure-pass routing behind the other reroutes

The listening, speaking and barge-in passes made the same platform route
calls as the button and device-change reroutes, off the same
_speakerphoneEnabled flag, without going through _routeSerial. A pass
that started before a headset was pulled out could finish after the
reroute and put the call back on the route it had just left.

They now queue with everything else, so each one reads the route
decision when it runs rather than when it was scheduled, and the default
route is only announced while the loudspeaker is still the current
choice.

* test(tts): assert with package:checks

The rest of the suite asserts with package:checks; this file was the odd
one out on expect(), which a review flagged while reading the new voice
call tests.

* fix(tts): stop a rewrite replaying the sentences it left alone

The cursor resumes at the point the old and new splits stop agreeing, so
a server that revises one sentence in the middle of an answer queues
everything after it a second time. The listener hears the tail twice.

Skip forward through the text playback already heard, in order, and only
speak the chunks that are not in it. Scanning forward rather than
searching the whole string keeps a sentence that genuinely repeats later
in the answer spoken once for each time it appears.

* fix(tts): hold the session open until the last feed lands

finishStreaming marks the response finalized before its own feed reaches
the serial chain. If the engine finishes the chunk it is speaking in that
window, playback sees a finalized session with nothing left queued, ends
it, and the feed carrying the rest of the answer finds no session to
append to. The answer stops mid-sentence.

Count the feeds still queued or running and treat playback as waiting
while any remain.

* fix(voice): keep automatic routing after a refused speaker press

The speaker button claimed the route before the platform had taken the
move, and kept the claim even when the move was refused. Nothing had
changed, but the call ignored every accessory event from then on: plug a
headset in afterwards and it stayed on the old route.

Count presses that are queued or on the wire so an automatic reroute
behind one still stands down, and only make the override permanent once
the platform reports the move applied.

* fix(voice): release the call route when stopping tts throws

The engine is shared with read-aloud, and the hand-back sat after the
stop call in the same teardown step. A stop that threw skipped it and
left read-aloud speaking on the call route. Give it its own step.

* fix(voice): hand the route back when the coordinator is disposed first

Riverpod gives no order to provider disposal, so the coordinator can go
before the controller that would have called deactivate. Its dispose only
cancelled the device watch, leaving the phone in communication mode with
the call's route still selected.

Disposal now runs the same teardown as hanging up, and that teardown is
safe to run twice. Session activation also joins the route queue, so a
configure pass already in flight cannot reactivate the session after
teardown drained the queue, and a default-route scan started during a
teardown is rejected instead of riding its generation bump through.

* fix(voice): keep the route shut once the coordinator is disposed

Teardown lifts the shutter again so the next call can route. A disposed
coordinator has no next call, so a deactivate arriving behind disposal
left the speaker button able to put the phone back into communication
mode after the coordinator was gone.

* fix(voice): retry an accessory move the platform refused

The transition is claimed in the accessory snapshot before the route
operation starts, so the burst of events a single headset sends collapses
into one move. A refused move left the claim standing, and since the
hardware never moved there is no fresh transition to come: the next
notification about the same headset matched the snapshot and was dropped
as old news, stranding the call on its previous route.

* fix(voice): keep a stale configure pass and overlapping teardowns apart

Session configuration is awaited before the activation step joins the
route queue, so a teardown that both starts and finishes inside that
window puts the shutter back up and the queued step reactivates a call
that is over. Each pass now carries the call generation it was started
for and stands down when it no longer matches.

Overlapping teardowns had the same shape: hanging up and disposal each
ran their own finally, and whichever finished first lifted the shutter
while the other was still restoring the platform route. The shutter now
waits for the last one out.

* fix(tts): keep heard sentences the held-back chunk hides

Mid stream the trailing chunk stays put until finalization, so a rewrite
arriving then leaves the sentences behind it out of the returned spoken
text. Finalization has nothing left to match them against and queues them
again. Carry the unconsumed heard history along instead.

* fix(voice): retry a default route the platform refused

A bare phone repeats the same device list rather than announcing a
transition, so the snapshot the refused default was picked from makes the
next notification look like old news and the call stays on the earpiece.

* fix(tts): stop a stale feed holding the next session open

A feed already inside the engine's speak call outlives the session that
queued it. Its count carried over to the next session, which then waited
for a chunk that was never coming and never finished speaking.

* fix(voice): drop a speaker press the teardown overtakes

A press queued before the call ended still reached the platform behind the
teardown, and answered the caller that the route had moved.

* fix(tts): hold server completion for a feed still on its way

finishStreaming marks the response finalized before its own feed reaches
the chain. The device path already waits that window out; the server path
ended the session there instead and dropped the rest of the answer.

* fix(tts): keep a stale fetch off the next session's bookkeeping

A fetch that outlives its session cleared the marker the next session had
put down for the same chunk index.

* fix(tts): keep a dead session's fetch error to itself

A failed fetch from a session that is over was reported as an error
against whatever is playing now.
2026-08-25 11:50:36 +05:30
..
app Fix voice call speech and default new calls to the speakerphone (#650) 2026-08-25 11:50:36 +05:30
gradle/wrapper feat: unify app surfaces with native grouped design (#628) 2026-08-15 18:37:46 +05:30
.gitignore chore: remove gradle wrapper tracking from android gitignore 2026-03-26 00:29:58 +05:30
build.gradle.kts feat(android): enforce specific version for glance-appwidget dependency 2026-05-20 12:49:47 +05:30
Gemfile chore: update README and remove localization documentation 2025-10-16 23:06:20 +05:30
Gemfile.lock chore: update README and remove localization documentation 2025-10-16 23:06:20 +05:30
gradle.properties chore(android): update Gradle and Kotlin versions, and adjust properties 2026-05-21 18:19:32 +05:30
settings.gradle.kts feat: unify app surfaces with native grouped design (#628) 2026-08-15 18:37:46 +05:30