@stream-io/video-react-native-sdk 1.44.0 → 1.45.1

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 (108) hide show
  1. package/CHANGELOG.md +35 -3
  2. package/android/src/main/java/com/streamvideo/reactnative/audio/AudioDeviceManager.kt +110 -58
  3. package/android/src/main/java/com/streamvideo/reactnative/audio/CommunicationModeKeepAlive.kt +247 -0
  4. package/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt +16 -5
  5. package/android/src/main/java/com/streamvideo/reactnative/keepalive/StreamCallKeepAliveHeadlessService.kt +112 -26
  6. package/android/src/main/java/com/streamvideo/reactnative/util/CallAlivePermissionsHelper.kt +68 -37
  7. package/dist/commonjs/hooks/push/useCallingExpWithCallingStateEffect.js +12 -6
  8. package/dist/commonjs/hooks/push/useCallingExpWithCallingStateEffect.js.map +1 -1
  9. package/dist/commonjs/hooks/useAndroidKeepCallAliveEffect.js +38 -11
  10. package/dist/commonjs/hooks/useAndroidKeepCallAliveEffect.js.map +1 -1
  11. package/dist/commonjs/hooks/useLoopbackRecording.js +10 -127
  12. package/dist/commonjs/hooks/useLoopbackRecording.js.map +1 -1
  13. package/dist/commonjs/utils/StreamVideoRN/index.js +25 -0
  14. package/dist/commonjs/utils/StreamVideoRN/index.js.map +1 -1
  15. package/dist/commonjs/utils/internal/callingx/callingx.js +38 -56
  16. package/dist/commonjs/utils/internal/callingx/callingx.js.map +1 -1
  17. package/dist/commonjs/utils/keepCallAliveHeadlessTask.js +43 -4
  18. package/dist/commonjs/utils/keepCallAliveHeadlessTask.js.map +1 -1
  19. package/dist/commonjs/utils/push/android.js +7 -167
  20. package/dist/commonjs/utils/push/android.js.map +1 -1
  21. package/dist/commonjs/utils/push/internal/android.js +204 -0
  22. package/dist/commonjs/utils/push/internal/android.js.map +1 -0
  23. package/dist/commonjs/utils/push/libs/firebaseMessaging/index.js +9 -1
  24. package/dist/commonjs/utils/push/libs/firebaseMessaging/index.js.map +1 -1
  25. package/dist/commonjs/utils/push/libs/firebaseMessaging/lib.js +1 -1
  26. package/dist/commonjs/utils/push/libs/firebaseMessaging/lib.js.map +1 -1
  27. package/dist/commonjs/utils/push/setupAndroidPushEvents.js +25 -0
  28. package/dist/commonjs/utils/push/setupAndroidPushEvents.js.map +1 -0
  29. package/dist/commonjs/utils/push/setupCallingExpEvents.js +6 -2
  30. package/dist/commonjs/utils/push/setupCallingExpEvents.js.map +1 -1
  31. package/dist/commonjs/utils/push/setupIosVoipPushEvents.js +6 -7
  32. package/dist/commonjs/utils/push/setupIosVoipPushEvents.js.map +1 -1
  33. package/dist/commonjs/utils/push/utils.js.map +1 -1
  34. package/dist/commonjs/version.js +1 -1
  35. package/dist/module/hooks/push/useCallingExpWithCallingStateEffect.js +12 -6
  36. package/dist/module/hooks/push/useCallingExpWithCallingStateEffect.js.map +1 -1
  37. package/dist/module/hooks/useAndroidKeepCallAliveEffect.js +39 -12
  38. package/dist/module/hooks/useAndroidKeepCallAliveEffect.js.map +1 -1
  39. package/dist/module/hooks/useLoopbackRecording.js +10 -127
  40. package/dist/module/hooks/useLoopbackRecording.js.map +1 -1
  41. package/dist/module/utils/StreamVideoRN/index.js +25 -0
  42. package/dist/module/utils/StreamVideoRN/index.js.map +1 -1
  43. package/dist/module/utils/internal/callingx/callingx.js +38 -56
  44. package/dist/module/utils/internal/callingx/callingx.js.map +1 -1
  45. package/dist/module/utils/keepCallAliveHeadlessTask.js +41 -3
  46. package/dist/module/utils/keepCallAliveHeadlessTask.js.map +1 -1
  47. package/dist/module/utils/push/android.js +9 -169
  48. package/dist/module/utils/push/android.js.map +1 -1
  49. package/dist/module/utils/push/internal/android.js +200 -0
  50. package/dist/module/utils/push/internal/android.js.map +1 -0
  51. package/dist/module/utils/push/libs/firebaseMessaging/index.js +9 -1
  52. package/dist/module/utils/push/libs/firebaseMessaging/index.js.map +1 -1
  53. package/dist/module/utils/push/libs/firebaseMessaging/lib.js +1 -1
  54. package/dist/module/utils/push/libs/firebaseMessaging/lib.js.map +1 -1
  55. package/dist/module/utils/push/setupAndroidPushEvents.js +21 -0
  56. package/dist/module/utils/push/setupAndroidPushEvents.js.map +1 -0
  57. package/dist/module/utils/push/setupCallingExpEvents.js +6 -2
  58. package/dist/module/utils/push/setupCallingExpEvents.js.map +1 -1
  59. package/dist/module/utils/push/setupIosVoipPushEvents.js +6 -7
  60. package/dist/module/utils/push/setupIosVoipPushEvents.js.map +1 -1
  61. package/dist/module/utils/push/utils.js.map +1 -1
  62. package/dist/module/version.js +1 -1
  63. package/dist/typescript/hooks/push/useCallingExpWithCallingStateEffect.d.ts.map +1 -1
  64. package/dist/typescript/hooks/useAndroidKeepCallAliveEffect.d.ts.map +1 -1
  65. package/dist/typescript/hooks/useLoopbackRecording.d.ts +12 -69
  66. package/dist/typescript/hooks/useLoopbackRecording.d.ts.map +1 -1
  67. package/dist/typescript/utils/StreamVideoRN/index.d.ts +13 -0
  68. package/dist/typescript/utils/StreamVideoRN/index.d.ts.map +1 -1
  69. package/dist/typescript/utils/StreamVideoRN/types.d.ts +1 -2
  70. package/dist/typescript/utils/StreamVideoRN/types.d.ts.map +1 -1
  71. package/dist/typescript/utils/internal/callingx/callingx.d.ts.map +1 -1
  72. package/dist/typescript/utils/keepCallAliveHeadlessTask.d.ts +9 -0
  73. package/dist/typescript/utils/keepCallAliveHeadlessTask.d.ts.map +1 -1
  74. package/dist/typescript/utils/push/android.d.ts +3 -4
  75. package/dist/typescript/utils/push/android.d.ts.map +1 -1
  76. package/dist/typescript/utils/push/internal/android.d.ts +8 -0
  77. package/dist/typescript/utils/push/internal/android.d.ts.map +1 -0
  78. package/dist/typescript/utils/push/libs/firebaseMessaging/index.d.ts +6 -4
  79. package/dist/typescript/utils/push/libs/firebaseMessaging/index.d.ts.map +1 -1
  80. package/dist/typescript/utils/push/libs/firebaseMessaging/lib.d.ts +2 -2
  81. package/dist/typescript/utils/push/libs/firebaseMessaging/lib.d.ts.map +1 -1
  82. package/dist/typescript/utils/push/setupAndroidPushEvents.d.ts +3 -0
  83. package/dist/typescript/utils/push/setupAndroidPushEvents.d.ts.map +1 -0
  84. package/dist/typescript/utils/push/setupCallingExpEvents.d.ts.map +1 -1
  85. package/dist/typescript/utils/push/setupIosVoipPushEvents.d.ts.map +1 -1
  86. package/dist/typescript/utils/push/utils.d.ts +2 -2
  87. package/dist/typescript/utils/push/utils.d.ts.map +1 -1
  88. package/dist/typescript/version.d.ts +1 -1
  89. package/expo-config-plugin/dist/withAndroidPermissions.js +1 -1
  90. package/ios/TracksRecorder/RecorderVideoSink.swift +92 -5
  91. package/package.json +4 -4
  92. package/src/hooks/push/useCallingExpWithCallingStateEffect.ts +25 -11
  93. package/src/hooks/useAndroidKeepCallAliveEffect.ts +59 -21
  94. package/src/hooks/useLoopbackRecording.ts +36 -248
  95. package/src/modules/call-manager/native-module.d.ts +14 -0
  96. package/src/utils/StreamVideoRN/index.ts +29 -0
  97. package/src/utils/StreamVideoRN/types.ts +1 -2
  98. package/src/utils/internal/callingx/callingx.ts +49 -68
  99. package/src/utils/keepCallAliveHeadlessTask.ts +45 -3
  100. package/src/utils/push/android.ts +12 -237
  101. package/src/utils/push/internal/android.ts +273 -0
  102. package/src/utils/push/libs/firebaseMessaging/index.ts +16 -5
  103. package/src/utils/push/libs/firebaseMessaging/lib.ts +3 -3
  104. package/src/utils/push/setupAndroidPushEvents.ts +25 -0
  105. package/src/utils/push/setupCallingExpEvents.ts +6 -3
  106. package/src/utils/push/setupIosVoipPushEvents.ts +8 -7
  107. package/src/utils/push/utils.ts +2 -4
  108. package/src/version.ts +1 -1
