Skip to content

feat: report a refused startCall with startCallFailed (iOS, Android) - #911

Merged
sboily merged 1 commit into
masterfrom
feat/start-call-failed
Oct 18, 2026
Merged

sboily merged 1 commit into
masterfrom
feat/start-call-failed

Conversation

@sboily

@sboily sboily commented Oct 18, 2026

Copy link
Copy Markdown
Member

Summary

When the system refuses a call started with startCall, JavaScript is never told: didReceiveStartCallAction doesn't come, the system shows no call, and the app keeps whatever it started for it (a SIP INVITE, a WebRTC leg). This adds a startCallFailed event on iOS and Android:

startCallFailed: {
  callUUID: string,   // the UUID passed to startCall
  handle: string,
  name: string,       // the contactIdentifier passed to startCall
  error?: string,     // iOS: CallKit's description of the error
  errorCode?: 'Unentitled' | 'UnknownCallProvider' | 'EmptyTransaction' | 'UnknownCallUUID'
            | 'CallUUIDAlreadyExists' | 'InvalidAction' | 'MaximumCallGroupsReached'
            | 'CallIsProtected' | 'Unknown',  // iOS
}

It supersedes #878 (@mjerris), which reported refused CallKit transactions on iOS only (didFailCallAction), and takes in its review.

iOS

Android

  • VoiceConnectionService overrides onCreateOutgoingConnectionFailed, which CallKeep never did: nothing reached JavaScript.
  • Telecom can refuse a call before giving it startCall's extras: on Android 10 and 16, the request for a second managed call while the first is dialing has only the number (Bundle[]). So startCall remembers the calls it placed until Telecom creates or refuses them, and the refused one is found by number (the latest with it). When the request carries the UUID (seen on Android 16 for a refusal at connection creation), that is used.
  • A refused call that startCall didn't place (from the Phone app) sends nothing: JavaScript doesn't know it.
  • hasOutgoingCall isn't reset: in the tested case, the first call is still dialing.
  • Limit, documented: in self-managed mode, a second call while one is connecting makes Telecom ask the user "Placing this call will end your call" (Cancel / OK). On Cancel, Telecom cancels the call without calling the ConnectionService (cancelPendingCall), so no event is possible. The extras remembered for such a call stay in the list (one Bundle per cancelled call).

Docs, types, example

actions.js, index.d.ts, docs/events.md (table, section, NotificationCenter list), a line under startCall in docs/api.md, the changelog, and a handler in the example app. The docs warn that CallUUIDAlreadyExists means the call with this UUID exists and goes on, so the app must not end it (#878 review).

Testing

Android: tests/android/CallKeepStartCallFailedTest.kt, a new instrumentation fixture with the real Telecom service in managed mode. The first call is dialing; a second call to +33 6 12 34 56 78 gets startCallFailed with its own UUID, name and number; the first call goes on and gets no event.

Android 10 (API 29 emulator) Android 16 (API 36 emulator)
this branch passes passes
master (control) fails: no startCallFailed fails: no startCallFailed

The self-managed confirmation was seen on both emulators (Telecom logs Prompting user to disconnect self-managed call, then cancelPendingCall on Cancel, and no onCreateOutgoingConnectionFailed).

iOS: iPhone 15 Pro Max, iOS 27.0.1, real CallKit, this branch's RNCallKeep.m with minimal React stubs (the harness from the #878 review):

  • startCall X twice: the second gets startCallFailed with CallUUIDAlreadyExists, and X stays live in CXCallObserver. The event and the notification come on the main thread, with callUUID (lowercase), handle, name, error and errorCode.
  • maximumCallGroups: '1', X live, startCall Y: MaximumCallGroupsReached for Y; X goes on.
  • 3,000 refused startCalls buffered while JS loops on getInitialEvents and converts the result on a JS queue: this branch without the main-queue hop and the copies, 1,241 and 1,246 mutated while being enumerated; this branch, 0 and 0, with all 3,000 events buffered.
  • Observed: on the first launch after install, the first startCall (no other call) was refused by CallKit with MaximumCallGroupsReached, in 2 first launches out of 2, and in none of 2 later launches. master does the same silently; the app now gets startCallFailed.
  • Not run on the device: didLoadWithEvents with buffered failures (same copy, read on the main queue).
  • Compiles with the same 25 warnings as master (-Wall -Wextra, arm64-apple-ios15.1).

Checks: npm run check passes (18 events consistent). The example app type-checks, and a misspelled errorCode is rejected.

When iOS or Android refused a call started with startCall, JavaScript
was never told: didReceiveStartCallAction didn't come, and the app kept
a call the system didn't show.

iOS: requestTransaction sends startCallFailed when CallKit refuses a
CXStartCallAction, with CallKit's error and errorCode. The completion
runs on CallKit's private queue, so the event is sent from the main
queue, where the events are buffered, and getInitialEvents and
didLoadWithEvents hand React Native a copy of the buffer.

Android: VoiceConnectionService overrides onCreateOutgoingConnectionFailed.
Telecom can refuse a call before giving it startCall's extras, so the
calls startCall placed are remembered until Telecom creates or refuses
them, and found by number when the request has no UUID.

Based on #878 by @mjerris.
@sboily
sboily merged commit 20d528a into master Oct 18, 2026
4 checks passed
@sboily
sboily deleted the feat/start-call-failed branch October 18, 2026 04:42
This was referenced Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant