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
This commit is contained in:
tonihei 2025-02-14 08:44:51 -08:00 committed by Copybara-Service
parent 792a2ae05d
commit 8a888d0d18
3 changed files with 109 additions and 27 deletions

View File

@ -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.

View File

@ -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;
*
* <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 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<MediaSession, ListenableFuture<MediaController>> 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
@Override
public boolean handleMessage(Message msg) {
if (msg.what == MSG_USER_ENGAGED_TIMEOUT) {
List<MediaSession> 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<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);
|| 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(
int notificationSequence, MediaSession session, MediaNotification mediaNotification) {
if (notificationSequence == totalNotificationCount) {
boolean startInForegroundRequired =
shouldRunInForeground(session, /* startInForegroundWhenPaused= */ false);
shouldRunInForeground(/* startInForegroundWhenPaused= */ false);
updateNotificationInternal(session, mediaNotification, startInForegroundRequired);
}
}
@ -233,12 +286,9 @@ import java.util.concurrent.TimeoutException;
* foreground.
*/
private void maybeStopForegroundService(boolean removeNotifications) {
List<MediaSession> sessions = mediaSessionService.getSessions();
for (int i = 0; i < sessions.size(); i++) {
if (shouldRunInForeground(sessions.get(i), /* startInForegroundWhenPaused= */ false)) {
if (shouldRunInForeground(/* startInForegroundWhenPaused= */ false)) {
return;
}
}
stopForeground(removeNotifications);
if (removeNotifications && mediaNotification != null) {
notificationManagerCompat.cancel(mediaNotification.notificationId);

View File

@ -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.
*
* <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
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
* override to release the sessions and other resources.
*/
@UnstableApi
public void pauseAllPlayersAndStopSelf() {
getMediaNotificationManager().disableUserEngagedTimeout();
List<MediaSession> 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}
*
* <p>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.
* <p>This method can be overridden to customize the behavior of when the app is dismissed from
* the recent apps.
*
* <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
* 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<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 {
@Override