@@ -1,146 +1,71 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { NativeModules } from 'react-native';
3
- import { combineLatest } from 'rxjs';
4
3
  import {
5
- Call,
6
4
  CallingState,
5
+ DEFAULT_LOOPBACK_RECORDING_DURATION_MS,
6
+ LOOPBACK_STREAMS_WAIT_TIMEOUT_MS,
7
+ clampLoopbackRecordingDuration,
8
+ getLoopbackStreams,
7
9
  videoLoggerSystem,
8
- type StreamVideoParticipant,
10
+ waitForLoopbackStreams,
11
+ withLoopbackAudioEnabled,
12
+ type LoopbackStreams,
13
+ type LoopbackTracks,
9
14
  } from '@stream-io/video-client';
15
+
10
16
  import { useCall, useCallStateHooks } from '@stream-io/video-react-bindings';
11
17
 
12
18
  /** @internal */
13
19
  const { StreamVideoReactNative } = NativeModules;
14
20
 
15
- // Upper bound on how long `startRecording` will wait for the SFU to
16
- // echo loopback tracks back via the Subscriber. Tuned generously since
17
- // this includes connection setup; consumers that want shorter feedback
18
- // should call `stopRecording` to cancel the wait early.
19
- const STREAMS_WAIT_TIMEOUT_MS = 10 * 1000;
20
- const DEFAULT_RECORDING_DURATION = 10 * 1000;
21
- const MIN_RECORDING_DURATION = 5 * 1000;
22
- const MAX_RECORDING_DURATION = 2 * 60 * 1000;
23
-
24
- type LoopbackStreams = {
25
- loopbackVideoStream?: MediaStream;
26
- loopbackAudioStream?: MediaStream;
27
- };
28
-
29
21
  export type LoopbackRecordingState = 'idle' | 'awaiting-streams' | 'recording';
