Split out of #283, whose iOS half landed in the relay-reconnect PR. Two Android gaps remain, and neither can be verified from a machine without an Android SDK — CI skips android/** on pull requests, so this needs someone who can build and run it on a device.
1. No audio focus, no interruption handling at all
There is still no AudioFocusRequest, onAudioFocusChange or AudioDeviceCallback anywhere under android/app/src/main/kotlin. A call takes the microphone with it and nothing tries to bring it back:
MicCapture opens one AudioRecord for the life of the flow. From Android 10 on, an app that loses the microphone to a call is fed silence rather than an error (MicCapture's own class doc says so), so read() keeps succeeding and the meeting records nothing.
MeetingSession.startMicMonitor already watches AudioRecordingConfiguration.isClientSilenced and surfaces micSilenced as a banner — that is detection, not recovery. Nothing reopens the AudioRecord when the call ends, and on devices where the record object is invalidated instead, read() returns ERROR_DEAD_OBJECT and the whole meeting fails.
What iOS does (ios/App/Parley/AudioCapture.swift, landed in #272) is the shape to mirror: treat capture as a thing to be held, rebuild it on every interruption/route change, retry with backoff, keep a watchdog for what the platform does not announce, and report each transition so the UI can say what is going on.
Concretely:
- Request audio focus (
AudioFocusRequest with AUDIOFOCUS_GAIN) while a meeting runs and abandon it on stop.
AUDIOFOCUS_LOSS_TRANSIENT / ..._TRANSIENT_CAN_DUCK → mark interrupted, keep the session alive, keep the encoder running.
AUDIOFOCUS_GAIN after a loss → reopen the AudioRecord, do not just resume reading the old one. The mic.start() collection in MeetingSession.runCapture needs to become a loop that can be restarted, with its own retry ceiling before it gives up as MeetingFailure.MIC_UNAVAILABLE.
AUDIOFOCUS_LOSS (permanent) → the mic is gone for good; end the meeting the way a mic failure ends it, which still saves and uploads what was captured.
- Route changes via
AudioManager.registerAudioDeviceCallback — a headset or Bluetooth mic disappearing mid-meeting is the same "reopen it" path.
micSilenced staying true past the end of a call is a signal to reopen too, not only to show a banner.
Strings for every new user-facing state go in values/strings.xml and values-zh-rTW/strings.xml.
2. Android throws the reconnect gap away
MeetingSession.scheduleReconnect already redials with backoff and per-leg id prefixes, but like iOS did before this week it computes the new leg's offset as elapsedRealtime() - captureStartedAt and simply stops feeding the relay in between — so the words spoken during the gap never reach it.
The iOS fix is RelayAudioBridge in ParleyKit (ios/ParleyKit/Sources/ParleyKit/RelayAudioBridge.swift), with tests in ios/ParleyKit/Tests/ParleyKitTests/RelayAudioBridgeTests.swift. The Kotlin port is small and belongs in android/parleykit:
- one object between
MicCapture and SttRelayClient, holding chunks while there is no leg,
- bounded by milliseconds with drop-oldest,
- handing the next leg the position of its first held chunk as
timeOffsetMs — not "now", which files the gap after the audio that followed it,
- flushing the held chunks into the new client before live audio resumes.
android/parleykit/src/test/kotlin/… already has the test patterns (SttRelayClientTest, SegmentBuilderTest).
Worth porting ReconnectPolicy at the same time so the two platforms' ladders cannot drift.
Verification (device only)
Per the checklist in #283, on Android:
- incoming call answered then ended → recording continues, transcript continues, no gap beyond the call
- incoming call declined → same
- Wi-Fi → cellular handover mid-recording
- airplane mode for ~10 s mid-recording → reconnects, and the words spoken during the gap arrive (that is what the bridge buys)
- Siri/Assistant, an alarm, and another app opening a recording session
- the relay restarted under a live session
- a Bluetooth headset connected and disconnected mid-recording
Split out of #283, whose iOS half landed in the relay-reconnect PR. Two Android gaps remain, and neither can be verified from a machine without an Android SDK — CI skips
android/**on pull requests, so this needs someone who can build and run it on a device.1. No audio focus, no interruption handling at all
There is still no
AudioFocusRequest,onAudioFocusChangeorAudioDeviceCallbackanywhere underandroid/app/src/main/kotlin. A call takes the microphone with it and nothing tries to bring it back:MicCaptureopens oneAudioRecordfor the life of the flow. From Android 10 on, an app that loses the microphone to a call is fed silence rather than an error (MicCapture's own class doc says so), soread()keeps succeeding and the meeting records nothing.MeetingSession.startMicMonitoralready watchesAudioRecordingConfiguration.isClientSilencedand surfacesmicSilencedas a banner — that is detection, not recovery. Nothing reopens theAudioRecordwhen the call ends, and on devices where the record object is invalidated instead,read()returnsERROR_DEAD_OBJECTand the whole meeting fails.What iOS does (
ios/App/Parley/AudioCapture.swift, landed in #272) is the shape to mirror: treat capture as a thing to be held, rebuild it on every interruption/route change, retry with backoff, keep a watchdog for what the platform does not announce, and report each transition so the UI can say what is going on.Concretely:
AudioFocusRequestwithAUDIOFOCUS_GAIN) while a meeting runs and abandon it on stop.AUDIOFOCUS_LOSS_TRANSIENT/..._TRANSIENT_CAN_DUCK→ mark interrupted, keep the session alive, keep the encoder running.AUDIOFOCUS_GAINafter a loss → reopen theAudioRecord, do not just resume reading the old one. Themic.start()collection inMeetingSession.runCaptureneeds to become a loop that can be restarted, with its own retry ceiling before it gives up asMeetingFailure.MIC_UNAVAILABLE.AUDIOFOCUS_LOSS(permanent) → the mic is gone for good; end the meeting the way a mic failure ends it, which still saves and uploads what was captured.AudioManager.registerAudioDeviceCallback— a headset or Bluetooth mic disappearing mid-meeting is the same "reopen it" path.micSilencedstaying true past the end of a call is a signal to reopen too, not only to show a banner.Strings for every new user-facing state go in
values/strings.xmlandvalues-zh-rTW/strings.xml.2. Android throws the reconnect gap away
MeetingSession.scheduleReconnectalready redials with backoff and per-leg id prefixes, but like iOS did before this week it computes the new leg's offset aselapsedRealtime() - captureStartedAtand simply stops feeding the relay in between — so the words spoken during the gap never reach it.The iOS fix is
RelayAudioBridgein ParleyKit (ios/ParleyKit/Sources/ParleyKit/RelayAudioBridge.swift), with tests inios/ParleyKit/Tests/ParleyKitTests/RelayAudioBridgeTests.swift. The Kotlin port is small and belongs inandroid/parleykit:MicCaptureandSttRelayClient, holding chunks while there is no leg,timeOffsetMs— not "now", which files the gap after the audio that followed it,android/parleykit/src/test/kotlin/…already has the test patterns (SttRelayClientTest,SegmentBuilderTest).Worth porting
ReconnectPolicyat the same time so the two platforms' ladders cannot drift.Verification (device only)
Per the checklist in #283, on Android: