From 1c61b687f1a99c2d42df73e9b4e1bfe584cd997a Mon Sep 17 00:00:00 2001 From: RWRZ Date: Wed, 15 Apr 2026 20:47:50 -0300 Subject: [PATCH 1/2] fix(video_player_android): satisfy FGS contract in PlaybackService PlaybackService relied on Media3's MediaNotificationManager to call startForeground() lazily once a playing session was added, but the enableBackgroundPlayback flow called startService()/startForegroundService() without guaranteeing startForeground() ever ran. Any path that released the session before Media3 posted the media notification (e.g. Enable->Disable, or backgrounding with the player paused) triggered RemoteServiceException within 5 s. Override onStartCommand() to immediately post a minimal placeholder media notification via ServiceCompat.startForeground() with FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK, unconditionally satisfying the 5-second contract. Media3's real media-style notification supersedes the placeholder once a playing session exists. releaseSession() now calls stopForeground(STOP_FOREGROUND_REMOVE) to clean up the placeholder when background playback is disabled. VideoPlayerPlugin.bindPlaybackService() switches to ContextCompat.startForegroundService() now that the contract is guaranteed. Adds a "Repro FGS race" button to the example app that reliably triggers the crash prior to this fix. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../video_player/example/lib/main.dart | 38 +++++++++++ .../videoplayer/VideoPlayerPlugin.java | 12 ++-- .../videoplayer/service/PlaybackService.java | 65 +++++++++++++++++++ 3 files changed, 110 insertions(+), 5 deletions(-) diff --git a/packages/video_player/video_player/example/lib/main.dart b/packages/video_player/video_player/example/lib/main.dart index 3585ceffd5fc..a13aa05fec77 100644 --- a/packages/video_player/video_player/example/lib/main.dart +++ b/packages/video_player/video_player/example/lib/main.dart @@ -719,6 +719,33 @@ class _PipBackgroundDemoState extends State<_PipBackgroundDemo> { ? () => _controller.disableBackgroundPlayback() : null, ), + ElevatedButton.icon( + icon: const Icon(Icons.bug_report), + label: const Text('Repro FGS race'), + onPressed: () async { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + duration: Duration(seconds: 4), + content: Text( + 'Press HOME in the next 4 seconds, then ' + 'return to the app.', + ), + ), + ); + await Future.delayed( + const Duration(seconds: 4)); + await _controller.enableBackgroundPlayback( + mediaInfo: const MediaInfo( + title: 'Repro', + artist: 'FGS race', + ), + ); + await _controller.play(); + await Future.delayed( + const Duration(milliseconds: 50)); + await _controller.disableBackgroundPlayback(); + }, + ), ], ), const SizedBox(height: 16), @@ -731,6 +758,17 @@ class _PipBackgroundDemoState extends State<_PipBackgroundDemo> { fontSize: 12, ), ), + const SizedBox(height: 8), + const Text( + 'Repro FGS race: tap the bug button, then press HOME ' + 'within 4 s. Media3 calls startForegroundService() ' + 'but PlaybackService.releaseSession() runs before ' + 'startForeground(), producing RemoteServiceException.', + style: TextStyle( + fontStyle: FontStyle.italic, + fontSize: 12, + ), + ), ], ), ), diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java index ecb238ff3220..8abbcbb533d8 100644 --- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java +++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java @@ -13,6 +13,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.OptIn; +import androidx.core.content.ContextCompat; import androidx.media3.common.util.UnstableApi; import androidx.media3.session.MediaSessionService; import androidx.media3.exoplayer.ExoPlayer; @@ -169,11 +170,12 @@ public void onServiceDisconnected(ComponentName name) { serviceBound = false; } }; - // The service must be started (not just bound) to support foreground mode. - // A bound-only service cannot call startForeground(), so Media3 can't post - // the media notification. startService() starts it; Media3 then internally - // calls startForeground() when playback begins. - context.startService(intent); + // Start the service as a foreground service. The 5-second contract is + // satisfied by PlaybackService.onStartCommand, which immediately posts a + // placeholder foreground notification. Media3's MediaNotificationManager + // subsequently replaces it with the full media-style notification once a + // session with a playing player is added. + ContextCompat.startForegroundService(context, intent); context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE); serviceBound = true; } diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/service/PlaybackService.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/service/PlaybackService.java index 9352e52e068a..975359cb4d88 100644 --- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/service/PlaybackService.java +++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/service/PlaybackService.java @@ -4,12 +4,19 @@ package io.flutter.plugins.videoplayer.service; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; import android.content.Intent; +import android.content.pm.ServiceInfo; import android.net.Uri; +import android.os.Build; import android.os.IBinder; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import androidx.core.app.ServiceCompat; import androidx.media3.common.MediaItem; import androidx.media3.common.MediaMetadata; import androidx.media3.exoplayer.ExoPlayer; @@ -18,17 +25,71 @@ public class PlaybackService extends MediaSessionService { private static final String TAG = "PlaybackService"; + private static final String PLACEHOLDER_CHANNEL_ID = "video_player_playback_placeholder"; + private static final int PLACEHOLDER_NOTIFICATION_ID = 0x7650414C; // 'vPAL' @Nullable private static PlaybackService instance; private MediaSession mediaSession = null; private ExoPlayer player = null; + private boolean placeholderForegroundActive = false; @Override public void onCreate() { super.onCreate(); instance = this; + createPlaceholderChannel(); Log.d(TAG, "PlaybackService created"); } + @Override + public int onStartCommand(@Nullable Intent intent, int flags, int startId) { + // Satisfy the startForegroundService() -> startForeground() 5-second + // contract immediately. Media3's MediaNotificationManager posts the + // real media-style notification once a session with a playing player + // is added, replacing (or superseding) this placeholder. Without this, + // if setPlayer()/addSession() is delayed or disableBackgroundPlayback() + // races the start, the kernel kills the app with RemoteServiceException. + if (!placeholderForegroundActive) { + Notification placeholder = buildPlaceholderNotification(); + int type = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + ? ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + : 0; + ServiceCompat.startForeground( + this, PLACEHOLDER_NOTIFICATION_ID, placeholder, type); + placeholderForegroundActive = true; + } + return super.onStartCommand(intent, flags, startId); + } + + private void createPlaceholderChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + NotificationManager manager = getSystemService(NotificationManager.class); + if (manager == null + || manager.getNotificationChannel(PLACEHOLDER_CHANNEL_ID) != null) { + return; + } + NotificationChannel channel = new NotificationChannel( + PLACEHOLDER_CHANNEL_ID, + "Playback", + NotificationManager.IMPORTANCE_LOW); + channel.setShowBadge(false); + manager.createNotificationChannel(channel); + } + + private Notification buildPlaceholderNotification() { + NotificationCompat.Builder builder = + new NotificationCompat.Builder(this, PLACEHOLDER_CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_media_play) + .setContentTitle(getApplicationInfo() + .loadLabel(getPackageManager()).toString()) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_TRANSPORT) + .setShowWhen(false); + return builder.build(); + } + @Nullable public static PlaybackService getInstance() { return instance; @@ -102,6 +163,10 @@ public void releaseSession() { mediaSession = null; } player = null; + if (placeholderForegroundActive) { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); + placeholderForegroundActive = false; + } } @Override From 594d123218cf5f7c44b51d3c611811f12bb6eda1 Mon Sep 17 00:00:00 2001 From: RWRZ Date: Wed, 15 Apr 2026 21:17:58 -0300 Subject: [PATCH 2/2] fix(video_player_android): share Media3 notification id and channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior commit's placeholder used its own notification id and channel, which produced two artifacts: a second "Playback" channel in the app's notification settings alongside Media3's "Now playing", and a stray placeholder notification that lingered after the app was swiped from recents (stopForeground cleared Media3's record but not the orphaned placeholder). Reuse DefaultMediaNotificationProvider.DEFAULT_NOTIFICATION_ID (1001) and DEFAULT_CHANNEL_ID so Media3's real media notification replaces the placeholder in place, and only one channel ever appears in settings. Add NotificationManagerCompat.cancel() in releaseSession() as a belt-and-braces cleanup — stopForeground's internal reference can drift after MediaSessionService's own teardown runs. Release the session explicitly in onTaskRemoved before stopSelf so the notification is removed synchronously when the task is swiped with the player paused. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../videoplayer/service/PlaybackService.java | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/service/PlaybackService.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/service/PlaybackService.java index 975359cb4d88..f096d8390113 100644 --- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/service/PlaybackService.java +++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/service/PlaybackService.java @@ -15,18 +15,29 @@ import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.OptIn; import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; import androidx.core.app.ServiceCompat; import androidx.media3.common.MediaItem; import androidx.media3.common.MediaMetadata; +import androidx.media3.common.util.UnstableApi; import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.session.DefaultMediaNotificationProvider; import androidx.media3.session.MediaSession; import androidx.media3.session.MediaSessionService; +@OptIn(markerClass = UnstableApi.class) public class PlaybackService extends MediaSessionService { private static final String TAG = "PlaybackService"; - private static final String PLACEHOLDER_CHANNEL_ID = "video_player_playback_placeholder"; - private static final int PLACEHOLDER_NOTIFICATION_ID = 0x7650414C; // 'vPAL' + // Reuse the notification id and channel id that Media3's + // DefaultMediaNotificationProvider uses so that when Media3 posts its real + // media-style notification it replaces our placeholder in place, and only + // one notification channel appears under the app's notification settings. + private static final int PLACEHOLDER_NOTIFICATION_ID = + DefaultMediaNotificationProvider.DEFAULT_NOTIFICATION_ID; + private static final String PLACEHOLDER_CHANNEL_ID = + DefaultMediaNotificationProvider.DEFAULT_CHANNEL_ID; @Nullable private static PlaybackService instance; private MediaSession mediaSession = null; private ExoPlayer player = null; @@ -67,11 +78,16 @@ private void createPlaceholderChannel() { NotificationManager manager = getSystemService(NotificationManager.class); if (manager == null || manager.getNotificationChannel(PLACEHOLDER_CHANNEL_ID) != null) { + // Channel already exists (usually because Media3's + // DefaultMediaNotificationProvider already created it). return; } + // Match Media3's default channel name so only one channel appears in + // the app's notification settings. Media3's provider will reuse this + // channel if it already exists when it goes to post its notification. NotificationChannel channel = new NotificationChannel( PLACEHOLDER_CHANNEL_ID, - "Playback", + "Now playing", NotificationManager.IMPORTANCE_LOW); channel.setShowBadge(false); manager.createNotificationChannel(channel); @@ -148,6 +164,10 @@ public void onTaskRemoved(@Nullable Intent rootIntent) { return; } } + // Release the session and clear the placeholder notification explicitly + // before stopSelf() so the user never sees a lingering "Playback" + // notification after swiping the app from recents. + releaseSession(); stopSelf(); } @@ -167,6 +187,11 @@ public void releaseSession() { ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); placeholderForegroundActive = false; } + // Belt-and-braces: stopForeground operates on the service's internal + // "current foreground notification" reference, which can drift after + // MediaSessionService's own teardown runs. Cancel by id as well so the + // user never sees a lingering placeholder if the task is swiped away. + NotificationManagerCompat.from(this).cancel(PLACEHOLDER_NOTIFICATION_ID); } @Override