Skip to content

feat(ios): emit didFailCallAction when CallKit refuses a requested action - #878

Closed
mjerris wants to merge 1 commit into
react-native-webrtc:masterfrom
signalwire:feat/did-fail-call-action
Closed

mjerris wants to merge 1 commit into
react-native-webrtc:masterfrom
signalwire:feat/did-fail-call-action

Conversation

@mjerris

@mjerris mjerris commented Oct 16, 2026

Copy link
Copy Markdown

This came out of building and device-testing a SignalWire React Native wrapper on iOS 27 and Android 17, which uses callkeep for CallKit and ConnectionService. The case that prompted it: a refused startCall, which leaves the app with a call CallKit knows nothing about and no event saying so.

Summary

requestTransaction: (ios/RNCallKeep/RNCallKeep.m) now emits a new event,
RNCallKeepDidFailCallAction (JS: didFailCallAction), when
CXCallController completes a transaction with an error. Until now it only
NSLogged the error. The existing NSLog stays.

Payload:

{
  callUUID: string;   // lowercase; '' if the action has no call
  action: 'startCall' | 'answerCall' | 'endCall' | 'setHeld' | 'setMuted' | 'playDTMF' | 'unknown';
  errorCode: 'Unentitled' | 'UnknownCallProvider' | 'EmptyTransaction' | 'UnknownCallUUID'
           | 'CallUUIDAlreadyExists' | 'InvalidAction' | 'MaximumCallGroupsReached'
           | 'CallIsProtected' | 'Unknown';
  error: string;      // the NSError's localizedDescription
}

The field names and the PascalCase error-code names follow the existing
didDisplayIncomingCall payload (error, errorCode: 'CallUUIDAlreadyExists' | …), so the two failure events read the same way. errorCode is mapped from
CXErrorDomainRequestTransaction codes 1–8. An error from another domain, or
an unlisted code, becomes Unknown. Code 8 (CallIsProtected) is matched as a
literal so the file still builds with SDKs that predate it.

Motivation

A refused CXStartCallAction is invisible to JavaScript today. The common
case is startCall while a call is active with maximumCallGroups: '1'.
CallKit refuses with MaximumCallGroupsReached: no outgoing call UI appears,
didReceiveStartCallAction never fires, and the only trace is

[RNCallKeep][requestTransaction] Error requesting transaction (...): (Error Domain=com.apple.CallKit.error.requesttransaction Code=7 ...)

in the device log. The app has already started its own call (a SIP/WebRTC leg)
for that UUID, and nothing tells it to stop. It keeps a phantom call outside
CallKit: no system UI, no audio session from CallKit, and nothing for the user
to end it with. The same blind spot applies to a refused answer, end, hold,
mute or DTMF action (for example UnknownCallUUID after the call has already
gone).

With the event, an app can drop or hang up the call it started:

RNCallKeep.addEventListener('didFailCallAction', ({ callUUID, action, errorCode }) => {
  if (action === 'startCall') {
    hangUpAndForget(callUUID);
  }
});

Changes

  • ios/RNCallKeep/RNCallKeep.m: the event name, an entry in
    supportedEvents, the emission in the requestTransaction: error branch,
    and two helpers, getCallActionName: and getRequestTransactionErrorCode:.
    The event goes through sendEventWithNameWrapper:, so one sent before JS is
    listening is replayed in didLoadWithEvents, like the other events.
  • actions.js: the didFailCallAction listener.
  • index.d.ts: NativeEvents.didFailCallAction and its EventsPayload type.
  • README.md: the events table row and a didFailCallAction section with an
    example.

Android: no change. Telecom reports its own failures there
(onCreateOutgoingConnectionFailed / createIncomingConnectionFailed), and
requestTransaction: is iOS only.

Behaviour change

None, apart from the new event. Nothing that worked before behaves
differently. The NSLog is still written, and no existing event, method or
payload changed.

Testing

  • Objective-C: clang -fsyntax-only -fobjc-arc -fmodules for
    arm64-apple-ios15.1 against the iPhoneOS SDK (Xcode 27), with React Native
    0.81.5's headers. It compiles with no errors, and gives the same 22 warnings
    as master (all pre-existing: nullability and deprecations). The file was
    not built in an app, and the event was not seen on a device.
  • Typings: a strict tsc check of a didFailCallAction listener against
    the updated index.d.ts. It passes, and a deliberately wrong type is
    rejected, which shows the payload types are in effect.
  • JS: the repo has no JS test harness, so there is no test file. A scratch
    check loaded actions.js with a mocked NativeEventEmitter, attached
    listeners.didFailCallAction, emitted RNCallKeepDidFailCallAction, and got
    the payload in the handler.
  • Suggested device check: with maximumCallGroups: '1', start a call,
    then startCall a second UUID. Expect didFailCallAction with
    action: 'startCall' and errorCode: 'MaximumCallGroupsReached'.

Release note: npm lags the repo

npm's latest is still 4.3.16, published 2024-11-18
(npm view react-native-callkeep time). master has a "release 4.3.17"
commit and a 4.3.17 tag (2026-09-10), but 4.3.17 was never published. master
is 15 commits (8 non-merge) ahead of the 4.3.16 commit and 6 ahead of the
4.3.17 tag. Apps installing from npm get none of that work, and they would
not get this event either until a release ships. A published 4.3.17 (or
4.3.18 with this change) would help.

🤖 Generated with Claude Code

https://claude.ai/code/session_01N7ztWHg5Dnq1KeknJQnfex

…tion

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7ztWHg5Dnq1KeknJQnfex
@sboily sboily self-assigned this Oct 16, 2026
@sboily sboily added the bug Something isn't working label Oct 16, 2026
@sboily

sboily commented Oct 16, 2026

Copy link
Copy Markdown
Member

Thanks, a refused startCall that JS never hears about is a real blind spot. Before reviewing, I ran the real RNCallKeep.m from this PR (with master as a control) against the real CallKit: a Mac Catalyst build with minimal React stubs, using the module's own CXCallController. The process is not entitled, so CallKit refuses with Unentitled rather than the UnknownCallUUID you would get on a device, but the same code paths run. Three problems showed up.

1. The event is buffered from CallKit's private queue, which can crash the app. -[CXCallController init] uses "a private, serial queue" (CXCallController.h). A refused endCall produced the event on com.apple.callkit.callcontroller.completion, not on the main thread. When nothing is listening yet, sendEventWithNameWrapper: therefore appends to _delayedEvents from that queue. Meanwhile the provider delegate writes from the main queue, getInitialEvents runs on the module's queue, and React Native converts the resolved array on the JS thread.

Test: 3,000 refused endCalls while JS calls getInitialEvents (in a loop, to make the window observable). React Native's conversion hit NSGenericException: Collection <__NSArrayM> was mutated while being enumerated 980, 1,059 and 1,055 times out of about 3,200 conversions per run. On master it never happened, because these failures are not buffered there. In an app, nothing catches that exception, so it is a crash.

The root cause predates this PR: getInitialEvents hands React Native the live NSMutableArray. Only moving the emission to the main queue is not enough (still 1,017 and 1,056 exceptions), because the main queue writes to the array too. This gave 0 exceptions in both runs:

// requestTransaction:, error branch
NSDictionary *body = @{ /* same payload */ };
dispatch_async(dispatch_get_main_queue(), ^{
    [self sendEventWithNameWrapper:RNCallKeepDidFailCallAction body:body];
});

RCT_EXPORT_METHOD(getInitialEvents:(RCTPromiseResolveBlock)resolve
                  reject:(RCTPromiseRejectBlock)reject)
{
    dispatch_async(dispatch_get_main_queue(), ^{
        resolve([self->_delayedEvents copy]);
    });
}

RCT_EXPORT_METHOD(clearInitialEvents)
{
    dispatch_async(dispatch_get_main_queue(), ^{
        self->_delayedEvents = [NSMutableArray array];
    });
}

startObserving passes _delayedEvents as the didLoadWithEvents body the same way, so it probably needs the same copy. I did not test that path.

2. A handler that cleans up with endCall loops forever. As the PR description says, an endCall after the call is gone is refused (UnknownCallUUID). So a natural cleanup like addEventListener('didFailCallAction', ({ callUUID }) => RNCallKeep.endCall(callUUID)) sets off another didFailCallAction for the same UUID, and so on. After a single endCall, the harness logged 31,011 events in 3 seconds. I'd suggest not emitting for endCall + UnknownCallUUID (the call is already gone, which is what endCall wanted), or at least warning about it in the README.

3. The README example can hang up a live call. This one comes from reading the code, not from a test: an unentitled process can't hold a call. if (action === 'startCall') hangUpAndForget(callUUID) also runs when errorCode === 'CallUUIDAlreadyExists'. That error means CallKit already has a call with this UUID (double tap, retry, or a UUID already reported as incoming), so the example would tear down the call that is actually live. Checking errorCode in the example would avoid that.

Note that #877 also changes how _delayedEvents is filled (events buffered before -init), so the two PRs should probably agree on how the buffer is accessed.

@sboily

sboily commented Oct 16, 2026

Copy link
Copy Markdown
Member

Update: I ran the same tests on a real iPhone 15 Pro Max (iOS 26.6.1) with the real CallKit. That includes point 3, which I had only from reading the code.

  • endCall on an unknown UUID: errorCode: 'UnknownCallUUID', emitted on com.apple.callkit.callcontroller.completion. The provider delegate events arrive on the main thread.
  • Point 3 confirmed: startCall(X) is accepted, and the call is live in CXCallObserver. A second startCall(X) emits didFailCallAction { action: 'startCall', errorCode: 'CallUUIDAlreadyExists', callUUID: X } while X is still live (outgoing, not ended). The README handler would hang up that call.
  • Loop: 29,665 didFailCallAction events in 3 seconds after a single endCall (master: 0).
  • Buffer: 1,290 mutated while being enumerated exceptions with this PR, 1,252 with only the main-queue hop, 0 with the main-queue hop plus the copy, and 0 on master.

@sboily

sboily commented Oct 18, 2026

Copy link
Copy Markdown
Member

Thanks again for this, @mjerris. I've taken it further in #911: a startCallFailed event for a refused startCall, on both iOS and Android, credited to this PR in the changelog. It keeps your error-code mapping and naming, and addresses the review above:

  • The event is sent from the main queue, and getInitialEvents and didLoadWithEvents hand React Native a copy of the buffer. In the same test on an iPhone (iOS 27.0.1), 3,000 refused startCalls gave 0 mutated while being enumerated, against 1,241 without these changes.
  • It's limited to startCall, so a handler that cleans up with endCall can't loop, and the docs warn that CallUUIDAlreadyExists refers to a call that's still live.
  • On Android, CallKeep never overrode onCreateOutgoingConnectionFailed, so nothing reached JavaScript there either. feat: report a refused startCall with startCallFailed (iOS, Android) #911 adds it; Telecom's request turned out to lack startCall's extras, so the call is found by number.

Closing this one in favor of #911. Reviews and device checks there are welcome.

@sboily sboily closed this Oct 18, 2026
@sboily sboily mentioned this pull request Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants