Skip to content

feat(react-native): reject call when busy #1856

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 35 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
db6f9d3
busy tone initial impl
kristian-mkd Jul 18, 2025
9a7ab09
busy callee fixes
kristian-mkd Jul 21, 2025
616de2d
add the default false value of the prop
kristian-mkd Jul 21, 2025
b892d89
Merge branch 'main' into rn-232-busy-tone
kristian-mkd Jul 21, 2025
13fdd3e
fix review remarks
kristian-mkd Jul 22, 2025
9b4a7ea
add call reject with busy tone for react
kristian-mkd Jul 22, 2025
9020b48
Merge branch 'main' into rn-232-busy-tone
kristian-mkd Jul 23, 2025
679f501
revert react implementation
kristian-mkd Jul 23, 2025
b9780df
fix review remark
kristian-mkd Jul 23, 2025
6f90324
Update packages/react-native-sdk/src/components/Call/CallControls/Inc…
kristian-mkd Jul 23, 2025
42a292a
revert not needed change
kristian-mkd Jul 23, 2025
1b142fb
fix review remarks
kristian-mkd Jul 24, 2025
90b803a
fix imports
kristian-mkd Jul 24, 2025
f292867
fix review remarks
kristian-mkd Jul 24, 2025
dbce92f
prevent showing notifications for rejected calls when busy
kristian-mkd Jul 28, 2025
5117697
revert new lines
kristian-mkd Jul 28, 2025
a4565c8
Merge branch 'main' into rn-232-busy-tone
kristian-mkd Jul 30, 2025
e539fc2
add native push rejection for ios when busy
kristian-mkd Aug 1, 2025
3696eff
podfile.lock changes
kristian-mkd Aug 1, 2025
9ba5763
move the alert to dogfood
kristian-mkd Aug 1, 2025
90b1ee1
remove commented out code
kristian-mkd Aug 1, 2025
faa6b43
fix review remarks
kristian-mkd Aug 2, 2025
cd40826
additional fixes
kristian-mkd Aug 2, 2025
b2f42ed
ios native call used for ios only
kristian-mkd Aug 2, 2025
2e6cb70
fix the join and leave call handlers
kristian-mkd Aug 5, 2025
d03d2f4
Merge branch 'main' into rn-232-busy-tone
kristian-mkd Aug 5, 2025
b4f94a9
keep the config off
kristian-mkd Aug 5, 2025
ee85623
Merge branch 'main' into rn-232-busy-tone
kristian-mkd Aug 5, 2025
7f28055
fix client usage in useeffect
kristian-mkd Aug 5, 2025
2395ef4
Merge branch 'main' into rn-232-busy-tone
kristian-mkd Aug 5, 2025
3779ec6
fix warning
kristian-mkd Aug 5, 2025
5bb8c49
fix review remarks
kristian-mkd Aug 6, 2025
34a0e5e
fix method rename
kristian-mkd Aug 7, 2025
1021ffe
fix review remarks
kristian-mkd Aug 7, 2025
4cf3453
self review fixes
kristian-mkd Aug 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/react-native-sdk/ios/StreamVideoReactNative.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,10 @@

+ (void)setup DEPRECATED_MSG_ATTRIBUTE("No need to use setup() anymore");

+ (BOOL)shouldRejectCallWhenBusy;

+ (void)setShouldRejectCallWhenBusy:(BOOL)shouldReject;

+ (BOOL)hasAnyActiveCall;

@end
27 changes: 27 additions & 0 deletions packages/react-native-sdk/ios/StreamVideoReactNative.m
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#import <React/RCTEventEmitter.h>
#import <React/RCTUIManager.h>
#import <UIKit/UIKit.h>
#import <CallKit/CallKit.h>
#import "StreamVideoReactNative.h"
#import "WebRTCModule.h"
#import "WebRTCModuleOptions.h"
Expand All @@ -14,6 +15,8 @@
static NSMutableDictionary *_incomingCallCidsByUUID = nil;
static dispatch_queue_t _dictionaryQueue = nil;

static BOOL _shouldRejectCallWhenBusy = NO;

void broadcastNotificationCallback(CFNotificationCenterRef center,
void *observer,
CFStringRef name,
Expand Down Expand Up @@ -325,4 +328,28 @@ +(void)registerIncomingCall:(NSString *)cid uuid:(NSString *)uuid {
return @[@"StreamVideoReactNative_Ios_Screenshare_Event", @"isLowPowerModeEnabled", @"thermalStateDidChange"];
}

+(BOOL)shouldRejectCallWhenBusy {
return _shouldRejectCallWhenBusy;
}

RCT_EXPORT_METHOD(setShouldRejectCallWhenBusy:(BOOL)shouldReject) {
_shouldRejectCallWhenBusy = shouldReject;
#ifdef DEBUG
NSLog(@"setShouldRejectCallWhenBusy: %@", shouldReject ? @"YES" : @"NO");
#endif
}

+ (BOOL)hasAnyActiveCall
{
CXCallObserver *callObserver = [[CXCallObserver alloc] init];

for(CXCall *call in callObserver.calls){
if(call.hasConnected){
NSLog(@"[RNCallKeep] Found active call with UUID: %@", call.UUID);
return YES;
}
}
return NO;
}

@end
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useEffect } from 'react';
import {
useCalls,
useStreamVideoClient,
} from '@stream-io/video-react-bindings';
import { CallingState, getLogger } from '@stream-io/video-client';
import InCallManager from 'react-native-incall-manager';
import { StreamVideoRN } from '../../utils';
import { Platform } from 'react-native';

