diff --git a/libraries/common/src/main/java/androidx/media3/common/util/Util.java b/libraries/common/src/main/java/androidx/media3/common/util/Util.java index 5a82bd0dc2..119a340397 100644 --- a/libraries/common/src/main/java/androidx/media3/common/util/Util.java +++ b/libraries/common/src/main/java/androidx/media3/common/util/Util.java @@ -878,7 +878,7 @@ public static boolean postOrRun(HandlerWrapper handler, Runnable runnable) { */ @UnstableApi public static ListenableFuture postOrRunWithCompletion( - Handler handler, Runnable runnable, T successValue) { + Handler handler, Runnable runnable, @Nullable T successValue) { SettableFuture outputFuture = SettableFuture.create(); postOrRun( handler, 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 5a73849849..7f06c16adb 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java @@ -21,6 +21,7 @@ import static java.util.concurrent.TimeUnit.MILLISECONDS; import android.annotation.SuppressLint; +import android.app.ForegroundServiceStartNotAllowedException; import android.app.Notification; import android.app.NotificationManager; import android.content.Context; @@ -32,11 +33,15 @@ import android.os.Looper; import android.os.Message; import android.util.Pair; +import androidx.annotation.GuardedBy; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; import androidx.core.app.NotificationCompat; import androidx.core.content.ContextCompat; import androidx.media3.common.Player; +import androidx.media3.common.util.BackgroundExecutor; import androidx.media3.common.util.Log; +import androidx.media3.common.util.NullableType; import androidx.media3.common.util.Util; import androidx.media3.session.MediaNotification.Provider.NotificationChannelInfo; import androidx.media3.session.MediaSessionService.ShowNotificationForIdlePlayerMode; @@ -45,20 +50,20 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; -import java.util.concurrent.Executor; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; /** * Manages media notifications for a {@link MediaSessionService} and sets the service as * foreground/background according to the player state. - * - *

All methods must be called on the main thread. */ /* package */ final class MediaNotificationManager implements Handler.Callback { @@ -69,22 +74,40 @@ private final MediaSessionService mediaSessionService; + private final Object lock; private final MediaNotification.ActionFactory actionFactory; private final NotificationManager notificationManager; private final Handler mainHandler; - private final Executor mainExecutor; private final Intent startSelfIntent; private final String startSelfIntentUid; - private final Map controllerMap; + private final Map controllerMap; // write only on main + + @GuardedBy("#lock") + private final Map mediaNotifications; - private MediaNotification.Provider mediaNotificationProvider; - private int totalNotificationCount; - @Nullable private MediaNotification mediaNotification; - private boolean startedInForeground; + private volatile MediaNotification.Provider mediaNotificationProvider; + + /** + * The session that provides the notification the foreground service is started with, or null if + * the service is not in foreground at the moment. Each service can only have one foreground + * notification, so we have to switch this around as needed if we have multiple sessions. + */ + @GuardedBy("#lock") + private @Nullable MediaSession foregroundSession; + + @GuardedBy("#lock") private boolean isUserEngaged; + + @GuardedBy("#lock") private boolean isUserEngagedTimeoutEnabled; + + @GuardedBy("#lock") private long userEngagedTimeoutMs; - @ShowNotificationForIdlePlayerMode int showNotificationForIdlePlayerMode; + + @GuardedBy("#lock") + private boolean notificationsEnabled; + + @ShowNotificationForIdlePlayerMode volatile int showNotificationForIdlePlayerMode; public MediaNotificationManager( MediaSessionService mediaSessionService, @@ -93,18 +116,19 @@ public MediaNotificationManager( this.mediaSessionService = mediaSessionService; this.mediaNotificationProvider = mediaNotificationProvider; this.actionFactory = actionFactory; + lock = new Object(); notificationManager = checkNotNull( (NotificationManager) mediaSessionService.getSystemService(Context.NOTIFICATION_SERVICE)); mainHandler = Util.createHandler(Looper.getMainLooper(), /* callback= */ this); - mainExecutor = (runnable) -> Util.postOrRun(mainHandler, runnable); startSelfIntent = new Intent(mediaSessionService, mediaSessionService.getClass()); startSelfIntentUid = UUID.randomUUID().toString(); startSelfIntent.putExtra(SELF_INTENT_UID_KEY, startSelfIntentUid); controllerMap = new HashMap<>(); - startedInForeground = false; + mediaNotifications = new HashMap<>(); isUserEngagedTimeoutEnabled = true; + notificationsEnabled = true; userEngagedTimeoutMs = MediaSessionService.DEFAULT_FOREGROUND_SERVICE_TIMEOUT_MS; showNotificationForIdlePlayerMode = MediaSessionService.SHOW_NOTIFICATION_FOR_IDLE_PLAYER_AFTER_STOP_OR_ERROR; @@ -118,6 +142,7 @@ public MediaNotificationManager( return startSelfIntentUid; } + // This method will be called on the main thread. public void addSession(MediaSession session) { if (controllerMap.containsKey(session)) { return; @@ -129,9 +154,12 @@ public void addSession(MediaSession session) { new MediaController.Builder(mediaSessionService, session.getToken()) .setConnectionHints(connectionHints) .setListener(listener) - .setApplicationLooper(Looper.getMainLooper()) + .setApplicationLooper(checkNotNull(session.getBackgroundLooper())) .buildAsync(); - controllerMap.put(session, new ControllerInfo(controllerFuture)); + Handler playerHandler = new Handler(session.getPlayer().getApplicationLooper()); + Handler backgroundHandler = new Handler(checkNotNull(session.getBackgroundLooper())); + controllerMap.put( + session, new ControllerInfo(controllerFuture, playerHandler, backgroundHandler)); controllerFuture.addListener( () -> { try { @@ -146,27 +174,35 @@ public void addSession(MediaSession session) { mediaSessionService.removeSession(session); } }, - mainExecutor); + r -> Util.postOrRun(backgroundHandler, r)); } + // This method will be called on the main thread. public void removeSession(MediaSession session) { @Nullable ControllerInfo controllerInfo = controllerMap.remove(session); if (controllerInfo != null) { - MediaController.releaseFuture(controllerInfo.controllerFuture); + // Update the notification count so that if a pending notification callback arrives (e.g., a + // bitmap is loaded), we don't show a stale notification. + controllerInfo.notificationSequence++; + controllerInfo.backgroundHandler.post( + () -> MediaController.releaseFuture(controllerInfo.controllerFuture)); } } + // This method will be called on the main thread. public void onCustomAction(MediaSession session, String action, Bundle extras) { - @Nullable MediaController mediaController = getConnectedControllerForSession(session); - if (mediaController == null) { + @Nullable ControllerInfo controllerInfo = getConnectedControllerInfoForSession(session); + if (controllerInfo == null) { return; } + MediaController mediaController = getControllerForControllerInfo(controllerInfo); // Let the notification provider handle the command first before forwarding it directly. Util.postOrRun( - new Handler(session.getPlayer().getApplicationLooper()), + controllerInfo.playerHandler, () -> { if (!mediaNotificationProvider.handleCustomCommand(session, action, extras)) { - mainExecutor.execute( + Util.postOrRun( + controllerInfo.backgroundHandler, () -> sendCustomCommandIfCommandIsAvailable(mediaController, action, extras)); } }); @@ -175,6 +211,8 @@ public void onCustomAction(MediaSession session, String action, Bundle extras) { /** * Updates the media notification provider. * + *

This method will be called on the main thread. + * * @param mediaNotificationProvider The {@link MediaNotification.Provider}. */ public void setMediaNotificationProvider(MediaNotification.Provider mediaNotificationProvider) { @@ -184,46 +222,100 @@ public void setMediaNotificationProvider(MediaNotification.Provider mediaNotific /** * Updates the notification. * + *

This method can be called on any thread. + * * @param session A session that needs notification update. * @param startInForegroundRequired Whether the service is required to start in the foreground. + * @return Future is set to false if a {@link ForegroundServiceStartNotAllowedException} prevented + * starting the foreground service, otherwise (even if no start attempted) true. */ - public void updateNotification(MediaSession session, boolean startInForegroundRequired) { - if (!mediaSessionService.isSessionAdded(session) || !shouldShowNotification(session)) { - removeNotification(); - return; + public ListenableFuture updateNotification( + MediaSession session, boolean startInForegroundRequired) { + @Nullable ControllerInfo controllerInfo = getConnectedControllerInfoForSession(session); + SettableFuture completion = SettableFuture.create(); + boolean updateStarted = false; + if (controllerInfo != null) { + updateStarted = + Util.postOrRun( + controllerInfo.backgroundHandler, + () -> { + if (!mediaSessionService.isSessionAdded(session) + || !shouldShowNotification(session)) { + removeNotification(session, startInForegroundRequired); + completion.set(true); + return; + } + + int notificationSequence = ++controllerInfo.notificationSequence; + ImmutableList mediaButtonPreferences = + getControllerForControllerInfo(controllerInfo).getMediaButtonPreferences(); + MediaNotification.Provider.Callback callback = + notification -> + Util.postOrRun( + controllerInfo.backgroundHandler, + () -> + onNotificationUpdated( + controllerInfo, notificationSequence, session, notification)); + Util.postOrRun( + controllerInfo.playerHandler, + () -> { + MediaNotification mediaNotification = + this.mediaNotificationProvider.createNotification( + session, mediaButtonPreferences, actionFactory, callback); + checkState( + /* expression= */ mediaNotification.notificationId + != SHUTDOWN_NOTIFICATION_ID, + /* errorMessage= */ "notification ID " + + SHUTDOWN_NOTIFICATION_ID + + " is already used internally."); + Util.postOrRun( + controllerInfo.backgroundHandler, + () -> + completion.set( + updateNotificationInternal( + session, mediaNotification, startInForegroundRequired))); + }); + }); + } + if (!updateStarted) { + postToControllerBackground( + controllerInfo, + session, + () -> { + removeNotification(session, startInForegroundRequired); + completion.set(true); + }); } + return completion; + } - int notificationSequence = ++totalNotificationCount; - ImmutableList mediaButtonPreferences = - checkNotNull(getConnectedControllerForSession(session)).getMediaButtonPreferences(); - MediaNotification.Provider.Callback callback = - notification -> - mainExecutor.execute( - () -> onNotificationUpdated(notificationSequence, session, notification)); - Util.postOrRun( - new Handler(session.getPlayer().getApplicationLooper()), - () -> { - MediaNotification mediaNotification = - this.mediaNotificationProvider.createNotification( - session, mediaButtonPreferences, actionFactory, callback); - checkState( - /* expression= */ mediaNotification.notificationId != SHUTDOWN_NOTIFICATION_ID, - /* errorMessage= */ "notification ID " - + SHUTDOWN_NOTIFICATION_ID - + " is already used internally."); - mainExecutor.execute( - () -> - updateNotificationInternal( - session, mediaNotification, startInForegroundRequired)); - }); + private void postToControllerBackground( + @Nullable ControllerInfo controllerInfo, MediaSession session, Runnable r) { + @Nullable Handler bgHandler = controllerInfo != null ? controllerInfo.backgroundHandler : null; + if (bgHandler == null) { + @Nullable Looper bgLooper = session.getBackgroundLooper(); + if (bgLooper != null) { + bgHandler = new Handler(bgLooper); + } + } + if (bgHandler == null || !bgHandler.post(r)) { + // If we end up here, the thread already quit. We should use another thread as stand-in. + BackgroundExecutor.get().execute(r); + } } + // This method can be called on any thread. public boolean isStartedInForeground() { - return startedInForeground; + synchronized (lock) { + return foregroundSession != null; + } } + // This method will be called on the main thread. public void setUserEngagedTimeoutMs(long userEngagedTimeoutMs) { - this.userEngagedTimeoutMs = userEngagedTimeoutMs; + synchronized (lock) { + this.userEngagedTimeoutMs = userEngagedTimeoutMs; + } } public void setShowNotificationForIdlePlayer( @@ -249,39 +341,101 @@ public boolean handleMessage(Message msg) { return false; } - /* package */ boolean shouldRunInForeground(boolean startInForegroundWhenPaused) { - boolean isUserEngaged = isAnySessionUserEngaged(startInForegroundWhenPaused); - boolean useTimeout = isUserEngagedTimeoutEnabled && userEngagedTimeoutMs > 0; - if (this.isUserEngaged && !isUserEngaged && useTimeout) { - mainHandler.sendEmptyMessageDelayed(MSG_USER_ENGAGED_TIMEOUT, userEngagedTimeoutMs); - } else if (isUserEngaged) { - mainHandler.removeMessages(MSG_USER_ENGAGED_TIMEOUT); - } - this.isUserEngaged = isUserEngaged; - boolean hasPendingTimeout = mainHandler.hasMessages(MSG_USER_ENGAGED_TIMEOUT); - return isUserEngaged || hasPendingTimeout; + // This method can be called on any thread. + /* package */ ListenableFuture shouldRunInForeground( + boolean startInForegroundWhenPaused) { + SettableFuture completion = SettableFuture.create(); + ListenableFuture isUserEngagedFuture = + isAnySessionUserEngaged(startInForegroundWhenPaused); + isUserEngagedFuture.addListener( + () -> { + boolean isUserEngaged; + try { + isUserEngaged = isUserEngagedFuture.get(); + } catch (ExecutionException | InterruptedException e) { + throw new IllegalStateException(e); + } + synchronized (lock) { + boolean useTimeout = isUserEngagedTimeoutEnabled && userEngagedTimeoutMs > 0; + if (this.isUserEngaged && !isUserEngaged && useTimeout) { + mainHandler.sendEmptyMessageDelayed(MSG_USER_ENGAGED_TIMEOUT, userEngagedTimeoutMs); + } else if (isUserEngaged) { + mainHandler.removeMessages(MSG_USER_ENGAGED_TIMEOUT); + } + this.isUserEngaged = isUserEngaged; + } + boolean hasPendingTimeout = mainHandler.hasMessages(MSG_USER_ENGAGED_TIMEOUT); + completion.set(isUserEngaged || hasPendingTimeout); + }, + MoreExecutors.directExecutor()); + return completion; } - private boolean isAnySessionUserEngaged(boolean startInForegroundWhenPaused) { + // This method can be called on any thread. + private ListenableFuture isAnySessionUserEngaged(boolean startInForegroundWhenPaused) { List sessions = mediaSessionService.getSessions(); + synchronized (lock) { + if (!notificationsEnabled || sessions.isEmpty()) { + return Futures.immediateFuture(false); + } + } + SettableFuture outputFuture = SettableFuture.create(); + List<@NullableType ListenableFuture> sessionFutures = new ArrayList<>(); + final AtomicInteger resultCount = new AtomicInteger(0); + Runnable handleSessionFuturesTask = + () -> { + int completedSessionFutureCount = resultCount.incrementAndGet(); + if (completedSessionFutureCount == sessions.size()) { + boolean value = false; + for (@NullableType ListenableFuture future : sessionFutures) { + if (future != null) { + try { + value = future.get(); + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + if (value) { + break; + } + } + } + outputFuture.set(value); + } + }; + 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; + MediaSession session = sessions.get(i); + @Nullable ControllerInfo controllerInfo = getConnectedControllerInfoForSession(session); + if (controllerInfo != null) { + MediaController controller = getControllerForControllerInfo(controllerInfo); + SettableFuture completion = SettableFuture.create(); + Util.postOrRun( + controllerInfo.backgroundHandler, + () -> + completion.set( + (controller.getPlayWhenReady() || startInForegroundWhenPaused) + && (controller.getPlaybackState() == Player.STATE_READY + || controller.getPlaybackState() == Player.STATE_BUFFERING))); + sessionFutures.add(completion); + completion.addListener(handleSessionFuturesTask, MoreExecutors.directExecutor()); + } else { + sessionFutures.add(null); + handleSessionFuturesTask.run(); } } - return false; + return outputFuture; } /** * Permanently disable the user engaged timeout, which is needed to immediately stop the * foreground service. + * + *

This method will be called on the main thread. */ /* package */ void disableUserEngagedTimeout() { - isUserEngagedTimeoutEnabled = false; + synchronized (lock) { + isUserEngagedTimeoutEnabled = false; + } if (mainHandler.hasMessages(MSG_USER_ENGAGED_TIMEOUT)) { mainHandler.removeMessages(MSG_USER_ENGAGED_TIMEOUT); List sessions = mediaSessionService.getSessions(); @@ -292,15 +446,50 @@ private boolean isAnySessionUserEngaged(boolean startInForegroundWhenPaused) { } } + /** + * Permanently disable both the user engaged timeout and posting new notifications, immediately + * stop the foreground service and cancel all media notifications. + * + *

This method will be called on the main thread. + */ + /* package */ void stopForegroundServiceAndDisableNotifications() { + synchronized (lock) { + isUserEngagedTimeoutEnabled = false; + notificationsEnabled = false; + if (mainHandler.hasMessages(MSG_USER_ENGAGED_TIMEOUT)) { + mainHandler.removeMessages(MSG_USER_ENGAGED_TIMEOUT); + } + List sessions = mediaSessionService.getSessions(); + for (int i = 0; i < sessions.size(); i++) { + removeNotification(sessions.get(i), false); + } + } + } + + // Will be called on controller's application thread. private void onNotificationUpdated( - int notificationSequence, MediaSession session, MediaNotification mediaNotification) { - if (notificationSequence == totalNotificationCount) { - boolean startInForegroundRequired = + ControllerInfo controllerInfo, + int notificationSequence, + MediaSession session, + MediaNotification mediaNotification) { + if (controllerInfo.notificationSequence == notificationSequence) { + ListenableFuture startInForegroundRequiredFuture = shouldRunInForeground(/* startInForegroundWhenPaused= */ false); - updateNotificationInternal(session, mediaNotification, startInForegroundRequired); + startInForegroundRequiredFuture.addListener( + () -> { + boolean startInForegroundRequired; + try { + startInForegroundRequired = startInForegroundRequiredFuture.get(); + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + updateNotificationInternal(session, mediaNotification, startInForegroundRequired); + }, + r -> Util.postOrRun(controllerInfo.backgroundHandler, r)); } } + // Will be called on controller's application thread. private void onNotificationDismissed(MediaSession session) { @Nullable ControllerInfo controllerInfo = controllerMap.get(session); if (controllerInfo != null) { @@ -308,47 +497,69 @@ private void onNotificationDismissed(MediaSession session) { } } - // POST_NOTIFICATIONS permission is not required for media session related notifications. - // https://developer.android.com/develop/ui/views/notifications/notification-permission#exemptions-media-sessions - @SuppressLint("MissingPermission") - private void updateNotificationInternal( + // Will be called on controller's application thread. + private boolean updateNotificationInternal( MediaSession session, MediaNotification mediaNotification, boolean startInForegroundRequired) { // Call Notification.MediaStyle#setMediaSession() indirectly. Token fwkToken = session.getPlatformToken(); mediaNotification.notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken); - this.mediaNotification = mediaNotification; - if (startInForegroundRequired) { - startForeground(mediaNotification); - } else { - // Notification manager has to be updated first to avoid missing updates - // (https://github.com/androidx/media/issues/192). - notificationManager.notify(mediaNotification.notificationId, mediaNotification.notification); - Util.stopForeground(mediaSessionService, /* removeNotification= */ false); + synchronized (lock) { + if (!notificationsEnabled) { + return true; + } + this.mediaNotifications.put(session, mediaNotification); + if (startInForegroundRequired) { + return startForeground(session, mediaNotification); + } else { + // Notification manager has to be updated first to avoid missing updates + // (https://github.com/androidx/media/issues/192). + notify(mediaNotification); + stopForeground(session, /* removeNotifications= */ false, false); + return true; + } } } /** Removes the notification and stops the foreground service if running. */ - private void removeNotification() { - // To hide the notification on all API levels, we need to call both Service.stopForeground(true) - // and notificationManagerCompat.cancel(notificationId). - Util.stopForeground(mediaSessionService, /* removeNotification= */ true); - if (mediaNotification != null) { - notificationManager.cancel(mediaNotification.notificationId); - // Update the notification count so that if a pending notification callback arrives (e.g., a - // bitmap is loaded), we don't show the notification. - totalNotificationCount++; - mediaNotification = null; + // Can be called on any thread. + private void removeNotification(MediaSession session, boolean startInForegroundRequired) { + synchronized (lock) { + // To hide the notification on all API levels, we need to call both + // Service.stopForeground(true) + // and notificationManagerCompat.cancel(notificationId). + stopForeground(session, /* removeNotifications= */ true, startInForegroundRequired); + @Nullable MediaNotification mediaNotification = mediaNotifications.remove(session); + if (mediaNotification != null) { + notificationManager.cancel(mediaNotification.notificationId); + @Nullable ControllerInfo controllerInfo = controllerMap.get(session); + // If the controllerInfo is already removed from the map, removeSession() has increased the + // sequence, so it's fine. + if (controllerInfo != null) { + // Update the notification count so that if a pending notification callback arrives (e.g., + // a bitmap is loaded), we don't show the notification. + controllerInfo.notificationSequence++; + } + } } } + // Will be called on controller's application thread. private boolean shouldShowNotification(MediaSession session) { - MediaController controller = getConnectedControllerForSession(session); - if (controller == null || controller.getCurrentTimeline().isEmpty()) { + synchronized (lock) { + if (!notificationsEnabled) { + return false; + } + } + ControllerInfo controllerInfo = getConnectedControllerInfoForSession(session); + if (controllerInfo == null) { + return false; + } + MediaController controller = getControllerForControllerInfo(controllerInfo); + if (controller.getCurrentTimeline().isEmpty()) { return false; } - ControllerInfo controllerInfo = checkNotNull(controllerMap.get(session)); if (controller.getPlaybackState() != Player.STATE_IDLE) { // Playback first prepared or restarted, reset previous notification dismissed flag. controllerInfo.wasNotificationDismissed = false; @@ -368,11 +579,19 @@ private boolean shouldShowNotification(MediaSession session) { } @Nullable - private MediaController getConnectedControllerForSession(MediaSession session) { + private ControllerInfo getConnectedControllerInfoForSession(MediaSession session) { @Nullable ControllerInfo controllerInfo = controllerMap.get(session); if (controllerInfo == null || !controllerInfo.controllerFuture.isDone()) { return null; } + return controllerInfo; + } + + private static MediaController getControllerForControllerInfo(ControllerInfo controllerInfo) { + if (!controllerInfo.controllerFuture.isDone()) { + // This is checked in getConnectedControllerInfoForSession() already, so can't happen. + throw new IllegalArgumentException("controllerInfo is not done"); + } try { return Futures.getDone(controllerInfo.controllerFuture); } catch (ExecutionException exception) { @@ -381,6 +600,7 @@ private MediaController getConnectedControllerForSession(MediaSession session) { } } + // Will be called on controller's application thread. private void sendCustomCommandIfCommandIsAvailable( MediaController mediaController, String action, Bundle extras) { @Nullable SessionCommand customCommand = null; @@ -455,12 +675,10 @@ public void onMediaButtonPreferencesChanged( session, /* startInForegroundWhenPaused= */ false); } - @Override - public void onAvailableSessionCommandsChanged( - MediaController controller, SessionCommands commands) { - mediaSessionService.onUpdateNotificationInternal( - session, /* startInForegroundWhenPaused= */ false); - } + // Note: Because setAvailableCommands can be called from any thread and processing the updated + // state requires executing code on both player and background thread, MediaSessionLegacyStub is + // responsible for manually triggering a refresh of the notification instead of this being + // handled here. @Override public ListenableFuture onCustomCommand( @@ -491,28 +709,106 @@ public void onEvents(Player player, Player.Events events) { Player.EVENT_PLAYBACK_STATE_CHANGED, Player.EVENT_PLAY_WHEN_READY_CHANGED, Player.EVENT_MEDIA_METADATA_CHANGED, - Player.EVENT_TIMELINE_CHANGED)) { + Player.EVENT_TIMELINE_CHANGED, + Player.EVENT_DEVICE_INFO_CHANGED)) { mediaSessionService.onUpdateNotificationInternal( session, /* startInForegroundWhenPaused= */ false); } } } + @GuardedBy("#lock") @SuppressLint("InlinedApi") // Using compile time constant FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK - private void startForeground(MediaNotification mediaNotification) { - ContextCompat.startForegroundService(mediaSessionService, startSelfIntent); - Util.setForegroundServiceNotification( - mediaSessionService, - mediaNotification.notificationId, - mediaNotification.notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK, - "mediaPlayback"); - startedInForeground = true; + private boolean startForeground(MediaSession session, MediaNotification newMediaNotification) { + MediaNotification mediaNotification; + boolean hasOtherForegroundSession; + if (foregroundSession != null && foregroundSession != session) { + // If we would use the newly updated notification, the old foreground session's notification + // would be canceled by the system because the service switched to another foreground service + // notification. However, we don't want to cancel it, so we just keep using the old session's + // notification as long as we can, and post our new notification as normal notification. + // We still need to call startForeground() in any case to avoid + // ForegroundServiceDidNotStartInTimeException. + mediaNotification = checkNotNull(mediaNotifications.get(foregroundSession)); + hasOtherForegroundSession = true; + } else { + mediaNotification = newMediaNotification; + hasOtherForegroundSession = false; + } + try { + ContextCompat.startForegroundService(mediaSessionService, startSelfIntent); + Util.setForegroundServiceNotification( + mediaSessionService, + mediaNotification.notificationId, + mediaNotification.notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK, + "mediaPlayback"); + } catch (IllegalStateException e) { + if (SDK_INT >= 31 && Api31.instanceOfForegroundServiceStartNotAllowedException(e)) { + Log.e(TAG, "Failed to start foreground", e); + return false; + } + throw e; + } + if (hasOtherForegroundSession) { + // startForeground() normally does this, but as we passed another notification to it, we have + // to do it manually here. + notify(newMediaNotification); + } else { + foregroundSession = session; + } + return true; + } + + @GuardedBy("#lock") + private void stopForeground( + MediaSession session, boolean removeNotifications, boolean startInForegroundRequired) { + if (foregroundSession != session) { + // This happens if there is another session in foreground since before this session wanted to + // go in the foreground. As this other session still wants to be in the foreground, we don't + // need to stop the foreground service. cancel() will be called by the caller if + // removeNotifications is true, so we don't need to do anything here. + return; + } + foregroundSession = null; + if (startInForegroundRequired) { + // If we shouldn't show a notification for this session anymore, but there's another session + // and it wants to stay in foreground, we have to change the foreground service notification + // to the other session. + List sessions = mediaSessionService.getSessions(); + for (MediaSession candidateSession : sessions) { + // Just take the first one willing to show a notification, it doesn't matter which one. + if (shouldShowNotification(candidateSession)) { + // Because we are already a foreground service, this should not fail. + startForeground(candidateSession, checkNotNull(mediaNotifications.get(candidateSession))); + // When calling startForeground() with a different notification ID, the old notification + // will be canceled by the system. It's an unfortunate limitation of the API we can't do + // anything about. Hence, if removeNotifications is false, we have to send the + // notification out again using notify() as we didn't want it to be canceled. + if (!removeNotifications) { + notify(checkNotNull(mediaNotifications.get(session))); + } + return; + } + } + } + // Either we don't have any notification left, or we don't want to stay in foreground. It's + // time to stop the foreground service. + Util.stopForeground(mediaSessionService, removeNotifications); + } + + // POST_NOTIFICATIONS permission is not required for media session related notifications. + // https://developer.android.com/develop/ui/views/notifications/notification-permission#exemptions-media-sessions + @SuppressLint("MissingPermission") + private void notify(MediaNotification notification) { + notificationManager.notify(notification.notificationId, notification.notification); } private static final class ControllerInfo { public final ListenableFuture controllerFuture; + public final Handler playerHandler; + public final Handler backgroundHandler; /** Indicates whether the user actively dismissed the notification. */ public boolean wasNotificationDismissed; @@ -520,8 +816,24 @@ private static final class ControllerInfo { /** Indicated whether the player has ever been prepared. */ public boolean hasBeenPrepared; - public ControllerInfo(ListenableFuture controllerFuture) { + /** The notification sequence number. */ + public int notificationSequence; + + public ControllerInfo( + ListenableFuture controllerFuture, + Handler playerHandler, + Handler backgroundHandler) { this.controllerFuture = controllerFuture; + this.playerHandler = playerHandler; + this.backgroundHandler = backgroundHandler; + } + } + + @RequiresApi(31) + private static final class Api31 { + public static boolean instanceOfForegroundServiceStartNotAllowedException( + IllegalStateException e) { + return e instanceof ForegroundServiceStartNotAllowedException; } } } 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 aa45300dc3..215b9fa867 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSession.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSession.java @@ -1512,6 +1512,10 @@ public final android.media.session.MediaSession.Token getPlatformToken() { return impl.getUri(); } + /* package */ @Nullable final Looper getBackgroundLooper() { + return impl.getBackgroundLooper(); + } + /** * A progress reporter to report progress for a custom command sent by a controller. * @@ -2450,7 +2454,7 @@ default void onError(int seq, SessionError sessionError) throws RemoteException /** * Listener for media session events. * - *

All methods must be called on the main thread. + *

All methods can be called on any thread. */ /* package */ interface Listener { @@ -2468,7 +2472,7 @@ default void onError(int seq, SessionError sessionError) throws RemoteException * @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); + ListenableFuture 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 64a74e601d..1a66d7a8d9 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java @@ -48,6 +48,7 @@ import android.os.Bundle; import android.os.DeadObjectException; import android.os.Handler; +import android.os.HandlerThread; import android.os.IBinder; import android.os.Looper; import android.os.Message; @@ -91,7 +92,6 @@ import com.google.common.base.Supplier; import com.google.common.base.Suppliers; import com.google.common.collect.ImmutableList; -import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import java.lang.ref.WeakReference; @@ -139,6 +139,7 @@ private final boolean isPeriodicPositionUpdateEnabled; private final boolean useLegacySurfaceHandling; private final ImmutableList commandButtonsForMediaItems; + private final HandlerThread backgroundThread; private PlayerInfo playerInfo; private PlayerWrapper playerWrapper; @@ -208,6 +209,9 @@ public MediaSessionImpl( sessionStub = new MediaSessionStub(thisRef); + backgroundThread = new HandlerThread("MediaSessionImpl:bg"); + backgroundThread.start(); + mainHandler = new Handler(Looper.getMainLooper()); Looper applicationLooper = player.getApplicationLooper(); applicationHandler = new Handler(applicationLooper); @@ -227,16 +231,16 @@ public MediaSessionImpl( new MediaSessionLegacyStub( /* session= */ thisRef, sessionUri, - applicationHandler, tokenExtras, playIfSuppressed, customLayout, mediaButtonPreferences, connectionResult.availableSessionCommands, connectionResult.availablePlayerCommands, - sessionExtras); + sessionExtras, + backgroundThread.getLooper()); - Token platformToken = sessionLegacyStub.getSessionCompat().getSessionToken().getToken(); + Token platformToken = sessionLegacyStub.getSessionToken().getToken(); sessionToken = new SessionToken( Process.myUid(), @@ -329,6 +333,7 @@ public void release() { } sessionLegacyStub.release(); sessionStub.release(); + backgroundThread.quitSafely(); } public PlayerWrapper getPlayerWrapper() { @@ -357,6 +362,10 @@ public SessionToken getToken() { return sessionToken; } + public @Nullable Looper getBackgroundLooper() { + return backgroundThread.getLooper(); + } + public List getConnectedControllers() { ImmutableList media3Controllers = sessionStub.getConnectedControllersManager().getConnectedControllers(); @@ -980,7 +989,7 @@ public void connectFromService(IMediaController caller, ControllerInfo controlle @SuppressWarnings("UnnecessarilyFullyQualified") // Avoiding confusion by just using "Token" public android.media.session.MediaSession.Token getPlatformToken() { - return sessionLegacyStub.getSessionCompat().getSessionToken().getToken(); + return sessionLegacyStub.getSessionToken().getToken(); } public void setLegacyControllerConnectionTimeoutMs(long timeoutMs) { @@ -1049,8 +1058,7 @@ protected IBinder getLegacyBrowserServiceBinder() { MediaSessionServiceLegacyStub legacyStub; synchronized (lock) { if (browserServiceLegacyStub == null) { - browserServiceLegacyStub = - createLegacyBrowserService(sessionLegacyStub.getSessionCompat().getSessionToken()); + browserServiceLegacyStub = createLegacyBrowserService(sessionLegacyStub.getSessionToken()); } legacyStub = browserServiceLegacyStub; } @@ -1122,32 +1130,16 @@ protected MediaSessionServiceLegacyStub getLegacyBrowserService() { } /* package */ void onNotificationRefreshRequired() { - postOrRun( - mainHandler, - () -> { - if (this.mediaSessionListener != null) { - this.mediaSessionListener.onNotificationRefreshRequired(instance); - } - }); + if (this.mediaSessionListener != null) { + this.mediaSessionListener.onNotificationRefreshRequired(instance); + } } - /* package */ boolean onPlayRequested() { - if (Looper.myLooper() != Looper.getMainLooper()) { - try { - return CallbackToFutureAdapter.getFuture( - completer -> { - mainHandler.post(() -> completer.set(onPlayRequested())); - return "onPlayRequested"; - }) - .get(); - } catch (InterruptedException | ExecutionException e) { - throw new IllegalStateException(e); - } - } + /* package */ ListenableFuture onPlayRequested() { if (this.mediaSessionListener != null) { return this.mediaSessionListener.onPlayRequested(instance); } - return true; + return Futures.immediateFuture(true); } /** @@ -1158,84 +1150,91 @@ protected MediaSessionServiceLegacyStub getLegacyBrowserService() { * * @param controller The controller requesting to play. */ - /* package */ void handleMediaControllerPlayRequest( + /* package */ ListenableFuture handleMediaControllerPlayRequest( ControllerInfo controller, boolean callOnPlayerInteractionFinished) { - if (!onPlayRequested()) { - // Request denied, e.g. due to missing foreground service abilities. - return; - } - boolean hasCurrentMediaItem = - playerWrapper.isCommandAvailable(Player.COMMAND_GET_CURRENT_MEDIA_ITEM) - && playerWrapper.getCurrentMediaItem() != null; - boolean canAddMediaItems = - playerWrapper.isCommandAvailable(COMMAND_SET_MEDIA_ITEM) - || playerWrapper.isCommandAvailable(COMMAND_CHANGE_MEDIA_ITEMS); - ControllerInfo controllerForRequest = resolveControllerInfoForCallback(controller); - Player.Commands playCommand = - new Player.Commands.Builder().add(Player.COMMAND_PLAY_PAUSE).build(); - if (hasCurrentMediaItem || !canAddMediaItems) { - // No playback resumption needed or possible. - if (!hasCurrentMediaItem) { - Log.w( - TAG, - "Play requested without current MediaItem, but playback resumption prevented by" - + " missing available commands"); - } - Util.handlePlayButtonAction(playerWrapper); - if (callOnPlayerInteractionFinished) { - onPlayerInteractionFinishedOnHandler(controllerForRequest, playCommand); - } - } else { - @Nullable - ListenableFuture future = - checkNotNull( - callback.onPlaybackResumption( - instance, controllerForRequest, /* isForPlayback= */ true), - "Callback.onPlaybackResumption must return a non-null future"); - Futures.addCallback( - future, - new FutureCallback() { - @Override - public void onSuccess(MediaItemsWithStartPosition mediaItemsWithStartPosition) { - callWithControllerForCurrentRequestSet( - controllerForRequest, - () -> { - MediaUtils.setMediaItemsWithStartIndexAndPosition( - playerWrapper, mediaItemsWithStartPosition); - Util.handlePlayButtonAction(playerWrapper); - if (callOnPlayerInteractionFinished) { - onPlayerInteractionFinishedOnHandler(controllerForRequest, playCommand); - } - }) - .run(); + return Futures.transformAsync( + onPlayRequested(), + playRequested -> { + if (!playRequested) { + // Request denied, e.g. due to missing foreground service abilities. + return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_ERROR_UNKNOWN)); + } + boolean hasCurrentMediaItem = + playerWrapper.isCommandAvailable(Player.COMMAND_GET_CURRENT_MEDIA_ITEM) + && playerWrapper.getCurrentMediaItem() != null; + boolean canAddMediaItems = + playerWrapper.isCommandAvailable(COMMAND_SET_MEDIA_ITEM) + || playerWrapper.isCommandAvailable(COMMAND_CHANGE_MEDIA_ITEMS); + ControllerInfo controllerForRequest = resolveControllerInfoForCallback(controller); + Player.Commands playCommand = + new Player.Commands.Builder().add(Player.COMMAND_PLAY_PAUSE).build(); + if (hasCurrentMediaItem || !canAddMediaItems) { + // No playback resumption needed or possible. + if (!hasCurrentMediaItem) { + Log.w( + TAG, + "Play requested without current MediaItem, but playback resumption prevented by" + + " missing available commands"); } - - @Override - public void onFailure(Throwable t) { - if (t instanceof UnsupportedOperationException) { - Log.w( - TAG, - "UnsupportedOperationException: Make sure to implement" - + " MediaSession.Callback.onPlaybackResumption() if you add a" - + " media button receiver to your manifest or if you implement the recent" - + " media item contract with your MediaLibraryService.", - t); - } else { - Log.e( - TAG, - "Failure calling MediaSession.Callback.onPlaybackResumption(): " - + t.getMessage(), - t); - } - // Play as requested even if playback resumption fails. - Util.handlePlayButtonAction(playerWrapper); - if (callOnPlayerInteractionFinished) { - onPlayerInteractionFinishedOnHandler(controllerForRequest, playCommand); - } + Util.handlePlayButtonAction(playerWrapper); + if (callOnPlayerInteractionFinished) { + onPlayerInteractionFinishedOnHandler(controllerForRequest, playCommand); } - }, - this::postOrRunOnApplicationHandler); - } + return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_SUCCESS)); + } else { + ListenableFuture future = + Futures.transform( + checkNotNull( + callback.onPlaybackResumption( + instance, controllerForRequest, /* isForPlayback= */ true), + "Callback.onPlaybackResumption must return a non-null future"), + mediaItemsWithStartPosition -> { + callWithControllerForCurrentRequestSet( + controllerForRequest, + () -> { + MediaUtils.setMediaItemsWithStartIndexAndPosition( + playerWrapper, mediaItemsWithStartPosition); + Util.handlePlayButtonAction(playerWrapper); + if (callOnPlayerInteractionFinished) { + onPlayerInteractionFinishedOnHandler( + controllerForRequest, playCommand); + } + }) + .run(); + return new SessionResult(SessionResult.RESULT_SUCCESS); + }, + this::postOrRunOnApplicationHandler); + return Futures.catching( + future, + Throwable.class, + t -> { + if (t instanceof UnsupportedOperationException) { + Log.w( + TAG, + "UnsupportedOperationException: Make sure to implement" + + " MediaSession.Callback.onPlaybackResumption() if you add a media" + + " button receiver to your manifest or if you implement the recent" + + " media item contract with your MediaLibraryService.", + t); + } else { + Log.e( + TAG, + "Failure calling MediaSession.Callback.onPlaybackResumption(): " + + t.getMessage(), + t); + } + // Play as requested even if playback resumption fails. + Util.handlePlayButtonAction(playerWrapper); + if (callOnPlayerInteractionFinished) { + onPlayerInteractionFinishedOnHandler(controllerForRequest, playCommand); + } + return new SessionResult(SessionResult.RESULT_SUCCESS); + }, + this::postOrRunOnApplicationHandler + ); + } + }, + this::postOrRunOnApplicationHandler); } /* package */ void triggerPlayerInfoUpdate() { @@ -1515,7 +1514,7 @@ private void handleAvailablePlayerCommandsChanged(Player.Commands availableComma sessionLegacyStub.onSkipToNext(); return true; } else if (callerInfo.getControllerVersion() != ControllerInfo.LEGACY_CONTROLLER_VERSION) { - sessionLegacyStub.getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent); + sessionLegacyStub.getControllerCompat().dispatchMediaButtonEvent(keyEvent); return true; } // This is an unhandled framework event. Return false to let the framework resolve by calling 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 a909dde9ce..2b9d1258ba 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java @@ -93,8 +93,8 @@ import androidx.media3.session.MediaSession.ControllerInfo; import androidx.media3.session.MediaSession.MediaItemsWithStartPosition; import androidx.media3.session.SessionCommand.CommandCode; +import androidx.media3.session.legacy.MediaControllerCompat; import androidx.media3.session.legacy.MediaDescriptionCompat; -import androidx.media3.session.legacy.MediaMetadataCompat; import androidx.media3.session.legacy.MediaSessionCompat; import androidx.media3.session.legacy.MediaSessionCompat.QueueItem; import androidx.media3.session.legacy.MediaSessionManager; @@ -138,11 +138,11 @@ private final MediaSessionCompat sessionCompat; @Nullable private final MediaButtonReceiver runtimeBroadcastReceiver; @Nullable private final ComponentName broadcastReceiverComponentName; - @Nullable private VolumeProviderCompat volumeProviderCompat; private final boolean playIfSuppressed; private volatile long connectionTimeoutMs; @Nullable private FutureCallback pendingBitmapLoadCallback; + @Nullable private VolumeProviderCompat volumeProviderCompat; private int sessionFlags; @Nullable private LegacyError legacyError; private Bundle legacyExtras; @@ -160,14 +160,14 @@ public MediaSessionLegacyStub( MediaSessionImpl session, Uri sessionUri, - Handler handler, Bundle tokenExtras, boolean playIfSuppressed, ImmutableList customLayout, ImmutableList mediaButtonPreferences, SessionCommands availableSessionCommands, Player.Commands availablePlayerCommands, - Bundle legacyExtras) { + Bundle legacyExtras, + Looper backgroundLooper) { this.sessionImpl = session; this.playIfSuppressed = playIfSuppressed; this.customLayout = customLayout; @@ -247,20 +247,17 @@ public MediaSessionLegacyStub( sessionCompatId, SDK_INT < 31 ? receiverComponentName : null, SDK_INT < 31 ? mediaButtonIntent : null, - /* sessionInfo= */ tokenExtras); + session.getSessionActivity(), + /* sessionInfo= */ tokenExtras, + backgroundLooper); if (SDK_INT >= 31 && broadcastReceiverComponentName != null) { Api31.setMediaButtonBroadcastReceiver(sessionCompat, broadcastReceiverComponentName); } - @Nullable PendingIntent sessionActivity = session.getSessionActivity(); - if (sessionActivity != null) { - sessionCompat.setSessionActivity(sessionActivity); - } - @SuppressWarnings("nullness:assignment") @Initialized MediaSessionLegacyStub thisRef = this; - sessionCompat.setCallback(thisRef, handler); + sessionCompat.setCallback(thisRef, session.getApplicationHandler()); } /** @@ -299,15 +296,25 @@ public void setAvailableCommands( /* defaultValue= */ false) != hadNextReservation); if (extrasChanged) { - getSessionCompat().setExtras(legacyExtras); + sessionCompat.setExtras(legacyExtras); } } - if (commandGetTimelineChanged) { - updateLegacySessionPlaybackStateAndQueue(sessionImpl.getPlayerWrapper()); - } else { - updateLegacySessionPlaybackState(sessionImpl.getPlayerWrapper()); - } + PlayerWrapper playerWrapper = sessionImpl.getPlayerWrapper(); + postOrRun( + sessionImpl.getApplicationHandler(), + () -> { + ListenableFuture completion = + sessionCompat.setPlaybackState(createPlaybackStateCompat(playerWrapper)); + completion.addListener( + sessionImpl::onNotificationRefreshRequired, MoreExecutors.directExecutor()); + if (commandGetTimelineChanged) { + controllerLegacyCbForBroadcast.updateQueue( + playerWrapper.getAvailableCommands().contains(Player.COMMAND_GET_TIMELINE) + ? playerWrapper.getCurrentTimeline() + : Timeline.EMPTY); + } + }); } /** @@ -362,7 +369,7 @@ public void setPlatformMediaButtonPreferences( /* defaultValue= */ false) != hadNextReservation); if (extrasChanged) { - getSessionCompat().setExtras(legacyExtras); + sessionCompat.setExtras(legacyExtras); } } @@ -382,8 +389,8 @@ public void setPlaybackException( customPlaybackException = playbackException; this.playerCommandsForErrorState = playerCommandsForErrorState; if (playbackException != null) { - updateLegacySessionPlaybackState(sessionImpl.getPlayerWrapper()); maybeUpdateFlags(sessionImpl.getPlayerWrapper()); + updateLegacySessionPlaybackState(sessionImpl.getPlayerWrapper()); } } @@ -466,10 +473,13 @@ public void start() { @SuppressWarnings("PendingIntentMutability") // We can't use SaferPendingIntent. public void release() { + if (runtimeBroadcastReceiver != null) { + sessionImpl.getContext().unregisterReceiver(runtimeBroadcastReceiver); + } if (SDK_INT < 31) { if (broadcastReceiverComponentName == null) { // No broadcast receiver available. Playback resumption not supported. - setMediaButtonReceiver(sessionCompat, /* mediaButtonReceiverIntent= */ null); + /* mediaButtonReceiverIntent= */ sessionCompat.setMediaButtonReceiver(null); } else { // Override the runtime receiver with the broadcast receiver for playback resumption. Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON, sessionImpl.getUri()); @@ -480,18 +490,19 @@ public void release() { /* requestCode= */ 0, intent, PENDING_INTENT_FLAG_MUTABLE); - setMediaButtonReceiver(sessionCompat, mediaButtonReceiverIntent); + sessionCompat.setMediaButtonReceiver(mediaButtonReceiverIntent); } } - if (runtimeBroadcastReceiver != null) { - sessionImpl.getContext().unregisterReceiver(runtimeBroadcastReceiver); - } // No check for COMMAND_RELEASE needed as MediaControllers can always be released. sessionCompat.release(); } - public MediaSessionCompat getSessionCompat() { - return sessionCompat; + public MediaSessionCompat.Token getSessionToken() { + return sessionCompat.getSessionToken(); // Safe to call on any thread. + } + + public MediaControllerCompat getControllerCompat() { + return sessionCompat.getController(); // Safe to call on any thread. } @Override @@ -850,9 +861,27 @@ public ConnectedControllersManager getConnectedControllersManage private void dispatchSessionTaskWithPlayRequest() { dispatchSessionTaskWithPlayerCommand( COMMAND_PLAY_PAUSE, - controller -> - sessionImpl.handleMediaControllerPlayRequest( - controller, /* callOnPlayerInteractionFinished= */ true), + controller -> { + ListenableFuture resultFuture = + sessionImpl.handleMediaControllerPlayRequest( + controller, /* callOnPlayerInteractionFinished= */ true); + Futures.addCallback( + resultFuture, + new FutureCallback() { + @Override + public void onSuccess(SessionResult result) { + if (result.resultCode != RESULT_SUCCESS) { + Log.w(TAG, "onPlay() failed: " + result + " (from: " + controller + ")"); + } + } + + @Override + public void onFailure(Throwable t) { + Log.e(TAG, "Unexpected exception in onPlay() of " + controller, t); + } + }, + MoreExecutors.directExecutor()); + }, sessionCompat.getCurrentControllerInfo(), /* callOnPlayerInteractionFinished= */ false); } @@ -869,65 +898,60 @@ private void dispatchSessionTaskWithPlayerCommand( Log.d(TAG, "RemoteUserInfo is null, ignoring command=" + command); return; } - postOrRun( - sessionImpl.getApplicationHandler(), - () -> { - if (sessionImpl.isReleased()) { - return; - } - if (!sessionCompat.isActive()) { - Log.w( - TAG, - "Ignore incoming player command before initialization. command=" - + command - + ", pid=" - + remoteUserInfo.getPid()); - return; - } - @Nullable ControllerInfo controller = tryGetController(remoteUserInfo); - if (controller == null) { - // Failed to get controller since connection was rejected. - return; - } - if (!connectedControllersManager.isPlayerCommandAvailable(controller, command)) { - if (command == COMMAND_PLAY_PAUSE - && !sessionImpl.getPlayerWrapper().getPlayWhenReady()) { - Log.w( - TAG, - "Calling play() omitted due to COMMAND_PLAY_PAUSE not being available. If this" - + " play command has started the service for instance for playback" - + " resumption, this may prevent the service from being started into the" - + " foreground."); - } - return; - } - int resultCode = sessionImpl.onPlayerCommandRequestOnHandler(controller, command); - if (resultCode != RESULT_SUCCESS) { - // Don't run rejected command. - return; - } + if (sessionImpl.isReleased()) { + return; + } + if (!sessionCompat.isActive()) { + Log.w( + TAG, + "Ignore incoming player command before initialization. command=" + + command + + ", pid=" + + remoteUserInfo.getPid()); + return; + } + @Nullable ControllerInfo controller = tryGetController(remoteUserInfo); + if (controller == null) { + // Failed to get controller since connection was rejected. + return; + } + if (!connectedControllersManager.isPlayerCommandAvailable(controller, command)) { + if (command == COMMAND_PLAY_PAUSE && !sessionImpl.getPlayerWrapper().getPlayWhenReady()) { + Log.w( + TAG, + "Calling play() omitted due to COMMAND_PLAY_PAUSE not being available. If this" + + " play command has started the service for instance for playback" + + " resumption, this may prevent the service from being started into the" + + " foreground."); + } + return; + } + int resultCode = sessionImpl.onPlayerCommandRequestOnHandler(controller, command); + if (resultCode != RESULT_SUCCESS) { + // Don't run rejected command. + return; + } - sessionImpl - .callWithControllerForCurrentRequestSet( - controller, - () -> { - try { - task.run(controller); - } catch (RemoteException e) { - // Currently it's TransactionTooLargeException or DeadSystemException. - // We'd better to leave log for those cases because - // - TransactionTooLargeException means that we may need to fix our code. - // (e.g. add pagination or special way to deliver Bitmap) - // - DeadSystemException means that errors around it can be ignored. - Log.w(TAG, "Exception in " + controller, e); - } - }) - .run(); - if (callOnPlayerInteractionFinished) { - sessionImpl.onPlayerInteractionFinishedOnHandler( - controller, new Player.Commands.Builder().add(command).build()); - } - }); + sessionImpl + .callWithControllerForCurrentRequestSet( + controller, + () -> { + try { + task.run(controller); + } catch (RemoteException e) { + // Currently it's TransactionTooLargeException or DeadSystemException. + // We'd better to leave log for those cases because + // - TransactionTooLargeException means that we may need to fix our code. + // (e.g. add pagination or special way to deliver Bitmap) + // - DeadSystemException means that errors around it can be ignored. + Log.w(TAG, "Exception in " + controller, e); + } + }) + .run(); + if (callOnPlayerInteractionFinished) { + sessionImpl.onPlayerInteractionFinishedOnHandler( + controller, new Player.Commands.Builder().add(command).build()); + } } private void dispatchSessionTaskWithSetRatingSessionCommand(Rating rating) { @@ -965,47 +989,42 @@ private void dispatchSessionTaskWithSessionCommandInternal( + (sessionCommand == null ? commandCode : sessionCommand)); return; } - postOrRun( - sessionImpl.getApplicationHandler(), - () -> { - if (sessionImpl.isReleased()) { - return; - } - if (!sessionCompat.isActive()) { - Log.w( - TAG, - "Ignore incoming session command before initialization. command=" - + (sessionCommand == null ? commandCode : sessionCommand.customAction) - + ", pid=" - + remoteUserInfo.getPid()); - return; - } - @Nullable ControllerInfo controller = tryGetController(remoteUserInfo); - if (controller == null) { - // Failed to get controller since connection was rejected. - return; - } - if (sessionCommand != null) { - if (!connectedControllersManager.isSessionCommandAvailable( - controller, sessionCommand)) { - return; - } - } else { - if (!connectedControllersManager.isSessionCommandAvailable(controller, commandCode)) { - return; - } - } - try { - task.run(controller); - } catch (RemoteException e) { - // Currently it's TransactionTooLargeException or DeadSystemException. - // We'd better to leave log for those cases because - // - TransactionTooLargeException means that we may need to fix our code. - // (e.g. add pagination or special way to deliver Bitmap) - // - DeadSystemException means that errors around it can be ignored. - Log.w(TAG, "Exception in " + controller, e); - } - }); + if (sessionImpl.isReleased()) { + return; + } + if (!sessionCompat.isActive()) { + Log.w( + TAG, + "Ignore incoming session command before initialization. command=" + + (sessionCommand == null ? commandCode : sessionCommand.customAction) + + ", pid=" + + remoteUserInfo.getPid()); + return; + } + @Nullable ControllerInfo controller = tryGetController(remoteUserInfo); + if (controller == null) { + // Failed to get controller since connection was rejected. + return; + } + if (sessionCommand != null) { + if (!connectedControllersManager.isSessionCommandAvailable(controller, sessionCommand)) { + return; + } + } else { + if (!connectedControllersManager.isSessionCommandAvailable(controller, commandCode)) { + return; + } + } + try { + task.run(controller); + } catch (RemoteException e) { + // Currently it's TransactionTooLargeException or DeadSystemException. + // We'd better to leave log for those cases because + // - TransactionTooLargeException means that we may need to fix our code. + // (e.g. add pagination or special way to deliver Bitmap) + // - DeadSystemException means that errors around it can be ignored. + Log.w(TAG, "Exception in " + controller, e); + } } private void dispatchCustomCommandAsPredefinedCommand(SessionCommand command) { @@ -1087,18 +1106,6 @@ public void updateLegacySessionPlaybackState(PlayerWrapper playerWrapper) { () -> sessionCompat.setPlaybackState(createPlaybackStateCompat(playerWrapper))); } - public void updateLegacySessionPlaybackStateAndQueue(PlayerWrapper playerWrapper) { - postOrRun( - sessionImpl.getApplicationHandler(), - () -> { - sessionCompat.setPlaybackState(createPlaybackStateCompat(playerWrapper)); - controllerLegacyCbForBroadcast.updateQueue( - playerWrapper.getAvailableCommands().contains(Player.COMMAND_GET_TIMELINE) - ? playerWrapper.getCurrentTimeline() - : Timeline.EMPTY); - }); - } - private void handleMediaRequest(MediaItem mediaItem, boolean play) { handleMediaRequest(mediaItem, /* prepare= */ true, play); } @@ -1226,28 +1233,6 @@ private static void ignoreFuture(Future unused) { // no-op } - @SuppressWarnings("nullness:argument") // MediaSessionCompat didn't annotate @Nullable. - private static void setMetadata( - MediaSessionCompat sessionCompat, @Nullable MediaMetadataCompat metadataCompat) { - sessionCompat.setMetadata(metadataCompat); - } - - @SuppressWarnings("nullness:argument") // MediaSessionCompat didn't annotate @Nullable. - private static void setMediaButtonReceiver( - MediaSessionCompat sessionCompat, @Nullable PendingIntent mediaButtonReceiverIntent) { - sessionCompat.setMediaButtonReceiver(mediaButtonReceiverIntent); - } - - @SuppressWarnings("nullness:argument") // MediaSessionCompat didn't annotate @Nullable. - private static void setQueue(MediaSessionCompat sessionCompat, @Nullable List queue) { - sessionCompat.setQueue(queue); - } - - @SuppressWarnings("nullness:argument") // MediaSessionCompat didn't annotate @Nullable. - private void setQueueTitle(MediaSessionCompat sessionCompat, @Nullable CharSequence title) { - sessionCompat.setQueueTitle(isQueueEnabled() ? title : null); - } - private boolean isQueueEnabled() { PlayerWrapper playerWrapper = sessionImpl.getPlayerWrapper(); return availablePlayerCommands.contains(Player.COMMAND_GET_TIMELINE) @@ -1466,7 +1451,7 @@ public void onError(int seq, SessionError sessionError) { if (!skipLegacySessionPlaybackStateUpdates()) { sessionCompat.setPlaybackState(createPlaybackStateCompat(playerWrapper)); legacyError = null; - sessionCompat.setPlaybackState(createPlaybackStateCompat(playerWrapper)); + updateLegacySessionPlaybackState(playerWrapper); } } @@ -1557,7 +1542,7 @@ public void onMediaItemTransition( sessionCompat.setRatingType( LegacyConversions.getRatingCompatStyle(mediaItem.mediaMetadata.userRating)); } - updateLegacySessionPlaybackState(sessionImpl.getPlayerWrapper()); + sessionCompat.setPlaybackState(createPlaybackStateCompat(sessionImpl.getPlayerWrapper())); } @Override @@ -1581,7 +1566,7 @@ public void onTimelineChanged( private void updateQueue(Timeline timeline) { if (!isQueueEnabled() || timeline.isEmpty()) { - setQueue(sessionCompat, /* queue= */ null); + sessionCompat.computeAndSetQueue(null); return; } List mediaItemList = LegacyConversions.convertToMediaItemList(timeline); @@ -1613,23 +1598,30 @@ private void updateQueue(Timeline timeline) { private void handleBitmapFuturesAllCompletedAndSetQueue( List<@NullableType ListenableFuture> bitmapFutures, List mediaItems) { - List queueItemList = new ArrayList<>(); - for (int i = 0; i < bitmapFutures.size(); i++) { - @Nullable ListenableFuture future = bitmapFutures.get(i); - @Nullable Bitmap bitmap = null; - if (future != null) { - try { - bitmap = Futures.getDone(future); - } catch (CancellationException | ExecutionException e) { - Log.d(TAG, "Failed to get bitmap", e); - } - } - queueItemList.add(LegacyConversions.convertToQueueItem(mediaItems.get(i), i, bitmap)); - } - // Framework MediaSession#setQueue() uses ParceledListSlice, // which means we can safely send long lists. - setQueue(sessionCompat, queueItemList); + // Do conversions on a background thread instead of the session thread, as we may + // have a lot of media items to convert. + sessionCompat.computeAndSetQueue( + () -> { + // Do conversions on a background thread instead of the session thread, as we may + // have a lot of media items to convert. + List queueItemList = new ArrayList<>(bitmapFutures.size()); + for (int i = 0; i < bitmapFutures.size(); i++) { + @Nullable ListenableFuture future = bitmapFutures.get(i); + @Nullable Bitmap bitmap = null; + if (future != null) { + try { + bitmap = Futures.getDone(future); + } catch (CancellationException | ExecutionException e) { + Log.d(TAG, "Failed to get bitmap", e); + } + } + queueItemList.add( + LegacyConversions.convertToQueueItem(mediaItems.get(i), i, bitmap)); + } + return queueItemList; + }); } @Override @@ -1640,9 +1632,9 @@ public void onPlaylistMetadataChanged(int seq, MediaMetadata playlistMetadata) { } // Since there is no 'queue metadata', only set title of the queue. @Nullable CharSequence queueTitle = sessionCompat.getController().getQueueTitle(); - @Nullable CharSequence newTitle = playlistMetadata.title; + @Nullable CharSequence newTitle = isQueueEnabled() ? playlistMetadata.title : null; if (!TextUtils.equals(queueTitle, newTitle)) { - setQueueTitle(sessionCompat, newTitle); + sessionCompat.setQueueTitle(newTitle); } } @@ -1672,7 +1664,8 @@ public void onDeviceInfoChanged(int seq, DeviceInfo deviceInfo) { PlayerWrapper player = sessionImpl.getPlayerWrapper(); volumeProviderCompat = createVolumeProviderCompat(player); if (volumeProviderCompat == null) { - sessionCompat.setPlaybackToLocal(player.getAudioAttributesWithCommandCheck()); + AudioAttributes audioAttributes = player.getAudioAttributesWithCommandCheck(); + sessionCompat.setPlaybackToLocal(audioAttributes); } else { sessionCompat.setPlaybackToRemote(volumeProviderCompat); } @@ -1744,15 +1737,16 @@ public void onSuccess(Bitmap result) { if (this != pendingBitmapLoadCallback) { return; } - setMetadata( - sessionCompat, - LegacyConversions.convertToMediaMetadataCompat( - newMediaMetadata, - newMediaId, - newMediaUri, - newDurationMs, - /* artworkBitmap= */ result)); - sessionImpl.onNotificationRefreshRequired(); + ListenableFuture completion = + sessionCompat.setMetadata( + LegacyConversions.convertToMediaMetadataCompat( + newMediaMetadata, + newMediaId, + newMediaUri, + newDurationMs, + /* artworkBitmap= */ result)); + completion.addListener( + sessionImpl::onNotificationRefreshRequired, MoreExecutors.directExecutor()); } @Override @@ -1769,8 +1763,7 @@ public void onFailure(Throwable t) { /* executor= */ sessionImpl.getApplicationHandler()::post); } } - setMetadata( - sessionCompat, + sessionCompat.setMetadata( LegacyConversions.convertToMediaMetadataCompat( newMediaMetadata, newMediaId, newMediaUri, newDurationMs, artworkBitmap)); } 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 4d20aebc70..c14e6ba73a 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java @@ -48,12 +48,17 @@ import androidx.lifecycle.LifecycleService; import androidx.media3.common.MediaLibraryInfo; import androidx.media3.common.Player; +import androidx.media3.common.util.BackgroundExecutor; 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 androidx.media3.session.legacy.MediaBrowserServiceCompat; import androidx.media3.session.legacy.MediaSessionManager; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -66,6 +71,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.concurrent.ExecutionException; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** @@ -118,17 +124,17 @@ * controller. If it's accepted, the controller will be available and keep the binding. If it's * rejected, the controller will unbind. * - *

{@link #onUpdateNotification(MediaSession, boolean)} will be called whenever a notification - * needs to be shown, updated or cancelled. The default implementation will display notifications - * using a default UI or using a {@link MediaNotification.Provider} that's set with {@link - * #setMediaNotificationProvider}. In addition, when playback starts, the service will become a foreground service. - * It's required to keep the playback after the controller is destroyed. The service will become a - * background service when all playbacks are stopped. Apps targeting {@code SDK_INT >= 28} must - * request the permission, {@link android.Manifest.permission#FOREGROUND_SERVICE}, in order to make - * the service foreground. You can control when to show or hide notifications by overriding {@link - * #onUpdateNotification(MediaSession, boolean)}. In this case, you must also start or stop the - * service from the foreground, when playback starts or stops respectively. + *

{@link #onUpdateNotificationAsync(MediaSession, boolean)} will be called whenever a + * notification needs to be shown, updated or cancelled. The default implementation will display + * notifications using a default UI or using a {@link MediaNotification.Provider} that's set with + * {@link #setMediaNotificationProvider}. In addition, when playback starts, the service will become + * a foreground + * service. It's required to keep the playback after the controller is destroyed. The service + * will become a background service when all playbacks are stopped. Apps targeting {@code SDK_INT >= + * 28} must request the permission, {@link android.Manifest.permission#FOREGROUND_SERVICE}, in order + * to make the service foreground. You can control when to show or hide notifications by overriding + * {@link #onUpdateNotificationAsync(MediaSession, boolean)}. In this case, you must also start or + * stop the service from the foreground, when playback starts or stops respectively. * *

The service will be destroyed when all sessions are {@linkplain MediaController#release() * released}, or no controller is binding to the service while the service is in the background. @@ -697,10 +703,12 @@ public final boolean isPlaybackOngoing() { */ @UnstableApi public final void pauseAllPlayersAndStopSelf() { - getMediaNotificationManager().disableUserEngagedTimeout(); List sessionList = getSessions(); + getMediaNotificationManager().stopForegroundServiceAndDisableNotifications(); for (int i = 0; i < sessionList.size(); i++) { - sessionList.get(i).getPlayer().setPlayWhenReady(false); + Player player = sessionList.get(i).getPlayer(); + Util.postOrRun( + new Handler(player.getApplicationLooper()), () -> player.setPlayWhenReady(false)); } stopSelf(); } @@ -763,13 +771,22 @@ public void onDestroy() { } /** - * @deprecated Use {@link #onUpdateNotification(MediaSession, boolean)} instead. + * @deprecated Use {@link #onUpdateNotificationAsync(MediaSession, boolean)} instead. */ @Deprecated public void onUpdateNotification(MediaSession session) { defaultMethodCalled = true; } + /** + * @deprecated Use {@link #onUpdateNotificationAsync(MediaSession, boolean)} instead. + */ + @Deprecated + @SuppressWarnings("deprecation") // Calling deprecated method. + public void onUpdateNotification(MediaSession session, boolean startInForegroundRequired) { + onUpdateNotification(session); + } + /** * Called when a notification needs to be updated. Override this method to show or cancel your own * notifications. @@ -794,17 +811,60 @@ public void onUpdateNotification(MediaSession session) { *

Apps targeting {@code SDK_INT >= 28} must request the permission, {@link * android.Manifest.permission#FOREGROUND_SERVICE}. * - *

This method will be called on the main thread. + *

This method can be called on any thread. The main thread may be blocked while this method is + * running, hence this method must not depend on the state of the main thread. * * @param session A session that needs notification update. * @param startInForegroundRequired Whether the service is required to start in the foreground. + * @return Future is set to false if a {@link ForegroundServiceStartNotAllowedException} prevented + * starting the foreground service, otherwise (even if no start attempted) true. */ @SuppressWarnings("deprecation") // Calling deprecated method. - public void onUpdateNotification(MediaSession session, boolean startInForegroundRequired) { - onUpdateNotification(session); - if (defaultMethodCalled) { - getMediaNotificationManager().updateNotification(session, startInForegroundRequired); - } + public ListenableFuture onUpdateNotificationAsync( + MediaSession session, boolean startInForegroundRequired) { + // This compatibility code is contained in this method in order to enforce that overriding + // classes can deal with any thread, so that when the currently deprecated overloads can be + // removed, this compatibility code can be removed alongside them. + SettableFuture resultFuture = SettableFuture.create(); + Util.postOrRun( + mainHandler, + () -> { + try { + onUpdateNotification(session, startInForegroundRequired); + } catch (/* ForegroundServiceStartNotAllowedException */ IllegalStateException e) { + // For backwards compatibility with overrides of onUpdateNotification(). Throwing this + // exception was part of the API to indicate we cannot start playback right now. + if ((SDK_INT >= 31) && Api31.instanceOfForegroundServiceStartNotAllowedException(e)) { + Log.e(TAG, "Failed to start foreground", e); + resultFuture.set(null); + return; + } + throw e; + } + resultFuture.set(defaultMethodCalled); + }); + SettableFuture completion = SettableFuture.create(); + resultFuture.addListener( + () -> { + @Nullable Boolean result; + try { + result = resultFuture.get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + if (result == null) { // ForegroundServiceStartNotAllowedException was thrown + completion.set(false); + } else if (result) { // Super was called, so use new implementation. + ListenableFuture completionInner = + getMediaNotificationManager() + .updateNotification(session, startInForegroundRequired); + completion.setFuture(completionInner); + } else { // Method was overridden without super call. + completion.set(true); + } + }, + BackgroundExecutor.get()); + return completion; } /** @@ -843,25 +903,42 @@ protected final void setMediaNotificationProvider( } /** - * Triggers notification update and handles {@code ForegroundServiceStartNotAllowedException}. + * Triggers notification update. * - *

This method will be called on the main thread. + *

This method can be called on any thread. */ - /* package */ boolean onUpdateNotificationInternal( + /* package */ ListenableFuture onUpdateNotificationInternal( MediaSession session, boolean startInForegroundWhenPaused) { - try { - boolean startInForegroundRequired = - getMediaNotificationManager().shouldRunInForeground(startInForegroundWhenPaused); - onUpdateNotification(session, startInForegroundRequired); - } catch (/* ForegroundServiceStartNotAllowedException */ IllegalStateException e) { - if ((SDK_INT >= 31) && Api31.instanceOfForegroundServiceStartNotAllowedException(e)) { - Log.e(TAG, "Failed to start foreground", e); - onForegroundServiceStartNotAllowedException(); - return false; - } - throw e; - } - return true; + SettableFuture completion = SettableFuture.create(); + ListenableFuture startInForegroundRequiredFuture = + getMediaNotificationManager().shouldRunInForeground(startInForegroundWhenPaused); + startInForegroundRequiredFuture.addListener( + () -> { + boolean startInForegroundRequired; + try { + startInForegroundRequired = startInForegroundRequiredFuture.get(); + } catch (ExecutionException | InterruptedException e) { + throw new IllegalStateException(e); + } + ListenableFuture resultFuture = + onUpdateNotificationAsync(session, startInForegroundRequired); + resultFuture.addListener( + () -> { + boolean result; + try { + result = resultFuture.get(); + } catch (ExecutionException | InterruptedException e) { + throw new IllegalStateException(e); // avoid eating unexpected errors + } + if (!result && SDK_INT >= 31) { + onForegroundServiceStartNotAllowedException(); + } + completion.set(result); + }, + MoreExecutors.directExecutor()); + }, + MoreExecutors.directExecutor()); + return completion; } private MediaNotificationManager getMediaNotificationManager() { @@ -929,15 +1006,15 @@ public void onNotificationRefreshRequired(MediaSession session) { } @Override - public boolean onPlayRequested(MediaSession session) { + public ListenableFuture onPlayRequested(MediaSession session) { if (SDK_INT < 31 || SDK_INT >= 33) { - return true; + return Futures.immediateFuture(true); } // Check if service can start foreground successfully on Android 12 and 12L. if (!getMediaNotificationManager().isStartedInForeground()) { return onUpdateNotificationInternal(session, /* startInForegroundWhenPaused= */ true); } - return true; + return Futures.immediateFuture(true); } } 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 cb10a6ad66..01dbfd9161 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java @@ -765,15 +765,10 @@ public void playForControllerInfo(ControllerInfo controller, int sequenceNumber) controller, sequenceNumber, COMMAND_PLAY_PAUSE, - sendSessionResultSuccess( - player -> { - @Nullable MediaSessionImpl impl = sessionImpl.get(); - if (impl == null || impl.isReleased()) { - return; - } - impl.handleMediaControllerPlayRequest( - controller, /* callOnPlayerInteractionFinished= */ false); - })); + sendSessionResultWhenReady( + (session, theController, sequenceId) -> + session.handleMediaControllerPlayRequest( + theController, /* callOnPlayerInteractionFinished= */ false))); } @Override diff --git a/libraries/session/src/main/java/androidx/media3/session/legacy/MediaSessionCompat.java b/libraries/session/src/main/java/androidx/media3/session/legacy/MediaSessionCompat.java index 6062cac0b7..0ccb62dbfd 100644 --- a/libraries/session/src/main/java/androidx/media3/session/legacy/MediaSessionCompat.java +++ b/libraries/session/src/main/java/androidx/media3/session/legacy/MediaSessionCompat.java @@ -58,9 +58,12 @@ import androidx.media3.common.AudioAttributes; import androidx.media3.common.util.Log; import androidx.media3.common.util.NullableType; +import androidx.media3.common.util.Util; import androidx.media3.session.legacy.MediaSessionManager.RemoteUserInfo; import androidx.versionedparcelable.ParcelUtils; import androidx.versionedparcelable.VersionedParcelable; +import com.google.common.base.Supplier; +import com.google.common.util.concurrent.ListenableFuture; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.ref.WeakReference; @@ -105,6 +108,7 @@ public class MediaSessionCompat { private final MediaSessionImpl impl; private final MediaControllerCompat controller; + private final Handler internalHandler; @IntDef( flag = true, @@ -325,7 +329,9 @@ public MediaSessionCompat( String tag, @Nullable ComponentName mbrComponent, @Nullable PendingIntent mbrIntent, - @Nullable Bundle sessionInfo) { + @Nullable PendingIntent sessionActivity, + @Nullable Bundle sessionInfo, + Looper internalLooper) { if (TextUtils.isEmpty(tag)) { throw new IllegalArgumentException("tag must not be null or empty"); } @@ -357,14 +363,20 @@ public MediaSessionCompat( impl = new MediaSessionImplApi23(context, tag, sessionInfo); } // Set default callback to respond to controllers' extra binder requests. - Looper myLooper = Looper.myLooper(); - Handler handler = new Handler(myLooper != null ? myLooper : Looper.getMainLooper()); - setCallback(new Callback() {}, handler); + internalHandler = new Handler(internalLooper); + setCallback(new Callback() {}, internalHandler); impl.setMediaButtonReceiver(mbrIntent); + if (sessionActivity != null) { + impl.setSessionActivity(sessionActivity); + } controller = new MediaControllerCompat(context, this); } + private void postOrRunOnPlatformSessionThread(Runnable r) { + Util.postOrRun(internalHandler, r); + } + /** * Sets the callback to receive updates for the MediaSession. This includes media button and * volume events. Set the callback to null to stop receiving events. @@ -387,7 +399,7 @@ public void setCallback(Callback callback, Handler handler) { * @param pi The intent to launch to show UI for this Session. */ public void setSessionActivity(@Nullable PendingIntent pi) { - impl.setSessionActivity(pi); + postOrRunOnPlatformSessionThread(() -> impl.setSessionActivity(pi)); } /** @@ -400,8 +412,8 @@ public void setSessionActivity(@Nullable PendingIntent pi) { * * @param mbr The {@link PendingIntent} to send the media button event to. */ - public void setMediaButtonReceiver(PendingIntent mbr) { - impl.setMediaButtonReceiver(mbr); + public void setMediaButtonReceiver(@Nullable PendingIntent mbr) { + postOrRunOnPlatformSessionThread(() -> impl.setMediaButtonReceiver(mbr)); } /** @@ -410,7 +422,7 @@ public void setMediaButtonReceiver(PendingIntent mbr) { * @param flags The flags to set for this session. */ public void setFlags(@SessionFlags int flags) { - impl.setFlags(flags); + postOrRunOnPlatformSessionThread(() -> impl.setFlags(flags)); } /** @@ -423,7 +435,7 @@ public void setFlags(@SessionFlags int flags) { * @param audioAttributes The {@link AudioAttributes} this session is using. */ public void setPlaybackToLocal(AudioAttributes audioAttributes) { - impl.setPlaybackToLocal(audioAttributes); + postOrRunOnPlatformSessionThread(() -> impl.setPlaybackToLocal(audioAttributes)); } /** @@ -439,7 +451,7 @@ public void setPlaybackToLocal(AudioAttributes audioAttributes) { * @param volumeProvider The provider that will handle volume changes. May not be null. */ public void setPlaybackToRemote(VolumeProviderCompat volumeProvider) { - impl.setPlaybackToRemote(volumeProvider); + postOrRunOnPlatformSessionThread(() -> impl.setPlaybackToRemote(volumeProvider)); } /** @@ -453,7 +465,7 @@ public void setPlaybackToRemote(VolumeProviderCompat volumeProvider) { * @param active Whether this session is active or not. */ public void setActive(boolean active) { - impl.setActive(active); + postOrRunOnPlatformSessionThread(() -> impl.setActive(active)); } /** @@ -476,7 +488,7 @@ public void sendSessionEvent(String event, @Nullable Bundle extras) { if (TextUtils.isEmpty(event)) { throw new IllegalArgumentException("event cannot be null or empty"); } - impl.sendSessionEvent(event, extras); + postOrRunOnPlatformSessionThread(() -> impl.sendSessionEvent(event, extras)); } /** @@ -485,7 +497,7 @@ public void sendSessionEvent(String event, @Nullable Bundle extras) { * service is being destroyed. */ public void release() { - impl.release(); + postOrRunOnPlatformSessionThread(impl::release); } /** @@ -518,8 +530,8 @@ public MediaControllerCompat getController() { * * @param state The current state of playback */ - public void setPlaybackState(PlaybackStateCompat state) { - impl.setPlaybackState(state); + public ListenableFuture setPlaybackState(PlaybackStateCompat state) { + return Util.postOrRunWithCompletion(internalHandler, () -> impl.setPlaybackState(state), null); } /** @@ -530,8 +542,8 @@ public void setPlaybackState(PlaybackStateCompat state) { * @param metadata The new metadata * @see androidx.media3.session.legacy.MediaMetadataCompat.Builder#putBitmap */ - public void setMetadata(@Nullable MediaMetadataCompat metadata) { - impl.setMetadata(metadata); + public ListenableFuture setMetadata(@Nullable MediaMetadataCompat metadata) { + return Util.postOrRunWithCompletion(internalHandler, () -> impl.setMetadata(metadata), null); } /** @@ -542,22 +554,29 @@ public void setMetadata(@Nullable MediaMetadataCompat metadata) { *

The queue should be of reasonable size. If the play queue is unbounded within your app, it * is better to send a reasonable amount in a sliding window instead. * - * @param queue A list of items in the play queue. + * @param queueSupplier A supplier of a list of items in the play queue, which will be executed on + * a background thread. */ - public void setQueue(@Nullable List queue) { - if (queue != null) { - Set set = new HashSet<>(); - for (QueueItem item : queue) { - if (set.contains(item.getQueueId())) { - Log.e( - TAG, - "Found duplicate queue id: " + item.getQueueId(), - new IllegalArgumentException("id of each queue item should be unique")); - } - set.add(item.getQueueId()); - } - } - impl.setQueue(queue); + public void computeAndSetQueue( + @Nullable Supplier> queueSupplier) { + Util.postOrRun( + internalHandler, + () -> { + @Nullable List queue = queueSupplier != null ? queueSupplier.get() : null; + if (queue != null) { + Set set = new HashSet<>(); + for (QueueItem item : queue) { + if (set.contains(item.getQueueId())) { + Log.e( + TAG, + "Found duplicate queue id: " + item.getQueueId(), + new IllegalArgumentException("id of each queue item should be unique")); + } + set.add(item.getQueueId()); + } + } + impl.setQueue(queue); + }); } /** @@ -566,8 +585,8 @@ public void setQueue(@Nullable List queue) { * * @param title The title of the play queue. */ - public void setQueueTitle(CharSequence title) { - impl.setQueueTitle(title); + public void setQueueTitle(@Nullable CharSequence title) { + postOrRunOnPlatformSessionThread(() -> impl.setQueueTitle(title)); } /** @@ -585,7 +604,7 @@ public void setQueueTitle(CharSequence title) { * */ public void setRatingType(@RatingCompat.Style int type) { - impl.setRatingType(type); + postOrRunOnPlatformSessionThread(() -> impl.setRatingType(type)); } /** @@ -599,7 +618,7 @@ public void setRatingType(@RatingCompat.Style int type) { * PlaybackStateCompat#REPEAT_MODE_ALL}, {@link PlaybackStateCompat#REPEAT_MODE_GROUP} */ public void setRepeatMode(@PlaybackStateCompat.RepeatMode int repeatMode) { - impl.setRepeatMode(repeatMode); + postOrRunOnPlatformSessionThread(() -> impl.setRepeatMode(repeatMode)); } /** @@ -613,7 +632,7 @@ public void setRepeatMode(@PlaybackStateCompat.RepeatMode int repeatMode) { * {@link PlaybackStateCompat#SHUFFLE_MODE_GROUP} */ public void setShuffleMode(@PlaybackStateCompat.ShuffleMode int shuffleMode) { - impl.setShuffleMode(shuffleMode); + postOrRunOnPlatformSessionThread(() -> impl.setShuffleMode(shuffleMode)); } /** @@ -624,7 +643,7 @@ public void setShuffleMode(@PlaybackStateCompat.ShuffleMode int shuffleMode) { * @param extras The extras associated with the session. */ public void setExtras(@Nullable Bundle extras) { - impl.setExtras(extras); + postOrRunOnPlatformSessionThread(() -> impl.setExtras(extras)); } /** @@ -1776,42 +1795,6 @@ public String toString() { } } - /** - * This is a wrapper for {@link ResultReceiver} for sending over aidl interfaces. The framework - * version was not exposed to aidls until {@link android.os.Build.VERSION_CODES#LOLLIPOP}. - */ - @SuppressLint("BanParcelableUsage") - /* package */ static final class ResultReceiverWrapper implements Parcelable { - ResultReceiver resultReceiver; - - ResultReceiverWrapper(Parcel in) { - resultReceiver = ResultReceiver.CREATOR.createFromParcel(in); - } - - public static final Creator CREATOR = - new Creator() { - @Override - public ResultReceiverWrapper createFromParcel(Parcel p) { - return new ResultReceiverWrapper(p); - } - - @Override - public ResultReceiverWrapper[] newArray(int size) { - return new ResultReceiverWrapper[size]; - } - }; - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - resultReceiver.writeToParcel(dest, flags); - } - } - interface MediaSessionImpl { void setCallback(@Nullable Callback callback, @Nullable Handler handler); @@ -1844,7 +1827,7 @@ interface MediaSessionImpl { void setQueue(@Nullable List queue); - void setQueueTitle(CharSequence title); + void setQueueTitle(@Nullable CharSequence title); void setRatingType(@RatingCompat.Style int type); @@ -2046,7 +2029,7 @@ public void setQueue(@Nullable List queue) { } @Override - public void setQueueTitle(CharSequence title) { + public void setQueueTitle(@Nullable CharSequence title) { sessionFwk.setQueueTitle(title); } diff --git a/libraries/session/src/test/java/androidx/media3/session/MediaSessionServiceTest.java b/libraries/session/src/test/java/androidx/media3/session/MediaSessionServiceTest.java index d1770d6740..a87bee1a2a 100644 --- a/libraries/session/src/test/java/androidx/media3/session/MediaSessionServiceTest.java +++ b/libraries/session/src/test/java/androidx/media3/session/MediaSessionServiceTest.java @@ -324,6 +324,8 @@ public void service_multipleSessionsOnMainThread_createsNotificationForEachSessi player1.release(); player2.release(); serviceController.destroy(); + assertThat(getStatusBarNotification(2001)).isNull(); + assertThat(getStatusBarNotification(2002)).isNull(); } @Test @@ -371,9 +373,11 @@ public void service_multipleSessionsOnDifferentThreads_createsNotificationForEac session2.release(); new Handler(thread1.getLooper()).post(player1::release); new Handler(thread2.getLooper()).post(player2::release); - thread1.quit(); - thread2.quit(); + thread1.quitSafely(); + thread2.quitSafely(); serviceController.destroy(); + assertThat(getStatusBarNotification(2001)).isNull(); + assertThat(getStatusBarNotification(2002)).isNull(); } @Test diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionServiceTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionServiceTest.java index 243ceea20e..c5dd534931 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionServiceTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionServiceTest.java @@ -33,14 +33,17 @@ import android.content.Intent; import android.os.Bundle; import android.os.Handler; +import android.os.HandlerThread; import android.os.Looper; import android.service.notification.StatusBarNotification; import android.support.v4.media.session.MediaControllerCompat; import android.support.v4.media.session.MediaSessionCompat; import android.support.v4.media.session.PlaybackStateCompat; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; import androidx.media3.common.ForwardingPlayer; import androidx.media3.common.MediaItem; +import androidx.media3.common.PlaybackParameters; import androidx.media3.common.Player; import androidx.media3.common.Player.Listener; import androidx.media3.common.Player.PositionInfo; @@ -62,6 +65,8 @@ import androidx.test.filters.MediumTest; import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.SettableFuture; import java.util.ArrayList; @@ -259,6 +264,70 @@ public ListenableFuture> onAddMediaItems( service.blockUntilAllControllersUnbind(TIMEOUT_MS); } + @Test + public void onPlayRequested_doesNotCauseDeadlock_ifPlaybackThreadIsNotMain() throws Exception { + HandlerThread ht = new HandlerThread("MSSTest:PlaybackThread"); + ht.start(); + TestServiceRegistry testServiceRegistry = TestServiceRegistry.getInstance(); + ConditionVariable playerToldToSpeedUp = new ConditionVariable(); + AtomicReference mediaController = new AtomicReference<>(); + AtomicReference mediaSession = new AtomicReference<>(); + testServiceRegistry.setOnGetSessionHandler( + controllerInfo -> { + MockMediaSessionService service = + (MockMediaSessionService) testServiceRegistry.getServiceInstance(); + Player player = new ExoPlayer.Builder(service).setLooper(ht.getLooper()).build(); + player.addListener( + new Player.Listener() { + @Override + public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { + if (playbackParameters.speed == 2f) { + playerToldToSpeedUp.open(); + } + } + }); + mediaSession.set(new MediaSession.Builder(service, player).build()); + return mediaSession.get(); + }); + TestHandler handler = new TestHandler(Looper.getMainLooper()); + TestHandler bgHandler = new TestHandler(ht.getLooper()); + handler.post( + () -> { + ListenableFuture controllerFuture = + new MediaController.Builder(context, token).buildAsync(); + Futures.addCallback( + controllerFuture, + new FutureCallback() { + @Override + public void onSuccess(MediaController controller) { + controller.addMediaItem(new MediaItem.Builder().setMediaId("media_id").build()); + controller.play(); + bgHandler.post( + () -> + handler.post( + () -> { + controller.setPlaybackSpeed(2f); + mediaController.set(controller); + })); + } + + @Override + public void onFailure(Throwable t) { + throw new RuntimeException(t); + } + }, + ContextCompat.getMainExecutor(context)); + }); + playerToldToSpeedUp.block(TIMEOUT_MS); + if (!playerToldToSpeedUp.isOpen()) { + ht.interrupt(); // avoid deadlocking the test forever. + } + assertThat(playerToldToSpeedUp.isOpen()).isTrue(); + handler.postAndSync(() -> mediaController.get().release()); + mediaSession.get().release(); + ht.quitSafely(); + } + @Test public void onCreate_withCustomLayout_correctSessionStateFromOnConnect() throws Exception { SessionCommand command1 = new SessionCommand("command1", Bundle.EMPTY);