Skip to content

fix(android): stop ActiveCallService when restarted with no calls metadata#346

Draft
SERDUN wants to merge 6 commits into
developfrom
fix/active-call-service-sticky-restart
Draft

fix(android): stop ActiveCallService when restarted with no calls metadata#346
SERDUN wants to merge 6 commits into
developfrom
fix/active-call-service-sticky-restart

Conversation

@SERDUN

@SERDUN SERDUN commented Jul 3, 2026

Copy link
Copy Markdown
Member

Overview

Fixes WT-1139.

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 the Hang up action has no visible effect (tearDownService tears 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:

  • Empty-metadata guard in onStartCommand: after satisfying the startForeground contract, an instance with no calls metadata logs a WARN, tears down the connection services, removes the notification and stops itself, returning START_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.
  • Foreground promotion degrades gracefully on Android 14+: promoting with the MICROPHONE type from the background (exactly the sticky-restart case) throws SecurityException; on that exception the promotion retries with the phone-call type only (not while-in-use restricted), mirroring the existing StandaloneCallService fallback, so the guard runs instead of crash-looping the restart.
  • hungUpCall falls back to the CallMetadata carried 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).
  • Both stop branches use stopSelf(startId) (extracted into a tearDownAndStop(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.
  • The "metadata" intent extra key is promoted to ActiveCallService.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's START_NOT_STICKY return prevents any restart loop (one restart, one cleanup, done).

Related cleanups:

  • IncomingCallService.performAnswerCall: dropped the misleading START_STICKY return value - the method is called from onConnectionEvent only, so the value never reached onStartCommand (that service deliberately stays START_NOT_STICKY everywhere).
  • build.gradle: includeAndroidResources = true so Robolectric can resolve the library's notification strings/icons in unit tests.

Tests

6 new Robolectric tests in ActiveCallServiceRestartTest:

  • null-intent restart stops self and removes the notification
  • explicitly empty metadata list stops self
  • start with calls metadata stays foreground and sticky
  • Hang up with no known calls stops self and removes the notification
  • Hang up with metadata only in the intent extras routes the hangup and keeps the service
  • Hang up with a known call keeps the service running (normal path)

Full Android native suite: 235 tests, 0 failures.

SERDUN added 6 commits July 3, 2026 19:54
…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
SERDUN marked this pull request as draft July 6, 2026 04:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant