Skip to content

Acquire the Android call session lazily instead of in the constructor - #376

Draft
MaxHeimbrock wants to merge 1 commit into
max/par-023-android-session-lifetimefrom
max/par-024-android-lazy-session-acquisition
Draft

Acquire the Android call session lazily instead of in the constructor#376
MaxHeimbrock wants to merge 1 commit into
max/par-023-android-session-lifetimefrom
max/par-024-android-lazy-session-acquisition

Conversation

@MaxHeimbrock

@MaxHeimbrock MaxHeimbrock commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Background

Stacked on #374 (PAR-023) — review that first; this PR's own diff is the last commit.

AndroidRouteController's constructor unconditionally entered MODE_IN_COMMUNICATION and 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 create PlatformAudio at startup — call SetSessionAudioEnabled(false) right after construction, as the Meet/Agents samples do — therefore produced a take → pin → clear flap at every app launch:

  • A measured 320 ms transient: setMode 3setCommunicationDeviceclearCommunicationDevicesetMode 0, mode owner released (PAR-023 F11c).
  • With a Bluetooth headset connected at launch, the constructor's pin started an asynchronous SCO activation that the immediate disable cleared mid-negotiation — inside the same startup window where the platform's SCO state machine is known to be fragile (see the README's known-limitation note). The churn is certain from the code; a contribution to the stuck-SCO platform state is plausible but not device-verified.

Changes

The public API surface and the enabled-at-creation default are unchanged. Only when the session is first taken moves:

  • The constructor now records the state but issues no setMode and no pin. Enumeration, the change listener, and the poll thread still start there; the initial Reevaluate is observation-only.
  • A new internal _sessionAcquired state 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:
    • an explicit SetSessionAudioEnabled(true) — including when the state was already enabled, so receive-only apps have a documented way in;
    • ApplyOutputPreference — which the OutputPreference/IsSpeakerOutputPreferred setters and the StartRecording re-assert funnel through (Android 13+ only honors the mode request during active capture anyway);
    • SelectOutput.
  • Disabling or disposing before anything acquired the session releases nothing, so the create → disable startup path issues zero audio-mode traffic. The poll thread and the change listener can neither resurrect a released session nor take a lazily-deferred one.
  • The mode save now happens at the actual acquisition; the PAR-000 save/restore idempotency rules are untouched (never restore a mode that was never saved, no unconditional MODE_NORMAL).
  • Docs updated: 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 SetSessionAudioEnabled used 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:

  • No setMode/setCommunicationDevice/clearCommunicationDevice traffic at app start with the sample's disable-after-create; F11c's 320 ms transient gone from logcat, no mode-owner churn in dumpsys audio.
  • Launch with a BT headset connected → no SCO churn at startup; join → setMode 3 + pin happen at join and routing works.
  • PAR-011's two customer scenarios still pass mid-call (headset drop → loudspeaker, never earpiece; reconnect → back to headset).
  • Join/leave cycles release the mode owner after each hang-up.

🤖 Generated with Claude Code

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