@stream-io/video-react-native-sdk 1.40.1 → 1.41.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 (86) hide show
  1. package/CHANGELOG.md +40 -2
  2. package/android/src/main/java/com/streamvideo/reactnative/audio/AudioDeviceManager.kt +45 -16
  3. package/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt +12 -0
  4. package/dist/commonjs/components/Call/CallLayout/CallParticipantsSpotlight.js +2 -0
  5. package/dist/commonjs/components/Call/CallLayout/CallParticipantsSpotlight.js.map +1 -1
  6. package/dist/commonjs/components/Call/CallParticipantsList/CallParticipantsList.js +22 -31
  7. package/dist/commonjs/components/Call/CallParticipantsList/CallParticipantsList.js.map +1 -1
  8. package/dist/commonjs/components/Call/RingingCallContent/IncomingCall.js +8 -5
  9. package/dist/commonjs/components/Call/RingingCallContent/IncomingCall.js.map +1 -1
  10. package/dist/commonjs/components/Participant/ParticipantView/VideoRenderer/TrackSubscriber.js +1 -1
  11. package/dist/commonjs/components/Participant/ParticipantView/VideoRenderer/TrackSubscriber.js.map +1 -1
  12. package/dist/commonjs/components/Participant/ParticipantView/VideoRenderer/index.js +2 -1
  13. package/dist/commonjs/components/Participant/ParticipantView/VideoRenderer/index.js.map +1 -1
  14. package/dist/commonjs/hooks/useAndroidKeepCallAliveEffect.js +36 -4
  15. package/dist/commonjs/hooks/useAndroidKeepCallAliveEffect.js.map +1 -1
  16. package/dist/commonjs/modules/call-manager/CallManager.js +133 -1
  17. package/dist/commonjs/modules/call-manager/CallManager.js.map +1 -1
  18. package/dist/commonjs/utils/internal/callingx/callingx.js +37 -0
  19. package/dist/commonjs/utils/internal/callingx/callingx.js.map +1 -1
  20. package/dist/commonjs/utils/internal/registerSDKGlobals.js +34 -0
  21. package/dist/commonjs/utils/internal/registerSDKGlobals.js.map +1 -1
  22. package/dist/commonjs/utils/push/android.js +101 -103
  23. package/dist/commonjs/utils/push/android.js.map +1 -1
  24. package/dist/commonjs/utils/push/libs/callingx.js +3 -0
  25. package/dist/commonjs/utils/push/libs/callingx.js.map +1 -1
  26. package/dist/commonjs/utils/push/setupCallingExpEvents.js +31 -0
  27. package/dist/commonjs/utils/push/setupCallingExpEvents.js.map +1 -1
  28. package/dist/commonjs/version.js +1 -1
  29. package/dist/module/components/Call/CallLayout/CallParticipantsSpotlight.js +3 -1
  30. package/dist/module/components/Call/CallLayout/CallParticipantsSpotlight.js.map +1 -1
  31. package/dist/module/components/Call/CallParticipantsList/CallParticipantsList.js +23 -32
  32. package/dist/module/components/Call/CallParticipantsList/CallParticipantsList.js.map +1 -1
  33. package/dist/module/components/Call/RingingCallContent/IncomingCall.js +9 -6
  34. package/dist/module/components/Call/RingingCallContent/IncomingCall.js.map +1 -1
  35. package/dist/module/components/Participant/ParticipantView/VideoRenderer/TrackSubscriber.js +2 -2
  36. package/dist/module/components/Participant/ParticipantView/VideoRenderer/TrackSubscriber.js.map +1 -1
  37. package/dist/module/components/Participant/ParticipantView/VideoRenderer/index.js +2 -1
  38. package/dist/module/components/Participant/ParticipantView/VideoRenderer/index.js.map +1 -1
  39. package/dist/module/hooks/useAndroidKeepCallAliveEffect.js +36 -4
  40. package/dist/module/hooks/useAndroidKeepCallAliveEffect.js.map +1 -1
  41. package/dist/module/modules/call-manager/CallManager.js +133 -1
  42. package/dist/module/modules/call-manager/CallManager.js.map +1 -1
  43. package/dist/module/utils/internal/callingx/callingx.js +38 -1
  44. package/dist/module/utils/internal/callingx/callingx.js.map +1 -1
  45. package/dist/module/utils/internal/registerSDKGlobals.js +34 -0
  46. package/dist/module/utils/internal/registerSDKGlobals.js.map +1 -1
  47. package/dist/module/utils/push/android.js +101 -103
  48. package/dist/module/utils/push/android.js.map +1 -1
  49. package/dist/module/utils/push/libs/callingx.js +3 -0
  50. package/dist/module/utils/push/libs/callingx.js.map +1 -1
  51. package/dist/module/utils/push/setupCallingExpEvents.js +32 -1
  52. package/dist/module/utils/push/setupCallingExpEvents.js.map +1 -1
  53. package/dist/module/version.js +1 -1
  54. package/dist/typescript/components/Call/CallLayout/CallParticipantsSpotlight.d.ts.map +1 -1
  55. package/dist/typescript/components/Call/CallParticipantsList/CallParticipantsList.d.ts.map +1 -1
  56. package/dist/typescript/components/Call/RingingCallContent/IncomingCall.d.ts.map +1 -1
  57. package/dist/typescript/components/Participant/ParticipantView/VideoRenderer/TrackSubscriber.d.ts.map +1 -1
  58. package/dist/typescript/components/Participant/ParticipantView/VideoRenderer/index.d.ts.map +1 -1
  59. package/dist/typescript/hooks/useAndroidKeepCallAliveEffect.d.ts.map +1 -1
  60. package/dist/typescript/modules/call-manager/CallManager.d.ts.map +1 -1
  61. package/dist/typescript/utils/StreamVideoRN/types.d.ts +5 -0
  62. package/dist/typescript/utils/StreamVideoRN/types.d.ts.map +1 -1
  63. package/dist/typescript/utils/internal/callingx/callingx.d.ts.map +1 -1
  64. package/dist/typescript/utils/internal/registerSDKGlobals.d.ts.map +1 -1
  65. package/dist/typescript/utils/push/android.d.ts.map +1 -1
  66. package/dist/typescript/utils/push/libs/callingx.d.ts.map +1 -1
  67. package/dist/typescript/utils/push/setupCallingExpEvents.d.ts.map +1 -1
  68. package/dist/typescript/version.d.ts +1 -1
  69. package/expo-config-plugin/dist/index.js +0 -2
  70. package/package.json +7 -11
  71. package/src/components/Call/CallLayout/CallParticipantsSpotlight.tsx +6 -1
  72. package/src/components/Call/CallParticipantsList/CallParticipantsList.tsx +63 -62
  73. package/src/components/Call/RingingCallContent/IncomingCall.tsx +10 -12
  74. package/src/components/Participant/ParticipantView/VideoRenderer/TrackSubscriber.tsx +3 -2
  75. package/src/components/Participant/ParticipantView/VideoRenderer/index.tsx +4 -1
  76. package/src/hooks/useAndroidKeepCallAliveEffect.ts +54 -10
  77. package/src/modules/call-manager/CallManager.ts +162 -1
  78. package/src/modules/call-manager/native-module.d.ts +8 -0
  79. package/src/utils/StreamVideoRN/types.ts +5 -0
  80. package/src/utils/internal/callingx/callingx.ts +44 -1
  81. package/src/utils/internal/registerSDKGlobals.ts +38 -0
  82. package/src/utils/push/android.ts +141 -140
  83. package/src/utils/push/libs/callingx.ts +4 -0
  84. package/src/utils/push/setupCallingExpEvents.ts +41 -1
  85. package/src/version.ts +1 -1
  86. package/expo-config-plugin/dist/withBuildProperties.js +0 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-react-native-sdk",
