diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c0460e7..f2132038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/android/src/main/java/io/wazo/callkeep/Constants.java b/android/src/main/java/io/wazo/callkeep/Constants.java index de5821a9..e1ed042a 100644 --- a/android/src/main/java/io/wazo/callkeep/Constants.java +++ b/android/src/main/java/io/wazo/callkeep/Constants.java @@ -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; } diff --git a/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java b/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java index a1010773..7ff18628 100644 --- a/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java +++ b/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java @@ -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()) { diff --git a/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java b/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java index 32bd1995..1d844a11 100644 --- a/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java +++ b/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java @@ -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; @@ -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()); } diff --git a/docs/setup.md b/docs/setup.md index f5b61ee5..36baeaba 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -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) diff --git a/index.d.ts b/index.d.ts index baa5c683..eaf13b3f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -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 } } } diff --git a/tests/android/CallKeepCallLifetimeOptionsTest.kt b/tests/android/CallKeepCallLifetimeOptionsTest.kt new file mode 100644 index 00000000..d17d5906 --- /dev/null +++ b/tests/android/CallKeepCallLifetimeOptionsTest.kt @@ -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() + } + } +}