Add timeout to foreground service handling

Currently, as soon as the playback is considered disengaged (not
ready and playing), the foreground service is stopped.
This causes problems if the app or the user attempts to restart
playback after a short amount of time, where apps may run into
foreground service start restrictions.

Almost all of these short-term interaction issues can be solved
by keeping the foreground service running for an additional
timeout period, which is chosen to be 10 minutes to match the
behavior of future Android system enforcements. For any longer
term interactions, apps need to implement playback resumption
paths that can restart the service with the previous playback.

One caveat is that we currently use player.pause() as a way to
stop the foreground service in onTaskRemoved() if the app wants
to abandon playback at this point. With the timeout, the service
can no longer be stopped immediately just by calling pause(),
so we need to explicitly disable the timeout in the corresponding
helper method.

Issue: androidx/media#1928
Issue: androidx/media#111
PiperOrigin-RevId: 726942625
(cherry picked from commit 8a888d0d1801ce018b5bca5dbab78be44507286e)
This commit is contained in:
tonihei 2025-02-14 08:44:51 -08:00
parent 3b897241d2
commit 1ceafc7a61
3 changed files with 109 additions and 27 deletions

View File

@ -35,6 +35,13 @@
* Muxers: * Muxers:
* IMA extension: * IMA extension:
* Session: * 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 * Keep notification visible when playback enters an error or stopped
state. The notification is only removed if the playlist is cleared or state. The notification is only removed if the playlist is cleared or
the player is released. the player is released.

View File

