@stream-io/video-react-native-sdk 1.40.2 → 1.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/android/src/main/java/com/streamvideo/reactnative/audio/AudioDeviceManager.kt +79 -54
  3. package/android/src/main/java/com/streamvideo/reactnative/audio/utils/AudioManagerUtil.kt +6 -2
  4. package/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt +3 -3
  5. package/dist/commonjs/components/Call/CallLayout/CallParticipantsSpotlight.js +2 -0
  6. package/dist/commonjs/components/Call/CallLayout/CallParticipantsSpotlight.js.map +1 -1
  7. package/dist/commonjs/components/Call/CallParticipantsList/CallParticipantsList.js +22 -31
  8. package/dist/commonjs/components/Call/CallParticipantsList/CallParticipantsList.js.map +1 -1
  9. package/dist/commonjs/components/Call/RingingCallContent/IncomingCall.js +8 -5
  10. package/dist/commonjs/components/Call/RingingCallContent/IncomingCall.js.map +1 -1
  11. package/dist/commonjs/components/Participant/ParticipantView/VideoRenderer/index.js +2 -1
  12. package/dist/commonjs/components/Participant/ParticipantView/VideoRenderer/index.js.map +1 -1
  13. package/dist/commonjs/hooks/index.js +11 -0
  14. package/dist/commonjs/hooks/index.js.map +1 -1
  15. package/dist/commonjs/hooks/push/useCallingExpWithCallingStateEffect.js +73 -0
  16. package/dist/commonjs/hooks/push/useCallingExpWithCallingStateEffect.js.map +1 -1
  17. package/dist/commonjs/hooks/useAudioDeviceStatus.js +76 -0
  18. package/dist/commonjs/hooks/useAudioDeviceStatus.js.map +1 -0
  19. package/dist/commonjs/modules/call-manager/CallManager.js +94 -47
  20. package/dist/commonjs/modules/call-manager/CallManager.js.map +1 -1
  21. package/dist/commonjs/utils/internal/callingx/callingx.js +0 -7
  22. package/dist/commonjs/utils/internal/callingx/callingx.js.map +1 -1
  23. package/dist/commonjs/utils/push/setupCallingExpEvents.js +31 -2
  24. package/dist/commonjs/utils/push/setupCallingExpEvents.js.map +1 -1
  25. package/dist/commonjs/version.js +1 -1
  26. package/dist/module/components/Call/CallLayout/CallParticipantsSpotlight.js +3 -1
  27. package/dist/module/components/Call/CallLayout/CallParticipantsSpotlight.js.map +1 -1
  28. package/dist/module/components/Call/CallParticipantsList/CallParticipantsList.js +23 -32
  29. package/dist/module/components/Call/CallParticipantsList/CallParticipantsList.js.map +1 -1
  30. package/dist/module/components/Call/RingingCallContent/IncomingCall.js +9 -6
  31. package/dist/module/components/Call/RingingCallContent/IncomingCall.js.map +1 -1
  32. package/dist/module/components/Participant/ParticipantView/VideoRenderer/index.js +2 -1
  33. package/dist/module/components/Participant/ParticipantView/VideoRenderer/index.js.map +1 -1
  34. package/dist/module/hooks/index.js +1 -0
  35. package/dist/module/hooks/index.js.map +1 -1
  36. package/dist/module/hooks/push/useCallingExpWithCallingStateEffect.js +73 -0
  37. package/dist/module/hooks/push/useCallingExpWithCallingStateEffect.js.map +1 -1
  38. package/dist/module/hooks/useAudioDeviceStatus.js +71 -0
  39. package/dist/module/hooks/useAudioDeviceStatus.js.map +1 -0
  40. package/dist/module/modules/call-manager/CallManager.js +94 -47
  41. package/dist/module/modules/call-manager/CallManager.js.map +1 -1
  42. package/dist/module/utils/internal/callingx/callingx.js +0 -7
  43. package/dist/module/utils/internal/callingx/callingx.js.map +1 -1
  44. package/dist/module/utils/push/setupCallingExpEvents.js +32 -3
  45. package/dist/module/utils/push/setupCallingExpEvents.js.map +1 -1
  46. package/dist/module/version.js +1 -1
  47. package/dist/typescript/components/Call/CallLayout/CallParticipantsSpotlight.d.ts.map +1 -1
  48. package/dist/typescript/components/Call/CallParticipantsList/CallParticipantsList.d.ts.map +1 -1
  49. package/dist/typescript/components/Call/RingingCallContent/IncomingCall.d.ts.map +1 -1
  50. package/dist/typescript/components/Participant/ParticipantView/VideoRenderer/index.d.ts.map +1 -1
  51. package/dist/typescript/hooks/index.d.ts +1 -0
  52. package/dist/typescript/hooks/index.d.ts.map +1 -1
  53. package/dist/typescript/hooks/push/useCallingExpWithCallingStateEffect.d.ts.map +1 -1
  54. package/dist/typescript/hooks/useAudioDeviceStatus.d.ts +21 -0
  55. package/dist/typescript/hooks/useAudioDeviceStatus.d.ts.map +1 -0
  56. package/dist/typescript/modules/call-manager/CallManager.d.ts +25 -11
  57. package/dist/typescript/modules/call-manager/CallManager.d.ts.map +1 -1
  58. package/dist/typescript/modules/call-manager/types.d.ts +29 -3
  59. package/dist/typescript/modules/call-manager/types.d.ts.map +1 -1
  60. package/dist/typescript/utils/internal/callingx/callingx.d.ts.map +1 -1
  61. package/dist/typescript/utils/push/setupCallingExpEvents.d.ts.map +1 -1
  62. package/dist/typescript/version.d.ts +1 -1
  63. package/expo-config-plugin/dist/index.js +2 -0
  64. package/expo-config-plugin/dist/withAndroidMessagingService.js +287 -0
  65. package/ios/StreamInCallManager.m +7 -0
  66. package/ios/StreamInCallManager.swift +257 -14
  67. package/package.json +8 -8
  68. package/src/components/Call/CallLayout/CallParticipantsSpotlight.tsx +6 -1
  69. package/src/components/Call/CallParticipantsList/CallParticipantsList.tsx +63 -62
  70. package/src/components/Call/RingingCallContent/IncomingCall.tsx +10 -12
  71. package/src/components/Participant/ParticipantView/VideoRenderer/index.tsx +4 -1
  72. package/src/hooks/index.ts +1 -0
  73. package/src/hooks/push/useCallingExpWithCallingStateEffect.ts +95 -0
  74. package/src/hooks/useAudioDeviceStatus.ts +90 -0
  75. package/src/modules/call-manager/CallManager.ts +122 -64
  76. package/src/modules/call-manager/native-module.d.ts +14 -7
  77. package/src/modules/call-manager/types.ts +30 -3
  78. package/src/utils/internal/callingx/callingx.ts +0 -8
  79. package/src/utils/push/setupCallingExpEvents.ts +41 -3
  80. package/src/version.ts +1 -1
  81. package/dist/commonjs/utils/internal/callingx/audioSessionPromise.js +0 -68
  82. package/dist/commonjs/utils/internal/callingx/audioSessionPromise.js.map +0 -1
  83. package/dist/module/utils/internal/callingx/audioSessionPromise.js +0 -63
  84. package/dist/module/utils/internal/callingx/audioSessionPromise.js.map +0 -1
  85. package/dist/typescript/utils/internal/callingx/audioSessionPromise.d.ts +0 -16
  86. package/dist/typescript/utils/internal/callingx/audioSessionPromise.d.ts.map +0 -1
  87. package/src/utils/internal/callingx/audioSessionPromise.ts +0 -65
