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 6ae6968d93..27c0cc4ece 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java @@ -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 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 onSetCustomLayout( MediaController controller, List 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 { diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSession.java b/libraries/session/src/main/java/androidx/media3/session/MediaSession.java index c8a1a80597..44af689db4 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSession.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSession.java @@ -878,10 +878,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(); } @@ -1273,6 +1278,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); } /** diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java index 5782dec04d..921d5acf36 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java @@ -580,16 +580,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); diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java index 069534ffad..3c25022e9d 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java @@ -313,7 +313,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; playerWrapper.seekTo( playerWrapper.getCurrentMediaItemIndex(), /* positionMs= */ C.TIME_UNSET); } - playerWrapper.play(); + if (sessionImpl.onPlayRequested()) { + playerWrapper.play(); + } }, sessionCompat.getCurrentControllerInfo()); } 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 0e8d21cca4..a93a6cf8a7 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java @@ -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}. + * + *

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 { *

Override this method to create your own notification and customize the foreground handling * of your service. * - *

The default implementation will present a default notification or the notification provided - * by the {@link MediaNotification.Provider} that is {@link + *

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 foreground 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. + * + *

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. + * + *

Override this method to create your own notification and customize the foreground handling + * of your service. + * + *

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 foreground when + * playback is ongoing and put back into background otherwise. + * + *

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 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; + } + } } diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java index b13b4d61fb..866e92d80e 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java @@ -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