From 40597d0251e298adae613a1bf959374d3bddc587 Mon Sep 17 00:00:00 2001 From: Dmytro Date: Fri, 3 Jul 2026 19:54:04 +0300 Subject: [PATCH 1/6] fix(android): stop ActiveCallService when restarted with no calls metadata A START_STICKY restart after a process kill delivers a null intent, leaving ActiveCallService with an empty calls list. NotificationManager tracks active calls in its own static list (reset by the process death), so nothing ever stops the orphaned instance: its ongoing FGS notification stays in the shade, cannot be swiped away, and Hang up has no visible effect (tearDownService tears down the connection services but does not stop this one). - onStartCommand: after satisfying the startForeground contract, an instance with no calls metadata logs a WARN, removes the notification and stops itself, returning START_NOT_STICKY - hungUpCall: the no-calls branch keeps the tearDownService fallback and now also removes the notification and stops the service - IncomingCallService.performAnswerCall: drop the misleading START_STICKY return value - it is called from onConnectionEvent only and the value never reaches onStartCommand - build.gradle: includeAndroidResources so Robolectric can build service notifications in unit tests - 5 Robolectric tests covering the restart guard, both Hang up branches and the normal foreground path --- webtrit_callkeep_android/android/build.gradle | 8 ++ .../services/active_call/ActiveCallService.kt | 36 ++++- .../incoming_call/IncomingCallService.kt | 4 +- .../ActiveCallServiceRestartTest.kt | 123 ++++++++++++++++++ 4 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 webtrit_callkeep_android/android/src/test/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallServiceRestartTest.kt diff --git a/webtrit_callkeep_android/android/build.gradle b/webtrit_callkeep_android/android/build.gradle index b5ccdc96..5ee9bd00 100644 --- a/webtrit_callkeep_android/android/build.gradle +++ b/webtrit_callkeep_android/android/build.gradle @@ -68,6 +68,14 @@ android { main.java.srcDirs += 'src/main/kotlin' test.java.srcDirs += 'src/test/kotlin' } + + testOptions { + unitTests { + // Robolectric needs the library's own resources (notification strings/icons) + // to build service notifications in unit tests. + includeAndroidResources = true + } + } } kotlin { diff --git a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt index 772e447b..77133b3f 100644 --- a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt +++ b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt @@ -49,6 +49,9 @@ class ActiveCallService : Service() { activeCallNotificationBuilder.setCallsMetaData(callsMetadata) val notification = activeCallNotificationBuilder.build() + // startForeground must be called even when there are no calls to show: the service may + // have been (re)started as foreground and skipping the promotion would kill the process + // with ForegroundServiceDidNotStartInTimeException. startForegroundServiceCompat( this, ActiveCallNotificationBuilder.NOTIFICATION_ID, @@ -56,13 +59,34 @@ class ActiveCallService : Service() { getForegroundServiceTypes(callsMetadata), ) + if (callsMetadata.isEmpty()) { + // Empty metadata means a START_STICKY restart delivered a null intent after the + // process was killed. NotificationManager tracks active calls in its own static + // list, so nothing will ever stop this orphaned instance - an ongoing notification + // left here would be undismissable until the user force-stops the app. + Log.w(TAG, "onStartCommand: no calls metadata (null-intent restart), stopping self") + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + return START_NOT_STICKY + } + return START_STICKY } - private fun hungUpCall() = - callsMetadata.firstOrNull()?.let { - CallkeepCore.instance.startHungUpCall(it) - } ?: CallkeepCore.instance.tearDownService() + private fun hungUpCall() { + val call = callsMetadata.firstOrNull() + if (call != null) { + CallkeepCore.instance.startHungUpCall(call) + } else { + // Hang up tapped on a notification with no known calls (re-posted by a null-intent + // restart). tearDownService only tears down the connection services and does not + // stop this one, so the notification has to be removed here. + Log.w(TAG, "hungUpCall: no calls metadata, tearing down and stopping self") + CallkeepCore.instance.tearDownService() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } private fun getForegroundServiceTypes(callsMetadata: List): Int? = when { @@ -102,4 +126,8 @@ class ActiveCallService : Service() { stopForeground(STOP_FOREGROUND_REMOVE) super.onDestroy() } + + companion object { + private const val TAG = "ActiveCallService" + } } diff --git a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/incoming_call/IncomingCallService.kt b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/incoming_call/IncomingCallService.kt index f445e711..9a82593f 100644 --- a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/incoming_call/IncomingCallService.kt +++ b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/incoming_call/IncomingCallService.kt @@ -280,9 +280,9 @@ class IncomingCallService : return START_NOT_STICKY } - private fun performAnswerCall(metadata: CallMetadata): Int { + // Called from onConnectionEvent only, never from onStartCommand - no start mode to return. + private fun performAnswerCall(metadata: CallMetadata) { callLifecycleHandler.performAnswerCall(metadata) - return START_STICKY } // Launches the service with the LAUNCH action and cancels the timeout diff --git a/webtrit_callkeep_android/android/src/test/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallServiceRestartTest.kt b/webtrit_callkeep_android/android/src/test/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallServiceRestartTest.kt new file mode 100644 index 00000000..abe1f414 --- /dev/null +++ b/webtrit_callkeep_android/android/src/test/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallServiceRestartTest.kt @@ -0,0 +1,123 @@ +package com.webtrit.callkeep.services.services.active_call + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.test.core.app.ApplicationProvider +import com.webtrit.callkeep.models.CallMetadata +import com.webtrit.callkeep.models.NotificationAction +import com.webtrit.callkeep.notifications.ActiveCallNotificationBuilder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * Unit tests for the [ActiveCallService] empty-metadata guard. + * + * A START_STICKY restart after a process kill delivers a null intent, leaving + * [ActiveCallService] with no calls metadata. NotificationManager tracks active calls in its + * own static list (reset by the process death), so nothing external ever stops such an + * orphaned instance: without the guard its ongoing FGS notification stays in the shade, + * cannot be swiped away, and the Hang up action has no visible effect. + * + * The guard: after satisfying the startForeground contract, an instance with no calls + * removes its notification and stops itself; the empty branch of hungUpCall does the same + * on top of the existing tearDownService fallback. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE]) +class ActiveCallServiceRestartTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + private fun buildService(): ActiveCallService = Robolectric.buildService(ActiveCallService::class.java).create().get() + + private fun metadataIntent(vararg callIds: String): Intent = + Intent(context, ActiveCallService::class.java).apply { + putParcelableArrayListExtra( + "metadata", + ArrayList(callIds.map { CallMetadata(callId = it).toBundle() }), + ) + } + + private fun declineIntent(): Intent = + Intent(context, ActiveCallService::class.java).apply { + action = NotificationAction.Decline.action + } + + @Test + fun `null-intent restart stops self and removes the notification`() { + val service = buildService() + + val result = service.onStartCommand(null, 0, 1) + + assertEquals(Service.START_NOT_STICKY, result) + val shadow = shadowOf(service) + assertTrue("service must stop itself", shadow.isStoppedBySelf) + assertTrue("foreground must be stopped", shadow.isForegroundStopped) + assertTrue("notification must be removed", shadow.notificationShouldRemoved) + } + + @Test + fun `start with an explicitly empty metadata list stops self`() { + val service = buildService() + + val result = service.onStartCommand(metadataIntent(), 0, 1) + + assertEquals(Service.START_NOT_STICKY, result) + val shadow = shadowOf(service) + assertTrue(shadow.isStoppedBySelf) + assertTrue(shadow.isForegroundStopped) + assertTrue(shadow.notificationShouldRemoved) + } + + @Test + fun `start with calls metadata stays foreground and sticky`() { + val service = buildService() + + val result = service.onStartCommand(metadataIntent("call-1"), 0, 1) + + assertEquals(Service.START_STICKY, result) + val shadow = shadowOf(service) + assertFalse("service must keep running", shadow.isStoppedBySelf) + assertFalse("foreground must stay", shadow.isForegroundStopped) + assertEquals(ActiveCallNotificationBuilder.NOTIFICATION_ID, shadow.lastForegroundNotificationId) + } + + @Test + fun `hang up with no known calls stops self and removes the notification`() { + // Decline tapped on a notification re-posted by a null-intent restart: callsMetadata + // is empty, tearDownService alone does not stop this service. + val service = buildService() + + val result = service.onStartCommand(declineIntent(), 0, 1) + + assertEquals(Service.START_NOT_STICKY, result) + val shadow = shadowOf(service) + assertTrue("service must stop itself", shadow.isStoppedBySelf) + assertTrue("foreground must be stopped", shadow.isForegroundStopped) + assertTrue("notification must be removed", shadow.notificationShouldRemoved) + } + + @Test + fun `hang up with a known call keeps the service running`() { + // The normal path: the hangup is routed to the connection service; this service is + // stopped later by NotificationManager once the connection reports disconnect. + val service = buildService() + service.onStartCommand(metadataIntent("call-1"), 0, 1) + + val result = service.onStartCommand(declineIntent(), 0, 2) + + assertEquals(Service.START_NOT_STICKY, result) + val shadow = shadowOf(service) + assertFalse("service must keep running until the connection disconnects", shadow.isStoppedBySelf) + assertFalse("foreground must stay", shadow.isForegroundStopped) + } +} From 97946fd8039df23d011287ea5c5504c9a1fae9d4 Mon Sep 17 00:00:00 2001 From: Dmytro Date: Fri, 3 Jul 2026 20:21:07 +0300 Subject: [PATCH 2/6] fix(android): degrade foreground type when microphone promotion is rejected On Android 14+ promoting ActiveCallService with the MICROPHONE foreground type from the background throws SecurityException (while-in-use restriction). The promotion runs before the empty-metadata guard, so on the very START_STICKY null-intent restart the guard targets, the service crash-loops and the guard never executes. Mirror the StandaloneCallService fallback: catch the SecurityException and re-promote with the phone-call type only, which is not while-in-use restricted, so the promotion and the guard behind it always run. --- .../services/active_call/ActiveCallService.kt | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt index 77133b3f..8f588c08 100644 --- a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt +++ b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt @@ -52,12 +52,28 @@ class ActiveCallService : Service() { // startForeground must be called even when there are no calls to show: the service may // have been (re)started as foreground and skipping the promotion would kill the process // with ForegroundServiceDidNotStartInTimeException. - startForegroundServiceCompat( - this, - ActiveCallNotificationBuilder.NOTIFICATION_ID, - notification, - getForegroundServiceTypes(callsMetadata), - ) + // + // On Android 14+ the MICROPHONE type is rejected with SecurityException when the + // promotion happens from the background - which is exactly the START_STICKY restart + // after a process kill. Fall back to the phone-call type (not while-in-use restricted) + // so the promotion, and the empty-metadata guard below, run instead of crash-looping + // the restart. + try { + startForegroundServiceCompat( + this, + ActiveCallNotificationBuilder.NOTIFICATION_ID, + notification, + getForegroundServiceTypes(callsMetadata), + ) + } catch (e: SecurityException) { + Log.w(TAG, "onStartCommand: typed promotion rejected, falling back to phone-call type", e) + startForegroundServiceCompat( + this, + ActiveCallNotificationBuilder.NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL, + ) + } if (callsMetadata.isEmpty()) { // Empty metadata means a START_STICKY restart delivered a null intent after the From d28b6fc32f13e6dca945b9f969b35d0f74a5488f Mon Sep 17 00:00:00 2001 From: Dmytro Date: Fri, 3 Jul 2026 20:30:42 +0300 Subject: [PATCH 3/6] fix(android): tear down surviving call legs on empty-metadata restart If LMK kills only the main process, the Telecom call survives in the :callkeep_core process (kept alive by the system server binding). The old unconditional sticky-restart notification accidentally provided the last hang-up handle for that leg: Hang up on the orphan notification reached tearDownService. The empty-metadata guard removed that handle silently, leaving the device stuck in a zombie Telecom call (new incoming calls are treated as busy/call-waiting) with no UI to end it until the app is reopened. Call tearDownService() in the guard branch before stopping: the zombie leg is now cleaned up automatically instead of requiring a manual tap. It must run before stopForeground - while the service is still foreground, the startService toward the connection services is exempt from background-start restrictions (both communicate() implementations are also try/catch-guarded). --- .../services/services/active_call/ActiveCallService.kt | 10 +++++++++- .../active_call/ActiveCallServiceRestartTest.kt | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt index 8f588c08..51c3594b 100644 --- a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt +++ b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt @@ -80,7 +80,15 @@ class ActiveCallService : Service() { // process was killed. NotificationManager tracks active calls in its own static // list, so nothing will ever stop this orphaned instance - an ongoing notification // left here would be undismissable until the user force-stops the app. - Log.w(TAG, "onStartCommand: no calls metadata (null-intent restart), stopping self") + // + // A call leg may have survived in :callkeep_core (Telecom keeps that process alive + // via its own binding), and this restart is the last signal the main process gets + // about it: tear the connection services down so the device is not left stuck in a + // zombie Telecom call with no UI to end it. Must run before stopForeground - while + // this service is still foreground the startService toward the connection services + // is exempt from background-start restrictions. + Log.w(TAG, "onStartCommand: no calls metadata (null-intent restart), tearing down and stopping self") + CallkeepCore.instance.tearDownService() stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() return START_NOT_STICKY diff --git a/webtrit_callkeep_android/android/src/test/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallServiceRestartTest.kt b/webtrit_callkeep_android/android/src/test/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallServiceRestartTest.kt index abe1f414..fcf66be0 100644 --- a/webtrit_callkeep_android/android/src/test/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallServiceRestartTest.kt +++ b/webtrit_callkeep_android/android/src/test/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallServiceRestartTest.kt @@ -29,6 +29,7 @@ import org.robolectric.annotation.Config * cannot be swiped away, and the Hang up action has no visible effect. * * The guard: after satisfying the startForeground contract, an instance with no calls + * tears down the connection services (a call leg may have survived in :callkeep_core), * removes its notification and stops itself; the empty branch of hungUpCall does the same * on top of the existing tearDownService fallback. */ From d3f4e8f54393d9beccd1b68bc4e6212650f8ad05 Mon Sep 17 00:00:00 2001 From: Dmytro Date: Fri, 3 Jul 2026 20:36:41 +0300 Subject: [PATCH 4/6] fix(android): hang up the call carried by the Decline intent extras The Decline PendingIntent built by ActiveCallNotificationBuilder carries the first call's metadata bundle in its extras, but hungUpCall only looked at the in-memory callsMetadata field. A Hang up tap that creates a fresh service instance (the field is empty after process death) therefore always fell into the blunt tearDownService branch: the per-call hangup/SIP BYE flow was skipped and, with multiple calls, all connections were torn down at once. Fall back to the metadata parsed from the intent extras before giving up on the per-call hangup; the tear-down branch remains only for the truly unknown case (notification re-posted by a null-intent restart carries no extras). New test: Decline with metadata only in the extras routes the hangup and keeps the service running. --- .../services/active_call/ActiveCallService.kt | 9 ++++++--- .../ActiveCallServiceRestartTest.kt | 19 ++++++++++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt index 51c3594b..359a0a15 100644 --- a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt +++ b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt @@ -35,7 +35,7 @@ class ActiveCallService : Service() { ): Int { // Handle the hangup action from the notification if (NotificationAction.Decline.action == intent?.action) { - hungUpCall() + hungUpCall(intent.extras?.let(CallMetadata::fromBundleOrNull)) return START_NOT_STICKY } @@ -97,8 +97,11 @@ class ActiveCallService : Service() { return START_STICKY } - private fun hungUpCall() { - val call = callsMetadata.firstOrNull() + private fun hungUpCall(intentMetadata: CallMetadata?) { + // The Decline PendingIntent carries the tapped call's bundle in its extras. When the tap + // creates a fresh service instance (process death cleared callsMetadata), that is the + // only way left to hang up the specific call instead of tearing everything down. + val call = callsMetadata.firstOrNull() ?: intentMetadata if (call != null) { CallkeepCore.instance.startHungUpCall(call) } else { diff --git a/webtrit_callkeep_android/android/src/test/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallServiceRestartTest.kt b/webtrit_callkeep_android/android/src/test/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallServiceRestartTest.kt index fcf66be0..b09232fd 100644 --- a/webtrit_callkeep_android/android/src/test/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallServiceRestartTest.kt +++ b/webtrit_callkeep_android/android/src/test/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallServiceRestartTest.kt @@ -48,9 +48,10 @@ class ActiveCallServiceRestartTest { ) } - private fun declineIntent(): Intent = + private fun declineIntent(metadata: CallMetadata? = null): Intent = Intent(context, ActiveCallService::class.java).apply { action = NotificationAction.Decline.action + metadata?.toBundle()?.let { putExtras(it) } } @Test @@ -107,6 +108,22 @@ class ActiveCallServiceRestartTest { assertTrue("notification must be removed", shadow.notificationShouldRemoved) } + @Test + fun `hang up with metadata only in the intent extras hangs up that call and keeps the service`() { + // Fresh instance created by the notification's Decline PendingIntent after process + // death: the callsMetadata field is empty, but the intent extras carry the tapped + // call's bundle (getHungUpCallIntent puts it there). The hangup must be routed to + // that call instead of falling into the tear-everything-down branch. + val service = buildService() + + val result = service.onStartCommand(declineIntent(CallMetadata(callId = "call-1")), 0, 1) + + assertEquals(Service.START_NOT_STICKY, result) + val shadow = shadowOf(service) + assertFalse("hangup must be routed, not torn down", shadow.isStoppedBySelf) + assertFalse("no notification to remove on a fresh instance", shadow.isForegroundStopped) + } + @Test fun `hang up with a known call keeps the service running`() { // The normal path: the hangup is routed to the connection service; this service is From 8b5b888708fe8df4225e51a6f7cadffcb7e86237 Mon Sep 17 00:00:00 2001 From: Dmytro Date: Fri, 3 Jul 2026 20:39:51 +0300 Subject: [PATCH 5/6] fix(android): stop with startId so a queued metadata start survives The empty-metadata guard and the empty branch of hungUpCall called the no-arg stopSelf(), which stops the service unconditionally. If a newer start carrying real call metadata is already queued behind the current command (the recovering app re-posting its call list right after the sticky restart), the unconditional stop cancels it together with the just-issued foreground notification, leaving a live call without its FGS. stopSelf(startId) only stops the service when no newer start has been delivered, so the queued metadata start proceeds normally. --- .../services/active_call/ActiveCallService.kt | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt index 359a0a15..2c118139 100644 --- a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt +++ b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt @@ -35,7 +35,7 @@ class ActiveCallService : Service() { ): Int { // Handle the hangup action from the notification if (NotificationAction.Decline.action == intent?.action) { - hungUpCall(intent.extras?.let(CallMetadata::fromBundleOrNull)) + hungUpCall(intent.extras?.let(CallMetadata::fromBundleOrNull), startId) return START_NOT_STICKY } @@ -90,14 +90,20 @@ class ActiveCallService : Service() { Log.w(TAG, "onStartCommand: no calls metadata (null-intent restart), tearing down and stopping self") CallkeepCore.instance.tearDownService() stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() + // stopSelf(startId), not stopSelf(): a newer start with real metadata may already + // be queued behind this one (the recovering app re-posting its call list), and an + // unconditional stop would cancel it together with its foreground notification. + stopSelf(startId) return START_NOT_STICKY } return START_STICKY } - private fun hungUpCall(intentMetadata: CallMetadata?) { + private fun hungUpCall( + intentMetadata: CallMetadata?, + startId: Int, + ) { // The Decline PendingIntent carries the tapped call's bundle in its extras. When the tap // creates a fresh service instance (process death cleared callsMetadata), that is the // only way left to hang up the specific call instead of tearing everything down. @@ -107,11 +113,12 @@ class ActiveCallService : Service() { } else { // Hang up tapped on a notification with no known calls (re-posted by a null-intent // restart). tearDownService only tears down the connection services and does not - // stop this one, so the notification has to be removed here. + // stop this one, so the notification has to be removed here. stopSelf(startId) + // keeps a newer queued start with real metadata alive. Log.w(TAG, "hungUpCall: no calls metadata, tearing down and stopping self") CallkeepCore.instance.tearDownService() stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() + stopSelf(startId) } } From 4ea7ed6a9a45c4309f036124d19d26f5dd1233ea Mon Sep 17 00:00:00 2001 From: Dmytro Date: Fri, 3 Jul 2026 20:44:04 +0300 Subject: [PATCH 6/6] refactor(android): dedupe teardown, test assertions and the metadata extra key - ActiveCallService: the tearDownService + stopForeground(REMOVE) + stopSelf(startId) sequence was duplicated in the empty-metadata guard and the empty branch of hungUpCall - extract a tearDownAndStop(startId) helper so a future device-specific workaround lands in one place - the intent extra key "metadata" was a magic string in three places (NotificationManager, ActiveCallService, the test) - promote it to ActiveCallService.EXTRA_CALLS_METADATA and reference it everywhere - ActiveCallServiceRestartTest: the stopped/foreground-stopped/notification- removed assertion block was copy-pasted across three tests - extract assertStoppedAndNotificationRemoved --- .../callkeep/managers/NotificationManager.kt | 2 +- .../services/active_call/ActiveCallService.kt | 33 +++++++++++-------- .../ActiveCallServiceRestartTest.kt | 24 +++++++------- 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/managers/NotificationManager.kt b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/managers/NotificationManager.kt index ccc14398..3521d82c 100644 --- a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/managers/NotificationManager.kt +++ b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/managers/NotificationManager.kt @@ -46,7 +46,7 @@ class NotificationManager { if (activeCalls.isNotEmpty()) { val activeCallsBundles = activeCalls.map { it.toBundle() } val intent = Intent(context, ActiveCallService::class.java) - intent.putExtra("metadata", ArrayList(activeCallsBundles)) + intent.putExtra(ActiveCallService.EXTRA_CALLS_METADATA, ArrayList(activeCallsBundles)) context.startService(intent) } else { context.stopService(Intent(context, ActiveCallService::class.java)) diff --git a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt index 2c118139..65d782f7 100644 --- a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt +++ b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallService.kt @@ -42,7 +42,7 @@ class ActiveCallService : Service() { callsMetadata = intent - ?.parcelableArrayList("metadata") + ?.parcelableArrayList(EXTRA_CALLS_METADATA) ?.map { CallMetadata.fromBundle(it) } ?.toMutableList() ?: mutableListOf() @@ -88,12 +88,7 @@ class ActiveCallService : Service() { // this service is still foreground the startService toward the connection services // is exempt from background-start restrictions. Log.w(TAG, "onStartCommand: no calls metadata (null-intent restart), tearing down and stopping self") - CallkeepCore.instance.tearDownService() - stopForeground(STOP_FOREGROUND_REMOVE) - // stopSelf(startId), not stopSelf(): a newer start with real metadata may already - // be queued behind this one (the recovering app re-posting its call list), and an - // unconditional stop would cancel it together with its foreground notification. - stopSelf(startId) + tearDownAndStop(startId) return START_NOT_STICKY } @@ -112,16 +107,24 @@ class ActiveCallService : Service() { CallkeepCore.instance.startHungUpCall(call) } else { // Hang up tapped on a notification with no known calls (re-posted by a null-intent - // restart). tearDownService only tears down the connection services and does not - // stop this one, so the notification has to be removed here. stopSelf(startId) - // keeps a newer queued start with real metadata alive. + // restart). Log.w(TAG, "hungUpCall: no calls metadata, tearing down and stopping self") - CallkeepCore.instance.tearDownService() - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf(startId) + tearDownAndStop(startId) } } + // Tears down the connection services (a call leg may have survived in :callkeep_core), + // removes the notification and stops this instance. tearDownService only tears down the + // connection services and does not stop this one, so the notification has to be removed + // here. stopSelf(startId), not stopSelf(): a newer start with real metadata may already + // be queued behind the current command (the recovering app re-posting its call list), and + // an unconditional stop would cancel it together with its foreground notification. + private fun tearDownAndStop(startId: Int) { + CallkeepCore.instance.tearDownService() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf(startId) + } + private fun getForegroundServiceTypes(callsMetadata: List): Int? = when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { @@ -162,6 +165,10 @@ class ActiveCallService : Service() { } companion object { + // Intent extra carrying the ArrayList of active calls; the writing side is + // NotificationManager.upsertActiveCallsService. + const val EXTRA_CALLS_METADATA = "metadata" + private const val TAG = "ActiveCallService" } } diff --git a/webtrit_callkeep_android/android/src/test/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallServiceRestartTest.kt b/webtrit_callkeep_android/android/src/test/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallServiceRestartTest.kt index b09232fd..370cd3d1 100644 --- a/webtrit_callkeep_android/android/src/test/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallServiceRestartTest.kt +++ b/webtrit_callkeep_android/android/src/test/kotlin/com/webtrit/callkeep/services/services/active_call/ActiveCallServiceRestartTest.kt @@ -43,7 +43,7 @@ class ActiveCallServiceRestartTest { private fun metadataIntent(vararg callIds: String): Intent = Intent(context, ActiveCallService::class.java).apply { putParcelableArrayListExtra( - "metadata", + ActiveCallService.EXTRA_CALLS_METADATA, ArrayList(callIds.map { CallMetadata(callId = it).toBundle() }), ) } @@ -54,6 +54,13 @@ class ActiveCallServiceRestartTest { metadata?.toBundle()?.let { putExtras(it) } } + private fun assertStoppedAndNotificationRemoved(service: ActiveCallService) { + val shadow = shadowOf(service) + assertTrue("service must stop itself", shadow.isStoppedBySelf) + assertTrue("foreground must be stopped", shadow.isForegroundStopped) + assertTrue("notification must be removed", shadow.notificationShouldRemoved) + } + @Test fun `null-intent restart stops self and removes the notification`() { val service = buildService() @@ -61,10 +68,7 @@ class ActiveCallServiceRestartTest { val result = service.onStartCommand(null, 0, 1) assertEquals(Service.START_NOT_STICKY, result) - val shadow = shadowOf(service) - assertTrue("service must stop itself", shadow.isStoppedBySelf) - assertTrue("foreground must be stopped", shadow.isForegroundStopped) - assertTrue("notification must be removed", shadow.notificationShouldRemoved) + assertStoppedAndNotificationRemoved(service) } @Test @@ -74,10 +78,7 @@ class ActiveCallServiceRestartTest { val result = service.onStartCommand(metadataIntent(), 0, 1) assertEquals(Service.START_NOT_STICKY, result) - val shadow = shadowOf(service) - assertTrue(shadow.isStoppedBySelf) - assertTrue(shadow.isForegroundStopped) - assertTrue(shadow.notificationShouldRemoved) + assertStoppedAndNotificationRemoved(service) } @Test @@ -102,10 +103,7 @@ class ActiveCallServiceRestartTest { val result = service.onStartCommand(declineIntent(), 0, 1) assertEquals(Service.START_NOT_STICKY, result) - val shadow = shadowOf(service) - assertTrue("service must stop itself", shadow.isStoppedBySelf) - assertTrue("foreground must be stopped", shadow.isForegroundStopped) - assertTrue("notification must be removed", shadow.notificationShouldRemoved) + assertStoppedAndNotificationRemoved(service) } @Test