30
22
 
31
- export type ResolvedStreams = {
32
- audioTrack?: MediaStreamTrack;
33
- videoTrack?: MediaStreamTrack;
34
- };
23
+ export type ResolvedStreams = LoopbackTracks;
35
24
 
36
25
  export interface StartLoopbackRecordingOptions {
37
- /**
38
- * Whether to include the loopback video track in the recording.
39
- * Defaults to `true`. Set to `false` for an audio-only recording.
40
- * Audio is always recorded — there is no video-only mode.
41
- */
26
+ /** Whether to include loopback video. Audio is always recorded. */
42
27
  includeVideo?: boolean;
43
- /**
44
- * Maximum recording duration in milliseconds, after which the
45
- * recording auto-stops and finalises the file.
46
- *
47
- * Defaults to `10_000` (10 seconds). Clamped to
48
- * `[5_000, 120_000]` (5 seconds — 2 minutes).
49
- */
28
+ /** Maximum recording duration in milliseconds. */
50
29
  maxDurationMs?: number;
51
30
  }
52
31
 
53
32
  export interface UseLoopbackRecordingResult {
54
- /**
55
- * Start a recording. The hook waits internally for the SFU loopback
56
- * streams to arrive on `localParticipant`, then begins recording.
57
- *
58
- * The returned promise resolves with the produced `file://` URI **at
59
- * the recording's terminal moment** — whether that is the auto-stop
60
- * timer expiring, an explicit `stopRecording` call, or a cleanup-
61
- * driven stop on unmount/leave. Resolves with `null` if no file was
62
- * produced (writer torn down before any buffer arrived, or
63
- * `stopRecording` was called while still awaiting streams). Rejects
64
- * on a fatal error, if a recording is already running, or if the
65
- * stream-wait times out.
66
- */
33
+ /** Starts recording and resolves with a `file://` URI. */
67
34
  startRecording: (
68
35
  options?: StartLoopbackRecordingOptions,
69
36
  ) => Promise<string | null>;
70
- /**
71
- * Signal an early termination. While `awaiting-streams` this aborts
72
- * the wait and the pending `startRecording` resolves with `null`.
73
- * While `recording` this signals native finalisation and resolves
74
- * once it completes.
75
- */
37
+ /** Stops the current recording or stream wait. */
76
38
  stopRecording: () => Promise<void>;
77
- /**
78
- * Recursively delete every file under the SDK's recordings directory.
79
- */
39
+ /** Deletes all SDK recording files. */
80
40
  clearRecordings: () => Promise<void>;
81
- /**
82
- * List every `file://` URI in the SDK's recordings directory, sorted
83
- * most-recent first. Returns an empty array if the directory doesn't
84
- * exist yet.
85
- */
41
+ /** Lists SDK recording file URIs. */
86
42
  getRecordings: () => Promise<string[]>;
87
- /**
88
- * Lifecycle phase of the recording, owned by the hook:
89
- * - `'idle'`: no recording in progress.
90
- * - `'awaiting-streams'`: `startRecording` was called but the SFU
91
- * has not yet echoed the loopback tracks back.
92
- * - `'recording'`: native pipeline is actively writing.
93
- */
43
+ /** Lifecycle phase of the recording. */
94
44
  recordingState: LoopbackRecordingState;
95
- /**
96
- * The SFU loopback video stream on the local participant, when
97
- * present. Identified by reference inequality against
98
- * `call.camera.state.mediaStream`.
99
- */
45
+ /** SFU-echoed video stream on the local participant. */
100
46
  loopbackVideoStream?: MediaStream;
101
- /**
102
- * The SFU loopback audio stream on the local participant, when
103
- * present. Identified by reference inequality against
104
- * `call.microphone.state.mediaStream`.
105
- */
47
+ /** SFU-echoed audio stream on the local participant. */
106
48
  loopbackAudioStream?: MediaStream;
107
49
  }
