Skip to content

Enforce using foreground service during playback#5563

Open
sztomek wants to merge 5 commits into
mainfrom
feat/android-17-audio-hardening
Open

Enforce using foreground service during playback#5563
sztomek wants to merge 5 commits into
mainfrom
feat/android-17-audio-hardening

Conversation

@sztomek

@sztomek sztomek commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Description

It's been reported that our app is not compatible with upcomfing Android 17-related audio playback restrictions as there were some scenarios when we didn't ensure that our playback service runs in the foreground. A couple of such scenarios:

  • resume a paused episode from the background via home screen widgets
  • cold background play (app process was killed before, starting playback from the media notification or via bluetooth commands)
  • auto play hits next episode of queue while phone screen is off
  • google assistant voice commands

This PR attempts to fix those scenarios. I have added the feature flag FOREGROUND_BEFORE_PLAYBACK and put my changes behind it. Previously we requested focus first and only promoted the service reactively, so background plays (Bluetooth, widgets, notifications, auto-play, Auto/Wear) got their focus request denied and were silently stopped. The core change is a suspend gate (ensureForegroundServiceStarted) called at the top of PlaybackManager.play() that starts the service via startForegroundService and waits on a shared isServiceForeground signal both services update, so the FGS is guaranteed live before focus regardless of feature-flag path (legacy or Media3) or entry point. It also keeps the service foreground across auto-play episode switches so a background advance never needs a fresh start exemption, and fails open — if the service genuinely can't start it falls back to the old behavior rather than blocking playback.

See: p1783932483344609-slack-C028JAG44VD

Testing Instructions

You'll need to spin up an emulator with Android 17.

  1. Install the debug build and open the app, play something once so there's an episode in Up Next.
  2. In dev settings, turn on the FOREGROUND_BEFORE_PLAYBACK flag (leave MEDIA3_SESSION on for now).
  3. Turn on enforcement: adb shell cmd audio set-enable-hardening enable.
  4. Check your logcat, filter for "AudioHardening|Playback"

Background play scenarios (the ones that used to break)
5. Lock the screen, then hit play on a Bluetooth headset → audio should actually start.
6. Background the app, tap play on a home-screen widget → audio starts, notification shows.
7. Trigger a new-episode notification, background the app, tap "Play" on it → audio starts.
8. Play something, screen off, let the episode finish → next episode auto-plays without dropping.
9. From Android Auto (or adb shell am a play-from-search), say/pick something to play → it plays.
10. After each: check adb dumpsys audio and logcat show no AudioHardening suppression for our package.

Same thing on the legacy path
11. Turn MEDIA3_SESSION off, force-stop the app, then repeat steps 5–10 → same results.

