Create MediaNotification.Provider

Define MediaNotification.Provider so that apps can customize
notification UX. Move MediaNotificationManager's functionality
around notifications on DefaultMediaNotificationProvider

PiperOrigin-RevId: 428024699
This commit is contained in:
christosts 2022-02-11 17:48:57 +00:00 committed by Ian Baker
parent 40a5c01275
commit 437e178ef8
6 changed files with 730 additions and 273 deletions

View File

@ -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);
}
}
}

View File

@ -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}.
*
* <h2>Actions</h2>
*
* The following actions are included in the provided notifications:
*
* <ul>
* <li>{@link MediaNotification.ActionFactory#COMMAND_PLAY} to start playback. Included when
* {@link MediaController#getPlayWhenReady()} returns {@code false}.
* <li>{@link MediaNotification.ActionFactory#COMMAND_PAUSE}, to pause playback. Included when
* ({@link MediaController#getPlayWhenReady()} returns {@code true}.
* <li>{@link MediaNotification.ActionFactory#COMMAND_SKIP_TO_PREVIOUS} to skip to the previous
* item.
* <li>{@link MediaNotification.ActionFactory#COMMAND_SKIP_TO_NEXT} to skip to the next item.
* </ul>
*
* <h2>Drawables</h2>
*
* The drawables used can be overridden by drawables with the same names defined the application.
* The drawables are:
*
* <ul>
* <li><b>{@code media3_notification_play}</b> - The play icon.
* <li><b>{@code media3_notification_pause}</b> - The pause icon.
* <li><b>{@code media3_notification_seek_to_previous}</b> - The previous icon.
* <li><b>{@code media3_notification_seek_to_next}</b> - The next icon.
* </ul>
*/
@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;
}
}
}

View File

@ -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}.
*
* <p>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.
*
* <p>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 <a
* href="https://developer.android.com/guide/components/foreground-services">foreground</a>.
* 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);
}
}

View File

@ -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<MediaSession, ListenableFuture<MediaController>> 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<MediaController> 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<MediaController> controllerFuture = controllerMap.remove(session);
@Nullable ListenableFuture<MediaController> 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<MediaController> 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<MediaController> 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<MediaSession> sessions = service.getSessions();
List<MediaSession> 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();
}
}

View File

@ -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}.
*
* <p>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.
*
* <p>To extend this class, declare the intent filter in your {@code AndroidManifest.xml}.
* <p>To extend this class, declare the intent filter in your {@code AndroidManifest.xml}:
*
* <pre>{@code
* <service android:name="NameOfYourService">
@ -89,20 +87,21 @@ import java.util.concurrent.ConcurrentHashMap;
*
* <p>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)}.
*
* <p>After the binding, the session's {@link MediaSession.SessionCallback#onConnect(MediaSession,
* <p>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.
*
* <p>When a playback is started on the service, {@link #onUpdateNotification(MediaSession)} is
* called and the service will become a <a
* href="https://developer.android.com/guide/components/foreground-services">foreground service</a>.
* 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.
* <p>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 <a href="https://developer.android.com/guide/components/foreground-services">foreground
* service</a>. 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.
*
* <p>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;
* <h2 id="MultipleSessions">Supporting Multiple Sessions</h2>
*
* <p>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 <a
* app can play multiple media contents at the same time, but only for playback of video-only media
* or remote playback, since the <a
* href="https://developer.android.com/guide/topics/media-apps/audio-focus">audio focus policy</a>
* 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.
*
* <p>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)}.
*
* <p>Note that {@link MediaController} can be created with {@link SessionToken} to connect to a
* <p>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)}.
*
* <p>There are two special cases where the {@link ControllerInfo#getPackageName()} returns
* <p>There are two special cases where the {@link ControllerInfo#getPackageName()} returns a
* non-existent package name:
*
* <ul>
@ -209,7 +212,7 @@ public abstract class MediaSessionService extends Service {
* Adds a {@link MediaSession} to this service. This is not necessary for most media apps. See <a
* href="#MultipleSessions">Supporting Multiple Sessions</a> for details.
*
* <p>Added session will be removed automatically when it's closed.
* <p>The added session will be removed automatically when it's closed.
*
* @param session A session to be added.
* @see #removeSession(MediaSession)
@ -227,11 +230,8 @@ public abstract class MediaSessionService extends Service {
if (old == null) {
// Session has returned for the first time. Register callbacks.
// TODO(b/191644474): Check whether the session is registered to multiple services.
MediaNotificationManager handler;
synchronized (lock) {
handler = checkStateNotNull(notificationHandler);
}
postOrRun(mainHandler, () -> handler.addSession(session));
MediaNotificationManager notificationManager = getMediaNotificationManager();
postOrRun(mainHandler, () -> notificationManager.addSession(session));
}
}
@ -245,39 +245,12 @@ public abstract class MediaSessionService extends Service {
*/
public final void removeSession(MediaSession session) {
checkNotNull(session, "session must not be null");
MediaNotificationManager handler;
synchronized (lock) {
checkArgument(sessions.containsKey(session.getId()), "session not found");
sessions.remove(session.getId());
handler = checkStateNotNull(notificationHandler);
}
postOrRun(mainHandler, () -> handler.removeSession(session));
}
/**
* Called when {@link MediaNotification} needs to be updated. Override this method to show or
* cancel your own notification.
*
* <p>This will be called on the application thread of the underlying {@link Player} of {@link
* MediaSession}.
*
* <p>With the notification returned by this method, the service becomes a <a
* href="https://developer.android.com/guide/components/foreground-services">foreground
* service</a> when the playback is started. Apps targeting {@code SDK_INT >= 28} must request the
* permission, {@link android.Manifest.permission#FOREGROUND_SERVICE}. It becomes a background
* service after the playback is stopped.
*
* @param session A session that needs notification update.
* @return A {@link MediaNotification}, or {@code null} if you don't want the automatic
* foreground/background transitions.
*/
@Nullable
public MediaNotification onUpdateNotification(MediaSession session) {
checkNotNull(session, "session must not be null");
MediaNotificationManager handler;
synchronized (lock) {
handler = checkStateNotNull(notificationHandler, "Service hasn't created");
}
return handler.onUpdateNotification(session);
MediaNotificationManager notificationManager = getMediaNotificationManager();
postOrRun(mainHandler, () -> notificationManager.removeSession(session));
}
/**
@ -347,9 +320,15 @@ public abstract class MediaSessionService extends Service {
if (intent == null) {
return START_STICKY;
}
if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())) {
DefaultActionFactory actionFactory;
synchronized (lock) {
actionFactory = checkStateNotNull(this.actionFactory);
}
@Nullable Uri uri = intent.getData();
@Nullable MediaSession session = uri != null ? MediaSession.getSession(uri) : null;
if (actionFactory.isMediaAction(intent)) {
if (session == null) {
ControllerInfo controllerInfo = ControllerInfo.createLegacyControllerInfo();
session = onGetSession(controllerInfo);
@ -358,10 +337,16 @@ public abstract class MediaSessionService extends Service {
}
addSession(session);
}
KeyEvent keyEvent = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
@Nullable KeyEvent keyEvent = actionFactory.getKeyEvent(intent);
if (keyEvent != null) {
session.getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent);
}
} else if (actionFactory.isCustomAction(intent)) {
@Nullable String customAction = actionFactory.getCustomAction(intent);
if (session != null && customAction != null) {
Bundle customExtras = actionFactory.getCustomActionExtras(intent);
getMediaNotificationManager().onCustomAction(session, customAction, customExtras);
}
}
return START_STICKY;
}
@ -383,35 +368,40 @@ public abstract class MediaSessionService extends Service {
}
}
/**
* Sets the {@link MediaNotification.Provider} to customize notifications.
*
* <p>This should be called before any session is attached to this service via {@link
* #onGetSession(ControllerInfo)} or {@link #addSession(MediaSession)}. Otherwise a default UX
* will be shown with {@link DefaultMediaNotificationProvider}.
*/
@UnstableApi
protected final void setMediaNotificationProvider(
MediaNotification.Provider mediaNotificationProvider) {
checkNotNull(mediaNotificationProvider);
synchronized (lock) {
this.mediaNotificationProvider = mediaNotificationProvider;
}
}
/* package */ IBinder getServiceBinder() {
synchronized (lock) {
return checkStateNotNull(stub).asBinder();
}
}
/** A notification for media playback returned by {@link #onUpdateNotification(MediaSession)}. */
public static final class MediaNotification {
/** 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} to make the {@link MediaSessionService} <a
* href="https://developer.android.com/guide/components/foreground-services">foreground</a>.
* 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);
private MediaNotificationManager getMediaNotificationManager() {
synchronized (lock) {
if (mediaNotificationManager == null) {
if (mediaNotificationProvider == null) {
mediaNotificationProvider = new DefaultMediaNotificationProvider(getApplicationContext());
}
actionFactory = new DefaultActionFactory(getApplicationContext());
mediaNotificationManager =
new MediaNotificationManager(
/* mediaSessionService= */ this, mediaNotificationProvider, actionFactory);
}
return mediaNotificationManager;
}
}

View File

@ -0,0 +1,64 @@
/*
* 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 com.google.common.truth.Truth.assertThat;
import static org.robolectric.Shadows.shadowOf;
import android.app.PendingIntent;
import android.content.Intent;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.shadows.ShadowPendingIntent;
/** Tests for {@link DefaultActionFactory}. */
@RunWith(AndroidJUnit4.class)
public class DefaultActionFactoryTest {
@Test
public void createMediaPendingIntent_intentIsMediaAction() {
DefaultActionFactory actionFactory =
new DefaultActionFactory(ApplicationProvider.getApplicationContext());
PendingIntent pendingIntent =
actionFactory.createMediaActionPendingIntent(MediaNotification.ActionFactory.COMMAND_PLAY);
ShadowPendingIntent shadowPendingIntent = shadowOf(pendingIntent);
assertThat(actionFactory.isMediaAction(shadowPendingIntent.getSavedIntent())).isTrue();
}
@Test
public void isMediaAction_withNonMediaIntent_returnsFalse() {
DefaultActionFactory actionFactory =
new DefaultActionFactory(ApplicationProvider.getApplicationContext());
Intent intent = new Intent("invalid_action");
assertThat(actionFactory.isMediaAction(intent)).isFalse();
}
@Test
public void isCustomAction_withNonCustomActionIntent_returnsFalse() {
DefaultActionFactory actionFactory =
new DefaultActionFactory(ApplicationProvider.getApplicationContext());
Intent intent = new Intent("invalid_action");
assertThat(actionFactory.isCustomAction(intent)).isFalse();
}
}