108
50
 
109
- /**
110
- * Records the SFU loopback streams (audio + video) on the local participant
111
- * to a local MP4 file. Designed for the `allowOwnTracksLoopback` pre-call test mode:
112
- * the SFU echoes the caller's published tracks back through the Subscriber
113
- * peer connection. The hook identifies the loopback streams on the local
114
- * participant by reference inequality against
115
- * `call.camera.state.mediaStream` / `call.microphone.state.mediaStream` —
116
- * the canonical references to the local capture — and captures them.
117
- */
51
+ /** Records SFU loopback streams to a local MP4 file. */
118
52
  export function useLoopbackRecording(): UseLoopbackRecordingResult {
119
53
  const call = useCall();
120
- const {
121
- useCallCallingState,
122
- useParticipantCount,
123
- useLocalParticipant,
124
- useCameraState,
125
- useMicrophoneState,
126
- } = useCallStateHooks();
54
+ const { useCallCallingState, useParticipantCount, useLocalParticipant } =
55
+ useCallStateHooks();
127
56
  const callingState = useCallCallingState();
128
57
  const participantCount = useParticipantCount();
129
58
  const localParticipant = useLocalParticipant();
130
- const { mediaStream: cameraStream } = useCameraState();
131
- const { mediaStream: microphoneStream } = useMicrophoneState();
132
59
 
133
60
  const [recordingState, setRecordingState] =
134
61
  useState<LoopbackRecordingState>('idle');
135
62
  const recordingStateRef = useRef<LoopbackRecordingState>('idle');
136
63
  const isMountedRef = useRef(true);
137
- // Used to abort the awaiting-streams wait on stop / leave / unmount.
138
64
  const awaitAbortRef = useRef<AbortController | null>(null);
139
65
 
140
66
  const loopbackStreams = useMemo<LoopbackStreams>(
141
- () =>
142
- getLoopbackStreamsFor(localParticipant, cameraStream, microphoneStream),
143
- [localParticipant, cameraStream, microphoneStream],
67
+ () => getLoopbackStreams(call, localParticipant),
68
+ [call, localParticipant],
144
69
  );
145
70
 
146
71
  const updateState = useCallback((next: LoopbackRecordingState) => {
@@ -177,7 +102,7 @@ export function useLoopbackRecording(): UseLoopbackRecordingResult {
177
102
  const startRecording = useCallback(
178
103
  async ({
179
104
  includeVideo = true,
180
- maxDurationMs = DEFAULT_RECORDING_DURATION,
105
+ maxDurationMs = DEFAULT_LOOPBACK_RECORDING_DURATION_MS,
181
106
  }: StartLoopbackRecordingOptions = {}): Promise<string | null> => {
182
107
  if (!call) {
183
108
  return null;
@@ -200,12 +125,11 @@ export function useLoopbackRecording(): UseLoopbackRecordingResult {
200
125
  awaitAbortRef.current = new AbortController();
201
126
  updateState('awaiting-streams');
202
127
 
203
- let audioTrack: MediaStreamTrack | undefined;
204
128
  try {
205
129
  const streams = await waitForLoopbackStreams(call, {
206
130
  includeVideo,
207
131
  signal: awaitAbortRef.current.signal,
208
- timeoutMs: STREAMS_WAIT_TIMEOUT_MS,
132
+ timeoutMs: LOOPBACK_STREAMS_WAIT_TIMEOUT_MS,
209
133
  });
210
134
 
211
135
  if (streams === null) {
@@ -215,38 +139,21 @@ export function useLoopbackRecording(): UseLoopbackRecordingResult {
215
139
  return null;
216
140
  }
217
141
 
218
- audioTrack = streams.audioTrack;
219
- const videoTrackId = streams.videoTrack?.id;
220
-
221
- // The loopback audio track lands disabled (the SDK default-mutes
222
- // it to prevent echo). Enable it so the native recording
223
- // pipeline receives PCM; `finally` returns it to muted.
224
- if (audioTrack) {
225
- audioTrack.enabled = true;
226
- }
227
142
  updateState('recording');
228
143
 
229
- const clampedDuration = Math.min(
230
- MAX_RECORDING_DURATION,
231
- Math.max(MIN_RECORDING_DURATION, maxDurationMs),
232
- );
233
-
234
- // Pre-size the native encoder to the publisher's max video
235
- // publish-option dimension. Without this, the encoder locks to
236
- // whatever the SFU happens to echo *first* and stays there for the
237
- // rest of the recording even after the network improves and
238
- // higher layers arrive.
239
144
  const publishMaxDim = call.getMaxVideoPublishDimension();
240
145
 
241
146
  try {
242
- const uri: string | null =
243
- await StreamVideoReactNative.startTrackRecording({
244
- videoTrackId,
245
- maxDurationMs: Math.round(clampedDuration),
246
- targetWidth: publishMaxDim?.width,
247
- targetHeight: publishMaxDim?.height,
248
- });
249
- return uri;
147
+ return await withLoopbackAudioEnabled(
148
+ streams,
149
+ (): Promise<string | null> =>
150
+ StreamVideoReactNative.startTrackRecording({
151
+ videoTrackId: streams.videoTrack?.id,
152
+ maxDurationMs: clampLoopbackRecordingDuration(maxDurationMs),
153
+ targetWidth: publishMaxDim?.width,
154
+ targetHeight: publishMaxDim?.height,
155
+ }),
156
+ );
250
157
  } catch (error) {
251
158
  videoLoggerSystem
252
159
  .getLogger('useLoopbackRecording')
@@ -254,9 +161,6 @@ export function useLoopbackRecording(): UseLoopbackRecordingResult {
254
161
  throw new Error('failed to start recording');
255
162
  }
256
163
  } finally {
257
- if (audioTrack) {
258
- audioTrack.enabled = false;
259
- }
260
164
  awaitAbortRef.current = null;
261
165
  updateState('idle');
262
166
  }
@@ -288,11 +192,6 @@ export function useLoopbackRecording(): UseLoopbackRecordingResult {
288
192
  }
289
193
  }, []);
290
194
 
291
- // Auto-stop on call leave / end. Aborts an awaiting-streams wait or
292
- // signals native finalisation depending on which phase we're in.
293
- // Without this, leaving the call mid-recording would leave native
294
- // encoders mid-write while the SFU subscriber tracks end under their
295
- // feet — undefined final file state.
296
195
  useEffect(() => {
297
196
  if (
298
197
  callingState === CallingState.LEFT ||
@@ -305,8 +204,6 @@ export function useLoopbackRecording(): UseLoopbackRecordingResult {
305
204
  }
306
205
  }, [callingState, stopRecording]);
307
206
 
308
- // Auto-stop if another participant joins. Loopback recording is a
309
- // single-user pre-call test.
310
207
  useEffect(() => {
311
208
  if (recordingState !== 'idle' && participantCount > 1) {
312
209
  stopRecording().catch(() => {});
@@ -337,112 +234,3 @@ export function useLoopbackRecording(): UseLoopbackRecordingResult {
337
234
  loopbackAudioStream: loopbackStreams.loopbackAudioStream,
338
235
  };
339
236
  }
340
-
341
- function getLoopbackStreamsFor(
342
- participant: StreamVideoParticipant | undefined,
343
- cameraStream: MediaStream | undefined,
344
- microphoneStream: MediaStream | undefined,
345
- ): LoopbackStreams {
346
- return {
347
- loopbackVideoStream:
348
- participant?.videoStream && participant.videoStream !== cameraStream
349
- ? participant.videoStream
350
- : undefined,
351
- loopbackAudioStream:
352
- participant?.audioStream && participant.audioStream !== microphoneStream
353
- ? participant.audioStream
354
- : undefined,
355
- };
356
- }
357
-
358
- /**
359
- * Subscribe to `localParticipant$` and resolve once the requested loopback
360
- * streams are present on the participant. Aborts cleanly on `signal`
361
- * (resolves `null`) and rejects on timeout.
362
- */
363
- function waitForLoopbackStreams(
364
- call: Call,
365
- opts: { includeVideo: boolean; signal: AbortSignal; timeoutMs: number },
366
- ): Promise<ResolvedStreams | null> {
367
- return new Promise((resolve, reject) => {
368
- const initial = getLoopbackStreams(
369
- call.state.localParticipant,
370
- call.camera.state.mediaStream,
371
- call.microphone.state.mediaStream,
372
- opts.includeVideo,
373
- );
374
- if (initial) {
375
- resolve(initial);
376
- return;
377
- }
378
-
379
- const cleanup = () => {
380
- subscription.unsubscribe();
381
- if (timeoutId !== undefined) {
382
- clearTimeout(timeoutId);
383
- }
384
- opts.signal.removeEventListener('abort', onAbort);
385
- };
386
-
387
- const onAbort = () => {
388
- cleanup();
389
- resolve(null);
390
- };
391
-
392
- opts.signal.addEventListener('abort', onAbort);
393
-
394
- const timeoutId = setTimeout(() => {
395
- cleanup();
396
- reject(
397
- new Error(
398
- 'useLoopbackRecording: timed out waiting for loopback streams. ' +
399
- 'Ensure the call was joined with `allowOwnTracksLoopback: true` and ' +
400
- 'that the SFU is configured to echo self-sub tracks.',
401
- ),
402
- );
403
- }, opts.timeoutMs);
404
-
405
- const subscription = combineLatest([
406
- call.state.localParticipant$,
407
- call.camera.state.mediaStream$,
408
- call.microphone.state.mediaStream$,
409
- ]).subscribe(([participant, cameraStream, microphoneStream]) => {
410
- const ready = getLoopbackStreams(
411
- participant,
412
- cameraStream,
413
- microphoneStream,
414
- opts.includeVideo,
415
- );
416
- if (ready) {
417
- cleanup();
418
- resolve(ready);
419
- }
420
- });
421
- });
422
- }
423
-
424
- function getLoopbackStreams(
425
- participant: StreamVideoParticipant | undefined,
426
- cameraStream: MediaStream | undefined,
427
- microphoneStream: MediaStream | undefined,
428
- includeVideo: boolean,
429
- ): ResolvedStreams | undefined {
430
- if (!participant) return undefined;
431
-
432
- const { loopbackAudioStream, loopbackVideoStream } = getLoopbackStreamsFor(
433
- participant,
434
- cameraStream,
435
- microphoneStream,
436
- );
437
-
438
- const audioTrack = loopbackAudioStream?.getAudioTracks()[0];
439
- const videoTrack = includeVideo
440
- ? loopbackVideoStream?.getVideoTracks()[0]
441
- : undefined;
442
-
443
- if (!audioTrack || (includeVideo && !videoTrack)) {
444
- return undefined;
445
- }
446
-
447
- return { audioTrack, videoTrack };
448
- }
@@ -32,6 +32,20 @@ export interface CallManager extends NativeModule {
32
32
  */
33
33
  setTelecomManagedMode: (enabled: boolean) => void;
34
34
 
35
+ /**
36
+ * Opt out of the Android 11+ communication-mode keep-alive workaround.
37
+ *
38
+ * On Android 11+ (API 30) the OS resets `MODE_IN_COMMUNICATION` ~6s after it is
39
+ * set when there is no active voice-communication playback/recording, which
40
+ * breaks audio routing and AEC. By default the SDK plays a silent
41
+ * voice-communication track for the duration of a communicator-role call to
42
+ * prevent this. Pass `true` to disable it. Sticky for the process lifetime.
43
+ *
44
+ * No-op on iOS and on Android below API 30. See
45
+ * https://issuetracker.google.com/issues/209493718
46
+ */
47
+ setDisableCommunicationModeWorkaround: (disabled: boolean) => void;
48
+
35
49
  /**
36
50
  * Choose an audio device endpoint by its stable id.
37
51
  * @param deviceId - The id of the audio device to choose (`AudioDevice.id`).
@@ -1,6 +1,7 @@
1
1
  import type { StreamVideoConfig } from './types';
2
2
  import pushLogoutCallbacks from '../internal/pushLogoutCallback';
3
3
  import { setupIosVoipPushEvents } from '../push/setupIosVoipPushEvents';
4
+ import { setupAndroidPushEvents } from '../push/setupAndroidPushEvents';
4
5
  import { setupCallingExpEvents } from '../push/setupCallingExpEvents';
5
6
  import {
6
7
  extractCallingExpOptions,
@@ -133,6 +134,7 @@ export class StreamVideoRN {
133
134
 
134
135
  setupCallingExpEvents(pushConfig);
135
136
  setupIosVoipPushEvents(pushConfig);
137
+ setupAndroidPushEvents(pushConfig);
136
138
  }
137
139
 
138
140
  static getConfig() {
@@ -156,6 +158,33 @@ export class StreamVideoRN {
156
158
  pushLogoutCallbacks.current = [];
157
159
  }
158
160
 
161
+ /**
162
+ * Android only. Opt out of the Android 11+ communication-mode keep-alive.
163
+ *
164
+ * By default the SDK plays a silent voice-communication track for the duration of a
165
+ * communicator-role call, so Android does not reset `MODE_IN_COMMUNICATION` ~6s after it is
166
+ * set (which breaks audio routing and echo cancellation).
167
+ * See {@link https://issuetracker.google.com/issues/209493718}
168
+ *
169
+ * Call this at app start, alongside {@link setPushConfig} — the native audio manager
170
+ * rejects the change once it has been activated for a call. No-op on iOS and on
171
+ * Android below API 30.
172
+ */
173
+ static setDisableCommunicationModeWorkaround(disabled: boolean) {
174
+ if (Platform.OS !== 'android') {
175
+ return;
176
+ }
177
+ try {
178
+ NativeModules.StreamInCallManager?.setDisableCommunicationModeWorkaround(
179
+ disabled,
180
+ );
181
+ } catch (error) {
182
+ videoLoggerSystem
183
+ .getLogger('StreamVideoRN')
184
+ .warn('setDisableCommunicationModeWorkaround failed', error);
185
+ }
186
+ }
187
+
159
188
  /**
160
189
  * Play native busy tone for call rejection
161
190
  */
@@ -176,8 +176,7 @@ export type StreamVideoConfig = {
176
176
  */
177
177
  notificationTexts: KeepAliveAndroidNotificationTexts;
178
178
  /**
179
- * The task to run in the foreground service
180
- * The task must resolve a promise once complete
179
+ * The task to run in the foreground service.
181
180
  */
182
181
  taskToRun: (call: Call) => Promise<void>;
183
182
  };
@@ -48,22 +48,31 @@ export function getCallDisplayName(
48
48
  if (names.length === 0) {
49
49
  names = [
50
50
  participants.find((participant) => participant.userId === currentUserId)
51
- ?.name ?? 'Call',
51
+ ?.name || 'Call',
52
52
  ];
53
53
  }
54
54
 
55
55
  return names.sort().join(', ');
56
56
  }
57
57
 
58
- function getCallDisplayNameFromCall(call: Call): string {
59
- return (
60
- call.state.custom?.display_name ??
58
+ /**
59
+ * Args shared by the callingx call registration APIs:
60
+ * `(callId, phoneNumber, callerName, hasVideo)`.
61
+ */
62
+ function getCallingxCallArgs(call: Call): [string, string, string, boolean] {
63
+ const callDisplayName =
64
+ call.state.custom?.display_name ||
61
65
  getCallDisplayName(
62
66
  call.state.members,
63
67
  call.state.participants,
64
68
  call.currentUserId,
65
- )
66
- );
69
+ );
70
+ return [
71
+ call.cid, // unique id for call
72
+ call.state.createdBy?.id ?? callDisplayName, // handle for native call UI (prefer createdBy user id, fallback to call display name)
73
+ callDisplayName, // display name for display in call screen
74
+ call.state.settings?.video?.enabled ?? false, // is video call?
75
+ ];
67
76
  }
68
77
 
69
78
  export async function registerOutgoingCall(call: Call) {
@@ -84,13 +93,7 @@ export async function registerOutgoingCall(call: Call) {
84
93
 
85
94
  try {
86
95
  logger.debug(`registerOutgoingCall: Registering outgoing call ${call.cid}`);
87
- const callDisplayName = getCallDisplayNameFromCall(call);
88
- await CallingxModule.startCall(
89
- call.cid, // unique id for call
90
- call.state.createdBy?.id ?? callDisplayName, // handle for native call UI (prefer createdBy user id, fallback to call display name)
91
- callDisplayName, // display name for display in call screen
92
- call.state.settings?.video?.enabled ?? false, // is video call?
93
- );
96
+ await CallingxModule.startCall(...getCallingxCallArgs(call));
94
97
  } catch (error) {
95
98
  logger.error(
96
99
  `registerOutgoingCall: Error registering outgoing call in callingx: ${call.cid}`,
@@ -119,67 +122,45 @@ export async function joinCallingxCall(call: Call, activeCalls: Call[]) {
119
122
  const logger = videoLoggerSystem.getLogger('callingx');
120
123
  const isOutcomingCall = call.ringing && call.isCreatedByMe;
121
124
  const isIncomingCall = call.ringing && !call.isCreatedByMe;
125
+ const isOngoingCall = (c: Call) =>
126
+ !c.ringing &&
127
+ CallingxModule.isOngoingCallsEnabled &&
128
+ !c.isOwnTracksLoopbackAllowed;
122
129
 
123
- const startCallInCallingx = async () => {
124
- logger.debug(`joinCallingxCall: Joining call ${call.cid}`);
125
- const callDisplayName = getCallDisplayNameFromCall(call);
126
- await CallingxModule.startCall(
127
- call.cid, // unique id for call
128
- call.state.createdBy?.id ?? callDisplayName, // handle for native call UI (prefer createdBy user id, fallback to call display name)
129
- callDisplayName, // display name for display in call screen
130
- call.state.settings?.video?.enabled ?? false, // is video call?
131
- );
132
- };
130
+ if (!isIncomingCall && !isOutcomingCall && !isOngoingCall(call)) {
131
+ return;
132
+ }
133
133
 
134
- if (
135
- isOutcomingCall ||
136
- (!call.ringing && CallingxModule.isOngoingCallsEnabled)
137
- ) {
138
- try {
139
- await startCallInCallingx();
140
- } catch (error) {
141
- logger.error(
142
- `startCallingxCall: Error starting call in callingx: ${call.cid}`,
143
- error,
134
+ try {
135
+ const activeCallsToLeave = activeCalls.filter(
136
+ (c) =>
137
+ c.cid !== call.cid &&
138
+ (c.ringing || isOngoingCall(c)) &&
139
+ c.state.callingState !== CallingState.LEFT,
140
+ );
141
+ for (const activeCall of activeCallsToLeave) {
142
+ logger.debug(
143
+ `leaving currently-active-call:${activeCall.cid} before joining the call:${call.cid}`,
144
144
  );
145
+ await activeCall.leave({ reason: 'cancel' }).catch((e) => {
146
+ logger.error(`failed to leave active call ${activeCall.cid}`, e);
147
+ });
145
148
  }
146
- } else if (isIncomingCall) {
147
- logger.debug(`joinCallingxCall: Joining incoming call ${call.cid}`);
148
-
149
- try {
150
- // Leave any existing active ringing calls before joining a new ringing call
151
- const activeCallsToLeave = activeCalls.filter(
152
- (c) =>
153
- c.cid !== call.cid &&
154
- c.ringing &&
155
- c.state.callingState !== CallingState.LEFT,
156
- );
157
- for (const activeCall of activeCallsToLeave) {
158
- logger.debug(
159
- `leaving active call ${activeCall.cid} before joining ${call.cid}`,
160
- );
161
- await activeCall.leave({ reason: 'cancel' }).catch((e) => {
162
- logger.error(`failed to leave active call ${activeCall.cid}`, e);
163
- });
164
- }
165
- // Awaits native CallKit/Telecom registration before answering.
166
- // Safe to call even if the call is already registered (e.g. from VoIP push) --
167
- // iOS early-returns with no error, Android sends the registered broadcast.
168
- const callDisplayName = getCallDisplayNameFromCall(call);
169
- await CallingxModule.displayIncomingCall(
170
- call.cid, // unique id for call
171
- call.state.createdBy?.id ?? callDisplayName, // handle for native call UI (prefer createdBy user id, fallback to call display name)
172
- callDisplayName, // display name for display in call screen
173
- call.state.settings?.video?.enabled ?? false, // is video call?
174
- );
175
-
149
+ logger.debug(
150
+ `joinCallingxCall: Joining call ${call.cid} isIncoming: ${isIncomingCall} isOutgoing: ${isOutcomingCall}`,
151
+ );
152
+ const callArgs = getCallingxCallArgs(call);
153
+ if (isIncomingCall) {
154
+ await CallingxModule.displayIncomingCall(...callArgs);
176
155
  await CallingxModule.answerIncomingCall(call.cid);
177
- } catch (error) {
178
- logger.error(
179
- `Error joining incoming call in callingx: ${call.cid}`,
180
- error,
181
- );
156
+ } else {
157
+ await CallingxModule.startCall(...callArgs);
182
158
  }
159
+ } catch (error) {
160
+ logger.error(
161
+ `startCallingxCall: Error starting call in callingx: ${call.cid} isIncoming: ${isIncomingCall} isOutgoing: ${isOutcomingCall}`,
162
+ error,
163
+ );
183
164
  }
184
165
  }
185
166
 
@@ -13,6 +13,21 @@ export const keepCallAliveCallRef: { current: Call | undefined } = {
13
13
  current: undefined,
14
14
  };
15
15
 
16
+ let resolveRunningTask: (() => void) | undefined;
17
+
18
+ /**
19
+ * Ends the currently running keep-alive headless task.
20
+ *
21
+ * The task's promise is app-provided (`foregroundService.android.taskToRun`) and by default never
22
+ * resolves, so stopping the native service alone would leave the task registered in
23
+ * `HeadlessJsTaskContext` forever - which keeps RN's timer choreographer callback posted for the
24
+ * rest of the process lifetime. Resolving it here lets React Native unwind the task properly.
25
+ */
26
+ export const endKeepCallAliveHeadlessTask = () => {
27
+ resolveRunningTask?.();
28
+ resolveRunningTask = undefined;
29
+ };
30
+
16
31
  function registerKeepCallAliveHeadlessTaskOnce() {
17
32
  if (Platform.OS !== 'android') return;
18
33
 
@@ -42,10 +57,37 @@ function registerKeepCallAliveHeadlessTaskOnce() {
42
57
 
43
58
  const config = StreamVideoRN.getConfig();
44
59
  const taskToRun = config.foregroundService.android.taskToRun;
60
+ // A previous task (if any) must not keep hanging around.
61
+ endKeepCallAliveHeadlessTask();
62
+ const taskEnded = new Promise<void>((resolve) => {
63
+ resolveRunningTask = resolve;
64
+ });
65
+ const ownResolver = resolveRunningTask;
66
+
67
+ // React Native stops the foreground service as soon as the promise returned from here
68
+ // settles. `taskToRun` is app-provided and may resolve early - or immediately - so its
69
+ // completion must not end the keep-alive: that would drop the call's background protection
70
+ // while the call is still ongoing. The task's lifetime is owned by the SDK instead, and ends
71
+ // via endKeepCallAliveHeadlessTask() when the call is left.
72
+ (async () => {
73
+ try {
74
+ await taskToRun(call);
75
+ logger.debug(
76
+ 'Keep-alive taskToRun completed, keeping the call alive until the call ends',
77
+ );
78
+ } catch (e) {
79
+ logger.error('Keep-alive headless task failed', e);
80
+ }
81
+ })();
82
+
45
83
  try {
46
- await taskToRun(call);
47
- } catch (e) {
48
- logger.error('Keep-alive headless task failed', e);
84
+ await taskEnded;
85
+ } finally {
86
+ // a newer task may already have installed its own resolver - clearing it here would leave
87
+ // that task with no way to be ended.
88
+ if (resolveRunningTask === ownResolver) {
89
+ resolveRunningTask = undefined;
90
+ }
49
91
  }
50
92
  },
51
93
  );