@@ -1,6 +1,8 @@
1
1
  import { Call, CallingState, videoLoggerSystem } from '@stream-io/video-client';
2
2
  import { useCall, useCallStateHooks } from '@stream-io/video-react-bindings';
3
3
  import { useEffect, useMemo } from 'react';
4
+ import { Platform } from 'react-native';
5
+ import { AudioDeviceModule } from '@stream-io/react-native-webrtc';
4
6
  import { filter, take } from 'rxjs/operators';
5
7
  import { getCallDisplayName } from '../../utils/internal/callingx/callingx';
6
8
  import { getCallingxLibIfAvailable } from '../../utils/push/libs/callingx';
@@ -190,4 +192,97 @@ export const useCallingExpWithCallingStateEffect = () => {
190
192
  subscription.remove();
191
193
  };
192
194
  }, [activeCallCid, microphone]);
195
+
196
+ // Trace CallKit audio session activation/deactivation onto the call's tracer.
197
+ useEffect(() => {
198
+ const callingx = getCallingxLibIfAvailable();
199
+ if (!callingx?.isSetup || !activeCall) {
200
+ return;
201
+ }
202
+
203
+ const activateSubscription = callingx.addEventListener(
204
+ 'didActivateAudioSession',
205
+ () => {
206
+ activeCall.tracer.trace('callingx.didActivateAudioSession', null);
207
+ },
208
+ );
209
+ const deactivateSubscription = callingx.addEventListener(
210
+ 'didDeactivateAudioSession',
211
+ () => {
212
+ activeCall.tracer.trace('callingx.didDeactivateAudioSession', null);
213
+ },
214
+ );
215
+
216
+ return () => {
217
+ activateSubscription.remove();
218
+ deactivateSubscription.remove();
219
+ };
220
+ }, [activeCall]);
221
+
222
+ // Trace the iOS ADM recording state to detect silent-mic calls: the engine's
223
+ // input bring-up racing the CallKit audio-session activation could leave
224
+ // recording silently never started (mic track live, zero audio RTP sent).
225
+ // Samples shortly after JOINED and after every didActivateAudioSession, and
226
+ // traces healthy calls too so the failure rate is measurable. Detection only;
227
+ // the engine-availability gate in callingx owns prevention.
228
+ useEffect(() => {
229
+ const callingx = getCallingxLibIfAvailable();
230
+ if (Platform.OS !== 'ios' || !callingx?.isSetup || !activeCall) {
231
+ return;
232
+ }
233
+
234
+ const timeouts = new Set<ReturnType<typeof setTimeout>>();
235
+
236
+ const traceRecordingState = (trigger: string) => {
237
+ if (activeCall.state.callingState !== CallingState.JOINED) return;
238
+ if (activeCall.microphone.state.status !== 'enabled') return;
239
+
240
+ let state;
241
+ try {
242
+ state = {
243
+ trigger,
244
+ isRecording: AudioDeviceModule.isRecording(),
245
+ isEngineRunning: AudioDeviceModule.isEngineRunning(),
246
+ isMicrophoneMuted: AudioDeviceModule.isMicrophoneMuted(),
247
+ };
248
+ } catch (error: unknown) {
249
+ logger.warn('failed to read the ADM recording state', error);
250
+ return;
251
+ }
252
+
253
+ activeCall.tracer.trace('ios.audioRecording.state', state);
254
+ if (!state.isRecording) {
255
+ logger.warn('mic is enabled but ADM is not recording', state);
256
+ }
257
+ };
258
+
259
+ const scheduleCheck = (trigger: string, delayMs: number) => {
260
+ const timeout = setTimeout(() => {
261
+ timeouts.delete(timeout);
262
+ traceRecordingState(trigger);
263
+ }, delayMs);
264
+ timeouts.add(timeout);
265
+ };
266
+
267
+ const joinedSubscription = activeCall.state.callingState$
268
+ .pipe(
269
+ filter((callingState) => callingState === CallingState.JOINED),
270
+ take(1),
271
+ )
272
+ // delayed so the normal engine bring-up can complete on its own
273
+ .subscribe(() => scheduleCheck('joined', 3000));
274
+
275
+ const activationSubscription = callingx.addEventListener(
276
+ 'didActivateAudioSession',
277
+ // delayed so the activation-driven engine start can run first
278
+ () => scheduleCheck('audioSessionActivated', 1000),
279
+ );
280
+
281
+ return () => {
282
+ joinedSubscription.unsubscribe();
283
+ activationSubscription.remove();
284
+ timeouts.forEach(clearTimeout);
285
+ timeouts.clear();
286
+ };
287
+ }, [activeCall]);
193
288
  };
