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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ are not listed here.

- **Android:** `foregroundService.foregroundServiceType` sets the types the foreground service starts with (Android 10 and later), as a mask of `ServiceInfo.FOREGROUND_SERVICE_TYPE_*` values. An app can ring with `phoneCall` alone while its manifest also declares `microphone`; CallKeep keeps the types for the whole call, so adding `microphone` after the answer is up to the app's native code (see the option in `docs/setup.md`). Without the option, the service still starts with every type the manifest declares.
- **Android:** `endCallsOnHostDestroy: false` keeps the calls, and the app's process, when the React activity is destroyed, for calls that must outlive it (owned by native code, for instance). By default CallKeep still ends every call and kills the process.
- **Android:** `RNCallKeepModule.setupNative(context, options)` and `RNCallKeepModule.displayIncomingCall(context, uuid, number, callerName, hasVideo)` set CallKeep up and show an incoming call from native code, before React Native runs or without it (self-managed mode, Android 8 and later), for instance from a Firebase messaging service. `VoiceConnectionService.nativeFocusListener` and `hasConnectionServiceFocus()` let native code follow Telecom's call focus (Android 9 and later). See [Setup from native code (Android)](docs/setup.md#setup-from-native-code-android).
- **iOS:** Events are also posted to `NotificationCenter`, so native code can observe the call's actions before a React Native runtime exists. See [Listening from native code (iOS)](docs/events.md#listening-from-native-code-ios).

### Changed

Expand Down
73 changes: 55 additions & 18 deletions android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ private RNCallKeepModule(ReactApplicationContext reactContext) {
this.reactContext = reactContext;
}

private boolean isSelfManaged() {
private static boolean isSelfManaged() {
try {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && _settings.hasKey("selfManaged") && _settings.getBoolean("selfManaged");
} catch (Exception e) {
Expand Down Expand Up @@ -231,8 +231,12 @@ public void initializeTelecomManager() {
Log.w(TAG, "[RNCallKeepModule][initializeTelecomManager] no react context found.");
return;
}
initializeTelecomManager(context);
}

private static void initializeTelecomManager(Context context) {
ComponentName cName = new ComponentName(context, VoiceConnectionService.class);
String appName = this.getApplicationName(context);
String appName = getApplicationName(context);

handle = new PhoneAccountHandle(cName, appName);
telecomManager = (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE);
Expand Down Expand Up @@ -383,7 +387,27 @@ public void setSettings(ReadableMap options) {
if (options == null) {
return;
}
_settings = storeSettings(options);
_settings = storeSettings(getAppContext(), options);
}

/**
* Sets up self-managed calls from native code, before React Native runs or without it, so that
* a service (an FCM service, for instance) can show an incoming call with the static
* displayIncomingCall. It stores the settings and registers the phone account as setup() does,
* but creates no module: the app shows its own incoming-call UI and listens to CallKeep's local
* broadcasts. When React Native starts later, its module uses the same settings.
* Self-managed mode only, Android 8 and later.
*/
public static void setupNative(Context context, ReadableMap options) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
!options.hasKey("selfManaged") || !options.getBoolean("selfManaged")) {
throw new IllegalArgumentException("Native setup requires self-managed calls on Android 8+");
}
_settings = storeSettings(context.getApplicationContext(), options);
registerPhoneAccount(context.getApplicationContext());
VoiceConnectionService.setPhoneAccountHandle(handle);
// The state setup() ends in once the phone account is registered: available, so initialized.
VoiceConnectionService.setAvailable(true);
}

@ReactMethod
Expand Down Expand Up @@ -443,7 +467,7 @@ public void registerPhoneAccount(ReadableMap options) {
return;
}

this.registerPhoneAccount(context);
registerPhoneAccount(context);
}

@ReactMethod
Expand Down Expand Up @@ -481,6 +505,26 @@ public void displayIncomingCall(String uuid, String number, String callerName, b

Log.d(TAG, "[RNCallKeepModule] displayIncomingCall, uuid: " + uuid + ", number: " + number + ", callerName: " + callerName + ", hasVideo: " + hasVideo + ", payload: " + payload);

this.listenToNativeCallsState();
addIncomingCall(uuid, number, callerName, hasVideo, payload);
}

