Acquire the Android call session lazily instead of in the constructor - #376
Draft
MaxHeimbrock wants to merge 1 commit into
Draft
Conversation
AndroidRouteController's constructor entered MODE_IN_COMMUNICATION and pinned the route because session audio defaults to enabled, and the recommended pattern of disabling right after construction then produced a take -> pin -> clear flap at every app launch (a measured 320 ms transient; with a Bluetooth headset connected, an SCO activation started only to be cleared mid-negotiation, inside the same startup window where the platform's SCO state machine is fragile). The enabled default and the PAR-019 API surface are unchanged. The constructor now only records the state; the session is first acquired by the first trigger that needs it while enabled: an explicit SetSessionAudioEnabled(true) call (also when the state was already enabled - the way in for receive-only apps), ApplyOutputPreference (which the StartRecording re-assert funnels through, where Android 13 starts honoring the mode request anyway) or SelectOutput. Disabling or disposing before anything acquired the session releases nothing, so the create -> disable startup path issues no audio-mode traffic at all. The mode save now happens at the actual acquisition and the save/restore idempotency rules are untouched; enumeration, the change listener and the poll thread still start in the constructor, and neither the poll nor the listener can take a lazily-deferred session. Behavior change, deliberate: an app that plays remote audio, never records, never touches routing and never calls SetSessionAudioEnabled no longer gets the mode and pin from construction; it opts in with SetSessionAudioEnabled(true) at its call boundary, per the documented "enabled == a call is in progress" contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Background
Stacked on #374 (PAR-023) — review that first; this PR's own diff is the last commit.
AndroidRouteController's constructor unconditionally enteredMODE_IN_COMMUNICATIONand pinned the route, because session audio defaults to enabled at creation (PAR-023 kept that default deliberately for compatibility). The recommended pattern for apps that createPlatformAudioat startup — callSetSessionAudioEnabled(false)right after construction, as the Meet/Agents samples do — therefore produced a take → pin → clear flap at every app launch:setMode 3→setCommunicationDevice→clearCommunicationDevice→setMode 0, mode owner released (PAR-023 F11c).Changes
The public API surface and the enabled-at-creation default are unchanged. Only when the session is first taken moves:
setModeand no pin. Enumeration, the change listener, and the poll thread still start there; the initialReevaluateis observation-only._sessionAcquiredstate separates "session audio enabled" (the API state) from "session actually held". The session is first acquired by the first trigger that needs it while enabled:SetSessionAudioEnabled(true)— including when the state was already enabled, so receive-only apps have a documented way in;ApplyOutputPreference— which theOutputPreference/IsSpeakerOutputPreferredsetters and theStartRecordingre-assert funnel through (Android 13+ only honors the mode request during active capture anyway);SelectOutput.MODE_NORMAL).AndroidRouteController,PlatformAudio(ctor,SetSessionAudioEnabled,StartRecording), README session-lifetime section and Android bullet, sample controller comment (both copies byte-identical).Deliberate behavior change (the receive-only exposure): an app that plays remote audio, never records, never touches routing, and never calls
SetSessionAudioEnabledused to get the mode + pin from construction and now gets nothing. On Android 13+ its mode request was already lapsing without active capture (and an idle-retained BT pin degraded the headset's A2DP link), so the practical loss is limited to Android-12 receive-only apps; the opt-in is one call,SetSessionAudioEnabled(true)at the call boundary, per the documented "enabled == a call is in progress" contract. Documented in the API docs and README.Compile-checked with csc, 7/7 clean: package (Android player, Android editor, iOS player defines) and Meet + Agents
Assembly-CSharp(Android + iOS player defines) against the freshly built package DLLs.Device verification (human, Pixel 8a + classic BT headset, from a clean SCO state —
mScoAudioState: SCO_STATE_INACTIVE) — all passed 2026-08-26:setMode/setCommunicationDevice/clearCommunicationDevicetraffic at app start with the sample's disable-after-create; F11c's 320 ms transient gone fromlogcat, no mode-owner churn indumpsys audio.setMode 3+ pin happen at join and routing works.🤖 Generated with Claude Code