mirror of
https://github.com/androidx/media.git
synced 2025-04-30 06:46:50 +08:00
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:
parent
792a2ae05d
commit
8a888d0d18
@ -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.
|
||||||
|
@ -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();
|
||||||
|
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.getPlayWhenReady() || startInForegroundWhenPaused)
|
||||||
&& (controller.getPlaybackState() == Player.STATE_READY
|
&& (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(
|
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,12 +286,9 @@ 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++) {
|
|
||||||
if (shouldRunInForeground(sessions.get(i), /* startInForegroundWhenPaused= */ false)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
stopForeground(removeNotifications);
|
stopForeground(removeNotifications);
|
||||||
if (removeNotifications && mediaNotification != null) {
|
if (removeNotifications && mediaNotification != null) {
|
||||||
notificationManagerCompat.cancel(mediaNotification.notificationId);
|
notificationManagerCompat.cancel(mediaNotification.notificationId);
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user