diff --git a/libraries/session/src/main/java/androidx/media3/session/DefaultActionFactory.java b/libraries/session/src/main/java/androidx/media3/session/DefaultActionFactory.java new file mode 100644 index 0000000000..fba9b917a9 --- /dev/null +++ b/libraries/session/src/main/java/androidx/media3/session/DefaultActionFactory.java @@ -0,0 +1,149 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.session; + +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.support.v4.media.session.PlaybackStateCompat; +import android.view.KeyEvent; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.core.app.NotificationCompat; +import androidx.core.graphics.drawable.IconCompat; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; + +/** The default {@link MediaNotification.ActionFactory}. */ +@UnstableApi +/* package */ final class DefaultActionFactory implements MediaNotification.ActionFactory { + + private static final String ACTION_CUSTOM = "androidx.media3.session.CUSTOM_NOTIFICATION_ACTION"; + private static final String EXTRAS_KEY_ACTION_CUSTOM = + "androidx.media3.session.EXTRAS_KEY_CUSTOM_NOTIFICATION_ACTION"; + public static final String EXTRAS_KEY_ACTION_CUSTOM_EXTRAS = + "androidx.media3.session.EXTRAS_KEY_CUSTOM_NOTIFICATION_ACTION_EXTRAS"; + + private final Context context; + + public DefaultActionFactory(Context context) { + this.context = context.getApplicationContext(); + } + + @Override + public NotificationCompat.Action createMediaAction( + IconCompat icon, CharSequence title, @Command long command) { + return new NotificationCompat.Action(icon, title, createMediaActionPendingIntent(command)); + } + + @Override + public NotificationCompat.Action createCustomAction( + IconCompat icon, CharSequence title, String customAction, Bundle extras) { + return new NotificationCompat.Action( + icon, title, createCustomActionPendingIntent(customAction, extras)); + } + + @Override + public PendingIntent createMediaActionPendingIntent(@Command long command) { + int keyCode = PlaybackStateCompat.toKeyCode(command); + Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON); + intent.setComponent(new ComponentName(context, context.getClass())); + intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, keyCode)); + if (Util.SDK_INT >= 26 && command != COMMAND_PAUSE && command != COMMAND_STOP) { + return Api26.createPendingIntent(context, /* requestCode= */ keyCode, intent); + } else { + return PendingIntent.getService( + context, + /* requestCode= */ keyCode, + intent, + Util.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0); + } + } + + private PendingIntent createCustomActionPendingIntent(String action, Bundle extras) { + Intent intent = new Intent(ACTION_CUSTOM); + intent.setComponent(new ComponentName(context, context.getClass())); + intent.putExtra(EXTRAS_KEY_ACTION_CUSTOM, action); + intent.putExtra(EXTRAS_KEY_ACTION_CUSTOM_EXTRAS, extras); + if (Util.SDK_INT >= 26) { + return Api26.createPendingIntent( + context, /* requestCode= */ KeyEvent.KEYCODE_UNKNOWN, intent); + } else { + return PendingIntent.getService( + context, + /* requestCode= */ KeyEvent.KEYCODE_UNKNOWN, + intent, + Util.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0); + } + } + + /** Returns whether {@code intent} was part of a {@link #createMediaAction media action}. */ + public boolean isMediaAction(Intent intent) { + return Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction()); + } + + /** Returns whether {@code intent} was part of a {@link #createCustomAction custom action }. */ + public boolean isCustomAction(Intent intent) { + return ACTION_CUSTOM.equals(intent.getAction()); + } + + /** + * Returns the {@link KeyEvent} that was included in the media action, or {@code null} if no + * {@link KeyEvent} is found in the {@code intent}. + */ + @Nullable + public KeyEvent getKeyEvent(Intent intent) { + @Nullable Bundle extras = intent.getExtras(); + if (extras != null && extras.containsKey(Intent.EXTRA_KEY_EVENT)) { + return extras.getParcelable(Intent.EXTRA_KEY_EVENT); + } + return null; + } + + /** + * Returns the custom action that was included in the {@link #createCustomAction custom action}, + * or {@code null} if no custom action is found in the {@code intent}. + */ + @Nullable + public String getCustomAction(Intent intent) { + @Nullable Bundle extras = intent.getExtras(); + @Nullable Object customAction = extras != null ? extras.get(EXTRAS_KEY_ACTION_CUSTOM) : null; + return customAction instanceof String ? (String) customAction : null; + } + + /** + * Returns extras that were included in the {@link #createCustomAction custom action}, or {@link + * Bundle#EMPTY} is no extras are found. + */ + public Bundle getCustomActionExtras(Intent intent) { + @Nullable Bundle extras = intent.getExtras(); + @Nullable + Object customExtras = extras != null ? extras.get(EXTRAS_KEY_ACTION_CUSTOM_EXTRAS) : null; + return customExtras instanceof Bundle ? (Bundle) customExtras : Bundle.EMPTY; + } + + @RequiresApi(26) + private static final class Api26 { + private Api26() {} + + public static PendingIntent createPendingIntent(Context context, int keyCode, Intent intent) { + return PendingIntent.getForegroundService( + context, /* requestCode= */ keyCode, intent, PendingIntent.FLAG_IMMUTABLE); + } + } +} diff --git a/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java b/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java new file mode 100644 index 0000000000..f253433905 --- /dev/null +++ b/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java @@ -0,0 +1,173 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +package androidx.media3.session; + +import static androidx.media3.common.util.Assertions.checkStateNotNull; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.Bundle; +import androidx.core.app.NotificationCompat; +import androidx.core.graphics.drawable.IconCompat; +import androidx.media3.common.MediaMetadata; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; + +/** + * The default {@link MediaNotification.Provider}. + * + *

Actions

+ * + * The following actions are included in the provided notifications: + * + * + * + *

Drawables

+ * + * The drawables used can be overridden by drawables with the same names defined the application. + * The drawables are: + * + * + */ +@UnstableApi +/* package */ final class DefaultMediaNotificationProvider implements MediaNotification.Provider { + + private static final int NOTIFICATION_ID = 1001; + private static final String NOTIFICATION_CHANNEL_ID = "default_channel_id"; + private static final String NOTIFICATION_CHANNEL_NAME = "Now playing"; + + private final Context context; + private final NotificationManager notificationManager; + + /** Creates an instance. */ + public DefaultMediaNotificationProvider(Context context) { + this.context = context.getApplicationContext(); + notificationManager = + checkStateNotNull( + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)); + } + + @Override + public MediaNotification createNotification( + MediaController mediaController, + MediaNotification.ActionFactory actionFactory, + Callback onNotificationChangedCallback) { + ensureNotificationChannel(); + + NotificationCompat.Builder builder = + new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID); + // TODO(b/193193926): Filter actions depending on the player's available commands. + // Skip to previous action. + builder.addAction( + actionFactory.createMediaAction( + IconCompat.createWithResource(context, R.drawable.media3_notification_seek_to_previous), + context.getString(R.string.media3_controls_seek_to_previous_description), + MediaNotification.ActionFactory.COMMAND_SKIP_TO_PREVIOUS)); + if (mediaController.getPlayWhenReady()) { + // Pause action. + builder.addAction( + actionFactory.createMediaAction( + IconCompat.createWithResource(context, R.drawable.media3_notification_pause), + context.getString(R.string.media3_controls_pause_description), + MediaNotification.ActionFactory.COMMAND_PAUSE)); + } else { + // Play action. + builder.addAction( + actionFactory.createMediaAction( + IconCompat.createWithResource(context, R.drawable.media3_notification_play), + context.getString(R.string.media3_controls_play_description), + MediaNotification.ActionFactory.COMMAND_PLAY)); + } + // Skip to next action. + builder.addAction( + actionFactory.createMediaAction( + IconCompat.createWithResource(context, R.drawable.media3_notification_seek_to_next), + context.getString(R.string.media3_controls_seek_to_next_description), + MediaNotification.ActionFactory.COMMAND_SKIP_TO_NEXT)); + + // Set metadata info in the notification. + MediaMetadata metadata = mediaController.getMediaMetadata(); + builder.setContentTitle(metadata.title).setContentText(metadata.artist); + if (metadata.artworkData != null) { + Bitmap artworkBitmap = + BitmapFactory.decodeByteArray(metadata.artworkData, 0, metadata.artworkData.length); + builder.setLargeIcon(artworkBitmap); + } + + androidx.media.app.NotificationCompat.MediaStyle mediaStyle = + new androidx.media.app.NotificationCompat.MediaStyle() + .setCancelButtonIntent( + actionFactory.createMediaActionPendingIntent( + MediaNotification.ActionFactory.COMMAND_STOP)) + .setShowActionsInCompactView(1 /* Show play/pause button only in compact view */); + + Notification notification = + builder + .setContentIntent(mediaController.getSessionActivity()) + .setDeleteIntent( + actionFactory.createMediaActionPendingIntent( + MediaNotification.ActionFactory.COMMAND_STOP)) + .setOnlyAlertOnce(true) + .setSmallIcon(getSmallIconResId(context)) + .setStyle(mediaStyle) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setOngoing(false) + .build(); + return new MediaNotification(NOTIFICATION_ID, notification); + } + + @Override + public void handleCustomAction(MediaController mediaController, String action, Bundle extras) { + // We don't handle custom commands. + } + + private void ensureNotificationChannel() { + if (Util.SDK_INT < 26 + || notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null) { + return; + } + NotificationChannel channel = + new NotificationChannel( + NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW); + notificationManager.createNotificationChannel(channel); + } + + private static int getSmallIconResId(Context context) { + int appIcon = context.getApplicationInfo().icon; + if (appIcon != 0) { + return appIcon; + } else { + return Util.SDK_INT >= 21 ? R.drawable.media_session_service_notification_ic_music_note : 0; + } + } +} diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaNotification.java b/libraries/session/src/main/java/androidx/media3/session/MediaNotification.java new file mode 100644 index 0000000000..698ab1efb4 --- /dev/null +++ b/libraries/session/src/main/java/androidx/media3/session/MediaNotification.java @@ -0,0 +1,190 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +package androidx.media3.session; + +import static androidx.media3.common.util.Assertions.checkNotNull; +import static java.lang.annotation.ElementType.TYPE_USE; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.os.Bundle; +import android.support.v4.media.session.PlaybackStateCompat; +import androidx.annotation.IntRange; +import androidx.annotation.LongDef; +import androidx.core.app.NotificationCompat; +import androidx.core.graphics.drawable.IconCompat; +import androidx.media3.common.util.UnstableApi; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** A notification for media playbacks. */ +public final class MediaNotification { + + /** + * Creates {@link NotificationCompat.Action actions} and {@link PendingIntent pending intents} for + * notifications. + */ + @UnstableApi + public interface ActionFactory { + + /** + * Commands that can be included in a media action. One of {@link #COMMAND_PLAY}, {@link + * #COMMAND_PAUSE}, {@link #COMMAND_STOP}, {@link #COMMAND_REWIND}, {@link + * #COMMAND_FAST_FORWARD}, {@link #COMMAND_SKIP_TO_PREVIOUS}, {@link #COMMAND_SKIP_TO_NEXT} or + * {@link #COMMAND_SET_CAPTIONING_ENABLED}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @Target({TYPE_USE}) + @LongDef({ + COMMAND_PLAY, + COMMAND_PAUSE, + COMMAND_STOP, + COMMAND_REWIND, + COMMAND_FAST_FORWARD, + COMMAND_SKIP_TO_PREVIOUS, + COMMAND_SKIP_TO_NEXT, + COMMAND_SET_CAPTIONING_ENABLED + }) + @interface Command {} + + /** The command to start playback. */ + long COMMAND_PLAY = PlaybackStateCompat.ACTION_PLAY; + /** The command to pause playback. */ + long COMMAND_PAUSE = PlaybackStateCompat.ACTION_PAUSE; + /** The command to stop playback. */ + long COMMAND_STOP = PlaybackStateCompat.ACTION_STOP; + /** The command to rewind. */ + long COMMAND_REWIND = PlaybackStateCompat.ACTION_REWIND; + /** The command to fast forward. */ + long COMMAND_FAST_FORWARD = PlaybackStateCompat.ACTION_FAST_FORWARD; + /** The command to skip to the previous item in the queue. */ + long COMMAND_SKIP_TO_PREVIOUS = PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS; + /** The command to skip to the next item in the queue. */ + long COMMAND_SKIP_TO_NEXT = PlaybackStateCompat.ACTION_SKIP_TO_NEXT; + /** The command to set captioning enabled. */ + long COMMAND_SET_CAPTIONING_ENABLED = PlaybackStateCompat.ACTION_SET_CAPTIONING_ENABLED; + + /** + * Creates a {@link NotificationCompat.Action} for a notification. These actions will be handled + * by the library. + * + * @param icon The icon to show for this action. + * @param title The title of the action. + * @param command A command to send when users trigger this action. + */ + NotificationCompat.Action createMediaAction( + IconCompat icon, CharSequence title, @Command long command); + + /** + * Creates a {@link NotificationCompat.Action} for a notification with a custom action. Actions + * created with this method are not expected to be handled by the library and will be forwarded + * to the {@link MediaNotification.Provider#handleCustomAction notification provider} that + * provided them. + * + * @param icon The icon to show for this action. + * @param title The title of the action. + * @param customAction The custom action set. + * @param extras Extras to be included in the action. + * @see MediaNotification.Provider#handleCustomAction + */ + NotificationCompat.Action createCustomAction( + IconCompat icon, CharSequence title, String customAction, Bundle extras); + + /** + * Creates a {@link PendingIntent} for a media action that will be handled by the library. + * + * @param command The intent's command. + */ + PendingIntent createMediaActionPendingIntent(@Command long command); + } + + /** + * Provides {@link MediaNotification media notifications} to be posted as notifications that + * reflect the state of a {@link MediaController} and to send media commands to a {@link + * MediaSession}. + * + *

The provider is required to create a {@link androidx.core.app.NotificationChannelCompat + * notification channel}, which is required to show notification for {@code SDK_INT >= 26}. + */ + @UnstableApi + public interface Provider { + /** Receives updates for a notification. */ + interface Callback { + /** + * Called when a {@link MediaNotification} is changed. + * + *

This callback is called when notifications are updated, for example after a bitmap is + * loaded asynchronously and needs to be displayed. + * + * @param notification The updated {@link MediaNotification} + */ + void onNotificationChanged(MediaNotification notification); + } + + /** + * Creates a new {@link MediaNotification}. + * + * @param mediaController The controller of the session. + * @param actionFactory The {@link ActionFactory} for creating notification {@link + * NotificationCompat.Action actions}. + * @param onNotificationChangedCallback A callback that the provider needs to notify when the + * notification has changed and needs to be posted again, for example after a bitmap has + * been loaded asynchronously. + */ + MediaNotification createNotification( + MediaController mediaController, + ActionFactory actionFactory, + Callback onNotificationChangedCallback); + + /** + * Handles a notification's custom action. + * + * @param mediaController The controller of the session. + * @param action The custom action. + * @param extras Extras set in the custom action, otherwise {@link Bundle#EMPTY}. + * @see ActionFactory#createCustomAction + */ + void handleCustomAction(MediaController mediaController, String action, Bundle extras); + } + + /** The notification id. */ + @IntRange(from = 1) + public final int notificationId; + + /** The {@link Notification}. */ + public final Notification notification; + + /** + * Creates an instance. + * + * @param notificationId The notification id to be used for {@link NotificationManager#notify(int, + * Notification)}. + * @param notification A {@link Notification} that reflects the sate of a {@link MediaController} + * and to send media commands to a {@link MediaSession}. The notification may be used to start + * a service in the foreground. + * It's highly recommended to use a {@link androidx.media.app.NotificationCompat.MediaStyle + * media style} {@link Notification notification}. + */ + public MediaNotification(@IntRange(from = 1) int notificationId, Notification notification) { + this.notificationId = notificationId; + this.notification = checkNotNull(notification); + } +} 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 50555885d3..c89ebe092d 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java @@ -15,30 +15,14 @@ */ package androidx.media3.session; -import static android.support.v4.media.session.PlaybackStateCompat.ACTION_PAUSE; -import static android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY; -import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_NEXT; -import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS; -import static android.support.v4.media.session.PlaybackStateCompat.ACTION_STOP; - import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.content.ComponentName; -import android.content.Context; import android.content.Intent; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; +import android.os.Bundle; import android.os.Handler; import android.os.Looper; -import android.support.v4.media.session.PlaybackStateCompat; -import android.view.KeyEvent; import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; import androidx.core.content.ContextCompat; -import androidx.media.app.NotificationCompat.MediaStyle; -import androidx.media3.common.MediaMetadata; import androidx.media3.common.Player; import androidx.media3.common.util.Util; import com.google.common.util.concurrent.ListenableFuture; @@ -52,63 +36,33 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; /** - * Provides default media notifications for {@link MediaSessionService} and sets the service as + * Manages media notifications for a {@link MediaSessionService} and sets the service as * foreground/background according to the player state. */ /* package */ final class MediaNotificationManager { - private static final int NOTIFICATION_ID = 1001; - private static final String NOTIFICATION_CHANNEL_ID = "default_channel_id"; - - private final MediaSessionService service; + private final MediaSessionService mediaSessionService; + private final MediaNotification.Provider mediaNotificationProvider; + private final MediaNotification.ActionFactory actionFactory; + private final NotificationManagerCompat notificationManagerCompat; private final Executor mainExecutor; - private final NotificationManager notificationManager; - private final String notificationChannelName; - private final Intent startSelfIntent; - private final NotificationCompat.Action playAction; - private final NotificationCompat.Action pauseAction; - private final NotificationCompat.Action skipToPrevAction; - private final NotificationCompat.Action skipToNextAction; - private final Map> controllerMap; - public MediaNotificationManager(MediaSessionService service) { - this.service = service; + private int totalNotificationCount; + + public MediaNotificationManager( + MediaSessionService mediaSessionService, + MediaNotification.Provider mediaNotificationProvider, + MediaNotification.ActionFactory actionFactory) { + this.mediaSessionService = mediaSessionService; + this.mediaNotificationProvider = mediaNotificationProvider; + this.actionFactory = actionFactory; + notificationManagerCompat = NotificationManagerCompat.from(mediaSessionService); Handler mainHandler = new Handler(Looper.getMainLooper()); mainExecutor = (runnable) -> Util.postOrRun(mainHandler, runnable); - startSelfIntent = new Intent(service, service.getClass()); + startSelfIntent = new Intent(mediaSessionService, mediaSessionService.getClass()); controllerMap = new HashMap<>(); - - notificationManager = - (NotificationManager) service.getSystemService(Context.NOTIFICATION_SERVICE); - notificationChannelName = - service.getResources().getString(R.string.default_notification_channel_name); - - playAction = - createNotificationAction( - service, - android.R.drawable.ic_media_play, - R.string.media3_controls_play_description, - ACTION_PLAY); - pauseAction = - createNotificationAction( - service, - android.R.drawable.ic_media_pause, - R.string.media3_controls_pause_description, - ACTION_PAUSE); - skipToPrevAction = - createNotificationAction( - service, - android.R.drawable.ic_media_previous, - R.string.media3_controls_seek_to_previous_description, - ACTION_SKIP_TO_PREVIOUS); - skipToNextAction = - createNotificationAction( - service, - android.R.drawable.ic_media_next, - R.string.media3_controls_seek_to_next_description, - ACTION_SKIP_TO_NEXT); } public void addSession(MediaSession session) { @@ -117,26 +71,22 @@ import java.util.concurrent.TimeoutException; } MediaControllerListener listener = new MediaControllerListener(session); ListenableFuture controllerFuture = - new MediaController.Builder(service, session.getToken()) + new MediaController.Builder(mediaSessionService, session.getToken()) .setListener(listener) .setApplicationLooper(Looper.getMainLooper()) .buildAsync(); controllerFuture.addListener( () -> { - MediaController controller; try { - controller = controllerFuture.get(/* time= */ 0, TimeUnit.MILLISECONDS); + MediaController controller = controllerFuture.get(/* time= */ 0, TimeUnit.MILLISECONDS); + listener.onConnected(); + controller.addListener(listener); } catch (CancellationException | ExecutionException | InterruptedException | TimeoutException e) { - // MediaSession or MediaController is released too early. Stop monitoring session. - service.removeSession(session); - return; - } - if (controller != null) { - listener.onConnected(); - controller.addListener(listener); + // MediaSession or MediaController is released too early. Stop monitoring the session. + mediaSessionService.removeSession(session); } }, mainExecutor); @@ -144,21 +94,59 @@ import java.util.concurrent.TimeoutException; } public void removeSession(MediaSession session) { - ListenableFuture controllerFuture = controllerMap.remove(session); + @Nullable ListenableFuture controllerFuture = controllerMap.remove(session); if (controllerFuture != null) { MediaController.releaseFuture(controllerFuture); } } - private void updateNotificationIfNeeded(MediaSession session) { - @Nullable - MediaSessionService.MediaNotification mediaNotification = service.onUpdateNotification(session); - if (mediaNotification == null) { - // The service implementation doesn't want to use the automatic start/stopForeground - // feature. + public void onCustomAction(MediaSession session, String action, Bundle extras) { + @Nullable ListenableFuture controllerFuture = controllerMap.get(session); + if (controllerFuture == null) { + return; + } + try { + MediaController mediaController = controllerFuture.get(0, TimeUnit.MILLISECONDS); + mediaNotificationProvider.handleCustomAction(mediaController, action, extras); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + // We should never reach this. + throw new IllegalStateException(e); + } + } + + private void updateNotification(MediaSession session) { + @Nullable ListenableFuture controllerFuture = controllerMap.get(session); + if (controllerFuture == null) { return; } + MediaController mediaController; + try { + mediaController = controllerFuture.get(0, TimeUnit.MILLISECONDS); + } catch (ExecutionException | InterruptedException | TimeoutException e) { + // We should never reach this point. + throw new IllegalStateException(e); + } + + int notificationSequence = ++this.totalNotificationCount; + MediaNotification.Provider.Callback callback = + notification -> + mainExecutor.execute( + () -> onNotificationUpdated(notificationSequence, session, notification)); + + MediaNotification mediaNotification = + this.mediaNotificationProvider.createNotification(mediaController, actionFactory, callback); + updateNotification(session, mediaNotification); + } + + private void onNotificationUpdated( + int notificationSequence, MediaSession session, MediaNotification mediaNotification) { + if (notificationSequence == this.totalNotificationCount) { + updateNotification(session, mediaNotification); + } + } + + private void updateNotification(MediaSession session, MediaNotification mediaNotification) { int id = mediaNotification.notificationId; Notification notification = mediaNotification.notification; @@ -172,16 +160,16 @@ import java.util.concurrent.TimeoutException; Player player = session.getPlayer(); if (player.getPlayWhenReady()) { - ContextCompat.startForegroundService(service, startSelfIntent); - service.startForeground(id, notification); + ContextCompat.startForegroundService(mediaSessionService, startSelfIntent); + mediaSessionService.startForeground(id, notification); } else { stopForegroundServiceIfNeeded(); - notificationManager.notify(id, notification); + notificationManagerCompat.notify(id, notification); } } private void stopForegroundServiceIfNeeded() { - List sessions = service.getSessions(); + List sessions = mediaSessionService.getSessions(); for (int i = 0; i < sessions.size(); i++) { Player player = sessions.get(i).getPlayer(); if (player.getPlayWhenReady()) { @@ -191,104 +179,7 @@ import java.util.concurrent.TimeoutException; // Calling stopForeground(true) is a workaround for pre-L devices which prevents // the media notification from being undismissable. boolean shouldRemoveNotification = Util.SDK_INT < 21; - service.stopForeground(shouldRemoveNotification); - } - - /** Creates a default media style notification for {@link MediaSessionService}. */ - public MediaSessionService.MediaNotification onUpdateNotification(MediaSession session) { - Player player = session.getPlayer(); - - ensureNotificationChannel(); - - NotificationCompat.Builder builder = - new NotificationCompat.Builder(service, NOTIFICATION_CHANNEL_ID); - - // TODO(b/193193926): Filter actions depending on the player's available commands. - builder.addAction(skipToPrevAction); - if (player.getPlayWhenReady()) { - builder.addAction(pauseAction); - } else { - builder.addAction(playAction); - } - builder.addAction(skipToNextAction); - - // Set metadata info in the notification. - MediaMetadata metadata = player.getMediaMetadata(); - builder.setContentTitle(metadata.title).setContentText(metadata.artist); - if (metadata.artworkData != null) { - Bitmap artworkBitmap = - BitmapFactory.decodeByteArray(metadata.artworkData, 0, metadata.artworkData.length); - builder.setLargeIcon(artworkBitmap); - } - - MediaStyle mediaStyle = - new MediaStyle() - .setCancelButtonIntent(createPendingIntent(service, ACTION_STOP)) - .setMediaSession(session.getSessionCompat().getSessionToken()) - .setShowActionsInCompactView(1 /* Show play/pause button only in compact view */); - - Notification notification = - builder - .setContentIntent(session.getImpl().getSessionActivity()) - .setDeleteIntent(createPendingIntent(service, ACTION_STOP)) - .setOnlyAlertOnce(true) - .setSmallIcon(getSmallIconResId()) - .setStyle(mediaStyle) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setOngoing(false) - .build(); - - return new MediaSessionService.MediaNotification(NOTIFICATION_ID, notification); - } - - private void ensureNotificationChannel() { - if (Util.SDK_INT < 26 - || notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null) { - return; - } - // Need to create a notification channel. - NotificationChannel channel = - new NotificationChannel( - NOTIFICATION_CHANNEL_ID, notificationChannelName, NotificationManager.IMPORTANCE_LOW); - notificationManager.createNotificationChannel(channel); - } - - private int getSmallIconResId() { - int appIcon = service.getApplicationInfo().icon; - if (appIcon != 0) { - return appIcon; - } else { - // App icon is not set. - return Util.SDK_INT >= 21 ? R.drawable.media_session_service_notification_ic_music_note : 0; - } - } - - private static NotificationCompat.Action createNotificationAction( - MediaSessionService service, - int iconResId, - int titleResId, - @PlaybackStateCompat.Actions long action) { - CharSequence title = service.getResources().getText(titleResId); - return new NotificationCompat.Action(iconResId, title, createPendingIntent(service, action)); - } - - private static PendingIntent createPendingIntent( - MediaSessionService service, @PlaybackStateCompat.Actions long action) { - int keyCode = PlaybackStateCompat.toKeyCode(action); - Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON); - intent.setComponent(new ComponentName(service, service.getClass())); - intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, keyCode)); - - if (Util.SDK_INT >= 26 && action != ACTION_PAUSE && action != ACTION_STOP) { - return PendingIntent.getForegroundService( - service, /* requestCode= */ keyCode, intent, PendingIntent.FLAG_IMMUTABLE); - } else { - return PendingIntent.getService( - service, - /* requestCode= */ keyCode, - intent, - Util.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0); - } + mediaSessionService.stopForeground(shouldRemoveNotification); } private final class MediaControllerListener implements MediaController.Listener, Player.Listener { @@ -299,20 +190,20 @@ import java.util.concurrent.TimeoutException; } public void onConnected() { - updateNotificationIfNeeded(session); + updateNotification(session); } @Override public void onEvents(Player player, Player.Events events) { if (events.containsAny( Player.EVENT_PLAY_WHEN_READY_CHANGED, Player.EVENT_MEDIA_METADATA_CHANGED)) { - updateNotificationIfNeeded(session); + updateNotification(session); } } @Override public void onDisconnected(MediaController controller) { - service.removeSession(session); + mediaSessionService.removeSession(session); stopForegroundServiceIfNeeded(); } } 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 3eedcec7e7..cc840c534a 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java @@ -20,8 +20,6 @@ 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.Notification; -import android.app.NotificationManager; import android.app.Service; import android.content.Context; import android.content.Intent; @@ -35,13 +33,12 @@ import android.os.RemoteException; import android.view.KeyEvent; import androidx.annotation.CallSuper; import androidx.annotation.GuardedBy; -import androidx.annotation.IntRange; import androidx.annotation.Nullable; 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.session.MediaSession.ControllerInfo; import java.lang.ref.WeakReference; import java.util.ArrayList; @@ -50,16 +47,17 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** * Superclass to be extended by services hosting {@link MediaSession media sessions}. * *

It's highly recommended for an app to use this class if they want to keep media playback in * the background. The service allows other apps to know that your app supports {@link MediaSession} - * even when your app isn't running. For example, user's voice command may start your app to play + * even when your app isn't running. For example, a user voice command may start your app to play * media. * - *

To extend this class, declare the intent filter in your {@code AndroidManifest.xml}. + *

To extend this class, declare the intent filter in your {@code AndroidManifest.xml}: * *

{@code
  * 
@@ -89,20 +87,21 @@ import java.util.concurrent.ConcurrentHashMap;
  *
  * 

A media session service is a bound service. When a {@link MediaController} is created for the * service, the controller binds to the service. {@link #onGetSession(ControllerInfo)} will be - * called inside of the {@link #onBind(Intent)}. + * called from {@link #onBind(Intent)}. * - *

After the binding, the session's {@link MediaSession.SessionCallback#onConnect(MediaSession, + *

After binding, the session's {@link MediaSession.SessionCallback#onConnect(MediaSession, * MediaSession.ControllerInfo)} will be called to accept or reject the connection request from the * controller. If it's accepted, the controller will be available and keep the binding. If it's * rejected, the controller will unbind. * - *

When a playback is started on the service, {@link #onUpdateNotification(MediaSession)} is - * called and 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. + *

When a playback is started on the service, the service will obtain a {@link MediaNotification} + * from the {@link MediaNotification.Provider} that's set with {@link #setMediaNotificationProvider} + * (or {@link DefaultMediaNotificationProvider}, if no provider is set), and 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. * *

The service will be destroyed when all sessions are closed, or no controller is binding to the * service while the service is in the background. @@ -110,18 +109,18 @@ import java.util.concurrent.ConcurrentHashMap; *

Supporting Multiple Sessions

* *

Generally, multiple sessions aren't necessary for most media apps. One exception is if your - * app can play multiple media content at the same time, but only for the playback of video-only - * media or remote playback, since the audio focus policy - * recommends not playing multiple audio content at the same time. Also, keep in mind that multiple - * media sessions would make Android Auto and Bluetooth device with display to show your apps + * recommends not playing multiple audio contents at the same time. Also, keep in mind that multiple + * media sessions would make Android Auto and Bluetooth devices with a display to show your apps * multiple times, because they list up media sessions, not media apps. * *

However, if you're capable of handling multiple playbacks and want to keep their sessions * while the app is in the background, create multiple sessions and add them to this service with * {@link #addSession(MediaSession)}. * - *

Note that {@link MediaController} can be created with {@link SessionToken} to connect to a + *

Note that a {@link MediaController} can be created with {@link SessionToken} to connect to a * session in this service. In that case, {@link #onGetSession(ControllerInfo)} will be called to * decide which session to handle the connection request. Pick the best session among the added * sessions, or create a new session and return it from {@link #onGetSession(ControllerInfo)}. @@ -144,8 +143,13 @@ public abstract class MediaSessionService extends Service { private MediaSessionServiceStub stub; @GuardedBy("lock") - @Nullable - private MediaNotificationManager notificationHandler; + private @MonotonicNonNull MediaNotificationManager mediaNotificationManager; + + @GuardedBy("lock") + private MediaNotification.@MonotonicNonNull Provider mediaNotificationProvider; + + @GuardedBy("lock") + private @MonotonicNonNull DefaultActionFactory actionFactory; /** Creates a service. */ public MediaSessionService() { @@ -165,7 +169,6 @@ public abstract class MediaSessionService extends Service { super.onCreate(); synchronized (lock) { stub = new MediaSessionServiceStub(this); - notificationHandler = new MediaNotificationManager(this); } } @@ -179,7 +182,7 @@ public abstract class MediaSessionService extends Service { * session is closed. You don't need to manually call {@link #addSession(MediaSession)} nor {@link * #removeSession(MediaSession)}. * - *

There are two special cases where the {@link ControllerInfo#getPackageName()} returns + *

There are two special cases where the {@link ControllerInfo#getPackageName()} returns a * non-existent package name: * *