3
- "version": "1.40.1",
3
+ "version": "1.41.0",
4
4
  "description": "Stream Video SDK for React Native",
5
5
  "author": "https://getstream.io",
6
6
  "homepage": "https://getstream.io/video/docs/react-native/",
@@ -17,6 +17,7 @@
17
17
  "build": "yarn copy-version && bob build && yarn build:expo-plugin",
18
18
  "test:expo-plugin": "jest --config=expo-config-plugin/jest.config.js --coverage",
19
19
  "test": "yarn copy-version && jest --coverage && yarn test:expo-plugin",
20
+ "test:types": "yarn copy-version && tsc -p tsconfig.spec.json",
20
21
  "copy-version": "echo \"export const version = '$npm_package_version';\" > ./src/version.ts"
21
22
  },
22
23
  "repository": {
@@ -51,8 +52,8 @@
51
52
  "!**/.*"
52
53
  ],
53
54
  "dependencies": {
54
- "@stream-io/video-client": "1.55.1",
55
- "@stream-io/video-react-bindings": "1.18.1",
55
+ "@stream-io/video-client": "1.55.2",
56
+ "@stream-io/video-react-bindings": "1.18.3",
56
57
  "intl-pluralrules": "2.0.1",
57
58
  "react-native-url-polyfill": "^3.0.0",
58
59
  "rxjs": "~7.8.2",
@@ -67,7 +68,6 @@
67
68
  "@stream-io/react-native-webrtc": "^145.0.0",
68
69
  "@stream-io/video-filters-react-native": ">=0.1.0",
69
70
  "expo": ">=47.0.0",
70
- "expo-build-properties": "*",
71
71
  "expo-notifications": "*",
72
72
  "react": ">=17.0.0",
73
73
  "react-native": ">=0.73.0",
@@ -94,9 +94,6 @@
94
94
  "expo": {
95
95
  "optional": true
96
96
  },
97
- "expo-build-properties": {
98
- "optional": true
99
- },
100
97
  "expo-notifications": {
101
98
  "optional": true
102
99
  },
@@ -117,11 +114,11 @@
117
114
  "@react-native-firebase/messaging": "^24.0.0",
118
115
  "@react-native/babel-preset": "0.85.3",
119
116
  "@react-native/metro-config": "0.85.3",
120
- "@stream-io/noise-cancellation-react-native": "^0.9.1",
121
- "@stream-io/react-native-callingx": "^0.6.1",
117
+ "@stream-io/noise-cancellation-react-native": "^0.9.3",
118
+ "@stream-io/react-native-callingx": "^0.7.0",
122
119
  "@stream-io/react-native-webrtc": "145.0.0",
123
120
  "@stream-io/typescript-config": "^0.0.0",
124
- "@stream-io/video-filters-react-native": "^0.14.1",
121
+ "@stream-io/video-filters-react-native": "^0.14.3",
125
122
  "@testing-library/jest-native": "^5.4.3",
126
123
  "@testing-library/react-native": "13.3.3",
127
124
  "@tsconfig/node18": "^18.2.6",
@@ -130,7 +127,6 @@
130
127
  "@types/react": "~19.2.15",
131
128
  "@types/react-test-renderer": "^19.1.0",
132
129
  "expo": "~56.0.9",
133
- "expo-build-properties": "~56.0.17",
134
130
  "expo-module-scripts": "^56.0.3",
135
131
  "expo-notifications": "~56.0.16",
136
132
  "jest": "^29.7.0",
@@ -3,7 +3,10 @@ import {
3
3
  hasScreenShare,
4
4
  type StreamVideoParticipant,
5
5
  } from '@stream-io/video-client';
6
- import { useCall } from '@stream-io/video-react-bindings';
6
+ import {
7
+ useCall,
8
+ useSpeakerLayoutSortPreset,
9
+ } from '@stream-io/video-react-bindings';
7
10
  import { StyleSheet, View } from 'react-native';
8
11
  import { debounceTime } from 'rxjs';
9
12
  import { ComponentTestIds } from '../../../constants/TestIds';
@@ -70,6 +73,8 @@ export const CallParticipantsSpotlight = ({
70
73
  .subscribe(setAllParticipants);
71
74
  return () => sub.unsubscribe();
72
75
  }, [call]);
76
+ const isOneOnOneCall = allParticipants.length === 2;
77
+ useSpeakerLayoutSortPreset(call, isOneOnOneCall);
73
78
  const [participantInSpotlight, ...otherParticipants] = allParticipants;
74
79
  const isScreenShareOnSpotlight =
75
80
  participantInSpotlight && hasScreenShare(participantInSpotlight);
@@ -2,6 +2,7 @@ import React, {
2
2
  useCallback,
3
3
  useEffect,
4
4
  useMemo,
5
+ useReducer,
5
6
  useRef,
6
7
  useState,
7
8
  } from 'react';
@@ -17,7 +18,6 @@ import {
17
18
  type StreamVideoParticipantPatches,
18
19
  VisibilityState,
19
20
  } from '@stream-io/video-client';
20
- import { Subject, debounceTime } from 'rxjs';
21
21
  import { useCall } from '@stream-io/video-react-bindings';
22
22
  import { ComponentTestIds } from '../../../constants/TestIds';
23
23
  import {
@@ -109,19 +109,15 @@ export const CallParticipantsList = ({
109
109
  if (!viewableParticipantSessionIds.current) {
110
110
  viewableParticipantSessionIds.current = new Set();
111
111
  }
112
- const forceUpdate$Ref = useRef<Subject<void>>(null!);
113
- if (!forceUpdate$Ref.current) {
114
- forceUpdate$Ref.current = new Subject<void>();
115
- }
116
- const forceUpdate$ = forceUpdate$Ref.current;
117
- const [forceUpdateValue, setForceUpdateValue] = useState(0);
118
- useEffect(() => {
119
- const sub = forceUpdate$.pipe(debounceTime(500)).subscribe(() => {
120
- setForceUpdateValue((v) => v + 1);
121
- });
122
- return () => sub.unsubscribe();
123
- }, [forceUpdate$]);
124
- const forceUpdate = useCallback(() => forceUpdate$.next(), [forceUpdate$]);
112
+ // Debounced force re-render: coalesces rapid viewability changes into a single
113
+ // re-render 500ms after the last change (drives the FlatList `extraData` below).
114
+ const [forceUpdateValue, forceRender] = useReducer((v: number) => v + 1, 0);
115
+ const forceUpdateTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
116
+ const forceUpdate = useCallback(() => {
117
+ clearTimeout(forceUpdateTimeout.current);
118
+ forceUpdateTimeout.current = setTimeout(forceRender, 500);
119
+ }, []);
120
+ useEffect(() => () => clearTimeout(forceUpdateTimeout.current), []);
125
121
 
126
122
  // we use a ref to store the active call object
127
123
  // so that it can be used in the onViewableItemsChanged callback
@@ -131,59 +127,64 @@ export const CallParticipantsList = ({
131
127
  // This is the function that gets called when the user scrolls the list of participants.
132
128
  // It updates viewableParticipantSessionIds HashSet with the session IDs
133
129
  // of the participants that are currently visible.
134
- const onViewableItemsChanged = useRef<
135
- FlatListProps['onViewableItemsChanged']
136
- >(({ viewableItems }) => {
137
- const participantPatches: StreamVideoParticipantPatches = {};
138
- let mustUpdate = false;
139
- const newVisibleParticipantSessionIds = new Set<string>(
140
- viewableItems.map((v) => v.key),
141
- );
142
- const oldVisibleParticipantSessionIds =
143
- viewableParticipantSessionIds.current;
144
- newVisibleParticipantSessionIds.forEach((key) => {
145
- if (!oldVisibleParticipantSessionIds.has(key)) {
146
- mustUpdate = true;
147
- participantPatches[key] = {
148
- viewportVisibilityState: {
149
- videoTrack: VisibilityState.VISIBLE,
150
- screenShareTrack: VisibilityState.UNKNOWN,
151
- },
152
- };
153
- }
154
- });
155
- oldVisibleParticipantSessionIds.forEach((key) => {
156
- if (!newVisibleParticipantSessionIds.has(key)) {
157
- mustUpdate = true;
158
- participantPatches[key] = {
159
- viewportVisibilityState: {
160
- videoTrack: VisibilityState.VISIBLE,
161
- screenShareTrack: VisibilityState.UNKNOWN,
162
- },
163
- };
130
+ const onViewableItemsChanged = useCallback<
131
+ NonNullable<FlatListProps['onViewableItemsChanged']>
132
+ >(
133
+ ({ viewableItems }) => {
134
+ const participantPatches: StreamVideoParticipantPatches = {};
135
+ let mustUpdate = false;
136
+ const newVisibleParticipantSessionIds = new Set<string>(
137
+ viewableItems.map((v) => v.key),
138
+ );
139
+ const oldVisibleParticipantSessionIds =
140
+ viewableParticipantSessionIds.current;
141
+ const visibilityPatch = (
142
+ videoTrack: VisibilityState,
143
+ ): StreamVideoParticipantPatches[string] => ({
144
+ viewportVisibilityState: {
145
+ videoTrack,
146
+ screenShareTrack: VisibilityState.UNKNOWN,
147
+ },
148
+ });
149
+ newVisibleParticipantSessionIds.forEach((key) => {
150
+ if (!oldVisibleParticipantSessionIds.has(key)) {
151
+ mustUpdate = true;
152
+ participantPatches[key] = visibilityPatch(VisibilityState.VISIBLE);
153
+ }
154
+ });
155
+ oldVisibleParticipantSessionIds.forEach((key) => {
156
+ if (!newVisibleParticipantSessionIds.has(key)) {
157
+ mustUpdate = true;
158
+ participantPatches[key] = visibilityPatch(VisibilityState.INVISIBLE);
159
+ }
160
+ });
161
+ viewableParticipantSessionIds.current = newVisibleParticipantSessionIds;
162
+ if (mustUpdate) {
163
+ activeCallRef.current?.state.updateParticipants(participantPatches);
164
+ forceUpdate();
164
165
  }
165
- });
166
- viewableParticipantSessionIds.current = newVisibleParticipantSessionIds;
167
- if (mustUpdate) {
168
- activeCallRef.current?.state.updateParticipants(participantPatches);
169
- forceUpdate();
170
- }
171
- }).current;
166
+ },
167
+ [forceUpdate],
168
+ );
172
169
 
173
170
  // NOTE: key must be sessionId always as it is used to track viewable participants
174
- const keyExtractor = useRef<NonNullable<FlatListProps['keyExtractor']>>(
171
+ const keyExtractor = useCallback<NonNullable<FlatListProps['keyExtractor']>>(
175
172
  (item) => item.sessionId,
176
- ).current;
173
+ [],
174
+ );
177
175
 
178
- const onLayout = useRef<NonNullable<FlatListProps['onLayout']>>((event) => {
179
- const { height, width } = event.nativeEvent.layout;
180
- setContainerLayout((prev) => {
181
- if (prev.height === height && prev.width === width) {
182
- return prev;
183
- }
184
- return { height, width };
185
- });
186
- }).current;
176
+ const onLayout = useCallback<NonNullable<FlatListProps['onLayout']>>(
177
+ (event) => {
178
+ const { height, width } = event.nativeEvent.layout;
179
+ setContainerLayout((prev) => {
180
+ if (prev.height === height && prev.width === width) {
181
+ return prev;
182
+ }
183
+ return { height, width };
184
+ });
185
+ },
186
+ [],
187
+ );
187
188
 
188
189
  const { itemHeight, itemWidth } = calculateParticipantViewSize({
189
190
  containerHeight: containerLayout.height,
@@ -1,11 +1,5 @@
1
1
  import React from 'react';
2
- import {
3
- ImageBackground,
4
- StyleSheet,
5
- Text,
6
- View,
7
- type ViewStyle,
8
- } from 'react-native';
2
+ import { Image, StyleSheet, Text, View, type ViewStyle } from 'react-native';
9
3
  import {
10
4
  useCallStateHooks,
11
5
  useConnectedUser,
@@ -120,18 +114,22 @@ const Background: React.FunctionComponent<{
120
114
 
121
115
  if (avatarsToShow.length) {
122
116
  return (
123
- <ImageBackground
124
- source={{
125
- uri: avatarsToShow[0],
126
- }}
117
+ <View
127
118
  style={[
128
119
  styles.background,
129
120
  { backgroundColor: colors.sheetTertiary },
130
121
  incomingCall.background,
131
122
  ]}
132
123
  >
124
+ <Image
125
+ source={{
126
+ uri: avatarsToShow[0],
127
+ }}
128
+ resizeMode="cover"
129
+ style={StyleSheet.absoluteFill}
130
+ />
133
131
  {children}
134
- </ImageBackground>
132
+ </View>
135
133
  );
136
134
  }
137
135
  return (
@@ -7,6 +7,7 @@ import {
7
7
  hasScreenShare,
8
8
  hasVideo,
9
9
  SfuModels,
10
+ type StreamVideoParticipant,
10
11
  type VideoTrackType,
11
12
  } from '@stream-io/video-client';
12
13
  import {
@@ -14,8 +15,8 @@ import {
14
15
  combineLatest,
15
16
  distinctUntilChanged,
16
17
  distinctUntilKeyChanged,
18
+ filter,
17
19
  map,
18
- takeWhile,
19
20
  } from 'rxjs';
20
21
  export type TrackSubscriberHandle = {
21
22
  onLayoutUpdate: (event: LayoutChangeEvent) => void;
@@ -67,7 +68,7 @@ const TrackSubscriber = forwardRef<TrackSubscriberHandle, TrackSubscriberProps>(
67
68
  };
68
69
  const isPublishingTrack$ = call.state.participants$.pipe(
69
70
  map((ps) => ps.find((p) => p.sessionId === participantSessionId)),
70
- takeWhile((p) => !!p),
71
+ filter((p): p is StreamVideoParticipant => !!p),
71
72
  distinctUntilKeyChanged('publishedTracks'),
72
73
  map((p) =>
73
74
  trackType === 'videoTrack' ? hasVideo(p) : hasScreenShare(p),
@@ -90,8 +90,11 @@ export const VideoRenderer = React.memo(
90
90
  ? screenShareStream
91
91
  : videoStream) as unknown as MediaStream | undefined;
92
92
 
93
+ const hasVideoTrackInStream =
94
+ !!videoStreamToRender && videoStreamToRender.getVideoTracks().length > 0;
95
+
93
96
  const canShowVideo =
94
- !!videoStreamToRender &&
97
+ hasVideoTrackInStream &&
95
98
  isVisible &&
96
99
  isPublishingVideoTrack &&
97
100
  !hasPausedTrack(participant, trackType) &&
@@ -82,6 +82,7 @@ async function startForegroundService(call_cid: string) {
82
82
  */
83
83
  export const useAndroidKeepCallAliveEffect = () => {
84
84
  const foregroundServiceStartedRef = useRef(false);
85
+ const callingxKeepAliveOwnerRef = useRef<string | undefined>(undefined);
85
86
 
86
87
  const call = useCall();
87
88
  keepCallAliveCallRef.current = call;
@@ -101,15 +102,46 @@ export const useAndroidKeepCallAliveEffect = () => {
101
102
  }
102
103
 
103
104
  const callingx = getCallingxLibIfAvailable();
104
- if (
105
- callingx?.isSetup &&
106
- (isRingingCall || (!isRingingCall && callingx?.isOngoingCallsEnabled))
107
- ) {
105
+ const isCallingxManaged =
106
+ !!callingx?.isSetup &&
107
+ (isRingingCall || (!isRingingCall && callingx?.isOngoingCallsEnabled));
108
+
109
+ const isCallEnded =
110
+ callingState === CallingState.IDLE || callingState === CallingState.LEFT;
111
+
112
+ // Release the callingx keep-alive task when the call ends.
113
+ if (callingxKeepAliveOwnerRef.current && isCallEnded) {
114
+ const currentOwner = callingxKeepAliveOwnerRef.current;
115
+ callingxKeepAliveOwnerRef.current = undefined;
116
+ callingx?.releaseBackgroundTask(currentOwner).catch(() => {});
117
+ return undefined;
118
+ }
119
+
120
+ if (isCallingxManaged && callingx) {
121
+ // Mutual exclusion: never acquire the callingx task while the SDK's own keep-alive FGS is
122
+ // running — only one keep-alive mechanism should be active per call.
123
+ if (
124
+ !callingxKeepAliveOwnerRef.current &&
125
+ !isCallEnded &&
126
+ !foregroundServiceStartedRef.current
127
+ ) {
128
+ const owner = `keepalive:${activeCallCid}`;
129
+ callingxKeepAliveOwnerRef.current = owner;
130
+ callingx.acquireBackgroundTask(owner).catch((e) => {
131
+ if (callingxKeepAliveOwnerRef.current === owner) {
132
+ callingxKeepAliveOwnerRef.current = undefined;
133
+ }
134
+ videoLoggerSystem
135
+ .getLogger('useAndroidKeepCallAliveEffect')
136
+ .warn('Failed to acquire callingx keep-alive background task', e);
137
+ });
138
+ }
108
139
  return undefined;
109
140
  }
110
141
 
111
- // start foreground service as soon as the call is joined
112
- if (shouldStartForegroundService) {
142
+ // Start keep-alive FGS as soon as the call is joined — but only if the callingx
143
+ // keep-alive task isn't already holding the call alive.
144
+ if (shouldStartForegroundService && !callingxKeepAliveOwnerRef.current) {
113
145
  const run = async () => {
114
146
  if (foregroundServiceStartedRef.current) {
115
147
  return;
@@ -135,10 +167,7 @@ export const useAndroidKeepCallAliveEffect = () => {
135
167
  return () => {
136
168
  sub.remove();
137
169
  };
138
- } else if (
139
- callingState === CallingState.IDLE ||
140
- callingState === CallingState.LEFT
141
- ) {
170
+ } else if (isCallEnded) {
142
171
  if (foregroundServiceStartedRef.current) {
143
172
  keepCallAliveCallRef.current = undefined;
144
173
  // stop foreground service when the call is not active
@@ -162,6 +191,21 @@ export const useAndroidKeepCallAliveEffect = () => {
162
191
  stopForegroundServiceNoThrow();
163
192
  foregroundServiceStartedRef.current = false;
164
193
  }
194
+ // release the callingx keep-alive task if still held
195
+ if (callingxKeepAliveOwnerRef.current) {
196
+ const owner = callingxKeepAliveOwnerRef.current;
197
+ callingxKeepAliveOwnerRef.current = undefined;
198
+ getCallingxLibIfAvailable()
199
+ ?.releaseBackgroundTask(owner)
200
+ .catch(() => {
201
+ videoLoggerSystem
202
+ .getLogger('useAndroidKeepCallAliveEffect')
203
+ .warn(
204
+ 'Failed to release callingx keep-alive background task',
205
+ owner,
206
+ );
207
+ });
208
+ }
165
209
  };
166
210
  }, []);
167
211
  };
@@ -4,6 +4,10 @@ import type {
4
4
  IOSAudioInterruptionEvent,
5
5
  StreamInCallManagerConfig,
6
6
  } from './types';
7
+ import type {
8
+ AudioEndpoint as CallingxAudioEndpoint,
9
+ AudioEndpointsSnapshot as CallingxAudioSnapshot,
10
+ } from '@stream-io/react-native-callingx';
7
11
  import { getCallingxLibIfAvailable } from '../../utils/push/libs/callingx';
8
12
  import { videoLoggerSystem } from '@stream-io/video-client';
9
13
 
@@ -15,6 +19,69 @@ const invariant = (condition: boolean, message: string) => {
15
19
  if (!condition) throw new Error(message);
16
20
  };
17
21
 
22
+ /**
23
+ * On Android, whether the current call is managed by Telecom (via callingx).
24
+ * In that mode, audio routing/mode is owned by Telecom and StreamInCallManager audio methods should not be used
25
+ */
26
+ const isAndroidTelecomManaged = (): boolean => {
27
+ if (Platform.OS !== 'android' || !CallingxModule) {
28
+ return false;
29
+ }
30
+ return (
31
+ CallingxModule.isSetup &&
32
+ CallingxModule.isTelecomBacked &&
33
+ (CallingxModule.hasRegisteredCall() || CallingxModule.isOngoingCallsEnabled)
34
+ );
35
+ };
36
+
37
+ /**
38
+ * When the current call is Telecom-managed, returns the callingx module and the
39
+ * registered callId. Centralizes the guard so call sites don't repeat the module /
40
+ * callId checks — `isAndroidTelecomManaged()` already implies the module exists, but
41
+ * the extra narrowing here is what makes that provable to the type-checker.
42
+ */
43
+ const getTelecomContext = ():
44
+ | { cx: NonNullable<typeof CallingxModule>; callId: string }
45
+ | undefined => {
46
+ if (!isAndroidTelecomManaged() || !CallingxModule) {
47
+ return undefined;
48
+ }
49
+ const callId = CallingxModule.getRegisteredCallIds()[0];
50
+ if (!callId) {
51
+ return undefined;
52
+ }
53
+ return { cx: CallingxModule, callId };
54
+ };
55
+
56
+ /** Map a generic Telecom endpoint type to the SDK's endpoint display name. */
57
+ const endpointTypeToDisplayName = (
58
+ type: string,
59
+ ): AudioDeviceStatus['currentEndpointType'] => {
60
+ switch (type) {
61
+ case 'earpiece':
62
+ return 'Earpiece';
63
+ case 'speaker':
64
+ return 'Speaker';
65
+ case 'wired_headset':
66
+ return 'Wired Headset';
67
+ case 'bluetooth':
68
+ return 'Bluetooth Device';
69
+ default:
70
+ return 'Unknown';
71
+ }
72
+ };
73
+
74
+ /** Adapt a callingx endpoints snapshot to the SDK's {@link AudioDeviceStatus}. */
75
+ const snapshotToStatus = (
76
+ snapshot: CallingxAudioSnapshot,
77
+ ): AudioDeviceStatus => ({
78
+ devices: snapshot.endpoints.map((e) => e.name),
79
+ currentEndpointType: snapshot.currentEndpoint
80
+ ? endpointTypeToDisplayName(snapshot.currentEndpoint.type)
81
+ : 'Unknown',
82
+ selectedDevice: snapshot.currentEndpoint?.name ?? '',
83
+ });
84
+
18
85
  class AndroidCallManager {
19
86
  private eventEmitter?: NativeEventEmitter;
20
87
 
@@ -23,6 +90,11 @@ class AndroidCallManager {
23
90
  */
24
91
  getAudioDeviceStatus = async (): Promise<AudioDeviceStatus> => {
25
92
  invariant(Platform.OS === 'android', 'Supported only on Android');
93
+ const tc = getTelecomContext();
94
+ if (tc) {
95
+ const snapshot = await tc.cx.getAvailableAudioEndpoints(tc.callId);
96
+ return snapshotToStatus(snapshot);
97
+ }
26
98
  return NativeManager.getAudioDeviceStatus();
27
99
  };
28
100
 
@@ -33,6 +105,30 @@ class AndroidCallManager {
33
105
  */
34
106
  selectAudioDevice = (endpointName: string): void => {
35
107
  invariant(Platform.OS === 'android', 'Supported only on Android');
108
+ const tc = getTelecomContext();
109
+ if (tc) {
110
+ 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,
116
+ );
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
+ });
130
+ return;
131
+ }
36
132
  NativeManager.chooseAudioDeviceEndpoint(endpointName);
37
133
  };
38
134
 
@@ -44,9 +140,26 @@ class AndroidCallManager {
44
140
  onChange: (audioDeviceStatus: AudioDeviceStatus) => void,
45
141
  ): (() => void) => {
46
142
  invariant(Platform.OS === 'android', 'Supported only on Android');
143
+ const cleanups: Array<() => void> = [];
144
+
145
+ // Telecom (callingx) route changes.
146
+ if (CallingxModule?.isSetup && CallingxModule.isTelecomBacked) {
147
+ const sub = CallingxModule.addEventListener(
148
+ 'didChangeAudioEndpoints',
149
+ (params) => onChange(snapshotToStatus(params)),
150
+ );
151
+ cleanups.push(() => sub.remove());
152
+ }
153
+
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.
47
158
  this.eventEmitter ??= new NativeEventEmitter(NativeManager);
48
159
  const s = this.eventEmitter.addListener('onAudioDeviceChanged', onChange);
49
- return () => s.remove();
160
+ cleanups.push(() => s.remove());
161
+
162
+ return () => cleanups.forEach((cleanup) => cleanup());
50
163
  };
51
164
  }
52
165
 
@@ -95,6 +208,38 @@ class SpeakerManager {
95
208
  * Forces speakerphone on/off.
96
209
  */
97
210
  setForceSpeakerphoneOn = (force: boolean): void => {
211
+ const tc = getTelecomContext();
212
+ if (tc) {
213
+ const { cx, callId } = tc;
214
+ // Telecom owns routing: map on -> speaker endpoint, off -> highest-priority
215
+ // non-speaker endpoint (wired > bluetooth > earpiece), mirroring classic behavior.
216
+ cx.getAvailableAudioEndpoints(callId)
217
+ .then((snapshot) => {
218
+ let target: CallingxAudioEndpoint | undefined;
219
+ if (force) {
220
+ target = snapshot.endpoints.find((e) => e.type === 'speaker');
221
+ } else {
222
+ // Priority for the "speakerphone off" fallback: prefer wired, then bluetooth, then earpiece.
223
+ for (const type of ['wired_headset', 'bluetooth', 'earpiece']) {
224
+ target = snapshot.endpoints.find((e) => e.type === type);
225
+ if (target) break;
226
+ }
227
+ }
228
+ if (target) {
229
+ return cx.requestAudioEndpointChange(callId, target.id);
230
+ }
231
+ return undefined;
232
+ })
233
+ .catch((error) => {
234
+ videoLoggerSystem
235
+ .getLogger('CallManager')
236
+ .warn(
237
+ `setForceSpeakerphoneOn(${force}): failed to route for call ${callId} via Telecom`,
238
+ error,
239
+ );
240
+ });
241
+ return;
242
+ }
98
243
  NativeManager.setForceSpeakerphoneOn(force);
99
244
  };
100
245
  }
@@ -149,6 +294,22 @@ export class CallManager {
149
294
  );
150
295
  return;
151
296
  }
297
+ if (isAndroidTelecomManaged()) {
298
+ // Telecom owns routing/focus; forward the sticky preference to callingx and run in
299
+ // telecom-managed mode (StreamInCallManager keeps proximity/keep-screen-on only).
300
+ if (config?.audioRole !== 'listener' && CallingxModule) {
301
+ CallingxModule.setDefaultAudioDeviceEndpointType(
302
+ config?.deviceEndpointType ?? 'speaker',
303
+ );
304
+ }
305
+ NativeManager.setTelecomManagedMode(true);
306
+ NativeManager.setAudioRole(config?.audioRole ?? 'communicator');
307
+ NativeManager.start();
308
+ return;
309
+ }
310
+ if (Platform.OS === 'android') {
311
+ NativeManager.setTelecomManagedMode(false);
312
+ }
152
313
  NativeManager.setAudioRole(config?.audioRole ?? 'communicator');
153
314
  if (config?.audioRole === 'communicator') {
154
315
  const type = config.deviceEndpointType ?? 'speaker';
@@ -24,6 +24,14 @@ export interface CallManager extends NativeModule {
24
24
  */
25
25
  setDefaultAudioDeviceEndpointType: (type: DeviceEndpointType) => void;
26
26
 
27
+ /**
28
+ * Enables/disables Telecom-managed mode (Android). Must be set before **start()**.
29
+ * When enabled, audio focus, mode and device routing are owned by the Android
30
+ * Telecom stack (via callingx); StreamInCallManager only keeps proximity,
31
+ * keep-screen-on and mic/output mute.
32
+ */
33
+ setTelecomManagedMode: (enabled: boolean) => void;
34
+
27
35
  /**
28
36
  * Choose an audio device endpoint.
29
37
  * @param endpointName - The name of the audio device endpoint to choose.
@@ -133,6 +133,11 @@ export type StreamVideoConfig = {
133
133
  * @default false
134
134
  */
135
135
  skipIncomingPushInForeground?: boolean;
136
+ /**
137
+ * Default audio endpoint for Telecom-managed calls on Android.
138
+ * @default 'speaker'
139
+ */
140
+ defaultDeviceEndpointType?: 'speaker' | 'earpiece';
136
141
  };
137
142
  /**
138
143
  * Whether to reject calls when the user is busy.