Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/react-native-sdk/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ The public `callManager` (`src/modules/call-manager/CallManager.ts`) is a **conf
- Sync engine hooks (`AudioDeviceModule.addEngineHooks`, WebRTC worker thread): the SDK registers willEnable (re-applies the configuration only, between `start()` and `stop()`; never `setActive`) and didCreateModule; no didDisable: the activation is held from `start()` to `stop()` because a REJOIN reconnect fully disables and rebuilds the engine mid-call; callingx registers all four (willEnable re-applies category/mode and sets the startup-mute flag, willStart/didDisable clear it, didCreateModule replays the desired availability). Hooks: no ADM calls, no `audioSessionQueue.sync`/`main.sync`, all `RTCAudioSession` locking via each package's `AudioSessionConfigLock` (lock, body, unlock like stream-video-swift; never nest: re-locking on the same thread crashes; other threads just wait). `setEngineAvailability` is a blocking call onto the worker thread: only from `audioSessionQueue`/`engineAvailabilityQueue` or a `didCreateModule` hook, never from worker-thread hooks nor inside `AudioSessionConfigLock.run`.
- Interruptions: the ADM stops/restarts the engine itself and `RTCAudioSession` re-activates once; a failed re-activation is repaired on interruption-end by `StreamInCallManagerImpl` (`sessionReactivated` diagnostic, `trigger: interruptionEnded`) and by the tracer.
- Recovery: `AudioSessionDiagnosticsTracer` samples `getEngineState()`, calls `recoverEngineIfNeeded` (≀3/episode, ≀6/call, β‰₯3 s apart), then one `call.rejoinForAudioEngineRecovery()` per call; traces `ios.audioEngine.*` and `audio.session.timeout`.
- Recovery is skipped while an audio interruption is active. With an inactive session the tracer first calls `callManager.ios.reactivateAudioSession` (the ADM cannot restart against an inactive session); when iOS refuses (`561017449`, another app owns the audio) it waits for the next interruption end / session activation / app foreground without spending its budget or rejoining; any other re-activation failure is re-checked on a budget of its own (same size/spacing, no rejoin). The audio rejoin is skipped (call stays joined) when the rejoin rate limiter has no room.
- Recovery is skipped while an audio interruption is active. With an inactive session the tracer first calls `callManager.ios.reactivateAudioSession` (the ADM cannot restart against an inactive session); when iOS refuses (`561017449`, another app owns the audio) it waits for the next interruption end / session activation / app foreground / audio route change (the only signal when the holder, e.g. an iPad FaceTime window, ends while the app stays in the foreground) without spending its budget or rejoining; any other re-activation failure is re-checked on a budget of its own (same size/spacing, no rejoin). The audio rejoin is skipped (call stays joined) when the rejoin rate limiter has no room.

## Per-Call PeerConnectionFactory (Media Engine)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -909,8 +909,7 @@ describe('AudioSessionDiagnosticsTracer', () => {
},
]);

// no checks until a signal
routeListener();
// the mic toggle is not a signal
act(() => {
call.status$.next('disabled');
});
Expand All @@ -921,6 +920,14 @@ describe('AudioSessionDiagnosticsTracer', () => {
expect(iosCallManager.reactivateAudioSession).toHaveBeenCalledTimes(1);
expect(adm.recoverEngineIfNeeded).not.toHaveBeenCalled();

// a route change is one (the holder may have released the hardware):
// tried again, still refused, no budget spent
routeListener();
await advance(1000);
expect(iosCallManager.reactivateAudioSession).toHaveBeenCalledTimes(2);
expect(adm.recoverEngineIfNeeded).not.toHaveBeenCalled();
expect(recoveryTraces(call)).toHaveLength(1);

// the real interruption end re-activated the session: full budget left
adm.getEngineState.mockResolvedValue(deadEngine);
interruptionListener({ source: 'callmanager', phase: 'ended' });
Expand All @@ -934,6 +941,85 @@ describe('AudioSessionDiagnosticsTracer', () => {
expect(call.rejoinForAudioEngineRecovery).not.toHaveBeenCalled();
});

it('re-activates a refused session on a route change, when the holder released the hardware', async () => {
const call = await renderJoined();
adm.getEngineState.mockResolvedValue({
...deadEngine,
sessionActive: false,
});
iosCallManager.reactivateAudioSession.mockResolvedValue(true);
iosCallManager.reactivateAudioSession.mockRejectedValueOnce(
Object.assign(new Error('!int'), { code: '561017449' }),
);
adm.recoverEngineIfNeeded.mockResolvedValue({
result: 'recovered',
before: deadEngine,
after: healthyEngine,
codes: { stopRecording: 0, startRecording: 0 },
});

appStateListener('active');
await advance(1000);
expect(iosCallManager.reactivateAudioSession).toHaveBeenCalledTimes(1);
expect(adm.recoverEngineIfNeeded).not.toHaveBeenCalled();

// FaceTime ended in its own window or by the other party: the app stays
// in the foreground and only a route change reaches it
await advance(3000);
routeListener();
await advance(1000);
expect(iosCallManager.reactivateAudioSession).toHaveBeenCalledTimes(2);
expect(adm.recoverEngineIfNeeded).toHaveBeenCalledTimes(1);
expect(recoveryTraces(call)).toEqual([
{
trigger: 'appActive',
result: 'skipped',
reason: 'sessionRefused',
attempt: 1,
},
expect.objectContaining({
trigger: 'audioRouteChanged',
result: 'recovered',
attempt: 1,
}),
]);
});

it('keeps a release signal that arrives while a re-activation is pending', async () => {
await renderJoined();
adm.getEngineState.mockResolvedValue({
...deadEngine,
sessionActive: false,
});
let rejectPending: (error: unknown) => void = () => {};
iosCallManager.reactivateAudioSession.mockResolvedValue(true);
iosCallManager.reactivateAudioSession.mockImplementationOnce(
() =>
new Promise<boolean>((_, reject) => {
rejectPending = reject;
}),
);
adm.recoverEngineIfNeeded.mockResolvedValue({
result: 'recovered',
before: deadEngine,
after: healthyEngine,
codes: { stopRecording: 0, startRecording: 0 },
});

appStateListener('active');
await advance(1000);
expect(iosCallManager.reactivateAudioSession).toHaveBeenCalledTimes(1);

// the holder released the hardware while the attempt was pending; the
// attempt's refusal must not latch over that signal
routeListener();
rejectPending(Object.assign(new Error('!int'), { code: '561017449' }));
// the route-change check (1 s) waits out the attempt spacing (3 s)
await advance(3000);
expect(iosCallManager.reactivateAudioSession).toHaveBeenCalledTimes(2);
expect(adm.recoverEngineIfNeeded).toHaveBeenCalledTimes(1);
});

it('recovers after re-activating the inactive session itself', async () => {
const call = await renderJoined();
adm.getEngineState.mockResolvedValue({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,24 @@ export const AudioSessionDiagnosticsTracer = () => {
// arriving meanwhile is kept (the latest one) and checked once afterwards
let inFlight: Promise<void> | undefined;
let pendingTrigger: string | undefined;
// iOS owns the audio during an interruption (phone call, Siri, alarm) and
// while it refuses to re-activate the session afterwards: no checks until
// the interruption ends, the session is re-activated or the app is active
// again (WebRTC treats the latter as an interruption end too)
// iOS owns the audio during an interruption (phone call, Siri, alarm): no
// checks until the interruption ends or the app is active again (WebRTC
// treats the latter as an interruption end too)
let interrupted = false;
// iOS refused to activate the session (another app holds non-mixable
// audio, e.g. a FaceTime or phone call): no checks until a signal says the
// holder is gone. A route change is the only signal when the app stays in
// the foreground (iPad FaceTime window, the other party hanging up): no
// interruption is delivered to a session that was never active.
let sessionRefused = false;
// release signals are counted: one arriving while a re-activation is
// pending must not be undone by that attempt's refusal (the check it
// scheduled would skip and no further signal may come)
let releaseSignals = 0;
const onReleaseSignal = () => {
sessionRefused = false;
releaseSignals += 1;
};

const runCheck = (trigger: string) => {
if (inFlight) {
Expand All @@ -159,7 +172,7 @@ export const AudioSessionDiagnosticsTracer = () => {

const checkEngine = async (trigger: string) => {
if (call.state.callingState !== CallingState.JOINED) return;
if (interrupted) {
if (interrupted || sessionRefused) {
logger.debug(
`check '${trigger}' skipped, the audio is interrupted or the session is inactive`,
);
Expand Down Expand Up @@ -226,6 +239,7 @@ export const AudioSessionDiagnosticsTracer = () => {
return;
}
lastActivationAt = Date.now();
const signalsBefore = releaseSignals;
const reason = await reactivateSession();
if (disposed) return;
if (reason) {
Expand All @@ -239,7 +253,7 @@ export const AudioSessionDiagnosticsTracer = () => {
}
logger.debug(`recovery '${trigger}' postponed, ${reason}`);
if (reason === 'sessionRefused') {
interrupted = true;
if (releaseSignals === signalsBefore) sessionRefused = true;
return;
}
// bounded re-check (no rejoin against an inactive session); later
Expand Down Expand Up @@ -309,6 +323,7 @@ export const AudioSessionDiagnosticsTracer = () => {
// intent would unmute a muted ADM (the mic toggle checks on its own)
if (
interrupted ||
sessionRefused ||
call.state.callingState !== CallingState.JOINED ||
(call.microphone.state.status === 'enabled') !== wantsRecording
) {
Expand Down Expand Up @@ -353,6 +368,7 @@ export const AudioSessionDiagnosticsTracer = () => {
const canRejoin =
!rejoined &&
!interrupted &&
!sessionRefused &&
call.state.callingState === CallingState.JOINED &&
AppState.currentState === 'active' &&
call.microphone.state.status === 'enabled';
Expand Down Expand Up @@ -459,21 +475,27 @@ export const AudioSessionDiagnosticsTracer = () => {
interrupted = true;
} else if (event.phase === 'ended') {
interrupted = false;
onReleaseSignal();
scheduleCheck('interruptionEnded', 1000);
}
};
const removeInterruptionListener =
callManager.ios.addAudioInterruptionListener(onInterruption);

const removeRouteListener = callManager.audioDevices.addChangeListener(() =>
scheduleCheck('audioRouteChanged', 1000),
const removeRouteListener = callManager.audioDevices.addChangeListener(
() => {
// the holder of a refused session released the hardware
onReleaseSignal();
scheduleCheck('audioRouteChanged', 1000);
},
);

const appStateSubscription = AppState.addEventListener(
'change',
(appState) => {
if (appState !== 'active') return;
interrupted = false;
onReleaseSignal();
scheduleCheck('appActive', 1000);
},
);
Expand All @@ -486,6 +508,7 @@ export const AudioSessionDiagnosticsTracer = () => {
// delayed so the activation-driven engine start can run first
() => {
interrupted = false;
onReleaseSignal();
scheduleCheck('audioSessionActivated', 1000);
},
),
Expand Down
Loading