diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c0460e7..337ad73b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ are not listed here. ### Fixed +- **Android:** The foreground service keeps the importance and sound of the notification channel named by `foregroundService.channelId` when that channel already exists; its name still follows `channelName`. An app could pass its own channel, such as the one its incoming calls ring in: creating it again with `IMPORTANCE_NONE` lowered its importance (from high to low on a Galaxy S24, Android 16), and its notifications stopped alerting. +- **Android:** A call created in a process where the CallKeep module doesn't exist gets its foreground service. The service read the stored settings only through the module: a call placed from the Phone app while the app isn't running started no foreground service, and an incoming call added from native code, whose connection loads the settings, crashed the app with a `NullPointerException`. Without the module, the notification opens no activity. - **Android:** `checkIsInManagedCall()`, `setAudioRoute()` and `toggleAudioRouteSpeaker()` no longer crash the app on Android 7 with `NoSuchMethodError`: they called Android 8 APIs. Before Android 8, `checkIsInManagedCall()` reports any ongoing call (there are no self-managed calls then), `setAudioRoute()` rejects and `toggleAudioRouteSpeaker()` does nothing. - **Android:** `setAudioRoute()` rejects when no call has the given UUID. Its promise never settled. - **Android:** Android lint passes on the library ([#646](https://github.com/react-native-webrtc/react-native-callkeep/issues/646)): `startCall` and `checkDefaultPhoneAccount` handle a `SecurityException` from Telecom (a permission can be revoked at any time), `endCall` uses `AudioManager.MODE_NORMAL`, and the library's `build.gradle` no longer declares the Android Gradle Plugin 3.0.1: the app's plugin builds it. ([#684](https://github.com/react-native-webrtc/react-native-callkeep/pull/684), @ramijarrar) diff --git a/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java b/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java index 32bd1995..80deb577 100644 --- a/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java +++ b/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java @@ -314,7 +314,7 @@ private void startForegroundService() { return; } Log.d(TAG, "[VoiceConnectionService] startForegroundService"); - ReadableMap foregroundSettings = getForegroundSettings(null); + ReadableMap foregroundSettings = getForegroundSettings(this); if (!this.isForegroundServiceConfigured()) { Log.w(TAG, "[VoiceConnectionService] Not creating foregroundService because not configured"); @@ -327,7 +327,16 @@ private void startForegroundService() { chan.setLockscreenVisibility(Notification.VISIBILITY_PRIVATE); NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); assert manager != null; - manager.createNotificationChannel(chan); + // An existing channel keeps its importance and sound: creating it again with IMPORTANCE_NONE + // would lower those of an app's own channel, whose notifications would stop alerting. Its name + // still follows the options. + NotificationChannel existing = manager.getNotificationChannel(NOTIFICATION_CHANNEL_ID); + if (existing == null) { + manager.createNotificationChannel(chan); + } else if (channelName != null && !channelName.contentEquals(existing.getName())) { + existing.setName(channelName); + manager.createNotificationChannel(existing); + } NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID); notificationBuilder.setOngoing(true) @@ -335,7 +344,10 @@ private void startForegroundService() { .setPriority(NotificationManager.IMPORTANCE_MIN) .setCategory(Notification.CATEGORY_SERVICE); - Activity currentActivity = RNCallKeepModule.instance.getCurrentReactActivity(); + // Telecom can create a connection in a process where the module doesn't exist yet: a call + // placed from the Phone app while the app isn't running, or one added from native code. + Activity currentActivity = RNCallKeepModule.instance == null + ? null : RNCallKeepModule.instance.getCurrentReactActivity(); if (currentActivity != null) { Intent notificationIntent = new Intent(this, currentActivity.getClass()); notificationIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); @@ -367,7 +379,7 @@ private void startForegroundService() { private void stopForegroundService() { Log.d(TAG, "[VoiceConnectionService] stopForegroundService"); - ReadableMap foregroundSettings = getForegroundSettings(null); + ReadableMap foregroundSettings = getForegroundSettings(this); if (!this.isForegroundServiceConfigured()) { Log.w(TAG, "[VoiceConnectionService] Not creating foregroundService because not configured"); @@ -382,7 +394,7 @@ private void stopForegroundService() { } private boolean isForegroundServiceConfigured() { - ReadableMap foregroundSettings = getForegroundSettings(null); + ReadableMap foregroundSettings = getForegroundSettings(this); try { return foregroundSettings != null && foregroundSettings.hasKey("channelId"); } catch (Exception e) { diff --git a/docs/setup.md b/docs/setup.md index f5b61ee5..09e76ea9 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -80,7 +80,7 @@ RNCallKeep.setup(options).then(accepted => {}); multiple popups to the user at different times. - `foregroundService`: object (optional) Keeps the app and its microphone running while a call is in progress; see [Foreground service](#foreground-service-android-11-and-later). - - `channelId`: string, the notification channel the service creates + - `channelId`: string, the notification channel the service posts in. CallKeep creates it if it doesn't exist; an existing channel, such as one your app created, keeps its importance and sound, and takes `channelName` - `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` diff --git a/tests/android/CallKeepForegroundServiceTest.kt b/tests/android/CallKeepForegroundServiceTest.kt index 0a35b199..b954ee03 100644 --- a/tests/android/CallKeepForegroundServiceTest.kt +++ b/tests/android/CallKeepForegroundServiceTest.kt @@ -1,15 +1,22 @@ package io.wazo.callkeep import android.app.ActivityManager +import android.app.NotificationChannel +import android.app.NotificationManager import android.content.Context +import android.net.Uri import android.os.Build +import android.os.Bundle import android.os.Handler import android.os.HandlerThread import android.telecom.Connection +import android.telecom.PhoneAccount +import android.telecom.TelecomManager 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 @@ -49,20 +56,21 @@ class CallKeepForegroundServiceTest { return condition() } + private fun selfManagedWithForegroundService(channelId: String): WritableMap = + Arguments.createMap().apply { + putBoolean("selfManaged", true) + putMap("foregroundService", Arguments.createMap().apply { + putString("channelId", channelId) + putString("channelName", "CallKeep test") + putString("notificationTitle", "Call in progress") + }) + } + /** The first call is answered, a second one comes in and is ended: the first call keeps its foreground service. */ @Test fun endingTheSecondCallKeepsTheForegroundServiceOfTheFirst() { assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) val module = RNCallKeepModule.getInstance(BridgeReactContext(context), true) - onModuleThread { - module.setup(Arguments.createMap().apply { - putBoolean("selfManaged", true) - putMap("foregroundService", Arguments.createMap().apply { - putString("channelId", "io.wazo.callkeep.test") - putString("channelName", "CallKeep test") - putString("notificationTitle", "Call in progress") - }) - }) - } + onModuleThread { module.setup(selfManagedWithForegroundService("io.wazo.callkeep.test")) } val first = UUID.randomUUID().toString() val second = UUID.randomUUID().toString() @@ -87,4 +95,64 @@ class CallKeepForegroundServiceTest { assertTrue("foreground service still running after the last call", waitFor { !isForeground() }) moduleThread.quitSafely() } + + /** The app passes its own channel, which rings: the service posts in it, keeps its importance and renames it. */ + @Test fun theForegroundServiceKeepsTheAppsOwnChannel() { + assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + val manager = context.getSystemService(NotificationManager::class.java) + // A deleted channel comes back with its old settings when its id is created again: a fresh id + // keeps one run from inheriting another's. + val channelId = "io.wazo.callkeep.test.app." + UUID.randomUUID() + manager.createNotificationChannel( + NotificationChannel(channelId, "Incoming calls", NotificationManager.IMPORTANCE_HIGH)) + val module = RNCallKeepModule.getInstance(BridgeReactContext(context), true) + onModuleThread { module.setup(selfManagedWithForegroundService(channelId)) } + val uuid = UUID.randomUUID().toString() + try { + onModuleThread { module.displayIncomingCall(uuid, "5551003", "App channel", false, null) } + assertTrue("call not created", waitFor { VoiceConnectionService.currentConnections.containsKey(uuid) }) + assertTrue("no foreground service", waitFor { isForeground() }) + val channel = manager.getNotificationChannel(channelId) + assertEquals("the app's channel lost its importance", NotificationManager.IMPORTANCE_HIGH, channel.importance) + assertEquals("the channel's name doesn't follow the options", "CallKeep test", channel.name.toString()) + } finally { + onModuleThread { module.endCall(uuid) } + // Android refuses to delete a channel a foreground service still posts in. + if (waitFor { !isForeground() }) manager.deleteNotificationChannel(channelId) + moduleThread.quitSafely() + } + } + + /** + * A call added from native code in a process where the module doesn't exist: the service reads the + * stored settings itself. Clearing `instance` and the loaded settings stands for that process. + */ + @Test fun theForegroundServiceStartsWithoutTheModule() { + assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + val module = RNCallKeepModule.getInstance(BridgeReactContext(context), true) + onModuleThread { module.setup(selfManagedWithForegroundService("io.wazo.callkeep.test")) } + val uuid = UUID.randomUUID().toString() + val loadedSettings = RNCallKeepModule::class.java.getDeclaredField("_settings").apply { isAccessible = true } + val settings = loadedSettings.get(null) + RNCallKeepModule.instance = null + loadedSettings.set(null, null) + try { + val extras = Bundle().apply { + putParcelable(TelecomManager.EXTRA_INCOMING_CALL_ADDRESS, + Uri.fromParts(PhoneAccount.SCHEME_TEL, "5551004", null)) + putString(Constants.EXTRA_CALL_UUID, uuid) + putString(Constants.EXTRA_CALLER_NAME, "No module") + putString(Constants.EXTRA_SELF_MANAGED, "true") + } + context.getSystemService(TelecomManager::class.java).addNewIncomingCall(RNCallKeepModule.handle, extras) + assertTrue("call not created", waitFor { VoiceConnectionService.currentConnections.containsKey(uuid) }) + assertTrue("no foreground service", waitFor { isForeground() }) + } finally { + RNCallKeepModule.instance = module + loadedSettings.set(null, settings) + onModuleThread { module.endCall(uuid) } + waitFor { !isForeground() } + moduleThread.quitSafely() + } + } }