Catch FgSStartNotAllowedException when playback resumes

This fix applies to Android 12 and above.

In this fix, the `MediaSessionService` will try to start in the foreground before the session playback resumes, if ForegroundServiceStartNotAllowedException is thrown, then the app can handle the exception with their customized implementation of MediaSessionService.Listener.onForegroundServiceStartNotAllowedException. If no exception thrown, the a media notification corresponding to paused state will be sent as the consequence of successfully starting in the foreground. And when the player actually resumes, another media notification corresponding to playing state will be sent.

PiperOrigin-RevId: 501803930
(cherry picked from commit 0d0cd786264aa82bf9301d4bcde6e5c78e332340)
This commit is contained in:
tianyifeng 2023-01-13 11:25:35 +00:00 committed by christosts
parent b644c67924
commit a2aaad65a8
6 changed files with 277 additions and 47 deletions

View File

@ -66,6 +66,7 @@ import java.util.concurrent.TimeoutException;
private int totalNotificationCount;
@Nullable private MediaNotification mediaNotification;
private boolean startedInForeground;
public MediaNotificationManager(
MediaSessionService mediaSessionService,
@ -80,6 +81,7 @@ import java.util.concurrent.TimeoutException;
startSelfIntent = new Intent(mediaSessionService, mediaSessionService.getClass());
controllerMap = new HashMap<>();
customLayoutMap = new HashMap<>();
startedInForeground = false;
}
public void addSession(MediaSession session) {
@ -163,9 +165,14 @@ import java.util.concurrent.TimeoutException;
}
}
public void updateNotification(MediaSession session) {
if (!mediaSessionService.isSessionAdded(session)
|| !shouldShowNotification(session.getPlayer())) {
/**
* Updates the notification.
*
* @param session A session that needs notification update.
* @param startInForegroundRequired Whether the service is required to start in the foreground.
*/
public void updateNotification(MediaSession session, boolean startInForegroundRequired) {
if (!mediaSessionService.isSessionAdded(session) || !shouldShowNotification(session)) {
maybeStopForegroundService(/* removeNotifications= */ true);
return;
}
@ -179,18 +186,27 @@ import java.util.concurrent.TimeoutException;
MediaNotification mediaNotification =
this.mediaNotificationProvider.createNotification(
session, checkStateNotNull(customLayoutMap.get(session)), actionFactory, callback);
updateNotificationInternal(session, mediaNotification);
updateNotificationInternal(session, mediaNotification, startInForegroundRequired);
}
public boolean isStartedInForeground() {
return startedInForeground;
}
private void onNotificationUpdated(
int notificationSequence, MediaSession session, MediaNotification mediaNotification) {
if (notificationSequence == totalNotificationCount) {
updateNotificationInternal(session, mediaNotification);
boolean startInForegroundRequired =
MediaSessionService.shouldRunInForeground(
session, /* startInForegroundWhenPaused= */ false);
updateNotificationInternal(session, mediaNotification, startInForegroundRequired);
}
}
private void updateNotificationInternal(
MediaSession session, MediaNotification mediaNotification) {
MediaSession session,
MediaNotification mediaNotification,
boolean startInForegroundRequired) {
if (Util.SDK_INT >= 21) {
// Call Notification.MediaStyle#setMediaSession() indirectly.
android.media.session.MediaSession.Token fwkToken =
@ -199,17 +215,9 @@ import java.util.concurrent.TimeoutException;
mediaNotification.notification.extras.putParcelable(
Notification.EXTRA_MEDIA_SESSION, fwkToken);
}
this.mediaNotification = mediaNotification;
Player player = session.getPlayer();
if (shouldRunInForeground(player)) {
ContextCompat.startForegroundService(mediaSessionService, startSelfIntent);
if (Util.SDK_INT >= 29) {
Api29.startForeground(mediaSessionService, mediaNotification);
} else {
mediaSessionService.startForeground(
mediaNotification.notificationId, mediaNotification.notification);
}
if (startInForegroundRequired) {
startForeground(mediaNotification);
} else {
maybeStopForegroundService(/* removeNotifications= */ false);
notificationManagerCompat.notify(
@ -226,19 +234,12 @@ import java.util.concurrent.TimeoutException;
private void maybeStopForegroundService(boolean removeNotifications) {
List<MediaSession> sessions = mediaSessionService.getSessions();
for (int i = 0; i < sessions.size(); i++) {
if (shouldRunInForeground(sessions.get(i).getPlayer())) {
if (MediaSessionService.shouldRunInForeground(
sessions.get(i), /* startInForegroundWhenPaused= */ false)) {
return;
}
}
// To hide the notification on all API levels, we need to call both Service.stopForeground(true)
// and notificationManagerCompat.cancel(notificationId).
if (Util.SDK_INT >= 24) {
Api24.stopForeground(mediaSessionService, removeNotifications);
} else {
// For pre-L devices, we must call Service.stopForeground(true) anyway as a workaround
// that prevents the media notification from being undismissable.
mediaSessionService.stopForeground(removeNotifications || Util.SDK_INT < 21);
}
stopForeground(removeNotifications);
if (removeNotifications && mediaNotification != null) {
notificationManagerCompat.cancel(mediaNotification.notificationId);
// Update the notification count so that if a pending notification callback arrives (e.g., a
@ -248,16 +249,11 @@ import java.util.concurrent.TimeoutException;
}
}
private static boolean shouldShowNotification(Player player) {
private static boolean shouldShowNotification(MediaSession session) {
Player player = session.getPlayer();
return !player.getCurrentTimeline().isEmpty() && player.getPlaybackState() != Player.STATE_IDLE;
}
private static boolean shouldRunInForeground(Player player) {
return player.getPlayWhenReady()
&& (player.getPlaybackState() == Player.STATE_READY
|| player.getPlaybackState() == Player.STATE_BUFFERING);
}
private static final class MediaControllerListener
implements MediaController.Listener, Player.Listener {
private final MediaSessionService mediaSessionService;
@ -274,8 +270,9 @@ import java.util.concurrent.TimeoutException;
}
public void onConnected() {
if (shouldShowNotification(session.getPlayer())) {
mediaSessionService.onUpdateNotification(session);
if (shouldShowNotification(session)) {
mediaSessionService.onUpdateNotificationInternal(
session, /* startInForegroundWhenPaused= */ false);
}
}
@ -283,7 +280,8 @@ import java.util.concurrent.TimeoutException;
public ListenableFuture<SessionResult> onSetCustomLayout(
MediaController controller, List<CommandButton> layout) {
customLayoutMap.put(session, ImmutableList.copyOf(layout));
mediaSessionService.onUpdateNotification(session);
mediaSessionService.onUpdateNotificationInternal(
session, /* startInForegroundWhenPaused= */ false);
return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_SUCCESS));
}
@ -296,7 +294,8 @@ import java.util.concurrent.TimeoutException;
Player.EVENT_PLAY_WHEN_READY_CHANGED,
Player.EVENT_MEDIA_METADATA_CHANGED,
Player.EVENT_TIMELINE_CHANGED)) {
mediaSessionService.onUpdateNotification(session);
mediaSessionService.onUpdateNotificationInternal(
session, /* startInForegroundWhenPaused= */ false);
}
}
@ -304,10 +303,35 @@ import java.util.concurrent.TimeoutException;
public void onDisconnected(MediaController controller) {
mediaSessionService.removeSession(session);
// We may need to hide the notification.
mediaSessionService.onUpdateNotification(session);
mediaSessionService.onUpdateNotificationInternal(
session, /* startInForegroundWhenPaused= */ false);
}
}
private void startForeground(MediaNotification mediaNotification) {
ContextCompat.startForegroundService(mediaSessionService, startSelfIntent);
if (Util.SDK_INT >= 29) {
Api29.startForeground(mediaSessionService, mediaNotification);
} else {
mediaSessionService.startForeground(
mediaNotification.notificationId, mediaNotification.notification);
}
startedInForeground = true;
}
private void stopForeground(boolean removeNotifications) {
// To hide the notification on all API levels, we need to call both Service.stopForeground(true)
// and notificationManagerCompat.cancel(notificationId).
if (Util.SDK_INT >= 24) {
Api24.stopForeground(mediaSessionService, removeNotifications);
} else {
// For pre-L devices, we must call Service.stopForeground(true) anyway as a workaround
// that prevents the media notification from being undismissable.
mediaSessionService.stopForeground(removeNotifications || Util.SDK_INT < 21);
}
startedInForeground = false;
}
@RequiresApi(24)
private static class Api24 {

View File

@ -877,10 +877,15 @@ public class MediaSession {
}
/** Sets the {@linkplain Listener listener}. */
/* package */ void setListener(@Nullable Listener listener) {
/* package */ void setListener(Listener listener) {
impl.setMediaSessionListener(listener);
}
/** Clears the {@linkplain Listener listener}. */
/* package */ void clearListener() {
impl.clearMediaSessionListener();
}
private Uri getUri() {
return impl.getUri();
}
@ -1272,6 +1277,15 @@ public class MediaSession {
* @param session The media session for which the notification requires to be refreshed.
*/
void onNotificationRefreshRequired(MediaSession session);
/**
* Called when the {@linkplain MediaSession session} receives the play command and requests from
* the listener on whether the media can be played.
*
* @param session The media session which requests if the media can be played.
* @return True if the media can be played, false otherwise.
*/
boolean onPlayRequested(MediaSession session);
}
/**

View File

@ -579,16 +579,27 @@ import org.checkerframework.checker.initialization.qual.Initialized;
}
}
/* package */ void setMediaSessionListener(@Nullable MediaSession.Listener listener) {
/* package */ void setMediaSessionListener(MediaSession.Listener listener) {
this.mediaSessionListener = listener;
}
/* package */ void clearMediaSessionListener() {
this.mediaSessionListener = null;
}
/* package */ void onNotificationRefreshRequired() {
if (this.mediaSessionListener != null) {
this.mediaSessionListener.onNotificationRefreshRequired(instance);
}
}
/* package */ boolean onPlayRequested() {
if (this.mediaSessionListener != null) {
return this.mediaSessionListener.onPlayRequested(instance);
}
return true;
}
private void dispatchRemoteControllerTaskToLegacyStub(RemoteControllerTask task) {
try {
task.run(sessionLegacyStub.getControllerLegacyCbForBroadcast(), /* seq= */ 0);

View File

@ -313,7 +313,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
playerWrapper.seekTo(
playerWrapper.getCurrentMediaItemIndex(), /* positionMs= */ C.TIME_UNSET);
}
if (sessionImpl.onPlayRequested()) {
playerWrapper.play();
}
},
sessionCompat.getCurrentControllerInfo());
}

View File

@ -20,6 +20,7 @@ import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkStateNotNull;
import static androidx.media3.common.util.Util.postOrRun;
import android.app.ForegroundServiceStartNotAllowedException;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
@ -32,13 +33,17 @@ import android.os.Looper;
import android.os.RemoteException;
import android.view.KeyEvent;
import androidx.annotation.CallSuper;
import androidx.annotation.DoNotInline;
import androidx.annotation.GuardedBy;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.collection.ArrayMap;
import androidx.media.MediaBrowserServiceCompat;
import androidx.media.MediaSessionManager;
import androidx.media3.common.Player;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.session.MediaSession.ControllerInfo;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
@ -134,6 +139,21 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
*/
public abstract class MediaSessionService extends Service {
/**
* Listener for {@link MediaSessionService}.
*
* <p>The methods will be called on the main thread.
*/
@UnstableApi
public interface Listener {
/**
* Called when the service fails to start in the foreground and a {@link
* ForegroundServiceStartNotAllowedException} is thrown on Android 12 or later.
*/
@RequiresApi(31)
default void onForegroundServiceStartNotAllowedException() {}
}
/** The action for {@link Intent} filter that must be declared by the service. */
public static final String SERVICE_INTERFACE = "androidx.media3.session.MediaSessionService";
@ -158,11 +178,19 @@ public abstract class MediaSessionService extends Service {
@GuardedBy("lock")
private @MonotonicNonNull DefaultActionFactory actionFactory;
@GuardedBy("lock")
@Nullable
private Listener listener;
@GuardedBy("lock")
private boolean defaultMethodCalled;
/** Creates a service. */
public MediaSessionService() {
lock = new Object();
mainHandler = new Handler(Looper.getMainLooper());
sessions = new ArrayMap<>();
defaultMethodCalled = false;
}
/**
@ -239,7 +267,7 @@ public abstract class MediaSessionService extends Service {
// TODO(b/191644474): Check whether the session is registered to multiple services.
MediaNotificationManager notificationManager = getMediaNotificationManager();
postOrRun(mainHandler, () -> notificationManager.addSession(session));
session.setListener(this::onUpdateNotification);
session.setListener(new MediaSessionListener());
}
}
@ -259,7 +287,7 @@ public abstract class MediaSessionService extends Service {
}
MediaNotificationManager notificationManager = getMediaNotificationManager();
postOrRun(mainHandler, () -> notificationManager.removeSession(session));
session.setListener(null);
session.clearListener();
}
/**
@ -282,6 +310,22 @@ public abstract class MediaSessionService extends Service {
}
}
/** Sets the {@linkplain Listener listener}. */
@UnstableApi
public final void setListener(Listener listener) {
synchronized (lock) {
this.listener = listener;
}
}
/** Clears the {@linkplain Listener listener}. */
@UnstableApi
public final void clearListener() {
synchronized (lock) {
this.listener = null;
}
}
/**
* Called when a component is about to bind to the service.
*
@ -395,8 +439,10 @@ public abstract class MediaSessionService extends Service {
* <p>Override this method to create your own notification and customize the foreground handling
* of your service.
*
* <p>The default implementation will present a default notification or the notification provided
* by the {@link MediaNotification.Provider} that is {@link
* <p>At most one of {@link #onUpdateNotification(MediaSession, boolean)} and this method should
* be overridden. If neither of the two methods is overridden, the default implementation will
* present a default notification or the notification provided by the {@link
* MediaNotification.Provider} that is {@link
* #setMediaNotificationProvider(MediaNotification.Provider) set} by the app. Further, the service
* is started in the <a
* href="https://developer.android.com/guide/components/foreground-services">foreground</a> when
@ -408,7 +454,42 @@ public abstract class MediaSessionService extends Service {
* @param session A session that needs notification update.
*/
public void onUpdateNotification(MediaSession session) {
getMediaNotificationManager().updateNotification(session);
setDefaultMethodCalled(true);
}
/**
* Called when a notification needs to be updated. Override this method to show or cancel your own
* notifications.
*
* <p>This method is called whenever the service has detected a change that requires to show,
* update or cancel a notification with a flag {@code startInForegroundRequired} suggested by the
* service whether starting in the foreground is required. The method will be called on the
* application thread of the app that the service belongs to.
*
* <p>Override this method to create your own notification and customize the foreground handling
* of your service.
*
* <p>At most one of {@link #onUpdateNotification(MediaSession)} and this method should be
* overridden. If neither of the two methods is overridden, the default implementation will
* present a default notification or the notification provided by the {@link
* MediaNotification.Provider} that is {@link
* #setMediaNotificationProvider(MediaNotification.Provider) set} by the app. Further, the service
* is started in the <a
* href="https://developer.android.com/guide/components/foreground-services">foreground</a> when
* playback is ongoing and put back into background otherwise.
*
* <p>Apps targeting {@code SDK_INT >= 28} must request the permission, {@link
* android.Manifest.permission#FOREGROUND_SERVICE}.
*
* @param session A session that needs notification update.
* @param startInForegroundRequired Whether the service is required to start in the foreground.
*/
@UnstableApi
public void onUpdateNotification(MediaSession session, boolean startInForegroundRequired) {
onUpdateNotification(session);
if (isDefaultMethodCalled()) {
getMediaNotificationManager().updateNotification(session, startInForegroundRequired);
}
}
/**
@ -431,6 +512,31 @@ public abstract class MediaSessionService extends Service {
}
}
/* package */ boolean onUpdateNotificationInternal(
MediaSession session, boolean startInForegroundWhenPaused) {
try {
boolean startInForegroundRequired =
shouldRunInForeground(session, startInForegroundWhenPaused);
onUpdateNotification(session, startInForegroundRequired);
} catch (/* ForegroundServiceStartNotAllowedException */ IllegalStateException e) {
if ((Util.SDK_INT >= 31) && Api31.instanceOfForegroundServiceStartNotAllowedException(e)) {
Log.e(TAG, "Failed to start foreground", e);
onForegroundServiceStartNotAllowedException();
return false;
}
throw e;
}
return true;
}
/* package */ static boolean shouldRunInForeground(
MediaSession session, boolean startInForegroundWhenPaused) {
Player player = session.getPlayer();
return (player.getPlayWhenReady() || startInForegroundWhenPaused)
&& (player.getPlaybackState() == Player.STATE_READY
|| player.getPlaybackState() == Player.STATE_BUFFERING);
}
private MediaNotificationManager getMediaNotificationManager() {
synchronized (lock) {
if (mediaNotificationManager == null) {
@ -455,6 +561,57 @@ public abstract class MediaSessionService extends Service {
}
}
@Nullable
private Listener getListener() {
synchronized (lock) {
return this.listener;
}
}
private boolean isDefaultMethodCalled() {
synchronized (lock) {
return this.defaultMethodCalled;
}
}
private void setDefaultMethodCalled(boolean defaultMethodCalled) {
synchronized (lock) {
this.defaultMethodCalled = defaultMethodCalled;
}
}
@RequiresApi(31)
private void onForegroundServiceStartNotAllowedException() {
mainHandler.post(
() -> {
@Nullable MediaSessionService.Listener serviceListener = getListener();
if (serviceListener != null) {
serviceListener.onForegroundServiceStartNotAllowedException();
}
});
}
private final class MediaSessionListener implements MediaSession.Listener {
@Override
public void onNotificationRefreshRequired(MediaSession session) {
MediaSessionService.this.onUpdateNotificationInternal(
session, /* startInForegroundWhenPaused= */ false);
}
@Override
public boolean onPlayRequested(MediaSession session) {
if (Util.SDK_INT < 31 || Util.SDK_INT >= 33) {
return true;
}
// Check if service can start foreground successfully on Android 12 and 12L.
if (!getMediaNotificationManager().isStartedInForeground()) {
return onUpdateNotificationInternal(session, /* startInForegroundWhenPaused= */ true);
}
return true;
}
}
private static final class MediaSessionServiceStub extends IMediaSessionService.Stub {
private final WeakReference<MediaSessionService> serviceReference;
@ -575,4 +732,13 @@ public abstract class MediaSessionService extends Service {
}
}
}
@RequiresApi(31)
private static final class Api31 {
@DoNotInline
public static boolean instanceOfForegroundServiceStartNotAllowedException(
IllegalStateException e) {
return e instanceof ForegroundServiceStartNotAllowedException;
}
}
}

View File

@ -611,7 +611,20 @@ import java.util.concurrent.ExecutionException;
return;
}
queueSessionTaskWithPlayerCommand(
caller, sequenceNumber, COMMAND_PLAY_PAUSE, sendSessionResultSuccess(Player::play));
caller,
sequenceNumber,
COMMAND_PLAY_PAUSE,
sendSessionResultSuccess(
player -> {
@Nullable MediaSessionImpl sessionImpl = this.sessionImpl.get();
if (sessionImpl == null || sessionImpl.isReleased()) {
return;
}
if (sessionImpl.onPlayRequested()) {
player.play();
}
}));
}
@Override