Regression / normal behavior
12. Normal foreground play/pause/skip still works, no weird "Loading" notification lingering.
13. Pause, swipe the notification away (if hide-on-pause off), then resume from the widget → still plays.
14. Get a transient interruption (nav prompt or a phone call) → playback ducks/pauses then resumes fine.
15. Cast to a Chromecast, play, then disconnect → local playback resumes normally.
16. Flip the flag off, repeat a couple of plays → behaves exactly like before (sanity that it's inert).

Teardown
17. Reset enforcement: adb shell cmd audio set-enable-hardening disable.

Screenshots or Screencast

Checklist

  • If this is a user-facing change, I have added an entry in CHANGELOG.md
  • Ensure the linter passes (./gradlew spotlessApply to automatically apply formatting/linting)
  • I have considered whether it makes sense to add tests for my changes
  • All strings that need to be localized are in modules/services/localization/src/main/res/values/strings.xml
  • Any jetpack compose components I added or changed are covered by compose previews
  • I have updated (or requested that someone edit) the Event Horizon schema to reflect any new or changed analytics.

I have tested any UI changes...

  • with different themes
  • with a landscape orientation
  • with the device set to have a large display and font size
  • for accessibility with TalkBack

@sztomek sztomek added this to the 8.17 milestone Jul 13, 2026
@sztomek
sztomek requested a review from a team as a code owner July 13, 2026 14:42
@sztomek
sztomek requested review from geekygecko and removed request for a team July 13, 2026 14:42
@sztomek sztomek added [Type] Enhancement Improve an existing feature. [Area] Playback Episode playback issue [Area] Android 17 labels Jul 13, 2026
@claude

claude Bot commented Jul 13, 2026

Copy link
Copy Markdown

Claude finished @sztomek's task in 4m 25s —— View job


PR Review: Enforce using foreground service during playback

Solid, well-scoped change — the suspend gate + shared isServiceForeground signal is a clean way to guarantee FGS-before-focus across both the Media3 and legacy paths, and gating everything behind FOREGROUND_BEFORE_PLAYBACK with a fail-open fallback is the right call. Nice touch keeping the service foreground across auto-play switches via isSwitchingPlayer. A few things worth addressing before merge (details left as inline comments):

🔴 Correctness

  1. Early promotion + isSwitchingPlayer not rolled back when play() returns early (PlaybackManager.kt:2261-2268). Once the gate promotes the service to the foreground (with the "Loading" notification) and resetPlayer() has set isSwitchingPlayer = true, a subsequent tryToGetAudioFocus() denial makes play() return without undoing either. Result: a stale "Loading" FGS notification lingers with no audio, and because both new notification-removal branches gate on !isSwitchingPlayer, the notification can never be removed on pause afterwards → leaked foreground service. Focus can still be denied after going foreground (another app refusing to yield), so this is reachable. This is my main concern. Fix this →

🟡 Worth verifying / polish

  1. Manual startForeground() inside the Media3 MediaLibraryService (PlaybackService.kt:160-186). Media3 manages the service's foreground state itself; promoting out-of-band can desync its internal bookkeeping (service not demoted at end-of-playback, or the plain notification not being cleanly replaced by DefaultMediaNotificationProvider). Understandable to satisfy the startForegroundService() 5s contract — please confirm on-device the notification hand-off and end-of-playback stop still behave.

  2. Inconsistent failure handling between the two services (LegacyPlaybackService.kt:139). Legacy calls stopSelf() unconditionally on early-start failure; the Media3 path only stops when there are no connected controllers. Align them so a failed early-start doesn't tear down a session that already has an active controller.

  3. Duplicated logic. startForegroundEarly() in both services (notification build + ForegroundServiceStartNotAllowedExceptionsetTimesToShowBatteryWarning(2 + …) + PlaybackForegroundServiceErrorEvent) is near-identical. Extracting the shared catch/battery-warning helper would prevent drift.

🟢 Minor notes

  • ensureForegroundServiceStarted() can suspend up to timeoutMs (2s) before playback proceeds in the failure case. It's a suspend (non-blocking) wait so no ANR risk, but worth being aware of the added latency on the unhappy path.
  • startServiceForeground() returns a Boolean that startServiceIfNeeded() ignores (fire-and-forget) — fine, just flagging the asymmetry with ensureForegroundServiceStarted() which does check it.
  • Tests: good coverage of the gate's three states. Consider adding a case for the roll-back behavior in point 1 (focus denied → foreground demoted, isSwitchingPlayer cleared) once fixed, and something exercising the isSwitchingPlayer-across-switch path.

✅ Looks good

  • Feature-flag definition, Free tier + Firebase remote flag + dev toggle all consistent with existing entries.
  • Notification channel (NOTIFICATION_CHANNEL_ID_PLAYBACK) is created in PocketCastsApplication, so the cold-start startForegroundEarly() notification won't hit a missing-channel failure.
  • Automotive and remote (Cast) players correctly excluded from the gate.

  • Read changed files and understand the approach
  • Review MediaSessionManager & PlaybackManager gating logic
  • Review both service implementations (Media3 + legacy)
  • Review NotificationDrawer changes
  • Review feature flag & tests
  • Post review feedback

Comment on lines +2261 to +2268
// Android 17: reach the foreground service before requesting audio focus, otherwise focus is denied.
if (FeatureFlag.isEnabled(Feature.FOREGROUND_BEFORE_PLAYBACK) && !Util.isAutomotive(application) && player?.isRemote != true) {
val foreground = mediaSessionManager.ensureForegroundServiceStarted(application)
if (!foreground) {
LogBuffer.e(LogBuffer.TAG_PLAYBACK, "Foreground service not ready before playback, proceeding with fallback")
eventHorizon.track(PlaybackForegroundServiceErrorEvent)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Foreground promotion (and isSwitchingPlayer) isn't rolled back when play() returns early right after this.

By the time we get here resetPlayer() has already set isSwitchingPlayer = true, and ensureForegroundServiceStarted() has promoted the service to the foreground with the "Loading" notification. But immediately below, if tryToGetAudioFocus() returns false, play() returns and nothing undoes either side effect:

  • the service stays foreground showing a stale "Loading" notification with no audio, and nothing demotes it (there is no stop() on focus denial here — stopAsync(isAudioFocusFailed = true) is only called from the focus-loss listener);
  • isSwitchingPlayer stays true, so the new !isSwitchingPlayer gates in PlaybackService.onUpdateNotification and LegacyPlaybackService.handlePlaybackState will never remove the notification on pause afterwards → the FGS leaks.

Focus can still be denied even after going foreground (another app refusing to yield), so this is reachable. Consider resetting isSwitchingPlayer and demoting the early promotion on the focus-denied return path.

Comment on lines +160 to +186
private fun startForegroundEarly() {
if (Util.isAutomotive(this)) return
val session = playbackManager.mediaSessionManager.getMedia3Session()
try {
val title = playbackManager.getCurrentEpisode()?.title ?: getString(LR.string.loading)
val builder = NotificationCompat.Builder(this, Settings.NotificationChannel.NOTIFICATION_CHANNEL_ID_PLAYBACK.id)
.setContentTitle(title)
.setSmallIcon(IR.drawable.notification)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
if (session != null) {
builder.setStyle(MediaStyleNotificationHelper.MediaStyle(session))
}
ServiceCompat.startForeground(this, Settings.NotificationId.PLAYING.value, builder.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
playbackManager.mediaSessionManager.setServiceForeground(true)
LogBuffer.i(LogBuffer.TAG_PLAYBACK, "startForeground early (preparing, media3)")
} catch (e: Exception) {
LogBuffer.e(LogBuffer.TAG_PLAYBACK, "startForeground early failed (media3): $e")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && e is ForegroundServiceStartNotAllowedException) {
val currentValue = settings.getTimesToShowBatteryWarning()
settings.setTimesToShowBatteryWarning(2 + currentValue)
eventHorizon.track(PlaybackForegroundServiceErrorEvent)
}
if (session?.connectedControllers.isNullOrEmpty()) {
stopSelf()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling ServiceCompat.startForeground(...) yourself inside a Media3 MediaLibraryService is worth double-checking. Media3 manages the service's foreground state internally (via onUpdateNotification / the notification provider). Manually promoting to foreground out-of-band can desync Media3's internal foreground bookkeeping, which historically leads to symptoms like the service not being demoted/stopped when playback ends, or a duplicate/overwritten notification. It looks intentional here to satisfy the startForegroundService() 5s contract, but please confirm on-device that (a) the notification built here (plain NotificationCompat.Builder) is cleanly replaced by the Media3 DefaultMediaNotificationProvider notification once playback starts, and (b) the service still stops correctly at end-of-playback.

Also, this method plus LegacyPlaybackService.startForegroundEarly() are near-identical (same notification shape, same ForegroundServiceStartNotAllowedExceptionsetTimesToShowBatteryWarning(2 + …) + PlaybackForegroundServiceErrorEvent handling). Consider extracting the shared catch/battery-warning logic to avoid the two copies drifting.

settings.setTimesToShowBatteryWarning(2 + currentValue)
eventHorizon.track(com.automattic.eventhorizon.PlaybackForegroundServiceErrorEvent)
}
stopSelf()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor inconsistency with the Media3 path: here stopSelf() is called unconditionally on failure, whereas PlaybackService.startForegroundEarly() only calls stopSelf() when session?.connectedControllers.isNullOrEmpty(). If a Bluetooth/Auto controller is already connected and driving playback when this early-start fails, an unconditional stopSelf() could tear the session down. Consider guarding this the same way the Media3 path does (only stop when there's nothing actively connected/playing).

val mediaSession = playbackManager.mediaSessionManager.mediaSession ?: return
try {
val notification = notificationDrawer.buildPreparingNotification(mediaSession.sessionToken)
ServiceCompat.startForeground(this, Settings.NotificationId.PLAYING.value, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
if (session != null) {
builder.setStyle(MediaStyleNotificationHelper.MediaStyle(session))
}
ServiceCompat.startForeground(this, Settings.NotificationId.PLAYING.value, builder.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
@wpmobilebot wpmobilebot modified the milestones: 8.17, 8.18 Jul 21, 2026
@wpmobilebot

Copy link
Copy Markdown
Collaborator

Version 8.17 has now entered code-freeze, so the milestone of this PR has been updated to 8.18.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Area] Android 17 [Area] Playback Episode playback issue [Type] Enhancement Improve an existing feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants