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 @@ -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)
Expand Down
22 changes: 17 additions & 5 deletions android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -327,15 +327,27 @@ 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)
.setContentTitle(foregroundSettings.getString("notificationTitle"))
.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);
Expand Down Expand Up @@ -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");
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
88 changes: 78 additions & 10 deletions tests/android/CallKeepForegroundServiceTest.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()

Expand All @@ -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()
}
}
}
Loading