Skip to content

Commit d579acd

Browse files
greenfrvrsanthoshvaioliverlazjdimovskaclaude
authored
feat: callkit/telecom integration (#2028)
### 💡 Overview Current pull request provides implementation of CallKit/Android Telecom functionality which includes the following: * Improved integration allows to register both **incoming** and **regular ongoing calls** in CallKit/Telecom * Unified system notification in calling style for Android * Synchronization of stream call mute state and corresponding registered call in CallKit/Telecom * Android keep call alive functionality within internal background task implementation, instead of using `notifee` implementation * Improved reject call when busy implementation * Improved Android notification handling - native Firebase notifications intercepter was added, which allows to handle stream incoming call notifications on native side. * Optimistic state update for Android notifications As part of the PR following dependencies became redundant: * `react-native-voip-push-notification` * `react-native-callkeep` Now neither ringing flow nor call alive functionality don't depend on `@notifee/react-native` which will allow to get rid of that dependency in near future. ### 📝 Implementation notes 🎫 Ticket: https://linear.app/stream/issue/RN-17/android-support-for-telecom-manager 📑 Docs: GetStream/docs-content#881 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * New native calling package with CallKit/Telecom integration, VoIP support, and background/headless task handling. * Android keep-alive foreground service to maintain calls. * Runtime audio-state introspection for easier debugging. * **Improvements** * Simplified push/ringing configuration and unified VOIP event flow. * Better audio session handling with optional CallKit bypass on iOS. * Streamlined notification/channel management and resources. * **Chores** * Replaced legacy callkeep/voip integrations with the new calling package. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Santhosh Vaiyapuri <santhoshvai@gmail.com> Co-authored-by: Santhosh Vaiyapuri <3846977+santhoshvai@users.noreply.github.com> Co-authored-by: Oliver Lazoroski <oliver.lazoroski@gmail.com> Co-authored-by: jdimovska <jona.dimovska@hotmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 152ae90 commit d579acd

163 files changed

Lines changed: 14916 additions & 4147 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/actions/rn-bootstrap/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ runs:
3333
3434
- uses: ruby/setup-ruby@v1
3535
with:
36-
ruby-version: 3.1
36+
ruby-version: 3.4
3737
working-directory: sample-apps/react-native/dogfood
3838
bundler-cache: true
3939

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"start:react-native:ios:dogfood": "yarn workspace @stream-io/video-react-native-dogfood run ios",
2424
"start:react-native:android:dogfood": "yarn workspace @stream-io/video-react-native-dogfood run android",
2525
"build:styling": "yarn workspace @stream-io/video-styling run build",
26+
"build:react-native-callingx": "yarn workspace @stream-io/react-native-callingx run build",
2627
"start:styling": "yarn workspace @stream-io/video-styling run start",
2728
"build:client": "yarn workspace @stream-io/video-client run build",
2829
"start:client": "yarn workspace @stream-io/video-client run start",
@@ -31,7 +32,7 @@
3132
"build:video-filters-react-native": "yarn workspace @stream-io/video-filters-react-native run build",
3233
"build:noise-cancellation-react-native": "yarn workspace @stream-io/noise-cancellation-react-native run build",
3334
"build:react:deps": "yarn workspaces foreach -Apv --topological-dev --include 'packages/{client,react-{sdk,bindings},styling,{video,audio}-filters-web}' run build",
34-
"build:react-native:deps": "yarn workspaces foreach -Apv --topological-dev --include 'packages/{client,react-bindings,audio-filters-web,{video-filters,noise-cancellation}-react-native,react-native-sdk}' run build",
35+
"build:react-native:deps": "yarn workspaces foreach -Apv --topological-dev --include 'packages/{client,react-bindings,audio-filters-web,{video-filters,noise-cancellation}-react-native,react-native-sdk,react-native-callingx}' run build",
3536
"build:vercel": "yarn build:react:deps && yarn build:react:dogfood",
3637
"start:egress": "yarn workspace @stream-io/egress-composite start",
3738
"build:egress": "yarn workspace @stream-io/egress-composite build",
@@ -58,10 +59,12 @@
5859
"release:react-bindings": "yarn workspace @stream-io/video-react-bindings npm publish --access=public --tag=latest",
5960
"release:react-sdk": "yarn workspace @stream-io/video-react-sdk npm publish --access=public --tag=latest",
6061
"release:react-native-sdk": "yarn workspace @stream-io/video-react-native-sdk npm publish --access=public --tag=latest",
62+
"release:react-native-sdk:beta": "node scripts/release-rn-sdk-beta.mjs",
6163
"release:audio-filters-web": "yarn workspace @stream-io/audio-filters-web npm publish --access=public --tag=latest",
6264
"release:video-filters-web": "yarn workspace @stream-io/video-filters-web npm publish --access=public --tag=latest",
6365
"release:video-filters-react-native": "yarn workspace @stream-io/video-filters-react-native npm publish --access=public --tag=latest",
6466
"release:noise-cancellation-react-native": "yarn workspace @stream-io/noise-cancellation-react-native npm publish --access=public --tag=latest",
67+
"release:react-native-callingx": "yarn workspace @stream-io/react-native-callingx npm publish --access=public --tag=latest",
6568
"release:styling": "yarn workspace @stream-io/video-styling npm publish --access=public --tag=latest",
6669
"postinstall": "husky"
6770
},

packages/client/src/Call.ts

Lines changed: 85 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,7 @@ export class Call {
421421
const currentUserId = this.currentUserId;
422422
if (currentUserId && blockedUserIds.includes(currentUserId)) {
423423
this.logger.info('Leaving call because of being blocked');
424+
globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'restricted');
424425
await this.leave({ message: 'user blocked' }).catch((err) => {
425426
this.logger.error('Error leaving call after being blocked', err);
426427
});
@@ -465,6 +466,10 @@ export class Call {
465466
(isAcceptedElsewhere || isRejectedByMe) &&
466467
!hasPending(this.joinLeaveConcurrencyTag)
467468
) {
469+
globalThis.streamRNVideoSDK?.callingX?.endCall(
470+
this,
471+
isAcceptedElsewhere ? 'answeredElsewhere' : 'rejected',
472+
);
468473
this.leave().catch(() => {
469474
this.logger.error(
470475
'Could not leave a call that was accepted or rejected elsewhere',
@@ -480,6 +485,10 @@ export class Call {
480485
const receiver_id = this.clientStore.connectedUser?.id;
481486
const ended_at = callSession?.ended_at;
482487
const created_by_id = this.state.createdBy?.id;
488+
489+
if (this.currentUserId && created_by_id === this.currentUserId) {
490+
globalThis.streamRNVideoSDK?.callingX?.registerOutgoingCall(this);
491+
}
483492
const rejected_by = callSession?.rejected_by;
484493
const accepted_by = callSession?.accepted_by;
485494
let leaveCallIdle = false;
@@ -636,16 +645,30 @@ export class Call {
636645

637646
if (callingState === CallingState.RINGING && reject !== false) {
638647
if (reject) {
639-
await this.reject(reason ?? 'decline');
648+
const reasonToEndCallReason = {
649+
timeout: 'missed',
650+
cancel: 'canceled',
651+
busy: 'busy',
652+
decline: 'rejected',
653+
} as const;
654+
const rejectReason = reason ?? 'decline';
655+
const endCallReason =
656+
reasonToEndCallReason[
657+
rejectReason as keyof typeof reasonToEndCallReason
658+
] ?? 'rejected';
659+
await this.reject(rejectReason);
660+
globalThis.streamRNVideoSDK?.callingX?.endCall(this, endCallReason);
640661
} else {
641662
// if reject was undefined, we still have to cancel the call automatically
642663
// when I am the creator and everyone else left the call
643664
const hasOtherParticipants = this.state.remoteParticipants.length > 0;
644665
if (this.isCreatedByMe && !hasOtherParticipants) {
645666
await this.reject('cancel');
667+
globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'canceled');
646668
}
647669
}
648670
}
671+
globalThis.streamRNVideoSDK?.callingX?.endCall(this);
649672

650673
this.statsReporter?.stop();
651674
this.statsReporter = undefined;
@@ -680,7 +703,9 @@ export class Call {
680703
this.cancelAutoDrop();
681704
this.clientStore.unregisterCall(this);
682705

683-
globalThis.streamRNVideoSDK?.callManager.stop();
706+
globalThis.streamRNVideoSDK?.callManager.stop({
707+
isRingingTypeCall: this.ringing,
708+
});
684709

685710
this.camera.dispose();
686711
this.microphone.dispose();
@@ -720,7 +745,9 @@ export class Call {
720745
* A flag indicating whether the call was created by the current user.
721746
*/
722747
get isCreatedByMe() {
723-
return this.state.createdBy?.id === this.currentUserId;
748+
return (
749+
this.currentUserId && this.state.createdBy?.id === this.currentUserId
750+
);
724751
}
725752

726753
/**
@@ -766,6 +793,7 @@ export class Call {
766793
video?: boolean;
767794
}): Promise<GetCallResponse> => {
768795
await this.setup();
796+
769797
const response = await this.streamClient.get<GetCallResponse>(
770798
this.streamClientBasePath,
771799
params,
@@ -805,6 +833,7 @@ export class Call {
805833
*/
806834
getOrCreate = async (data?: GetOrCreateCallRequest) => {
807835
await this.setup();
836+
808837
const response = await this.streamClient.post<
809838
GetOrCreateCallResponse,
810839
GetOrCreateCallRequest
@@ -930,60 +959,73 @@ export class Call {
930959
joinResponseTimeout?: number;
931960
rpcRequestTimeout?: number;
932961
} = {}): Promise<void> => {
933-
await this.setup();
934962
const callingState = this.state.callingState;
935963

936964
if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
937965
throw new Error(`Illegal State: call.join() shall be called only once`);
938966
}
939967

968+
if (data?.ring) {
969+
this.ringingSubject.next(true);
970+
}
971+
const callingX = globalThis.streamRNVideoSDK?.callingX;
972+
if (callingX) {
973+
// for Android/iOS, we need to start the call in the callingx library as soon as possible
974+
await callingX.joinCall(this, this.clientStore.calls);
975+
}
976+
977+
await this.setup();
978+
940979
this.joinResponseTimeout = joinResponseTimeout;
941980
this.rpcRequestTimeout = rpcRequestTimeout;
942-
943981
// we will count the number of join failures per SFU.
944982
// once the number of failures reaches 2, we will piggyback on the `migrating_from`
945983
// field to force the coordinator to provide us another SFU
946984
const sfuJoinFailures = new Map<string, number>();
947985
const joinData: JoinCallData = data;
948986
maxJoinRetries = Math.max(maxJoinRetries, 1);
949-
for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
950-
try {
951-
this.logger.trace(`Joining call (${attempt})`, this.cid);
952-
await this.doJoin(data);
953-
delete joinData.migrating_from;
954-
delete joinData.migrating_from_list;
955-
break;
956-
} catch (err) {
957-
this.logger.warn(`Failed to join call (${attempt})`, this.cid);
958-
if (
959-
(err instanceof ErrorFromResponse && err.unrecoverable) ||
960-
(err instanceof SfuJoinError && err.unrecoverable)
961-
) {
962-
// if the error is unrecoverable, we should not retry as that signals
963-
// that connectivity is good, but the coordinator doesn't allow the user
964-
// to join the call due to some reason (e.g., ended call, expired token...)
965-
throw err;
966-
}
987+
try {
988+
for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
989+
try {
990+
this.logger.trace(`Joining call (${attempt})`, this.cid);
991+
await this.doJoin(data);
992+
delete joinData.migrating_from;
993+
delete joinData.migrating_from_list;
994+
break;
995+
} catch (err) {
996+
this.logger.warn(`Failed to join call (${attempt})`, this.cid);
997+
if (
998+
(err instanceof ErrorFromResponse && err.unrecoverable) ||
999+
(err instanceof SfuJoinError && err.unrecoverable)
1000+
) {
1001+
// if the error is unrecoverable, we should not retry as that signals
1002+
// that connectivity is good, but the coordinator doesn't allow the user
1003+
// to join the call due to some reason (e.g., ended call, expired token...)
1004+
throw err;
1005+
}
9671006

968-
// immediately switch to a different SFU in case of recoverable join error
969-
const switchSfu =
970-
err instanceof SfuJoinError &&
971-
SfuJoinError.isJoinErrorCode(err.errorEvent);
972-
973-
const sfuId = this.credentials?.server.edge_name || '';
974-
const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
975-
sfuJoinFailures.set(sfuId, failures);
976-
if (switchSfu || failures >= 2) {
977-
joinData.migrating_from = sfuId;
978-
joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
979-
}
1007+
// immediately switch to a different SFU in case of recoverable join error
1008+
const switchSfu =
1009+
err instanceof SfuJoinError &&
1010+
SfuJoinError.isJoinErrorCode(err.errorEvent);
1011+
1012+
const sfuId = this.credentials?.server.edge_name || '';
1013+
const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
1014+
sfuJoinFailures.set(sfuId, failures);
1015+
if (switchSfu || failures >= 2) {
1016+
joinData.migrating_from = sfuId;
1017+
joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
1018+
}
9801019

981-
if (attempt === maxJoinRetries - 1) {
982-
throw err;
1020+
if (attempt === maxJoinRetries - 1) {
1021+
throw err;
1022+
}
9831023
}
1024+
await sleep(retryInterval(attempt));
9841025
}
985-
986-
await sleep(retryInterval(attempt));
1026+
} catch (error) {
1027+
callingX?.endCall(this, 'error');
1028+
throw error;
9871029
}
9881030
};
9891031

@@ -1166,7 +1208,9 @@ export class Call {
11661208
// re-apply them on later reconnections or server-side data fetches
11671209
if (!this.deviceSettingsAppliedOnce && this.state.settings) {
11681210
await this.applyDeviceConfig(this.state.settings, true, false);
1169-
globalThis.streamRNVideoSDK?.callManager.start();
1211+
globalThis.streamRNVideoSDK?.callManager.start({
1212+
isRingingTypeCall: this.ringing,
1213+
});
11701214
this.deviceSettingsAppliedOnce = true;
11711215
}
11721216

@@ -1711,6 +1755,7 @@ export class Call {
17111755
if (SfuJoinError.isJoinErrorCode(e)) return;
17121756
if (strategy === WebsocketReconnectStrategy.UNSPECIFIED) return;
17131757
if (strategy === WebsocketReconnectStrategy.DISCONNECT) {
1758+
globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'error');
17141759
this.leave({ message: 'SFU instructed to disconnect' }).catch((err) => {
17151760
this.logger.warn(`Can't leave call after disconnect request`, err);
17161761
});

packages/client/src/devices/SpeakerManager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export class SpeakerManager {
8585
this.defaultDevice = defaultDevice;
8686
globalThis.streamRNVideoSDK?.callManager.setup({
8787
defaultDevice,
88+
isRingingTypeCall: this.call.ringing,
8889
});
8990
}
9091
}

packages/client/src/events/call.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export const watchCallRejected = (call: Call) => {
6969
} else {
7070
if (rejectedBy[eventCall.created_by.id]) {
7171
call.logger.info('call creator rejected, leaving call');
72+
globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
7273
await call.leave({ message: 'ring: creator rejected' });
7374
}
7475
}
@@ -80,6 +81,7 @@ export const watchCallRejected = (call: Call) => {
8081
*/
8182
export const watchCallEnded = (call: Call) => {
8283
return function onCallEnded() {
84+
globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
8385
const { callingState } = call.state;
8486
if (
8587
callingState !== CallingState.IDLE &&
@@ -113,6 +115,7 @@ export const watchSfuCallEnded = (call: Call) => {
113115
// update the call state to reflect the call has ended.
114116
call.state.setEndedAt(new Date());
115117
const reason = CallEndedReason[e.reason];
118+
globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
116119
await call.leave({ message: `callEnded received: ${reason}` });
117120
} catch (err) {
118121
call.logger.error(

packages/client/src/helpers/RNSpeechDetector.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,15 @@ export class RNSpeechDetector {
2323
: await navigator.mediaDevices.getUserMedia({ audio: true });
2424
this.audioStream = audioStream;
2525

26-
this.pc1.addEventListener('icecandidate', async (e) => {
27-
await this.pc2.addIceCandidate(e.candidate);
26+
this.pc1.addEventListener('icecandidate', (e) => {
27+
this.pc2.addIceCandidate(e.candidate).catch(() => {
28+
// do nothing
29+
});
2830
});
2931
this.pc2.addEventListener('icecandidate', async (e) => {
30-
await this.pc1.addIceCandidate(e.candidate);
32+
this.pc1.addIceCandidate(e.candidate).catch(() => {
33+
// do nothing
34+
});
3135
});
3236
this.pc2.addEventListener('track', (e) => {
3337
e.streams[0].getTracks().forEach((track) => {

packages/client/src/store/stateStore.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export class StreamVideoWriteableStateStore {
4242
* The currently connected user.
4343
*/
4444
get connectedUser(): OwnUserResponse | undefined {
45-
return RxUtils.getCurrentValue(this.connectedUserSubject);
45+
return this.connectedUserSubject.getValue();
4646
}
4747

4848
/**

0 commit comments

Comments
 (0)