fix(android): stop ActiveCallService when restarted with no calls metadata#346
Draft
SERDUN wants to merge 6 commits into
Draft
fix(android): stop ActiveCallService when restarted with no calls metadata#346SERDUN wants to merge 6 commits into
SERDUN wants to merge 6 commits into
Conversation
…adata 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
…jected 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.
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).
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.
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.
…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
SERDUN
marked this pull request as draft
July 6, 2026 04:40
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
Fixes WT-1139.
A START_STICKY restart after a process kill delivers a null intent, leaving
ActiveCallServicewith an empty calls list.NotificationManagertracks 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 the Hang up action has no visible effect (tearDownServicetears down the connection services but does not stop this one). The user has to force-stop the app to get rid of the notification.Changes
ActiveCallService:onStartCommand: after satisfying thestartForegroundcontract, an instance with no calls metadata logs a WARN, tears down the connection services, removes the notification and stops itself, returningSTART_NOT_STICKY. The teardown matters beyond the notification: if only the main process was killed, 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 - without the teardown the device stays stuck in a zombie Telecom call with no UI to end it.StandaloneCallServicefallback, so the guard runs instead of crash-looping the restart.hungUpCallfalls back to theCallMetadatacarried in the Decline PendingIntent extras: a Hang up tap that creates a fresh service instance (the in-memory list is empty after process death) now hangs up the specific call via the normal SIP BYE flow instead of tearing all connections down. The tear-down branch remains only for the truly unknown case (a notification re-posted by a null-intent restart carries no extras).stopSelf(startId)(extracted into atearDownAndStop(startId)helper), so a newer start with real metadata already queued behind the current command (the recovering app re-posting its call list) survives together with its foreground notification."metadata"intent extra key is promoted toActiveCallService.EXTRA_CALLS_METADATA, shared by the writer (NotificationManager.upsertActiveCallsService), the reader and the test.The normal path deliberately stays
START_STICKY: the sticky restart is now the mechanism that delivers the zombie-leg cleanup above, and the guard'sSTART_NOT_STICKYreturn prevents any restart loop (one restart, one cleanup, done).Related cleanups:
IncomingCallService.performAnswerCall: dropped the misleadingSTART_STICKYreturn value - the method is called fromonConnectionEventonly, so the value never reachedonStartCommand(that service deliberately staysSTART_NOT_STICKYeverywhere).build.gradle:includeAndroidResources = trueso Robolectric can resolve the library's notification strings/icons in unit tests.Tests
6 new Robolectric tests in
ActiveCallServiceRestartTest:Full Android native suite: 235 tests, 0 failures.