diff --git a/RELEASENOTES.md b/RELEASENOTES.md index d52ea09c2e..dd3202363b 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -35,6 +35,13 @@ * Muxers: * IMA extension: * Session: + * Keep foreground service state for an additional 10 minutes when playback + pauses, stops or fails. This allows users to resume playback within this + timeout without risking foreground service restrictions on various + devices. Note that simply calling `player.pause()` can no longer be used + to stop the foreground service before `stopSelf()` when overriding + `onTaskRemoved`, use `MediaSessionService.pauseAllPlayersAndStopSelf()` + instead. * Keep notification visible when playback enters an error or stopped state. The notification is only removed if the playlist is cleared or the player is released. diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java b/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java index fc4ccdce6d..d135c66634 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java @@ -26,6 +26,7 @@ import android.content.pm.ServiceInfo; import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.os.Message; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.core.app.NotificationManagerCompat; @@ -52,14 +53,17 @@ import java.util.concurrent.TimeoutException; * *

All methods must be called on the main thread. */ -/* package */ final class MediaNotificationManager { +/* package */ final class MediaNotificationManager implements Handler.Callback { private static final String TAG = "MediaNtfMng"; + private static final int MSG_USER_ENGAGED_TIMEOUT = 1; + private static final long USER_ENGAGED_TIMEOUT_MS = 600_000; private final MediaSessionService mediaSessionService; private final MediaNotification.Provider mediaNotificationProvider; private final MediaNotification.ActionFactory actionFactory; private final NotificationManagerCompat notificationManagerCompat; + private final Handler mainHandler; private final Executor mainExecutor; private final Intent startSelfIntent; private final Map> controllerMap; @@ -67,6 +71,8 @@ import java.util.concurrent.TimeoutException; private int totalNotificationCount; @Nullable private MediaNotification mediaNotification; private boolean startedInForeground; + private boolean isUserEngaged; + private boolean isUserEngagedTimeoutEnabled; public MediaNotificationManager( MediaSessionService mediaSessionService, @@ -76,11 +82,12 @@ import java.util.concurrent.TimeoutException; this.mediaNotificationProvider = mediaNotificationProvider; this.actionFactory = actionFactory; notificationManagerCompat = NotificationManagerCompat.from(mediaSessionService); - Handler mainHandler = new Handler(Looper.getMainLooper()); + mainHandler = Util.createHandler(Looper.getMainLooper(), /* callback= */ this); mainExecutor = (runnable) -> Util.postOrRun(mainHandler, runnable); startSelfIntent = new Intent(mediaSessionService, mediaSessionService.getClass()); controllerMap = new HashMap<>(); startedInForeground = false; + isUserEngagedTimeoutEnabled = true; } public void addSession(MediaSession session) { @@ -184,20 +191,66 @@ import java.util.concurrent.TimeoutException; return startedInForeground; } - /* package */ boolean shouldRunInForeground( - MediaSession session, boolean startInForegroundWhenPaused) { - @Nullable MediaController controller = getConnectedControllerForSession(session); - return controller != null - && (controller.getPlayWhenReady() || startInForegroundWhenPaused) - && (controller.getPlaybackState() == Player.STATE_READY - || controller.getPlaybackState() == Player.STATE_BUFFERING); + @Override + public boolean handleMessage(Message msg) { + if (msg.what == MSG_USER_ENGAGED_TIMEOUT) { + List sessions = mediaSessionService.getSessions(); + for (int i = 0; i < sessions.size(); i++) { + mediaSessionService.onUpdateNotificationInternal( + sessions.get(i), /* startInForegroundWhenPaused= */ false); + } + return true; + } + return false; + } + + /* package */ boolean shouldRunInForeground(boolean startInForegroundWhenPaused) { + boolean isUserEngaged = isAnySessionUserEngaged(startInForegroundWhenPaused); + if (this.isUserEngaged && !isUserEngaged && isUserEngagedTimeoutEnabled) { + mainHandler.sendEmptyMessageDelayed(MSG_USER_ENGAGED_TIMEOUT, USER_ENGAGED_TIMEOUT_MS); + } else if (isUserEngaged) { + mainHandler.removeMessages(MSG_USER_ENGAGED_TIMEOUT); + } + this.isUserEngaged = isUserEngaged; + boolean hasPendingTimeout = mainHandler.hasMessages(MSG_USER_ENGAGED_TIMEOUT); + return isUserEngaged || hasPendingTimeout; + } + + private boolean isAnySessionUserEngaged(boolean startInForegroundWhenPaused) { + List sessions = mediaSessionService.getSessions(); + for (int i = 0; i < sessions.size(); i++) { + @Nullable MediaController controller = getConnectedControllerForSession(sessions.get(i)); + if (controller != null + && (controller.getPlayWhenReady() || startInForegroundWhenPaused) + && (controller.getPlaybackState() == Player.STATE_READY + || controller.getPlaybackState() == Player.STATE_BUFFERING)) { + return true; + } + } + return false; + } + + /** + * Permanently disable the user engaged timeout, which is needed to immediately stop the + * foreground service. + */ + /* package */ void disableUserEngagedTimeout() { + isUserEngagedTimeoutEnabled = false; + if (mainHandler.hasMessages(MSG_USER_ENGAGED_TIMEOUT)) { + mainHandler.removeMessages(MSG_USER_ENGAGED_TIMEOUT); + List sessions = mediaSessionService.getSessions(); + for (int i = 0; i < sessions.size(); i++) { + mediaSessionService.onUpdateNotificationInternal( + sessions.get(i), /* startInForegroundWhenPaused= */ false); + } + } } private void onNotificationUpdated( int notificationSequence, MediaSession session, MediaNotification mediaNotification) { if (notificationSequence == totalNotificationCount) { boolean startInForegroundRequired = - shouldRunInForeground(session, /* startInForegroundWhenPaused= */ false); + shouldRunInForeground(/* startInForegroundWhenPaused= */ false); updateNotificationInternal(session, mediaNotification, startInForegroundRequired); } } @@ -233,11 +286,8 @@ import java.util.concurrent.TimeoutException; * foreground. */ private void maybeStopForegroundService(boolean removeNotifications) { - List sessions = mediaSessionService.getSessions(); - for (int i = 0; i < sessions.size(); i++) { - if (shouldRunInForeground(sessions.get(i), /* startInForegroundWhenPaused= */ false)) { - return; - } + if (shouldRunInForeground(/* startInForegroundWhenPaused= */ false)) { + return; } stopForeground(removeNotifications); if (removeNotifications && mediaNotification != null) { diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java index 6756aa730e..91d7323c89 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java @@ -39,6 +39,7 @@ import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.collection.ArrayMap; import androidx.media3.common.MediaLibraryInfo; +import androidx.media3.common.Player; import androidx.media3.common.util.Log; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; @@ -471,8 +472,15 @@ public abstract class MediaSessionService extends Service { } /** - * Returns whether there is a session with ongoing playback that must be paused or stopped before - * being able to terminate the service by calling {@link #stopSelf()}. + * Returns whether there is a session with ongoing user-engaged playback that is run in a + * foreground service. + * + *

It is only possible to terminate the service with {@link #stopSelf()} if this method returns + * {@code false}. + * + *

Note that sessions are kept in foreground and this method returns {@code true} for a period + * of 10 minutes after they paused, stopped or failed. Use {@link #pauseAllPlayersAndStopSelf()} + * to pause all ongoing playbacks immediately and terminate the service. */ @UnstableApi public boolean isPlaybackOngoing() { @@ -480,13 +488,15 @@ public abstract class MediaSessionService extends Service { } /** - * Pauses the player of each session managed by the service and calls {@link #stopSelf()}. + * Pauses the player of each session managed by the service, ensures the foreground service is + * stopped, and calls {@link #stopSelf()}. * *

This terminates the service lifecycle and triggers {@link #onDestroy()} that an app can * override to release the sessions and other resources. */ @UnstableApi public void pauseAllPlayersAndStopSelf() { + getMediaNotificationManager().disableUserEngagedTimeout(); List sessionList = getSessions(); for (int i = 0; i < sessionList.size(); i++) { sessionList.get(i).getPlayer().setPlayWhenReady(false); @@ -497,10 +507,15 @@ public abstract class MediaSessionService extends Service { /** * {@inheritDoc} * - *

If {@linkplain #isPlaybackOngoing() playback is ongoing}, the service continues running in - * the foreground when the app is dismissed from the recent apps. Otherwise, the service is - * stopped by calling {@link #stopSelf()} which terminates the service lifecycle and triggers - * {@link #onDestroy()} that an app can override to release the sessions and other resources. + *

This method can be overridden to customize the behavior of when the app is dismissed from + * the recent apps. + * + *

The default behavior is that if {@linkplain #isPlaybackOngoing() playback is ongoing}, which + * means the service is already running in the foreground, and at least one media session {@link + * Player#isPlaying() is playing}, the service is kept running. Otherwise, playbacks are paused + * and the service is stopped by calling {@link #pauseAllPlayersAndStopSelf()} which terminates + * the service lifecycle and triggers {@link #onDestroy()} that an app can override to release the + * sessions and other resources. * *

An app can safely override this method without calling super to implement a different * behaviour, for instance unconditionally calling {@link #pauseAllPlayersAndStopSelf()} to stop @@ -519,10 +534,10 @@ public abstract class MediaSessionService extends Service { */ @Override public void onTaskRemoved(@Nullable Intent rootIntent) { - if (!isPlaybackOngoing()) { - // The service needs to be stopped when playback is not ongoing and the service is not in the - // foreground. - stopSelf(); + if (!isPlaybackOngoing() || !isAnySessionPlaying()) { + // The service needs to be stopped when playback is not ongoing (i.e, the service is not in + // the foreground). It is also force-stopped if no session is playing. + pauseAllPlayersAndStopSelf(); } } @@ -619,7 +634,7 @@ public abstract class MediaSessionService extends Service { MediaSession session, boolean startInForegroundWhenPaused) { try { boolean startInForegroundRequired = - getMediaNotificationManager().shouldRunInForeground(session, startInForegroundWhenPaused); + getMediaNotificationManager().shouldRunInForeground(startInForegroundWhenPaused); onUpdateNotification(session, startInForegroundRequired); } catch (/* ForegroundServiceStartNotAllowedException */ IllegalStateException e) { if ((Util.SDK_INT >= 31) && Api31.instanceOfForegroundServiceStartNotAllowedException(e)) { @@ -674,6 +689,16 @@ public abstract class MediaSessionService extends Service { }); } + private boolean isAnySessionPlaying() { + List sessionList = getSessions(); + for (int i = 0; i < sessionList.size(); i++) { + if (sessionList.get(i).getPlayer().isPlaying()) { + return true; + } + } + return false; + } + private final class MediaSessionListener implements MediaSession.Listener { @Override