Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ are not listed here.

## [Unreleased]

### Added

- `startCallFailed` event: sent when iOS or Android refuses a call started with `startCall`. It went unreported: `didReceiveStartCallAction` never came, and the app kept a call the system didn't show. On iOS it carries CallKit's `errorCode`, such as `MaximumCallGroupsReached`. On Android it covers Telecom's refusals, such as a second managed call while one is dialing; in self-managed mode Android asks the user instead, and tells the app nothing if they cancel. See [startCallFailed](docs/events.md#startcallfailed). Based on [#878](https://github.com/react-native-webrtc/react-native-callkeep/pull/878) (@mjerris).

## [4.5.0] - 2026-10-17

On Android, calls now outlive the app's activity, as in other calling apps: read the first *Changed* entry if your app relied on calls ending with it. Native code can now set CallKeep up and show calls on Android, and observe its events on iOS, before React Native runs.
Expand Down
5 changes: 5 additions & 0 deletions actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const RNCallKeepDidLoadWithEvents = 'RNCallKeepDidLoadWithEvents';
const RNCallKeepShowIncomingCallUi = 'RNCallKeepShowIncomingCallUi';
const RNCallKeepOnSilenceIncomingCall = 'RNCallKeepOnSilenceIncomingCall';
const RNCallKeepOnIncomingConnectionFailed = 'RNCallKeepOnIncomingConnectionFailed';
const RNCallKeepStartCallFailed = 'RNCallKeepStartCallFailed';
const RNCallKeepDidChangeAudioRoute = 'RNCallKeepDidChangeAudioRoute';
const RNCallKeepHasActiveCall = 'RNCallKeepHasActiveCall';
const isIOS = Platform.OS === 'ios';
Expand Down Expand Up @@ -88,6 +89,9 @@ const silenceIncomingCall = handler =>
const createIncomingConnectionFailed = handler =>
eventEmitter.addListener(RNCallKeepOnIncomingConnectionFailed, (data) => handler(data));

const startCallFailed = handler =>
eventEmitter.addListener(RNCallKeepStartCallFailed, (data) => handler(data));

export const emit = (eventName, payload) => eventEmitter.emit(eventName, payload);

export const listeners = {
Expand All @@ -106,6 +110,7 @@ export const listeners = {
showIncomingCallUi,
silenceIncomingCall,
createIncomingConnectionFailed,
startCallFailed,
didChangeAudioRoute,
onHasActiveCall
};
1 change: 1 addition & 0 deletions android/src/main/java/io/wazo/callkeep/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public class Constants {
public static final String ACTION_SHOW_INCOMING_CALL_UI = "ACTION_SHOW_INCOMING_CALL_UI";
public static final String ACTION_ON_SILENCE_INCOMING_CALL = "ACTION_ON_SILENCE_INCOMING_CALL";
public static final String ACTION_ON_CREATE_CONNECTION_FAILED = "ACTION_ON_CREATE_CONNECTION_FAILED";
public static final String ACTION_ON_CREATE_OUTGOING_CONNECTION_FAILED = "ACTION_ON_CREATE_OUTGOING_CONNECTION_FAILED";
public static final String ACTION_DID_CHANGE_AUDIO_ROUTE = "ACTION_DID_CHANGE_AUDIO_ROUTE";
public static final String ACTION_DID_DISPLAY_INCOMING_CALL = "ACTION_DID_DISPLAY_INCOMING_CALL";

Expand Down
10 changes: 10 additions & 0 deletions android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
import static io.wazo.callkeep.Constants.ACTION_SHOW_INCOMING_CALL_UI;
import static io.wazo.callkeep.Constants.ACTION_ON_SILENCE_INCOMING_CALL;
import static io.wazo.callkeep.Constants.ACTION_ON_CREATE_CONNECTION_FAILED;
import static io.wazo.callkeep.Constants.ACTION_ON_CREATE_OUTGOING_CONNECTION_FAILED;
import static io.wazo.callkeep.Constants.ACTION_DID_CHANGE_AUDIO_ROUTE;
import static io.wazo.callkeep.Constants.ACTION_DID_DISPLAY_INCOMING_CALL;

Expand Down Expand Up @@ -589,11 +590,13 @@ public void startCall(String uuid, String number, String callerName, boolean has

Log.d(TAG, "[RNCallKeepModule] startCall, uuid: " + uuid);
this.listenToNativeCallsState();
VoiceConnectionService.addPlacedCall(callExtras);
try {
telecomManager.placeCall(uri, extras);
} catch (SecurityException e) {
// hasPermissions() is checked above, but a permission can be revoked at any time.
Log.w(TAG, "[RNCallKeepModule] startCall failed: " + e.getMessage());
VoiceConnectionService.takePlacedCall(uuid, null);
}
}

Expand Down Expand Up @@ -1348,6 +1351,7 @@ protected void registerReceiver() {
intentFilter.addAction(ACTION_SHOW_INCOMING_CALL_UI);
intentFilter.addAction(ACTION_ON_SILENCE_INCOMING_CALL);
intentFilter.addAction(ACTION_ON_CREATE_CONNECTION_FAILED);
intentFilter.addAction(ACTION_ON_CREATE_OUTGOING_CONNECTION_FAILED);
intentFilter.addAction(ACTION_DID_CHANGE_AUDIO_ROUTE);
intentFilter.addAction(ACTION_DID_DISPLAY_INCOMING_CALL);

Expand Down Expand Up @@ -1512,6 +1516,12 @@ public void onReceive(Context context, Intent intent) {
args.putString("name", attributeMap.get(EXTRA_CALLER_NAME));
sendEventToJS("RNCallKeepOnIncomingConnectionFailed", args);
break;
case ACTION_ON_CREATE_OUTGOING_CONNECTION_FAILED:
args.putString("handle", attributeMap.get(EXTRA_CALL_NUMBER));
args.putString("callUUID", attributeMap.get(EXTRA_CALL_UUID));
args.putString("name", attributeMap.get(EXTRA_CALLER_NAME));
sendEventToJS("RNCallKeepStartCallFailed", args);
break;
case ACTION_DID_CHANGE_AUDIO_ROUTE:
args.putString("handle", attributeMap.get(EXTRA_CALL_NUMBER));
args.putString("callUUID", attributeMap.get(EXTRA_CALL_UUID));
Expand Down
53 changes: 53 additions & 0 deletions android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
import static io.wazo.callkeep.Constants.EXTRA_DISABLE_ADD_CALL;
import static io.wazo.callkeep.Constants.FOREGROUND_SERVICE_NOTIFICATION_ID;
import static io.wazo.callkeep.Constants.ACTION_ON_CREATE_CONNECTION_FAILED;
import static io.wazo.callkeep.Constants.ACTION_ON_CREATE_OUTGOING_CONNECTION_FAILED;
import static io.wazo.callkeep.Constants.ACTION_DID_DISPLAY_INCOMING_CALL;
import static io.wazo.callkeep.Constants.EXTRA_HAS_VIDEO;

Expand All @@ -95,6 +96,9 @@ public class VoiceConnectionService extends ConnectionService {

public static Map<String, VoiceConnection> currentConnections = new HashMap<>();
public static Boolean hasOutgoingCall = false;
// The extras of the calls startCall placed, until Telecom creates or refuses them: when Telecom
// refuses a call before creating it, the request only has the number.
private static final List<Bundle> placedCalls = new ArrayList<>();
public static VoiceConnectionService currentConnectionService = null;

public static Connection getConnection(String connectionId) {
Expand All @@ -111,6 +115,29 @@ public VoiceConnectionService() {
currentConnectionService = this;
}

public static void addPlacedCall(Bundle extras) {
synchronized (placedCalls) {
placedCalls.add(extras);
}
}

// Removes and returns the latest placed call with this UUID or, without one, this number.
@Nullable
public static Bundle takePlacedCall(@Nullable String uuid, @Nullable String number) {
synchronized (placedCalls) {
for (int i = placedCalls.size() - 1; i >= 0; i--) {
Bundle call = placedCalls.get(i);
boolean matches = uuid != null
? uuid.equals(call.getString(EXTRA_CALL_UUID))
: number != null && number.equals(call.getString(EXTRA_CALL_NUMBER));
if (matches) {
return placedCalls.remove(i);
}
}
return null;
}
}

public static void setPhoneAccountHandle(PhoneAccountHandle phoneAccountHandle) {
VoiceConnectionService.phoneAccountHandle = phoneAccountHandle;
}
Expand Down Expand Up @@ -269,6 +296,7 @@ public Connection onCreateOutgoingConnection(PhoneAccountHandle connectionManage

Bundle extras = request.getExtras();
String callUUID = extras.getString(EXTRA_CALL_UUID);
takePlacedCall(callUUID, null);

if(callUUID == null || callUUID == ""){
callUUID = UUID.randomUUID().toString();
Expand Down Expand Up @@ -604,6 +632,31 @@ public void onCreateIncomingConnectionFailed(PhoneAccountHandle connectionManage
sendCallRequestToActivity(ACTION_ON_CREATE_CONNECTION_FAILED, extrasMap, true);
}

// Telecom refused an outgoing call without creating it, for instance while another outgoing call
// is dialing.
@Override
public void onCreateOutgoingConnectionFailed(PhoneAccountHandle connectionManagerPhoneAccount, ConnectionRequest request) {
super.onCreateOutgoingConnectionFailed(connectionManagerPhoneAccount, request);
Log.w(TAG, "[VoiceConnectionService] onCreateOutgoingConnectionFailed: " + request);

String number = request.getAddress() == null ? null : request.getAddress().getSchemeSpecificPart();
Bundle extras = request.getExtras();
String callUUID = extras == null ? null : extras.getString(EXTRA_CALL_UUID);
Bundle placedCall = takePlacedCall(callUUID, number);
if (callUUID == null && placedCall != null) {
extras = placedCall;
callUUID = extras.getString(EXTRA_CALL_UUID);
}
if (callUUID == null) {
// Not a call startCall placed, e.g. one from the Phone app: JavaScript doesn't know it.
return;
}

HashMap<String, String> extrasMap = this.bundleToMap(extras);
extrasMap.put(EXTRA_CALL_NUMBER, number);
sendCallRequestToActivity(ACTION_ON_CREATE_OUTGOING_CONNECTION_FAILED, extrasMap, true);
}

// When a listener is available for `sendCallRequestToActivity`, send delayed events.
public static void startObserving() {
new Handler(Looper.getMainLooper()).post(new Runnable() {
Expand Down
2 changes: 2 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,8 @@ RNCallKeep.startCall(uuid, handle, contactIdentifier);
- `false` (default)
- `true` (you know... when not false)

When the system accepts the call, you get [`didReceiveStartCallAction`](events.md#didreceivestartcallaction); when it refuses it, [`startCallFailed`](events.md#startcallfailed).


### reportConnectingOutgoingCallWithUUID

Expand Down
33 changes: 33 additions & 0 deletions docs/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ subscription.remove();
| Event | iOS | Android |
| ----------------------------------------------------------------- | :--: | :-----: |
| [didReceiveStartCallAction](#didreceivestartcallaction) | ✅ | ✅ |
| [startCallFailed](#startcallfailed) | ✅ | ✅ |
| [answerCall](#answercall) | ✅ | ✅ |
| [endCall](#endcall) | ✅ | ✅ |
| [didActivateAudioSession](#didactivateaudiosession) | ✅ | ✅ |
Expand All @@ -37,6 +38,8 @@ or by the app calling `RNCallKeep.startCall`.

Try to start your app call action from here (e.g. get credentials of the user by `data.handle` and/or send INVITE to your SIP server)

If the system refuses a call started with `startCall`, you get [`startCallFailed`](#startcallfailed) instead.

Note: on iOS, when the call comes from Recents, Contacts or Siri, `callUUID` is not defined: CallKit doesn't manage the call yet. Generate a UUID and call `startCall`, which sends this event again with the `callUUID`.

```js
Expand All @@ -52,6 +55,35 @@ RNCallKeep.addEventListener('didReceiveStartCallAction', ({ handle, callUUID, na
- `name` (string)
- Name of the callee

### startCallFailed

The system refused a call started with `startCall`: it shows no call, and [`didReceiveStartCallAction`](#didreceivestartcallaction) won't come for it. Stop what your app started for this call, and tell the user.

- On iOS, CallKit refused the call. `errorCode` says why, for instance `MaximumCallGroupsReached` when calls already fill the [`maximumCallGroups`](setup.md#options) option.
- On Android, Telecom refused the call before creating it, for instance in managed mode while another outgoing call is dialing. Android doesn't say why. In self-managed mode, Android asks the user whether to end the ongoing call instead; if they cancel, Android doesn't tell the app, and this event isn't sent.

With `errorCode` `CallUUIDAlreadyExists` (iOS), CallKit already has a call with this UUID, for instance after a second `startCall` for the same call: that call goes on, so don't end it.

```js
RNCallKeep.addEventListener('startCallFailed', ({ callUUID, handle, name, errorCode }) => {
if (errorCode === 'CallUUIDAlreadyExists') {
return; // the call with this UUID goes on
}
// Stop your app's call for callUUID, e.g. cancel the INVITE sent to your SIP server.
});
```

- `callUUID` (string)
- The UUID passed to `startCall`.
- `handle` (string)
- Phone number of the callee.
- `name` (string)
- The `contactIdentifier` passed to `startCall`.
- `error` (string)
- iOS only. CallKit's description of the error.
- `errorCode` (string)
- iOS only. Possible values: "Unentitled", "UnknownCallProvider", "EmptyTransaction", "UnknownCallUUID", "CallUUIDAlreadyExists", "InvalidAction", "MaximumCallGroupsReached", "CallIsProtected", "Unknown". See https://developer.apple.com/documentation/callkit/cxerrorcoderequesttransactionerror for more information.

### answerCall

User answer the incoming call
Expand Down Expand Up @@ -334,6 +366,7 @@ Each notification is posted on the thread that sends the event, which can be Cal
| Event | Notification name |
| ---------------------------- | ---------------------------------------- |
| didReceiveStartCallAction | `RNCallKeepDidReceiveStartCallAction` |
| startCallFailed | `RNCallKeepStartCallFailed` |
| answerCall | `RNCallKeepPerformAnswerCallAction` |
| endCall | `RNCallKeepPerformEndCallAction` |
| didActivateAudioSession | `RNCallKeepDidActivateAudioSession` |
Expand Down
8 changes: 8 additions & 0 deletions example/src/useCallKeep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type LogEntry = { id: number; time: string; event: string; data?: string
const EVENTS: Events[] = [
'didLoadWithEvents',
'didReceiveStartCallAction',
'startCallFailed',
'answerCall',
'endCall',
'didDisplayIncomingCall',
Expand Down Expand Up @@ -129,6 +130,13 @@ const handlers: Handlers = {
});
connectOutgoingCall(callUUID);
},
// The system refused a call startCall started. With CallUUIDAlreadyExists (iOS), the call with this
// UUID exists and goes on.
startCallFailed: ({ callUUID, errorCode }) => {
if (errorCode !== 'CallUUIDAlreadyExists') {
removeCall(callUUID);
}
},
answerCall: ({ callUUID }) => {
// Answered from the system call screen or with answerIncomingCall(). An app connects the media
// here; on Android it then tells Telecom the call is active.
Expand Down
8 changes: 8 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ declare module 'react-native-callkeep' {
showIncomingCallUi: 'RNCallKeepShowIncomingCallUi';
silenceIncomingCall: 'RNCallKeepOnSilenceIncomingCall';
createIncomingConnectionFailed: 'RNCallKeepOnIncomingConnectionFailed';
startCallFailed: 'RNCallKeepStartCallFailed';
checkReachability: 'RNCallKeepCheckReachability';
didResetProvider: 'RNCallKeepProviderReset';
didLoadWithEvents: 'RNCallKeepDidLoadWithEvents';
Expand Down Expand Up @@ -52,6 +53,13 @@ declare module 'react-native-callkeep' {
showIncomingCallUi: { handle: string, callUUID: string, name: string };
silenceIncomingCall: { handle: string, callUUID: string, name: string };
createIncomingConnectionFailed: { handle: string, callUUID: string, name: string };
startCallFailed: {
callUUID: string,
handle: string,
name: string,
error?: string,
errorCode?: 'Unentitled' | 'UnknownCallProvider' | 'EmptyTransaction' | 'UnknownCallUUID' | 'CallUUIDAlreadyExists' | 'InvalidAction' | 'MaximumCallGroupsReached' | 'CallIsProtected' | 'Unknown',
};
checkReachability: undefined;
didResetProvider: undefined;
didLoadWithEvents: InitialEvents;
Expand Down
Loading
Loading