/**
* This is a renderless component to reject calls when the user is busy.
*/
export const RejectCallWhenBusy = () => {
const client = useStreamVideoClient();
const calls = useCalls();

useEffect(() => {
if (!client) return;
return client?.on('call.rejected', async (event) => {
// Workaround needed for the busy tone:
// This is because the call was rejected without even starting,
// before calling the stop method with busy tone we need to start the call first.
InCallManager.start({ media: 'audio' });

const callCid = event.call_cid;
const callId = callCid.split(':')[1];
if (!callId) return;

const rejectedCall = client?.call(event.call.type, callId);
await rejectedCall?.getOrCreate();

const isCalleeBusy =
rejectedCall && rejectedCall.isCreatedByMe && event.reason === 'busy';

if (isCalleeBusy) {
InCallManager.stop({ busytone: '_DTMF_' });
}
});
}, [client]);

const pushConfig = StreamVideoRN.getConfig().push;
const shouldRejectCallWhenBusy = pushConfig?.shouldRejectCallWhenBusy;

useEffect(() => {
// android rejection is done in android's firebaseDataHandler
if (Platform.OS === 'android') return;
if (!shouldRejectCallWhenBusy) return;

const ringingCallsInProgress = calls.filter(
(c) => c.ringing && c.state.callingState === CallingState.JOINED,
);
const callsForRejection = calls.filter(
(c) => c.ringing && c.state.callingState === CallingState.RINGING,
);
const alreadyInAnotherRingingCall = ringingCallsInProgress.length > 0;
if (callsForRejection.length > 0 && alreadyInAnotherRingingCall) {
callsForRejection.forEach((c) => {
c.leave({ reject: true, reason: 'busy' }).catch((err) => {
const logger = getLogger(['RejectCallWhenBusy']);
logger('error', 'Error rejecting Call when busy', err);
});
});
}
}, [calls, shouldRejectCallWhenBusy]);

return null;
};
2 changes: 2 additions & 0 deletions packages/react-native-sdk/src/providers/StreamCall/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { useAndroidKeepCallAliveEffect } from '../../hooks/useAndroidKeepCallAliveEffect';
import { AppStateListener } from './AppStateListener';
import { DeviceStats } from './DeviceStats';
import { RejectCallWhenBusy } from './RejectCallWhenBusy';

// const PIP_CHANGE_EVENT = 'StreamVideoReactNative_PIP_CHANGE_EVENT';

Expand Down Expand Up @@ -39,6 +40,7 @@ export const StreamCall = ({
<IosInformCallkeepCallEnd />
<ClearPushWSSubscriptions />
<DeviceStats />
<RejectCallWhenBusy />
{children}
</StreamCallProvider>
);
Expand Down
7 changes: 7 additions & 0 deletions packages/react-native-sdk/src/utils/StreamVideoRN/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import newNotificationCallbacks, {
} from '../internal/newNotificationCallbacks';
import { setupIosCallKeepEvents } from '../push/setupIosCallKeepEvents';
import { setupIosVoipPushEvents } from '../push/setupIosVoipPushEvents';
import { NativeModules, Platform } from 'react-native';

// Utility type for deep partial
type DeepPartial<T> = {
Expand Down Expand Up @@ -121,6 +122,12 @@ export class StreamVideoRN {

this.config.push = pushConfig;

// Configure native iOS module if shouldRejectCallWhenBusy is set
if (Platform.OS === 'ios') {
NativeModules.StreamVideoReactNative?.setShouldRejectCallWhenBusy(
pushConfig?.shouldRejectCallWhenBusy ?? false,
);
}
setupIosCallKeepEvents(pushConfig);
setupIosVoipPushEvents(pushConfig);
}
Expand Down
7 changes: 7 additions & 0 deletions packages/react-native-sdk/src/utils/StreamVideoRN/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ export type StreamVideoConfig = {
* @internal
*/
publishOptions?: ClientPublishOptions;

/**
* Whether to reject the call when the user is busy.
* @default false
*/
shouldRejectCallWhenBusy?: boolean;

ios: {
/**
* The name for the alias of push provider used for iOS
Expand Down
30 changes: 30 additions & 0 deletions packages/react-native-sdk/src/utils/push/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,36 @@ export const firebaseDataHandler = async (
const created_by_id = data.created_by_id as string;
const receiver_id = data.receiver_id as string;

const shouldRejectCallWhenBusy = pushConfig.shouldRejectCallWhenBusy;

const video_client = await pushConfig.createStreamVideoClient();
if (video_client && shouldRejectCallWhenBusy) {
try {
const calls = video_client.state.calls;
const ringingCallsInProgress = calls.filter(
(c) => c.ringing && c.state.callingState === CallingState.JOINED,
);

if (ringingCallsInProgress.length > 0) {
getLogger(['firebaseDataHandler'])(
'debug',
`User is already in a call. Silently rejecting incoming call: ${call_cid}`,
);

const callFromPush = await video_client.onRingingCall(call_cid);
await callFromPush.leave({ reject: true, reason: 'busy' });

return;
}
} catch (err) {
getLogger(['firebaseDataHandler'])(
'error',
'Error checking if user is already in a call',
err,
);
}
}

const shouldCallBeClosed = (callToCheck: Call) => {
const { mustEndCall } = shouldCallBeEnded(
callToCheck,
Expand Down
12 changes: 11 additions & 1 deletion sample-apps/react-native/dogfood/ios/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
completion() // Ensure completion handler is called even if parsing fails
return
}


// Check if user is busy BEFORE registering the call
let shouldReject = StreamVideoReactNative.shouldRejectCallWhenBusy()
let hasAnyActiveCall = StreamVideoReactNative.hasAnyActiveCall()

if shouldReject && hasAnyActiveCall {
// Complete the VoIP notification without showing CallKit UI
completion()
return
}

let uuid = UUID().uuidString
let videoIncluded = stream["video"] as? String
let hasVideo = videoIncluded == "false" ? false : true
Expand Down
Loading