From ea8f9f5c634474c15c7c82f456e33e06510e5b31 Mon Sep 17 00:00:00 2001 From: Sylvain BOILY Date: Sun, 18 Oct 2026 00:37:03 -0400 Subject: [PATCH] feat: report a refused startCall with startCallFailed 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. --- CHANGELOG.md | 4 + actions.js | 5 + .../main/java/io/wazo/callkeep/Constants.java | 1 + .../io/wazo/callkeep/RNCallKeepModule.java | 10 ++ .../wazo/callkeep/VoiceConnectionService.java | 53 ++++++++ docs/api.md | 2 + docs/events.md | 33 +++++ example/src/useCallKeep.ts | 8 ++ index.d.ts | 8 ++ ios/RNCallKeep/RNCallKeep.m | 66 +++++++++- tests/android/CallKeepStartCallFailedTest.kt | 123 ++++++++++++++++++ 11 files changed, 307 insertions(+), 6 deletions(-) create mode 100644 tests/android/CallKeepStartCallFailedTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 20db988c..bc8c93bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/actions.js b/actions.js index fbbe4ca2..21e37476 100644 --- a/actions.js +++ b/actions.js @@ -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'; @@ -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 = { @@ -106,6 +110,7 @@ export const listeners = { showIncomingCallUi, silenceIncomingCall, createIncomingConnectionFailed, + startCallFailed, didChangeAudioRoute, onHasActiveCall }; diff --git a/android/src/main/java/io/wazo/callkeep/Constants.java b/android/src/main/java/io/wazo/callkeep/Constants.java index e1ed042a..d004b75b 100644 --- a/android/src/main/java/io/wazo/callkeep/Constants.java +++ b/android/src/main/java/io/wazo/callkeep/Constants.java @@ -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"; diff --git a/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java b/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java index 9f910f91..e28cda92 100644 --- a/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java +++ b/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java @@ -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; @@ -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); } } @@ -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); @@ -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)); diff --git a/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java b/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java index c24dc012..95fbab77 100644 --- a/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java +++ b/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java @@ -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; @@ -95,6 +96,9 @@ public class VoiceConnectionService extends ConnectionService { public static Map 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 placedCalls = new ArrayList<>(); public static VoiceConnectionService currentConnectionService = null; public static Connection getConnection(String connectionId) { @@ -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; } @@ -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(); @@ -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 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() { diff --git a/docs/api.md b/docs/api.md index dcd187ca..ba854ea0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 diff --git a/docs/events.md b/docs/events.md index b2e5280d..ae62ce06 100644 --- a/docs/events.md +++ b/docs/events.md @@ -13,6 +13,7 @@ subscription.remove(); | Event | iOS | Android | | ----------------------------------------------------------------- | :--: | :-----: | | [didReceiveStartCallAction](#didreceivestartcallaction) | ✅ | ✅ | +| [startCallFailed](#startcallfailed) | ✅ | ✅ | | [answerCall](#answercall) | ✅ | ✅ | | [endCall](#endcall) | ✅ | ✅ | | [didActivateAudioSession](#didactivateaudiosession) | ✅ | ✅ | @@ -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 @@ -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 @@ -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` | diff --git a/example/src/useCallKeep.ts b/example/src/useCallKeep.ts index bd24b23b..e29d76cd 100644 --- a/example/src/useCallKeep.ts +++ b/example/src/useCallKeep.ts @@ -22,6 +22,7 @@ export type LogEntry = { id: number; time: string; event: string; data?: string const EVENTS: Events[] = [ 'didLoadWithEvents', 'didReceiveStartCallAction', + 'startCallFailed', 'answerCall', 'endCall', 'didDisplayIncomingCall', @@ -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. diff --git a/index.d.ts b/index.d.ts index d5bcf045..d631d519 100644 --- a/index.d.ts +++ b/index.d.ts @@ -13,6 +13,7 @@ declare module 'react-native-callkeep' { showIncomingCallUi: 'RNCallKeepShowIncomingCallUi'; silenceIncomingCall: 'RNCallKeepOnSilenceIncomingCall'; createIncomingConnectionFailed: 'RNCallKeepOnIncomingConnectionFailed'; + startCallFailed: 'RNCallKeepStartCallFailed'; checkReachability: 'RNCallKeepCheckReachability'; didResetProvider: 'RNCallKeepProviderReset'; didLoadWithEvents: 'RNCallKeepDidLoadWithEvents'; @@ -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; diff --git a/ios/RNCallKeep/RNCallKeep.m b/ios/RNCallKeep/RNCallKeep.m index 403e48b6..8f3a4a2b 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 RNCallKeepStartCallFailed = @"RNCallKeepStartCallFailed"; @implementation RNCallKeep { @@ -116,7 +117,8 @@ - (void)dealloc RNCallKeepProviderReset, RNCallKeepCheckReachability, RNCallKeepDidLoadWithEvents, - RNCallKeepDidChangeAudioRoute + RNCallKeepDidChangeAudioRoute, + RNCallKeepStartCallFailed ]; } @@ -124,9 +126,13 @@ - (void)startObserving { NSLog(@"[RNCallKeep][startObserving]"); _hasListeners = YES; - if ([_delayedEvents count] > 0) { - [self sendEventWithName:RNCallKeepDidLoadWithEvents body:_delayedEvents]; - } + // The events are buffered on the main queue. React Native converts the body on another thread, + // so it gets a copy. + dispatch_async(dispatch_get_main_queue(), ^{ + if ([self->_delayedEvents count] > 0) { + [self sendEventWithName:RNCallKeepDidLoadWithEvents body:[self->_delayedEvents copy]]; + } + }); } - (void)stopObserving @@ -343,7 +349,10 @@ - (void)setupWithOptions:(NSDictionary *)options #ifdef DEBUG NSLog(@"[RNCallKeep][getInitialEvents]"); #endif - resolve(_delayedEvents); + // As in startObserving: read the buffer on the main queue, and resolve with a copy. + dispatch_async(dispatch_get_main_queue(), ^{ + resolve([self->_delayedEvents copy]); + }); } RCT_EXPORT_METHOD(clearInitialEvents) @@ -351,7 +360,9 @@ - (void)setupWithOptions:(NSDictionary *)options #ifdef DEBUG NSLog(@"[RNCallKeep][clearInitialEvents]"); #endif - _delayedEvents = [NSMutableArray array]; + dispatch_async(dispatch_get_main_queue(), ^{ + self->_delayedEvents = [NSMutableArray array]; + }); } RCT_EXPORT_METHOD(startCall:(NSString *)uuidString @@ -691,6 +702,23 @@ - (void)requestTransaction:(CXTransaction *)transaction [self.callKeepCallController requestTransaction:transaction completion:^(NSError * _Nullable error) { if (error != nil) { NSLog(@"[RNCallKeep][requestTransaction] Error requesting transaction (%@): (%@)", transaction.actions, error); + + // CallKit refused to start the call: performStartCallAction won't be called. + if ([[transaction.actions firstObject] isKindOfClass:[CXStartCallAction class]]) { + CXStartCallAction *startCallAction = [transaction.actions firstObject]; + NSDictionary *body = @{ + @"callUUID": [startCallAction.callUUID.UUIDString lowercaseString] ?: @"", + @"handle": startCallAction.handle.value ?: @"", + @"name": startCallAction.contactIdentifier ?: @"", + @"error": error.localizedDescription ?: @"", + @"errorCode": [self getRequestTransactionErrorCode:error], + }; + // This completion runs on CallKit's private queue, and the events are buffered on the + // main queue, as the provider's delegate sends them. + dispatch_async(dispatch_get_main_queue(), ^{ + [self sendEventWithNameWrapper:RNCallKeepStartCallFailed body:body]; + }); + } } else { NSLog(@"[RNCallKeep][requestTransaction] Requested transaction successfully"); @@ -846,6 +874,32 @@ - (NSString *)getIncomingCallErrorCode:(NSError *)error { } +- (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, missing from older SDKs + return @"CallIsProtected"; + default: + return @"Unknown"; + } +} + - (BOOL)lessThanIos10_2 { if (_version.majorVersion < 10) { diff --git a/tests/android/CallKeepStartCallFailedTest.kt b/tests/android/CallKeepStartCallFailedTest.kt new file mode 100644 index 00000000..fcc384cb --- /dev/null +++ b/tests/android/CallKeepStartCallFailedTest.kt @@ -0,0 +1,123 @@ +package io.wazo.callkeep + +import android.content.Context +import android.os.ParcelFileDescriptor +import android.os.Process +import android.os.UserManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.BridgeReactContext +import com.facebook.react.bridge.PromiseImpl +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import io.wazo.callkeep.RNCallKeepModule +import io.wazo.callkeep.VoiceConnectionService +import java.util.UUID +import org.junit.After +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +/** Uses the real Telecom service. JavaScript doesn't run, so events stay queued and are read with + * getInitialEvents. The host declares VoiceConnectionService, RECORD_AUDIO, CALL_PHONE, + * READ_PHONE_STATE and READ_PHONE_NUMBERS, granted when installed (`adb install -g`): unlike + * CallKeepSelfManagedTest's host, which must not grant READ_PHONE_NUMBERS. */ +@RunWith(AndroidJUnit4::class) +class CallKeepStartCallFailedTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private lateinit var module: RNCallKeepModule + private val calls = mutableListOf() + + @After fun tearDown() { + if (!::module.isInitialized) return + instrumentation.runOnMainSync { + calls.forEach { VoiceConnectionService.getConnection(it)?.onDisconnect() } + module.clearInitialEvents() + } + // Leaves no managed phone account for the tests that run in self-managed mode. + shell("telecom unregister-phone-account ${account()}") + } + + /** Telecom allows one managed outgoing call at a time. In self-managed mode, it asks the user + * whether to end the first call instead, and sends nothing to the app when they cancel. */ + @Test fun aSecondCallWhileTheFirstIsDialingIsRefused() { + // Android 16 doesn't turn a self-managed phone account, left by another test, into a managed one. + shell("telecom unregister-phone-account ${account()}") + instrumentation.runOnMainSync { + module = RNCallKeepModule.getInstance(BridgeReactContext(instrumentation.targetContext), true) + module.setup(Arguments.createMap().apply { putBoolean("selfManaged", false) }) + module.clearInitialEvents() + } + // A phone account that isn't self-managed is disabled until the user enables it. + shell("telecom set-phone-account-enabled ${account()}") + + val first = startCall("5551001", "First") + waitFor("the first call is dialing") { VoiceConnectionService.getConnection(first) != null } + + // Telecom's request has the number, not startCall's extras: CallKeep finds the call by number. + val second = startCall("+33 6 12 34 56 78", "Second") + val event = startCallFailed(second) + assertEquals("+33 6 12 34 56 78", event.getString("handle")) + assertEquals("Second", event.getString("name")) + instrumentation.runOnMainSync { + assertNull(VoiceConnectionService.getConnection(second)) + assertNotNull("the first call goes on", VoiceConnectionService.getConnection(first)) + } + assertTrue("startCallFailed is not sent for the first call", events(first).isEmpty()) + } + + /** The phone account's component, id and user serial number, as `adb shell telecom` takes them. */ + private fun account(): String { + val context = instrumentation.targetContext + val id = context.applicationInfo.loadLabel(context.packageManager) + val user = (context.getSystemService(Context.USER_SERVICE) as UserManager) + .getSerialNumberForUser(Process.myUserHandle()) + return "${context.packageName}/${VoiceConnectionService::class.java.name} $id $user" + } + + private fun shell(command: String) { + val output = instrumentation.uiAutomation.executeShellCommand(command) + ParcelFileDescriptor.AutoCloseInputStream(output).use { it.readBytes() } + } + + private fun startCall(number: String, name: String): String { + val uuid = UUID.randomUUID().toString().also { calls += it } + instrumentation.runOnMainSync { module.startCall(uuid, number, name, false) } + return uuid + } + + private fun waitFor(what: String, condition: () -> Boolean) { + val deadline = System.nanoTime() + 5_000_000_000L + while (System.nanoTime() < deadline) { + var met = false + instrumentation.runOnMainSync { met = condition() } + if (met) return + Thread.sleep(50) + } + fail("timed out: $what") + } + + private fun events(uuid: String): List { + var pending: ReadableArray? = null + instrumentation.runOnMainSync { + module.getInitialEvents(PromiseImpl({ args -> pending = args[0] as ReadableArray }, + { args -> fail(args.contentToString()) })) + } + val all = requireNotNull(pending) + return (0 until all.size()).mapNotNull { all.getMap(it) } + .filter { it.getString("name") == "RNCallKeepStartCallFailed" } + .mapNotNull { it.getMap("data") } + .filter { it.getString("callUUID") == uuid } + } + + private fun startCallFailed(uuid: String): ReadableMap { + val deadline = System.nanoTime() + 5_000_000_000L + while (System.nanoTime() < deadline) { + events(uuid).firstOrNull()?.let { return it } + Thread.sleep(50) + } + fail("no startCallFailed for $uuid") + throw AssertionError() + } +}