@@ -0,0 +1,90 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { callManager } from '../modules/call-manager';
3
+ import type { AudioDevicesState } from '../modules/call-manager/types';
4
+
5
+ /**
6
+ * Structural equality for two audio device states to avoid redundant re-renders.
7
+ */
8
+ const areStatesEqual = (
9
+ a: AudioDevicesState | undefined,
10
+ b: AudioDevicesState,
11
+ ): boolean => {
12
+ if (!a) return false;
13
+ if (
14
+ a.selectedDeviceId !== b.selectedDeviceId ||
15
+ a.currentEndpointType !== b.currentEndpointType ||
16
+ a.devices.length !== b.devices.length
17
+ ) {
18
+ return false;
19
+ }
20
+ return a.devices.every((device, i) => {
21
+ const other = b.devices[i];
22
+ return (
23
+ other !== undefined &&
24
+ device.id === other.id &&
25
+ device.name === other.name &&
26
+ device.type === other.type
27
+ );
28
+ });
29
+ };
30
+
31
+ /**
32
+ * Subscribes to the current audio output device state and keeps it in sync.
33
+ *
34
+ * Works on Android, iOS, and iOS with CallKit
35
+ * (`@stream-io/react-native-callingx`) — the underlying source is selected
36
+ * automatically. Pair with `callManager.audioDevices.select(device.id)` to
37
+ * build a custom audio route picker.
38
+ *
39
+ * @returns the latest {@link AudioDevicesState}, or `undefined` until the
40
+ * initial status resolves.
41
+ *
42
+ * @example
43
+ * ```tsx
44
+ * const status = useAudioDeviceStatus();
45
+ * const { devices, selectedDeviceId } = status ?? {};
46
+ * // callManager.audioDevices.select(devices[0].id);
47
+ * ```
48
+ */
49
+ export const useAudioDeviceStatus = (): AudioDevicesState | undefined => {
50
+ const [state, setState] = useState<AudioDevicesState>();
51
+
52
+ useEffect(() => {
53
+ let active = true;
54
+ let receivedUpdate = false;
55
+ let unsubscribe = () => {};
56
+
57
+ const applyIfChanged = (next: AudioDevicesState) => {
58
+ setState((prev) => (areStatesEqual(prev, next) ? prev : next));
59
+ };
60
+
61
+ // Live change events take precedence over the initial fetch.
62
+ const onChange = (next: AudioDevicesState) => {
63
+ if (!active) return;
64
+ receivedUpdate = true;
65
+ applyIfChanged(next);
66
+ };
67
+
68
+ try {
69
+ // Subscribe before fetching so no change between the two is missed.
70
+ unsubscribe = callManager.audioDevices.addChangeListener(onChange);
71
+ callManager.audioDevices
72
+ .getStatus()
73
+ .then((initial) => {
74
+ // Don't let the initial snapshot clobber newer event-driven state,
75
+ // and ignore it entirely after unmount.
76
+ if (active && !receivedUpdate) applyIfChanged(initial);
77
+ })
78
+ .catch(() => {});
79
+ } catch {
80
+ // Native/bridge error during setup — leave state undefined.
81
+ }
82
+
83
+ return () => {
84
+ active = false;
85
+ unsubscribe();
86
+ };
87
+ }, []);
88
+
89
+ return state;
90
+ };
@@ -1,6 +1,7 @@
1
1
  import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
2
2
  import type {
3
- AudioDeviceStatus,
3
+ AudioDeviceEndpointType,
4
+ AudioDevicesState,
4
5
  IOSAudioInterruptionEvent,
5
6
  StreamInCallManagerConfig,
6
7
  } from './types';
@@ -14,11 +15,24 @@ import { videoLoggerSystem } from '@stream-io/video-client';
14
15
  const NativeManager = NativeModules.StreamInCallManager;
15
16
  const CallingxModule = getCallingxLibIfAvailable();
16
17
  const AUDIO_INTERRUPTION_EVENT = 'StreamInCallManagerAudioInterruption';
18
+ const AUDIO_DEVICE_CHANGED_EVENT = 'onAudioDeviceChanged';
17
19
 
18
20
  const invariant = (condition: boolean, message: string) => {
19
21
  if (!condition) throw new Error(message);
20
22
  };
21
23
 
24
+ /**
25
+ * Runs a fire-and-forget native call, logging instead of throwing on a bridge
26
+ * error so it can't crash the caller or an event-dispatch loop.
27
+ */
28
+ const safeNativeCall = (label: string, fn: () => void): void => {
29
+ try {
30
+ fn();
31
+ } catch (error) {
32
+ videoLoggerSystem.getLogger('CallManager').warn(`${label} failed`, error);
33
+ }
34
+ };
35
+
22
36
  /**
23
37
  * On Android, whether the current call is managed by Telecom (via callingx).
24
38
  * In that mode, audio routing/mode is owned by Telecom and StreamInCallManager audio methods should not be used
@@ -53,10 +67,8 @@ const getTelecomContext = ():
53
67
  return { cx: CallingxModule, callId };
54
68
  };
55
69
 
56
- /** Map a generic Telecom endpoint type to the SDK's endpoint display name. */
57
- const endpointTypeToDisplayName = (
58
- type: string,
59
- ): AudioDeviceStatus['currentEndpointType'] => {
70
+ /** Map a generic Telecom endpoint type to the SDK's endpoint type. */
71
+ const endpointTypeToDisplayName = (type: string): AudioDeviceEndpointType => {
60
72
  switch (type) {
61
73
  case 'earpiece':
62
74
  return 'Earpiece';
@@ -71,95 +83,136 @@ const endpointTypeToDisplayName = (
71
83
  }
72
84
  };
73
85
 
74
- /** Adapt a callingx endpoints snapshot to the SDK's {@link AudioDeviceStatus}. */
75
- const snapshotToStatus = (
86
+ /** Adapt a callingx endpoints snapshot to the SDK's {@link AudioDevicesState}. */
87
+ const snapshotToState = (
76
88
  snapshot: CallingxAudioSnapshot,
77
- ): AudioDeviceStatus => ({
78
- devices: snapshot.endpoints.map((e) => e.name),
89
+ ): AudioDevicesState => ({
90
+ // callingx endpoint ids are stable and unique — use them directly as the
91
+ // device id, so `select(device.id)` maps straight back to a Telecom endpoint.
92
+ devices: snapshot.endpoints.map((e) => ({
93
+ id: e.id,
94
+ name: e.name,
95
+ type: endpointTypeToDisplayName(e.type),
96
+ })),
97
+ selectedDeviceId: snapshot.currentEndpoint?.id,
79
98
  currentEndpointType: snapshot.currentEndpoint
80
99
  ? endpointTypeToDisplayName(snapshot.currentEndpoint.type)
81
100
  : 'Unknown',
82
- selectedDevice: snapshot.currentEndpoint?.name ?? '',
83
101
  });
84
102
 
85
- class AndroidCallManager {
103
+ /**
104
+ * Cross-platform audio output device picker.
105
+ */
106
+ class AudioDevicesManager {
86
107
  private eventEmitter?: NativeEventEmitter;
108
+ private interruptionReassertSetup = false;
87
109
 
88
110
  /**
89
- * Get the current audio device status.
111
+ * iOS + CallKit only: re-assert the user's output pick after an interruption ends.
112
+ *
113
+ * On interruption-end iOS clears the ephemeral route override (`setPreferredInput` /
114
+ * `overrideOutputAudioPort`) and doesn't restore it, and callingx re-applies its config
115
+ * only on engine-enable — not the engine-restart of interruption recovery.
90
116
  */
91
- getAudioDeviceStatus = async (): Promise<AudioDeviceStatus> => {
92
- invariant(Platform.OS === 'android', 'Supported only on Android');
117
+ private ensureInterruptionReassert = () => {
118
+ if (this.interruptionReassertSetup) return; // subscibed once per app lifetime
119
+ if (Platform.OS !== 'ios' || !CallingxModule) return;
120
+ this.interruptionReassertSetup = true;
121
+ CallingxModule.addEventListener('didAudioInterruption', (event) => {
122
+ if (event.phase === 'ended') {
123
+ safeNativeCall('reapplyAudioRoute', () =>
124
+ NativeManager.reapplyAudioRoute(),
125
+ );
126
+ }
127
+ });
128
+ };
129
+
130
+ /**
131
+ * Get the current audio device state (available devices + the selected one).
132
+ * Read directly from the audio session, so it works on every path.
133
+ */
134
+ getStatus = async (): Promise<AudioDevicesState> => {
135
+ // Android Telecom owns routing: read the endpoint snapshot from callingx.
93
136
  const tc = getTelecomContext();
94
137
  if (tc) {
95
138
  const snapshot = await tc.cx.getAvailableAudioEndpoints(tc.callId);
96
- return snapshotToStatus(snapshot);
139
+ return snapshotToState(snapshot);
97
140
  }
98
141
  return NativeManager.getAudioDeviceStatus();
99
142
  };
100
143
 
101
144
  /**
102
- * Switches the audio device to the specified endpoint.
145
+ * Switch the audio output to the device with the given id.
103
146
  *
104
- * @param endpointName the device name.
147
+ * @param deviceId the stable {@link AudioDevice.id} (not the display name).
105
148
  */
106
- selectAudioDevice = (endpointName: string): void => {
107
- invariant(Platform.OS === 'android', 'Supported only on Android');
149
+ select = (deviceId: string): void => {
150
+ // Android Telecom owns routing: the device id is the callingx endpoint id,
151
+ // so route by it directly (no name lookup needed).
108
152
  const tc = getTelecomContext();
109
153
  if (tc) {
110
154
  const { cx, callId } = tc;
111
- // Resolve name -> endpoint id from the current Telecom snapshot, then route via Telecom.
112
- cx.getAvailableAudioEndpoints(callId)
113
- .then((snapshot) => {
114
- const target = snapshot.endpoints.find(
115
- (e) => e.name === endpointName,
155
+ cx.requestAudioEndpointChange(callId, deviceId).catch((error) => {
156
+ videoLoggerSystem
157
+ .getLogger('CallManager')
158
+ .warn(
159
+ `select: failed to route to "${deviceId}" for call ${callId} via Telecom`,
160
+ error,
116
161
  );
117
- if (target) {
118
- return cx.requestAudioEndpointChange(callId, target.id);
119
- }
120
- return undefined;
121
- })
122
- .catch((error) => {
123
- videoLoggerSystem
124
- .getLogger('CallManager')
125
- .warn(
126
- `selectAudioDevice: failed to route to "${endpointName}" for call ${callId} via Telecom`,
127
- error,
128
- );
129
- });
162
+ });
130
163
  return;
131
164
  }
132
- NativeManager.chooseAudioDeviceEndpoint(endpointName);
165
+ this.ensureInterruptionReassert();
166
+ safeNativeCall('chooseAudioDeviceEndpoint', () =>
167
+ NativeManager.chooseAudioDeviceEndpoint(deviceId),
168
+ );
133
169
  };
134
170
 
135
171
  /**
136
- * Register a listener for audio device changes.
137
- * @param onChange callback to be called when the audio device changes.
172
+ * Register a listener for audio device changes. Returns an unsubscribe fn.
173
+ *
174
+ * @param onChange called with the latest {@link AudioDevicesState} on change.
138
175
  */
139
- addAudioDeviceChangeListener = (
140
- onChange: (audioDeviceStatus: AudioDeviceStatus) => void,
176
+ addChangeListener = (
177
+ onChange: (state: AudioDevicesState) => void,
141
178
  ): (() => void) => {
142
- invariant(Platform.OS === 'android', 'Supported only on Android');
143
- const cleanups: Array<() => void> = [];
179
+ let active = true;
180
+ const unsubscribes: Array<() => void> = [];
144
181
 
145
- // Telecom (callingx) route changes.
146
- if (CallingxModule?.isSetup && CallingxModule.isTelecomBacked) {
147
- const sub = CallingxModule.addEventListener(
148
- 'didChangeAudioEndpoints',
149
- (params) => onChange(snapshotToStatus(params)),
182
+ // callingx-owned route changes (Android Telecom + iOS CallKit). The event is
183
+ // signal-only, so re-read the current state — ignoring a resolve that lands
184
+ // after unsubscribe, and swallowing bridge rejections.
185
+ if (CallingxModule) {
186
+ const cxSub = CallingxModule.addEventListener(
187
+ 'didChangeAudioRoute',
188
+ () => {
189
+ this.getStatus()
190
+ .then((state) => {
191
+ if (active) onChange(state);
192
+ })
193
+ .catch((error) => {
194
+ videoLoggerSystem
195
+ .getLogger('CallManager')
196
+ .warn('addChangeListener: getStatus failed', error);
197
+ });
198
+ },
150
199
  );
151
- cleanups.push(() => sub.remove());
200
+ unsubscribes.push(() => cxSub.remove());
152
201
  }
153
202
 
154
- // StreamInCallManager route changes (non-Telecom calls). Native
155
- // suppresses these while in telecom-managed mode, and callingx emits no
156
- // endpoint events for non-Telecom calls, so the two sources never overlap
157
- // at runtime — safe to keep both subscribed in parallel.
158
- this.eventEmitter ??= new NativeEventEmitter(NativeManager);
159
- const s = this.eventEmitter.addListener('onAudioDeviceChanged', onChange);
160
- cleanups.push(() => s.remove());
203
+ // SDK-managed route changes (non-callingx calls).
204
+ this.eventEmitter =
205
+ this.eventEmitter ?? new NativeEventEmitter(NativeManager);
206
+ const sdkSub = this.eventEmitter.addListener(
207
+ AUDIO_DEVICE_CHANGED_EVENT,
208
+ onChange,
209
+ );
210
+ unsubscribes.push(() => sdkSub.remove());
161
211
 
162
- return () => cleanups.forEach((cleanup) => cleanup());
212
+ return () => {
213
+ active = false;
214
+ unsubscribes.forEach((unsubscribe) => unsubscribe());
215
+ };
163
216
  };
164
217
  }
165
218
 
@@ -258,7 +311,7 @@ const shouldBypassForCallKit = (): boolean => {
258
311
  };
259
312
 
260
313
  export class CallManager {
261
- android = new AndroidCallManager();
314
+ audioDevices = new AudioDevicesManager();
262
315
  ios = new IOSCallManager();
263
316
  speaker = new SpeakerManager();
264
317
 
@@ -281,11 +334,14 @@ export class CallManager {
281
334
  */
282
335
  start = (config?: StreamInCallManagerConfig): void => {
283
336
  if (shouldBypassForCallKit()) {
284
- // Forward only the passive endpoint preference; callingx reads it when
285
- // CallKit drives session activation.
286
337
  if (config?.audioRole === 'communicator' && CallingxModule) {
287
338
  const type = config.deviceEndpointType ?? 'speaker';
288
- CallingxModule.setDefaultAudioDeviceEndpointType(type);
339
+ safeNativeCall('setDefaultAudioDeviceEndpointType (callingx)', () =>
340
+ CallingxModule.setDefaultAudioDeviceEndpointType(type),
341
+ );
342
+ safeNativeCall('setDefaultAudioDeviceEndpointType', () =>
343
+ NativeManager.setDefaultAudioDeviceEndpointType(type),
344
+ );
289
345
  }
290
346
  videoLoggerSystem
291
347
  .getLogger('CallManager')
@@ -298,8 +354,10 @@ export class CallManager {
298
354
  // Telecom owns routing/focus; forward the sticky preference to callingx and run in
299
355
  // telecom-managed mode (StreamInCallManager keeps proximity/keep-screen-on only).
300
356
  if (config?.audioRole !== 'listener' && CallingxModule) {
301
- CallingxModule.setDefaultAudioDeviceEndpointType(
302
- config?.deviceEndpointType ?? 'speaker',
357
+ safeNativeCall('setDefaultAudioDeviceEndpointType (callingx)', () =>
358
+ CallingxModule.setDefaultAudioDeviceEndpointType(
359
+ config?.deviceEndpointType ?? 'speaker',
360
+ ),
303
361
  );
304
362
  }
305
363
  NativeManager.setTelecomManagedMode(true);
@@ -1,6 +1,6 @@
1
1
  import 'react-native';
2
2
  import type { NativeModule } from 'react-native';
3
- import type { AudioDeviceStatus, AudioRole, DeviceEndpointType } from './types';
3
+ import type { AudioDevicesState, AudioRole, DeviceEndpointType } from './types';
4
4
 
5
5
  export interface CallManager extends NativeModule {
6
6
  /**
@@ -33,16 +33,23 @@ export interface CallManager extends NativeModule {
33
33
  setTelecomManagedMode: (enabled: boolean) => void;
34
34
 
35
35
  /**
36
- * Choose an audio device endpoint.
37
- * @param endpointName - The name of the audio device endpoint to choose.
36
+ * Choose an audio device endpoint by its stable id.
37
+ * @param deviceId - The id of the audio device to choose (`AudioDevice.id`).
38
38
  */
39
- chooseAudioDeviceEndpoint: (endpoint: string) => void;
39
+ chooseAudioDeviceEndpoint: (deviceId: string) => void;
40
40
 
41
41
  /**
42
- * Get the current audio device status.
43
- * @returns The audio device status.
42
+ * Re-applies the current audio output pick (iOS). Used to restore a Bluetooth/wired
43
+ * route after an interruption ends, where the ephemeral route override is lost.
44
+ * No-op if no explicit pick has been made.
44
45
  */
45
- getAudioDeviceStatus: () => Promise<AudioDeviceStatus>;
46
+ reapplyAudioRoute: () => void;
47
+
48
+ /**
49
+ * Get the current audio device state (available devices + selected one).
50
+ * @returns The audio devices state.
51
+ */
52
+ getAudioDeviceStatus: () => Promise<AudioDevicesState>;
46
53
 
47
54
  /**
48
55
  * Shows the iOS audio route picker.
@@ -6,10 +6,37 @@ export type AudioDeviceEndpointType =
6
6
  | 'Unknown'
7
7
  | (string & {});
8
8
 
9
- export type AudioDeviceStatus = {
10
- devices: string[];
9
+ /**
10
+ * A selectable audio output device.
11
+ *
12
+ * Devices are identified by a stable {@link AudioDevice.id} — never by name —
13
+ * so two devices that share a display name (e.g. two "AirPods Pro") never
14
+ * collide. On iOS the id is the `AVAudioSessionPortDescription.uid`
15
+ * (or the synthetic `'speaker'` / `'earpiece'` for the built-in outputs).
16
+ * On Android it is the `AudioDeviceInfo.id` for SDK-managed routing, or the
17
+ * Telecom endpoint id (a `CallEndpointCompat` identifier) when the call is
18
+ * Telecom-managed via callingx — stable for the lifetime of that call.
19
+ */
20
+ export type AudioDevice = {
21
+ /** Stable, unique device identifier. Pass this to `callManager.audioDevices.select()`. */
22
+ id: string;
23
+ /** Human-readable label for display (e.g. "AirPods Pro", "Speaker"). Not stable — do not use as a key. */
24
+ name: string;
25
+ /** The kind of endpoint, useful for picking an icon. */
26
+ type: AudioDeviceEndpointType;
27
+ };
28
+
29
+ /**
30
+ * The current audio-output device state. Returned by
31
+ * `callManager.audioDevices.getStatus()` and the `useAudioDeviceStatus()` hook.
32
+ */
33
+ export type AudioDevicesState = {
34
+ /** All currently available output devices. */
35
+ devices: AudioDevice[];
36
+ /** The id of the active device, matching one of `devices[].id` (when known). */
37
+ selectedDeviceId?: string;
38
+ /** The endpoint type of the active device. */
11
39
  currentEndpointType: AudioDeviceEndpointType;
12
- selectedDevice: string;
13
40
  };
14
41
 
15
42
  export type AudioRole = 'communicator' | 'listener';
@@ -5,7 +5,6 @@
5
5
  import { NativeModules, Platform } from 'react-native';
6
6
  import type { EndCallReason } from '@stream-io/react-native-callingx';
7
7
  import { getCallingxLibIfAvailable } from '../../push/libs/callingx';
8
- import { waitForAudioSessionActivation } from './audioSessionPromise';
9
8
  import type {
10
9
  Call,
11
10
  MemberResponse,
@@ -171,9 +170,6 @@ export async function joinCallingxCall(call: Call, activeCalls: Call[]) {
171
170
  callDisplayName, // display name for display in call screen
172
171
  call.state.settings?.video?.enabled ?? false, // is video call?
173
172
  );
174
- if (Platform.OS === 'ios') {
175
- await waitForAudioSessionActivation();
176
- }
177
173
  };
178
174
 
179
175
  if (
@@ -220,10 +216,6 @@ export async function joinCallingxCall(call: Call, activeCalls: Call[]) {
220
216
  );
221
217
 
222
218
  await CallingxModule.answerIncomingCall(call.cid);
223
-
224
- if (Platform.OS === 'ios') {
225
- await waitForAudioSessionActivation();
226
- }
227
219
  } catch (error) {
228
220
  logger.error(
229
221
  `Error joining incoming call in callingx: ${call.cid}`,
@@ -1,10 +1,13 @@
1
- import { videoLoggerSystem } from '@stream-io/video-client';
1
+ import {
2
+ CallingState,
3
+ videoLoggerSystem,
4
+ type StreamVideoClient,
5
+ } from '@stream-io/video-client';
2
6
  import type { StreamVideoConfig } from '../StreamVideoRN/types';
3
7
  import {
4
8
  clearPushWSEventSubscriptions,
5
9
  processCallFromPushInBackground,
6
10
  } from './internal/utils';
7
- import { resolvePendingAudioSession } from '../internal/callingx/audioSessionPromise';
8
11
  import {
9
12
  getCallingxLib,
10
13
  type EventData,
@@ -50,6 +53,10 @@ export function setupCallingExpEvents(pushConfig: NonNullable<PushConfig>) {
50
53
  onDidDeactivateAudioSession,
51
54
  );
52
55
 
56
+ callingx.addEventListener('providerReset', (params) => {
57
+ onProviderReset(pushConfig)(params);
58
+ });
59
+
53
60
  //NOTE: until getInitialEvents invocation, events are delayed and won't be sent to event listeners, this is a way to make sure none of required events are missed
54
61
  //in most cases there will be no delayed answers or ends, but if so we don't want to miss any of them
55
62
  const events = callingx.getInitialEvents();
@@ -65,19 +72,50 @@ export function setupCallingExpEvents(pushConfig: NonNullable<PushConfig>) {
65
72
  onDidActivateAudioSession();
66
73
  } else if (eventName === 'didDeactivateAudioSession') {
67
74
  onDidDeactivateAudioSession();
75
+ } else if (eventName === 'providerReset') {
76
+ onProviderReset(pushConfig)(params as EventParams['providerReset']);
68
77
  }
69
78
  });
70
79
  }
71
80
 
72
81
  const onDidActivateAudioSession = () => {
73
82
  logger.debug('callingExpDidActivateAudioSession');
74
- resolvePendingAudioSession();
75
83
  };
76
84
 
77
85
  const onDidDeactivateAudioSession = () => {
78
86
  logger.debug('callingExpDidDeactivateAudioSession');
79
87
  };
80
88
 
89
+ const onProviderReset =
90
+ (pushConfig: PushConfig) =>
91
+ async ({ callCids }: EventParams['providerReset']) => {
92
+ logger.debug(`onProviderReset event callCids: ${callCids?.join(', ')}`);
93
+
94
+ if (!callCids?.length) {
95
+ return;
96
+ }
97
+
98
+ let videoClient: StreamVideoClient | undefined;
99
+ try {
100
+ videoClient = await pushConfig.createStreamVideoClient();
101
+ } catch (e) {
102
+ logger.error('onProviderReset: failed to resolve video client', e);
103
+ return;
104
+ }
105
+ if (!videoClient) {
106
+ return;
107
+ }
108
+
109
+ for (const cid of callCids) {
110
+ const call = videoClient.state.calls.find((c) => c.cid === cid);
111
+ if (call && call.state.callingState !== CallingState.LEFT) {
112
+ call?.leave().catch((error: unknown) => {
113
+ logger.error(`onProviderReset: error leaving call ${cid}`, error);
114
+ });
115
+ }
116
+ }
117
+ };
118
+
81
119
  const onAcceptCall =
82
120
  (pushConfig: PushConfig) =>
83
121
  ({ callId: call_cid, source }: EventParams['answerCall']) => {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '1.40.2';
1
+ export const version = '1.42.0';