/**
* Shows an incoming call from native code, with the phone account set up by setupNative (or
* setup) in this process. Telecom then reports it through CallKeep's broadcasts, as for a call
* shown from JavaScript.
*/
public static void displayIncomingCall(Context context, String uuid, String number,
String callerName, boolean hasVideo) {
if (!isSelfManaged()) {
throw new IllegalStateException("Call setup is required before native display");
}
initializeTelecomManager(context.getApplicationContext());
addIncomingCall(uuid, number, callerName, hasVideo, null);
}

private static void addIncomingCall(String uuid, String number, String callerName,
boolean hasVideo, @Nullable Bundle payload) {
Bundle extras = new Bundle();
Uri uri = Uri.fromParts(PhoneAccount.SCHEME_TEL, number, null);

Expand All @@ -493,7 +537,6 @@ public void displayIncomingCall(String uuid, String number, String callerName, b
extras.putBundle(EXTRA_PAYLOAD, payload);
incomingCallPayloads.put(uuid, payload);
}
this.listenToNativeCallsState();
telecomManager.addNewIncomingCall(handle, extras);
}

Expand Down Expand Up @@ -1201,19 +1244,14 @@ public Activity getCurrentReactActivity() {
return this.reactContext.getCurrentActivity();
}

private void registerPhoneAccount(Context appContext) {
private static void registerPhoneAccount(Context context) {
if (!isConnectionServiceAvailable()) {
Log.w(TAG, "[RNCallKeepModule] registerPhoneAccount ignored due to no ConnectionService");
return;
}

this.initializeTelecomManager();
Context context = this.getAppContext();
if (context == null) {
Log.w(TAG, "[RNCallKeepModule][registerPhoneAccount] no react context found.");
return;
}
String appName = this.getApplicationName(context);
initializeTelecomManager(context);
String appName = getApplicationName(context);

PhoneAccount.Builder builder = new PhoneAccount.Builder(handle, appName);
if (isSelfManaged()) {
Expand All @@ -1224,8 +1262,8 @@ private void registerPhoneAccount(Context appContext) {
}

if (_settings != null && _settings.hasKey("imageName")) {
int identifier = appContext.getResources().getIdentifier(_settings.getString("imageName"), "drawable", appContext.getPackageName());
Icon icon = Icon.createWithResource(appContext, identifier);
int identifier = context.getResources().getIdentifier(_settings.getString("imageName"), "drawable", context.getPackageName());
Icon icon = Icon.createWithResource(context, identifier);
builder.setIcon(icon);
}

Expand All @@ -1252,7 +1290,7 @@ public void sendEventToJS(String eventName, @Nullable WritableMap params) {
}
}

private String getApplicationName(Context appContext) {
private static String getApplicationName(Context appContext) {
ApplicationInfo applicationInfo = appContext.getApplicationInfo();
int stringId = applicationInfo.labelRes;

Expand Down Expand Up @@ -1324,8 +1362,7 @@ private Context getAppContext() {
}

// Store all callkeep settings in JSON
private WritableMap storeSettings(ReadableMap options) {
Context context = getAppContext();
private static WritableMap storeSettings(@Nullable Context context, ReadableMap options) {
if (context == null) {
Log.w(TAG, "[RNCallKeepModule][storeSettings] no react context found.");
return MapUtils.readableToWritableMap(options);
Expand Down
32 changes: 32 additions & 0 deletions android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,36 @@ public static void setInitialized(boolean value) {
isInitialized = value;
}

/**
* Lets native code follow the call focus Telecom gives self-managed calls (Android 9 and later),
* to start a call's media before React Native runs. When the focus is lost, release the call's
* audio and microphone, then call connectionServiceFocusReleased() on the service.
*/
public interface NativeFocusListener {
void onFocusGained();
void onFocusLost();
}
public static NativeFocusListener nativeFocusListener;
private static boolean connectionServiceFocused = false;

public static boolean hasConnectionServiceFocus() {
return connectionServiceFocused;
}

@Override
public void onConnectionServiceFocusGained() {
super.onConnectionServiceFocusGained();
connectionServiceFocused = true;
if (nativeFocusListener != null) nativeFocusListener.onFocusGained();
}

@Override
public void onConnectionServiceFocusLost() {
super.onConnectionServiceFocusLost();
connectionServiceFocused = false;
if (nativeFocusListener != null) nativeFocusListener.onFocusLost();
}

public static void deinitConnection(String connectionId) {
Log.d(TAG, "[VoiceConnectionService] deinitConnection:" + connectionId);
VoiceConnectionService.hasOutgoingCall = false;
Expand All @@ -165,6 +195,8 @@ public static void deinitConnection(String connectionId) {
}

if (currentConnections.isEmpty()) {
// Telecom doesn't call onConnectionServiceFocusLost when the last call ends.
connectionServiceFocused = false;
Log.d(TAG, "[VoiceConnectionService] deinitConnection. No other connections left -> RUN stopForegroundService!");
currentConnectionService.stopForegroundService();
} else {
Expand Down
30 changes: 30 additions & 0 deletions docs/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,3 +324,33 @@ RNCallKeep.addEventListener('onHasActiveCall', () => {
});

```

## Listening from native code (iOS)

iOS also posts these events to `NotificationCenter.default`, under the names below, with the event's data as `userInfo`. Native code can observe them before a React Native runtime exists, or while JavaScript is busy; the events JavaScript receives are unchanged. `didLoadWithEvents` and `didChangeAudioRoute` are not posted.

Each notification is posted on the thread that sends the event, which can be CallKit's private queue (`didDisplayIncomingCall`, for instance). Observe with `queue: .main`, as below, or move to the main queue yourself before touching the UI or the app's state.

| Event | Notification name |
| ---------------------------- | ---------------------------------------- |
| didReceiveStartCallAction | `RNCallKeepDidReceiveStartCallAction` |
| answerCall | `RNCallKeepPerformAnswerCallAction` |
| endCall | `RNCallKeepPerformEndCallAction` |
| didActivateAudioSession | `RNCallKeepDidActivateAudioSession` |
| didDeactivateAudioSession | `RNCallKeepDidDeactivateAudioSession` |
| didDisplayIncomingCall | `RNCallKeepDidDisplayIncomingCall` |
| didPerformSetMutedCallAction | `RNCallKeepDidPerformSetMutedCallAction` |
| didToggleHoldCallAction | `RNCallKeepDidToggleHoldAction` |
| didPerformDTMFAction | `RNCallKeepDidPerformDTMFAction` |
| didResetProvider | `RNCallKeepProviderReset` |

```swift
NotificationCenter.default.addObserver(
forName: Notification.Name("RNCallKeepPerformAnswerCallAction"), object: nil, queue: .main
) { notification in
let callUUID = notification.userInfo?["callUUID"] as? String
// ...
}
```

On Android, native code receives CallKeep's local broadcasts: see [Setup from native code (Android)](setup.md#setup-from-native-code-android).
15 changes: 15 additions & 0 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,21 @@ On iOS you can also perform setup in `AppDelegate`. Doing this allows capturing

```

## Setup from native code (Android)

In self-managed mode, native code can set CallKeep up and show an incoming call before React Native runs, or without it: from a Firebase messaging service, for instance, when a call push wakes the app. `setupNative` takes the `android` options of `setup()`, with `selfManaged: true` (Android 8 and later):

```kotlin
RNCallKeepModule.setupNative(context, Arguments.createMap().apply {
putBoolean("selfManaged", true)
})
RNCallKeepModule.displayIncomingCall(context, callUUID, number, callerName, false)
```

It stores the settings and registers the phone account as `setup()` does, but creates no module and no React context. Your app then shows its own incoming-call UI and handles the call from CallKeep's local broadcasts: register a receiver with `LocalBroadcastManager` for the `Constants.ACTION_*` actions (`ACTION_SHOW_INCOMING_CALL_UI`, `ACTION_ANSWER_CALL`, `ACTION_END_CALL`, ...); each intent carries the call's `Constants.EXTRA_CALL_UUID` in its `attributeMap` extra. When React Native starts later, its module uses the same settings. Register your receiver before `displayIncomingCall`: the call's actions (`ACTION_SHOW_INCOMING_CALL_UI`, `ACTION_ANSWER_CALL`, `ACTION_END_CALL`, hold, DTMF...) reach only the receivers registered when they happen. Only the connection service's events (`ACTION_DID_DISPLAY_INCOMING_CALL`, `ACTION_ON_CREATE_CONNECTION_FAILED`...) are kept for JavaScript when no receiver gets them, so once your receiver is registered, React Native's [`getInitialEvents()`](api.md#getinitialevents) doesn't return them either.

Telecom gives self-managed calls the call focus (Android 9 and later). To start a call's audio before React Native runs, set `VoiceConnectionService.nativeFocusListener`: `onFocusGained` tells when the call may use audio, and on `onFocusLost` release its audio and microphone, then call `connectionServiceFocusReleased()` on `VoiceConnectionService.currentConnectionService`. `VoiceConnectionService.hasConnectionServiceFocus()` reads the current state; it turns false when the last call ends, for which Telecom calls no `onFocusLost`.

## Foreground service (Android 11 and later)

Since Android 11, an app needs a [foreground service](https://developer.android.com/about/versions/11/privacy/foreground-services) to use the microphone in the background. Set the `foregroundService` key in the [`setup()` options](#options), and declare the service's types in the manifest: see [Android installation](android-installation.md#manifest).
7 changes: 7 additions & 0 deletions ios/RNCallKeep/RNCallKeep.m
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,13 @@ - (void)onAudioRouteChange:(NSNotification *)notification
- (void)sendEventWithNameWrapper:(NSString *)name body:(id)body {
NSLog(@"[RNCallKeep] sendEventWithNameWrapper: %@, hasListeners : %@", name, _hasListeners ? @"YES": @"NO");

// Native code can observe every event too, under the same name with the body as userInfo,
// as Android's local broadcasts allow: before a React Native runtime exists, and while
// JavaScript is busy. The event sent to JavaScript below is unchanged.
[[NSNotificationCenter defaultCenter] postNotificationName:name
object:nil
userInfo:body ?: @{}];

if (_hasListeners) {
[self sendEventWithName:name body:body];
} else {
Expand Down
71 changes: 71 additions & 0 deletions tests/android/CallKeepNativeSetupTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package io.wazo.callkeep

import android.os.Build
import android.telecom.Connection
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.bridge.Arguments
import java.util.UUID
import org.junit.Assert.*
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith

/**
* Setup and an incoming call from native code, without a React context or a CallKeep module, then
* Telecom's call focus, gained by the active call and gone with the last call. Same host setup as CallKeepSelfManagedTest. Android 9+ (the call focus).
*/
@RunWith(AndroidJUnit4::class)
class CallKeepNativeSetupTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val context get() = instrumentation.targetContext

private fun waitFor(condition: () -> Boolean): Boolean {
val deadline = System.currentTimeMillis() + 5000
while (System.currentTimeMillis() < deadline) {
var met = false
instrumentation.runOnMainSync { met = condition() }
if (met) return true
Thread.sleep(100)
}
return false
}

@Test fun aNativeIncomingCallGetsTheCallFocusUntilItEnds() {
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
// Other fixtures may have created the module in this process: this one runs without it.
val module = RNCallKeepModule.instance
RNCallKeepModule.instance = null
var gained = false
val uuid = UUID.randomUUID().toString()
try {
instrumentation.runOnMainSync {
VoiceConnectionService.nativeFocusListener = object : VoiceConnectionService.NativeFocusListener {
override fun onFocusGained() { gained = true }
override fun onFocusLost() { VoiceConnectionService.currentConnectionService?.connectionServiceFocusReleased() }
}
RNCallKeepModule.setupNative(context, Arguments.createMap().apply { putBoolean("selfManaged", true) })
RNCallKeepModule.displayIncomingCall(context, uuid, "5551007", "Native call", false)
}
assertTrue("Telecom did not create the incoming connection", waitFor { VoiceConnectionService.getConnection(uuid) != null })
instrumentation.runOnMainSync {
assertNull("native setup created a module", RNCallKeepModule.instance)
assertEquals(Connection.PROPERTY_SELF_MANAGED,
VoiceConnectionService.getConnection(uuid)!!.connectionProperties and Connection.PROPERTY_SELF_MANAGED)
VoiceConnectionService.getConnection(uuid)!!.onAnswer()
VoiceConnectionService.setState(uuid, Connection.STATE_ACTIVE)
}
assertTrue("the active call did not get the call focus",
waitFor { gained && VoiceConnectionService.hasConnectionServiceFocus() })
instrumentation.runOnMainSync { VoiceConnectionService.getConnection(uuid)!!.onDisconnect() }
assertTrue("the call focus outlived the last call",
waitFor { VoiceConnectionService.getConnection(uuid) == null && !VoiceConnectionService.hasConnectionServiceFocus() })
} finally {
instrumentation.runOnMainSync {
VoiceConnectionService.getConnection(uuid)?.onDisconnect()
VoiceConnectionService.nativeFocusListener = null
}
RNCallKeepModule.instance = module
}
}
}
Loading