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

## [Unreleased]

### Added

- **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.

### Changed

- **Docs:** The Expo guide explains the config plugin's setup and what to add to it: the `audio` background mode and microphone text on iOS; on Android, the foreground service permissions, without which Android 14 and later refuse CallKeep's foreground service, and the `phoneCall|microphone` service types; `RECORD_AUDIO` with Expo SDK 54 and earlier. ([#849](https://github.com/react-native-webrtc/react-native-callkeep/pull/849), @sultanmyrza)
Expand Down
5 changes: 4 additions & 1 deletion android/src/main/java/io/wazo/callkeep/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,8 @@ public class Constants {
// Can't use telecom.EXTRA_DISABLE_ADD_CALL ...
public static final String EXTRA_DISABLE_ADD_CALL = "android.telecom.extra.DISABLE_ADD_CALL";

public static final int FOREGROUND_SERVICE_TYPE_MICROPHONE = 128;
// The id of the foreground service's notification. FOREGROUND_SERVICE_TYPE_MICROPHONE is the same
// value under its historical name, kept for apps that use it: it isn't a service type here.
public static final int FOREGROUND_SERVICE_NOTIFICATION_ID = 128;
public static final int FOREGROUND_SERVICE_TYPE_MICROPHONE = FOREGROUND_SERVICE_NOTIFICATION_ID;
}
7 changes: 7 additions & 0 deletions android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,13 @@ public void onHostPause() {

@Override
public void onHostDestroy() {
// Calls that must outlive the activity (owned by native code, for instance) stay, and so
// does the process.
WritableMap settings = getSettings(this.getAppContext());
if (settings != null && settings.hasKey("endCallsOnHostDestroy") && !settings.getBoolean("endCallsOnHostDestroy")) {
Log.d(TAG, "[RNCallKeepModule] onHostDestroy: calls kept, endCallsOnHostDestroy is false");
return;
}
// When activity destroyed end all calls
Log.d(TAG, "[RNCallKeepModule] onHostDestroy called");
if (!isConnectionServiceAvailable() || !hasPhoneAccount()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
import static io.wazo.callkeep.Constants.EXTRA_CALL_UUID;
import static io.wazo.callkeep.Constants.EXTRA_SELF_MANAGED;
import static io.wazo.callkeep.Constants.EXTRA_DISABLE_ADD_CALL;
import static io.wazo.callkeep.Constants.FOREGROUND_SERVICE_TYPE_MICROPHONE;
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_DID_DISPLAY_INCOMING_CALL;
import static io.wazo.callkeep.Constants.EXTRA_HAS_VIDEO;
Expand Down Expand Up @@ -359,7 +359,14 @@ private void startForegroundService() {
Notification notification = notificationBuilder.build();

try {
startForeground(FOREGROUND_SERVICE_TYPE_MICROPHONE, notification);
// Without a type, Android 10 and later start the service with every type its manifest
// entry declares.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && foregroundSettings.hasKey("foregroundServiceType")) {
startForeground(FOREGROUND_SERVICE_NOTIFICATION_ID, notification,
foregroundSettings.getInt("foregroundServiceType"));
} else {
startForeground(FOREGROUND_SERVICE_NOTIFICATION_ID, notification);
}
} catch (Exception e) {
Log.w(TAG, "[VoiceConnectionService] Can't start foreground service : " + e.toString());
}
Expand Down
3 changes: 3 additions & 0 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ RNCallKeep.setup(options).then(accepted => {});
- `channelName`: string, the channel's name in the system settings
- `notificationTitle`: string
- `notificationIcon`: string (optional), the name of a `mipmap` resource, such as `ic_launcher`
- `foregroundServiceType`: number (optional, Android 10 and later), the types the service starts with, as a mask of [`ServiceInfo.FOREGROUND_SERVICE_TYPE_*`](https://developer.android.com/reference/android/content/pm/ServiceInfo#FOREGROUND_SERVICE_TYPE_PHONE_CALL) values: `4` for `phoneCall`, `132` for `phoneCall` and `microphone`. Each type must be declared on the service in the manifest. Without it, the service starts with every type the manifest declares. CallKeep starts the service when the call is created and keeps these types for the whole call: with `4` alone, an answered call has no microphone access while the app is in the background. To add `microphone` once the call is answered, call `startForeground(Constants.FOREGROUND_SERVICE_NOTIFICATION_ID, notification, types)` on `VoiceConnectionService.currentConnectionService` from native code, with both types
- `endCallsOnHostDestroy`: boolean (optional, default `true`)
When the React activity is destroyed, CallKeep ends every call and kills the app's process. Set it to `false` when your calls must outlive the activity, for instance when native code owns them: ending them is then up to your app.
- `selfManaged`: boolean (optional)
When set to true, call keep will configure itself to run as a self managed connection service. See the [self-managed mode guide](guides/android-self-managed.md) and [Google's documentation](https://developer.android.com/guide/topics/connectivity/telecom/selfManaged).
- `displayCallReachabilityTimeout`: number in ms (optional)
Expand Down
6 changes: 5 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,15 @@ declare module 'react-native-callkeep' {
imageName?: string,
additionalPermissions: string[],
selfManaged?: boolean,
/** End every call and the process when the React activity is destroyed (default true). */
endCallsOnHostDestroy?: boolean,
foregroundService?: {
channelId: string,
channelName: string,
notificationTitle: string,
notificationIcon?: string
notificationIcon?: string,
/** Android 10+: a mask of ServiceInfo.FOREGROUND_SERVICE_TYPE_* values; default: the manifest's types. */
foregroundServiceType?: number
}
}
}
Expand Down
115 changes: 115 additions & 0 deletions tests/android/CallKeepCallLifetimeOptionsTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package io.wazo.callkeep

import android.app.ActivityManager
import android.content.ComponentName
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
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.WritableMap
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.junit.Assert.*
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith

/**
* The `foregroundService.foregroundServiceType` and `endCallsOnHostDestroy` options, through Telecom.
* Same host setup as CallKeepForegroundServiceTest. The host manifest must declare
* `phoneCall|microphone` on VoiceConnectionService: with `phoneCall` alone, the service starts with
* `phoneCall` without the option too, and the type test would prove nothing, so it asserts this.
* Android 8+, and 10+ for the service type.
*/
@RunWith(AndroidJUnit4::class)
@Suppress("DEPRECATION")
class CallKeepCallLifetimeOptionsTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val context: Context get() = instrumentation.targetContext

private fun isForeground(): Boolean =
context.getSystemService(ActivityManager::class.java).getRunningServices(Int.MAX_VALUE)
.any { it.service.className == VoiceConnectionService::class.java.name && it.foreground }

// React Native calls module methods on its native-modules thread, which has a looper.
private val moduleThread = HandlerThread("mqt_native_modules").apply { start() }

private fun onModuleThread(work: () -> Unit) {
val done = CountDownLatch(1)
Handler(moduleThread.looper).post { work(); done.countDown() }
assertTrue("module call did not return", done.await(3, TimeUnit.SECONDS))
}

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

private fun selfManagedOptions(foregroundServiceType: Int? = null, endCallsOnHostDestroy: Boolean? = null): WritableMap =
Arguments.createMap().apply {
putBoolean("selfManaged", true)
endCallsOnHostDestroy?.let { putBoolean("endCallsOnHostDestroy", it) }
putMap("foregroundService", Arguments.createMap().apply {
putString("channelId", "io.wazo.callkeep.test")
putString("channelName", "CallKeep test")
putString("notificationTitle", "Call in progress")
foregroundServiceType?.let { putInt("foregroundServiceType", it) }
})
}

/** A call can ring with the `phoneCall` type alone, although the manifest also declares `microphone`. */
@Test fun theForegroundServiceStartsWithTheGivenType() {
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
val bothTypes = ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
val declared = context.packageManager
.getServiceInfo(ComponentName(context, VoiceConnectionService::class.java), 0).foregroundServiceType
assertEquals("the host must declare phoneCall|microphone on VoiceConnectionService", bothTypes, declared and bothTypes)
val module = RNCallKeepModule.getInstance(BridgeReactContext(context), true)
onModuleThread {
module.setup(selfManagedOptions(foregroundServiceType = ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL))
}
val uuid = UUID.randomUUID().toString()
try {
onModuleThread { module.displayIncomingCall(uuid, "5551005", "Phone call type", false, null) }
assertTrue("call not created", waitFor { VoiceConnectionService.currentConnections.containsKey(uuid) })
assertTrue("no foreground service", waitFor { isForeground() })
assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL,
VoiceConnectionService.currentConnectionService!!.foregroundServiceType)
} finally {
onModuleThread { module.endCall(uuid) }
waitFor { !isForeground() }
moduleThread.quitSafely()
}
}

/** With `endCallsOnHostDestroy: false`, destroying the activity leaves the call and the process. */
@Test fun theCallOutlivesTheActivityWhenAsked() {
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
val module = RNCallKeepModule.getInstance(BridgeReactContext(context), true)
onModuleThread { module.setup(selfManagedOptions(endCallsOnHostDestroy = false)) }
val uuid = UUID.randomUUID().toString()
try {
onModuleThread { module.displayIncomingCall(uuid, "5551006", "Outlives the activity", false, null) }
assertTrue("call not created", waitFor { VoiceConnectionService.currentConnections.containsKey(uuid) })
// React Native calls this on the main thread. Without the option, it ends the calls and kills
// the process, which the instrumentation reports as a crash.
instrumentation.runOnMainSync { module.onHostDestroy() }
Thread.sleep(500)
assertTrue("the call ended with the activity", VoiceConnectionService.currentConnections.containsKey(uuid))
} finally {
onModuleThread { module.endCall(uuid) }
waitFor { !isForeground() }
moduleThread.quitSafely()
}
}
}
Loading