@ -26,6 +26,7 @@ import android.content.pm.ServiceInfo;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.os.Message;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi; import androidx.annotation.RequiresApi;
import androidx.core.app.NotificationManagerCompat; import androidx.core.app.NotificationManagerCompat;
@ -52,14 +53,17 @@ import java.util.concurrent.TimeoutException;
* *
* <p>All methods must be called on the main thread. * <p>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 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 MediaSessionService mediaSessionService;
private final MediaNotification.Provider mediaNotificationProvider; private final MediaNotification.Provider mediaNotificationProvider;
private final MediaNotification.ActionFactory actionFactory; private final MediaNotification.ActionFactory actionFactory;
private final NotificationManagerCompat notificationManagerCompat; private final NotificationManagerCompat notificationManagerCompat;
private final Handler mainHandler;
private final Executor mainExecutor; private final Executor mainExecutor;
private final Intent startSelfIntent; private final Intent startSelfIntent;
private final Map<MediaSession, ListenableFuture<MediaController>> controllerMap; private final Map<MediaSession, ListenableFuture<MediaController>> controllerMap;
@ -67,6 +71,8 @@ import java.util.concurrent.TimeoutException;
private int totalNotificationCount; private int totalNotificationCount;
@Nullable private MediaNotification mediaNotification; @Nullable private MediaNotification mediaNotification;
private boolean startedInForeground; private boolean startedInForeground;
private boolean isUserEngaged;
private boolean isUserEngagedTimeoutEnabled;
public MediaNotificationManager( public MediaNotificationManager(
MediaSessionService mediaSessionService, MediaSessionService mediaSessionService,
@ -76,11 +82,12 @@ import java.util.concurrent.TimeoutException;
this.mediaNotificationProvider = mediaNotificationProvider; this.mediaNotificationProvider = mediaNotificationProvider;
this.actionFactory = actionFactory; this.actionFactory = actionFactory;
notificationManagerCompat = NotificationManagerCompat.from(mediaSessionService); notificationManagerCompat = NotificationManagerCompat.from(mediaSessionService);
Handler mainHandler = new Handler(Looper.getMainLooper()); mainHandler = Util.createHandler(Looper.getMainLooper(), /* callback= */ this);
mainExecutor = (runnable) -> Util.postOrRun(mainHandler, runnable); mainExecutor = (runnable) -> Util.postOrRun(mainHandler, runnable);
startSelfIntent = new Intent(mediaSessionService, mediaSessionService.getClass()); startSelfIntent = new Intent(mediaSessionService, mediaSessionService.getClass());
controllerMap = new HashMap<>(); controllerMap = new HashMap<>();
startedInForeground = false; startedInForeground = false;
isUserEngagedTimeoutEnabled = true;
} }
public void addSession(MediaSession session) { public void addSession(MediaSession session) {
@ -184,20 +191,66 @@ import java.util.concurrent.TimeoutException;
return startedInForeground; return startedInForeground;
} }
/* package */ boolean shouldRunInForeground( @Override
MediaSession session, boolean startInForegroundWhenPaused) { public boolean handleMessage(Message msg) {
@Nullable MediaController controller = getConnectedControllerForSession(session); if (msg.what == MSG_USER_ENGAGED_TIMEOUT) {
return controller != null List<MediaSession> sessions = mediaSessionService.getSessions();
&& (controller.getPlayWhenReady() || startInForegroundWhenPaused) for (int i = 0; i < sessions.size(); i++) {
&& (controller.getPlaybackState() == Player.STATE_READY mediaSessionService.onUpdateNotificationInternal(
|| controller.getPlaybackState() == Player.STATE_BUFFERING); 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<MediaSession> 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<MediaSession> sessions = mediaSessionService.getSessions();
for (int i = 0; i < sessions.size(); i++) {
mediaSessionService.onUpdateNotificationInternal(
sessions.get(i), /* startInForegroundWhenPaused= */ false);
}
}
} }
private void onNotificationUpdated( private void onNotificationUpdated(
int notificationSequence, MediaSession session, MediaNotification mediaNotification) { int notificationSequence, MediaSession session, MediaNotification mediaNotification) {
if (notificationSequence == totalNotificationCount) { if (notificationSequence == totalNotificationCount) {
boolean startInForegroundRequired = boolean startInForegroundRequired =
shouldRunInForeground(session, /* startInForegroundWhenPaused= */ false); shouldRunInForeground(/* startInForegroundWhenPaused= */ false);
updateNotificationInternal(session, mediaNotification, startInForegroundRequired); updateNotificationInternal(session, mediaNotification, startInForegroundRequired);
} }
} }
@ -233,11 +286,8 @@ import java.util.concurrent.TimeoutException;
* foreground. * foreground.
*/ */
private void maybeStopForegroundService(boolean removeNotifications) { private void maybeStopForegroundService(boolean removeNotifications) {
List<MediaSession> sessions = mediaSessionService.getSessions(); if (shouldRunInForeground(/* startInForegroundWhenPaused= */ false)) {
for (int i = 0; i < sessions.size(); i++) { return;
if (shouldRunInForeground(sessions.get(i), /* startInForegroundWhenPaused= */ false)) {
return;
}
} }
stopForeground(removeNotifications); stopForeground(removeNotifications);
if (removeNotifications && mediaNotification != null) { if (removeNotifications && mediaNotification != null) {

View File

@ -39,6 +39,7 @@ import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi; import androidx.annotation.RequiresApi;
import androidx.collection.ArrayMap; import androidx.collection.ArrayMap;
import androidx.media3.common.MediaLibraryInfo; import androidx.media3.common.MediaLibraryInfo;
import androidx.media3.common.Player;
import androidx.media3.common.util.Log; import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util; 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 * Returns whether there is a session with ongoing user-engaged playback that is run in a
* being able to terminate the service by calling {@link #stopSelf()}. * foreground service.
*
* <p>It is only possible to terminate the service with {@link #stopSelf()} if this method returns
* {@code false}.
*
* <p>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 @UnstableApi
public boolean isPlaybackOngoing() { 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()}.
* *
* <p>This terminates the service lifecycle and triggers {@link #onDestroy()} that an app can * <p>This terminates the service lifecycle and triggers {@link #onDestroy()} that an app can
* override to release the sessions and other resources. * override to release the sessions and other resources.
*/ */
@UnstableApi @UnstableApi
public void pauseAllPlayersAndStopSelf() { public void pauseAllPlayersAndStopSelf() {
getMediaNotificationManager().disableUserEngagedTimeout();
List<MediaSession> sessionList = getSessions(); List<MediaSession> sessionList = getSessions();
for (int i = 0; i < sessionList.size(); i++) { for (int i = 0; i < sessionList.size(); i++) {
sessionList.get(i).getPlayer().setPlayWhenReady(false); sessionList.get(i).getPlayer().setPlayWhenReady(false);
@ -497,10 +507,15 @@ public abstract class MediaSessionService extends Service {
/** /**
* {@inheritDoc} * {@inheritDoc}
* *
* <p>If {@linkplain #isPlaybackOngoing() playback is ongoing}, the service continues running in * <p>This method can be overridden to customize the behavior of when the app is dismissed from
* the foreground when the app is dismissed from the recent apps. Otherwise, the service is * the recent apps.
* 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. * <p>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.
* *
* <p>An app can safely override this method without calling super to implement a different * <p>An app can safely override this method without calling super to implement a different
* behaviour, for instance unconditionally calling {@link #pauseAllPlayersAndStopSelf()} to stop * behaviour, for instance unconditionally calling {@link #pauseAllPlayersAndStopSelf()} to stop
@ -519,10 +534,10 @@ public abstract class MediaSessionService extends Service {
*/ */
@Override @Override
public void onTaskRemoved(@Nullable Intent rootIntent) { public void onTaskRemoved(@Nullable Intent rootIntent) {
if (!isPlaybackOngoing()) { if (!isPlaybackOngoing() || !isAnySessionPlaying()) {
// The service needs to be stopped when playback is not ongoing and the service is not in the // The service needs to be stopped when playback is not ongoing (i.e, the service is not in
// foreground. // the foreground). It is also force-stopped if no session is playing.
stopSelf(); pauseAllPlayersAndStopSelf();
} }
} }
@ -619,7 +634,7 @@ public abstract class MediaSessionService extends Service {
MediaSession session, boolean startInForegroundWhenPaused) { MediaSession session, boolean startInForegroundWhenPaused) {
try { try {
boolean startInForegroundRequired = boolean startInForegroundRequired =
getMediaNotificationManager().shouldRunInForeground(session, startInForegroundWhenPaused); getMediaNotificationManager().shouldRunInForeground(startInForegroundWhenPaused);
onUpdateNotification(session, startInForegroundRequired); onUpdateNotification(session, startInForegroundRequired);
} catch (/* ForegroundServiceStartNotAllowedException */ IllegalStateException e) { } catch (/* ForegroundServiceStartNotAllowedException */ IllegalStateException e) {
if ((Util.SDK_INT >= 31) && Api31.instanceOfForegroundServiceStartNotAllowedException(e)) { if ((Util.SDK_INT >= 31) && Api31.instanceOfForegroundServiceStartNotAllowedException(e)) {
@ -674,6 +689,16 @@ public abstract class MediaSessionService extends Service {
}); });
} }
private boolean isAnySessionPlaying() {
List<MediaSession> 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 { private final class MediaSessionListener implements MediaSession.Listener {
@Override @Override