From 70574b18c8bcc0375d0c0f186b2c59e024a42985 Mon Sep 17 00:00:00 2001 From: Michael Jerris Date: Fri, 16 Oct 2026 15:46:03 -0400 Subject: [PATCH] feat(ios): emit didFailCallAction when CallKit refuses a requested action requestTransaction: only NSLogged a refused CXTransaction, so JavaScript never learned that, for example, a startCall was refused with MaximumCallGroupsReached; the app kept a call CallKit did not know about. Emit RNCallKeepDidFailCallAction with the call's UUID, the action, the CXErrorCodeRequestTransactionError name and the localized description. Adds the JS listener, the typings and the README entry. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01N7ztWHg5Dnq1KeknJQnfex --- README.md | 25 ++++++++++++++++ actions.js | 7 ++++- index.d.ts | 7 +++++ ios/RNCallKeep/RNCallKeep.m | 58 ++++++++++++++++++++++++++++++++++++- 4 files changed, 95 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index be61547f..15f50249 100644 --- a/README.md +++ b/README.md @@ -753,6 +753,7 @@ RNCallKeep.registerAndroidEvents(); | [checkReachability](#checkReachability) | ❌ | ✅ | | [didChangeAudioRoute](#didChangeAudioRoute) | ✅ | ✅ | | [onHasActiveCall](#onHasActiveCall) | ❌ | ✅ | +| [didFailCallAction](#didFailCallAction) | ✅ | ❌ | ### didReceiveStartCallAction @@ -901,6 +902,30 @@ RNCallKeep.addEventListener('didPerformDTMFAction', ({ digits, callUUID }) => { - `callUUID` (string) - The UUID of the call. +### didFailCallAction + +CallKit refused a call action that callkeep requested: `startCall`, `answerIncomingCall`, `endCall`, `setOnHold`, `setMutedCall` or `sendDTMF`. Until now such a refusal was only written to the native log, so JavaScript kept believing the action had happened. The most common case is `startCall` while another call is active with `maximumCallGroups: '1'`: CallKit answers `MaximumCallGroupsReached`, no outgoing call is shown, and no `didReceiveStartCallAction` follows. + +```js +RNCallKeep.addEventListener('didFailCallAction', ({ callUUID, action, errorCode, error }) => { + if (action === 'startCall') { + // CallKit never started this call: hang up your own leg and forget it. + hangUpAndForget(callUUID); + } +}); +``` + +- `callUUID` (string) + - The UUID of the call the refused action was for (lowercase), or `''` when the action has none. +- `action` (string) + - `startCall`, `answerCall`, `endCall`, `setHeld`, `setMuted`, `playDTMF`, or `unknown`. +- `errorCode` (string) + - The `CXErrorCodeRequestTransactionError`: `Unentitled`, `UnknownCallProvider`, `EmptyTransaction`, `UnknownCallUUID`, `CallUUIDAlreadyExists`, `InvalidAction`, `MaximumCallGroupsReached`, `CallIsProtected`, or `Unknown`. +- `error` (string) + - The error's localized description. + +iOS only. One sent before JavaScript is listening is replayed in `didLoadWithEvents`, like the other events. + ### didLoadWithEvents iOS only. diff --git a/actions.js b/actions.js index fbbe4ca2..55511e7c 100644 --- a/actions.js +++ b/actions.js @@ -20,6 +20,7 @@ const RNCallKeepOnSilenceIncomingCall = 'RNCallKeepOnSilenceIncomingCall'; const RNCallKeepOnIncomingConnectionFailed = 'RNCallKeepOnIncomingConnectionFailed'; const RNCallKeepDidChangeAudioRoute = 'RNCallKeepDidChangeAudioRoute'; const RNCallKeepHasActiveCall = 'RNCallKeepHasActiveCall'; +const RNCallKeepDidFailCallAction = 'RNCallKeepDidFailCallAction'; const isIOS = Platform.OS === 'ios'; const didReceiveStartCallAction = handler => { @@ -76,6 +77,9 @@ const didResetProvider = handler => const checkReachability = handler => eventEmitter.addListener(RNCallKeepCheckReachability, handler); +const didFailCallAction = handler => + eventEmitter.addListener(RNCallKeepDidFailCallAction, (data) => handler(data)); + const didLoadWithEvents = handler => eventEmitter.addListener(RNCallKeepDidLoadWithEvents, handler); @@ -107,5 +111,6 @@ export const listeners = { silenceIncomingCall, createIncomingConnectionFailed, didChangeAudioRoute, - onHasActiveCall + onHasActiveCall, + didFailCallAction }; diff --git a/index.d.ts b/index.d.ts index 25b43da9..a72c24ac 100644 --- a/index.d.ts +++ b/index.d.ts @@ -17,6 +17,7 @@ declare module 'react-native-callkeep' { didResetProvider: 'RNCallKeepProviderReset'; didLoadWithEvents: 'RNCallKeepDidLoadWithEvents'; onHasActiveCall : 'onHasActiveCall'; + didFailCallAction: 'RNCallKeepDidFailCallAction'; } export type InitialEvents = Array<{ @@ -56,6 +57,12 @@ declare module 'react-native-callkeep' { didResetProvider: undefined; didLoadWithEvents: InitialEvents; onHasActiveCall : undefined; + didFailCallAction: { + callUUID: string, + action: 'startCall' | 'answerCall' | 'endCall' | 'setHeld' | 'setMuted' | 'playDTMF' | 'unknown', + errorCode: 'Unentitled' | 'UnknownCallProvider' | 'EmptyTransaction' | 'UnknownCallUUID' | 'CallUUIDAlreadyExists' | 'InvalidAction' | 'MaximumCallGroupsReached' | 'CallIsProtected' | 'Unknown', + error: string, + }; } type HandleType = 'generic' | 'number' | 'email'; diff --git a/ios/RNCallKeep/RNCallKeep.m b/ios/RNCallKeep/RNCallKeep.m index 475cd623..92878fbb 100644 --- a/ios/RNCallKeep/RNCallKeep.m +++ b/ios/RNCallKeep/RNCallKeep.m @@ -37,6 +37,7 @@ static NSString *const RNCallKeepCheckReachability = @"RNCallKeepCheckReachability"; static NSString *const RNCallKeepDidChangeAudioRoute = @"RNCallKeepDidChangeAudioRoute"; static NSString *const RNCallKeepDidLoadWithEvents = @"RNCallKeepDidLoadWithEvents"; +static NSString *const RNCallKeepDidFailCallAction = @"RNCallKeepDidFailCallAction"; @implementation RNCallKeep { @@ -115,7 +116,8 @@ - (void)dealloc RNCallKeepProviderReset, RNCallKeepCheckReachability, RNCallKeepDidLoadWithEvents, - RNCallKeepDidChangeAudioRoute + RNCallKeepDidChangeAudioRoute, + RNCallKeepDidFailCallAction ]; } @@ -675,6 +677,17 @@ - (void)requestTransaction:(CXTransaction *)transaction [self.callKeepCallController requestTransaction:transaction completion:^(NSError * _Nullable error) { if (error != nil) { NSLog(@"[RNCallKeep][requestTransaction] Error requesting transaction (%@): (%@)", transaction.actions, error); + // Without this event JavaScript never learns that CallKit refused the + // action: a refused startCall (e.g. maximumCallGroups reached) leaves + // the app holding a call that CallKit does not know about. + CXAction *action = [transaction.actions firstObject]; + NSUUID *callUUID = [action isKindOfClass:[CXCallAction class]] ? ((CXCallAction *)action).callUUID : nil; + [self sendEventWithNameWrapper:RNCallKeepDidFailCallAction body:@{ + @"callUUID": callUUID ? [callUUID.UUIDString lowercaseString] : @"", + @"action": [self getCallActionName:action], + @"errorCode": [self getRequestTransactionErrorCode:error], + @"error": error.localizedDescription ? error.localizedDescription : @"", + }]; } else { NSLog(@"[RNCallKeep][requestTransaction] Requested transaction successfully"); @@ -815,6 +828,49 @@ + (void)reportNewIncomingCall:(NSString *)uuidString }]; } +- (NSString *)getCallActionName:(CXAction *)action { + if ([action isKindOfClass:[CXStartCallAction class]]) { + return @"startCall"; + } else if ([action isKindOfClass:[CXAnswerCallAction class]]) { + return @"answerCall"; + } else if ([action isKindOfClass:[CXEndCallAction class]]) { + return @"endCall"; + } else if ([action isKindOfClass:[CXSetHeldCallAction class]]) { + return @"setHeld"; + } else if ([action isKindOfClass:[CXSetMutedCallAction class]]) { + return @"setMuted"; + } else if ([action isKindOfClass:[CXPlayDTMFCallAction class]]) { + return @"playDTMF"; + } + return @"unknown"; +} + +- (NSString *)getRequestTransactionErrorCode:(NSError *)error { + if (![[error domain] isEqualToString:CXErrorDomainRequestTransaction]) { + return @"Unknown"; + } + switch ([error code]) { + case CXErrorCodeRequestTransactionErrorUnentitled: + return @"Unentitled"; + case CXErrorCodeRequestTransactionErrorUnknownCallProvider: + return @"UnknownCallProvider"; + case CXErrorCodeRequestTransactionErrorEmptyTransaction: + return @"EmptyTransaction"; + case CXErrorCodeRequestTransactionErrorUnknownCallUUID: + return @"UnknownCallUUID"; + case CXErrorCodeRequestTransactionErrorCallUUIDAlreadyExists: + return @"CallUUIDAlreadyExists"; + case CXErrorCodeRequestTransactionErrorInvalidAction: + return @"InvalidAction"; + case CXErrorCodeRequestTransactionErrorMaximumCallGroupsReached: + return @"MaximumCallGroupsReached"; + case 8: // CXErrorCodeRequestTransactionErrorCallIsProtected; a literal so older SDKs still build + return @"CallIsProtected"; + default: + return @"Unknown"; + } +} + - (NSString *)getIncomingCallErrorCode:(NSError *)error { if ([error code] == CXErrorCodeIncomingCallErrorUnentitled) { return @"Unentitled";