/*
* 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.C.INDEX_UNSET;
import static androidx.media3.common.Player.COMMAND_INVALID;
import static androidx.media3.common.Player.COMMAND_PLAY_PAUSE;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM;
import static androidx.media3.common.Player.COMMAND_STOP;
import static androidx.media3.common.Player.STATE_ENDED;
import static androidx.media3.common.util.Assertions.checkState;
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.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import androidx.annotation.DoNotInline;
import androidx.annotation.DrawableRes;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.StringRes;
import androidx.core.app.NotificationCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.media.app.NotificationCompat.MediaStyle;
import androidx.media3.common.C;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Player;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
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 com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ExecutionException;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
/**
* The default {@link MediaNotification.Provider}.
*
* <h2>Actions</h2>
*
* The following actions are included in the provided notifications:
*
* <ul>
* <li>{@link MediaController#COMMAND_PLAY_PAUSE} to start or pause playback.
* <li>{@link MediaController#COMMAND_SEEK_TO_PREVIOUS} to seek to the previous item.
* <li>{@link MediaController#COMMAND_SEEK_TO_NEXT} to seek to the next item.
* </ul>
*
* <h2>Custom commands</h2>
*
* Custom actions are sent to the session under the hood. You can receive them by overriding the
* session callback method {@link MediaSession.Callback#onCustomCommand(MediaSession,
* MediaSession.ControllerInfo, SessionCommand, Bundle)}. This is useful because starting with
* Android 13, the System UI notification sends commands directly to the session. So handling the
* custom commands on the session level allows you to handle them at the same callback for all API
* levels.
*
* <h2>Drawables</h2>
*
* The drawables used can be overridden by drawables with the same file names in {@code
* res/drawables} of the application module. Alternatively, you can override the drawable resource
* ID with a {@code drawable} element in a resource file in {@code res/values}. The drawable
* resource IDs 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.
* <li><b>{@code media3_notification_small_icon}</b> - The {@link
* NotificationCompat.Builder#setSmallIcon(int) small icon}. A different icon can be set with
* {@link #setSmallIcon(int)}.
* </ul>
*
* <h2>String resources</h2>
*
* String resources used can be overridden by resources with the same resource IDs defined by the
* application. The string resource IDs are:
*
* <ul>
* <li><b>{@code media3_controls_play_description}</b> - The description of the play icon.
* <li><b>{@code media3_controls_pause_description}</b> - The description of the pause icon.
* <li><b>{@code media3_controls_seek_to_previous_description}</b> - The description of the
* previous icon.
* <li><b>{@code media3_controls_seek_to_next_description}</b> - The description of the next icon.
* <li><b>{@code default_notification_channel_name}</b> The name of the {@link
* NotificationChannel} on which created notifications are posted. A different string resource
* can be set when constructing the provider with {@link
* DefaultMediaNotificationProvider.Builder#setChannelName(int)}.
* </ul>
*/
@UnstableApi
public class DefaultMediaNotificationProvider implements MediaNotification.Provider {
/** A builder for {@link DefaultMediaNotificationProvider} instances. */
public static final class Builder {
private final Context context;
private NotificationIdProvider notificationIdProvider;
private String channelId;
@StringRes private int channelNameResourceId;
private boolean built;
/**
* Creates a builder.
*
* @param context Any {@link Context}.
*/
public Builder(Context context) {
this.context = context;
notificationIdProvider = session -> DEFAULT_NOTIFICATION_ID;
channelId = DEFAULT_CHANNEL_ID;
channelNameResourceId = DEFAULT_CHANNEL_NAME_RESOURCE_ID;
}
/**
* Sets the {@link MediaNotification#notificationId} used for the created notifications. By
* default, this is set to {@link #DEFAULT_NOTIFICATION_ID}.
*
* <p>Overwrites anything set in {@link #setNotificationIdProvider(NotificationIdProvider)}.
*
* @param notificationId The notification ID.
* @return This builder.
*/
@CanIgnoreReturnValue
public Builder setNotificationId(int notificationId) {
this.notificationIdProvider = session -> notificationId;
return this;
}
/**
* Sets the provider for the {@link MediaNotification#notificationId} used for the created
* notifications. By default, this is set to a provider that always returns {@link
* #DEFAULT_NOTIFICATION_ID}.
*
* <p>Overwrites anything set in {@link #setNotificationId(int)}.
*
* @param notificationIdProvider The notification ID provider.
* @return This builder.
*/
@CanIgnoreReturnValue
public Builder setNotificationIdProvider(NotificationIdProvider notificationIdProvider) {
this.notificationIdProvider = notificationIdProvider;
return this;
}
/**
* Sets the ID of the {@link NotificationChannel} on which created notifications are posted on.
* By default, this is set to {@link #DEFAULT_CHANNEL_ID}.
*
* @param channelId The channel ID.
* @return This builder.
*/
@CanIgnoreReturnValue
public Builder setChannelId(String channelId) {
this.channelId = channelId;
return this;
}
/**
* Sets the name of the {@link NotificationChannel} on which created notifications are posted
* on. By default, this is set to {@link #DEFAULT_CHANNEL_NAME_RESOURCE_ID}.
*
* @param channelNameResourceId The string resource ID with the channel name.
* @return This builder.
*/
@CanIgnoreReturnValue
public Builder setChannelName(@StringRes int channelNameResourceId) {
this.channelNameResourceId = channelNameResourceId;
return this;
}
/**
* Builds the {@link DefaultMediaNotificationProvider}. The method can be called at most once.
*/
public DefaultMediaNotificationProvider build() {
checkState(!built);
DefaultMediaNotificationProvider provider = new DefaultMediaNotificationProvider(this);
built = true;
return provider;
}
}
/**
* Provides notification IDs for posting media notifications for given media sessions.
*
* @see Builder#setNotificationIdProvider(NotificationIdProvider)
*/
public interface NotificationIdProvider {
/** Returns the notification ID for the media notification of the given session. */
int getNotificationId(MediaSession mediaSession);
}
/**
* An extras key that can be used to define the index of a {@link CommandButton} in {@linkplain
* Notification.MediaStyle#setShowActionsInCompactView(int...) compact view}.
*/
public static final String COMMAND_KEY_COMPACT_VIEW_INDEX =
"androidx.media3.session.command.COMPACT_VIEW_INDEX";
/** The default ID used for the {@link MediaNotification#notificationId}. */
public static final int DEFAULT_NOTIFICATION_ID = 1001;
/**
* The default ID used for the {@link NotificationChannel} on which created notifications are
* posted on.
*/
public static final String DEFAULT_CHANNEL_ID = "default_channel_id";
/**
* The default name used for the {@link NotificationChannel} on which created notifications are
* posted on.
*/
@StringRes
public static final int DEFAULT_CHANNEL_NAME_RESOURCE_ID =
R.string.default_notification_channel_name;
private static final String TAG = "NotificationProvider";
private final Context context;
private final NotificationIdProvider notificationIdProvider;
private final String channelId;
@StringRes private final int channelNameResourceId;
private final NotificationManager notificationManager;
private final Handler mainHandler;
private @MonotonicNonNull OnBitmapLoadedFutureCallback pendingOnBitmapLoadedFutureCallback;
@DrawableRes private int smallIconResourceId;
/**
* Creates an instance. Use this constructor only when you want to override methods of this class.
* Otherwise use {@link Builder}.
*/
public DefaultMediaNotificationProvider(Context context) {
this(
context,
session -> DEFAULT_NOTIFICATION_ID,
DEFAULT_CHANNEL_ID,
DEFAULT_CHANNEL_NAME_RESOURCE_ID);
}
/**
* Creates an instance. Use this constructor only when you want to override methods of this class.
* Otherwise use {@link Builder}.
*/
public DefaultMediaNotificationProvider(
Context context,
NotificationIdProvider notificationIdProvider,
String channelId,
int channelNameResourceId) {
this.context = context;
this.notificationIdProvider = notificationIdProvider;
this.channelId = channelId;
this.channelNameResourceId = channelNameResourceId;
notificationManager =
checkStateNotNull(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE));
mainHandler = new Handler(Looper.getMainLooper());
smallIconResourceId = R.drawable.media3_notification_small_icon;
}
private DefaultMediaNotificationProvider(Builder builder) {
this(
builder.context,
builder.notificationIdProvider,
builder.channelId,
builder.channelNameResourceId);
}
// MediaNotification.Provider implementation
@Override
public final MediaNotification createNotification(
MediaSession mediaSession,
ImmutableList<CommandButton> customLayout,
MediaNotification.ActionFactory actionFactory,
Callback onNotificationChangedCallback) {
ensureNotificationChannel();
Player player = mediaSession.getPlayer();
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId);
int notificationId = notificationIdProvider.getNotificationId(mediaSession);
MediaStyle mediaStyle = new MediaStyle();
int[] compactViewIndices =
addNotificationActions(
mediaSession,
getMediaButtons(
mediaSession,
player.getAvailableCommands(),
customLayout,
/* showPauseButton= */ player.getPlayWhenReady()
&& player.getPlaybackState() != STATE_ENDED),
builder,
actionFactory);
mediaStyle.setShowActionsInCompactView(compactViewIndices);
// Set metadata info in the notification.
if (player.isCommandAvailable(Player.COMMAND_GET_MEDIA_ITEMS_METADATA)) {
MediaMetadata metadata = player.getMediaMetadata();
builder
.setContentTitle(getNotificationContentTitle(metadata))
.setContentText(getNotificationContentText(metadata));
@Nullable
ListenableFuture<Bitmap> bitmapFuture =
mediaSession.getBitmapLoader().loadBitmapFromMetadata(metadata);
if (bitmapFuture != null) {
if (pendingOnBitmapLoadedFutureCallback != null) {
pendingOnBitmapLoadedFutureCallback.discardIfPending();
}
if (bitmapFuture.isDone()) {
try {
builder.setLargeIcon(Futures.getDone(bitmapFuture));
} catch (ExecutionException e) {
Log.w(TAG, getBitmapLoadErrorMessage(e));
}
} else {
pendingOnBitmapLoadedFutureCallback =
new OnBitmapLoadedFutureCallback(
notificationId, builder, onNotificationChangedCallback);
Futures.addCallback(
bitmapFuture,
pendingOnBitmapLoadedFutureCallback,
// This callback must be executed on the next looper iteration, after this method has
// returned a media notification.
mainHandler::post);
}
}
}
if (player.isCommandAvailable(COMMAND_STOP) || Util.SDK_INT < 21) {
// We must include a cancel intent for pre-L devices.
mediaStyle.setCancelButtonIntent(
actionFactory.createMediaActionPendingIntent(mediaSession, COMMAND_STOP));
}
long playbackStartTimeMs = getPlaybackStartTimeEpochMs(player);
boolean displayElapsedTimeWithChronometer = playbackStartTimeMs != C.TIME_UNSET;
builder
.setWhen(playbackStartTimeMs)
.setShowWhen(displayElapsedTimeWithChronometer)
.setUsesChronometer(displayElapsedTimeWithChronometer);
Notification notification =
builder
.setContentIntent(mediaSession.getSessionActivity())
.setDeleteIntent(
actionFactory.createMediaActionPendingIntent(mediaSession, COMMAND_STOP))
.setOnlyAlertOnce(true)
.setSmallIcon(smallIconResourceId)
.setStyle(mediaStyle)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(false)
.build();
return new MediaNotification(notificationId, notification);
}
@Override
public final boolean handleCustomCommand(MediaSession session, String action, Bundle extras) {
// Make the custom action being delegated to the session as a custom session command.
return false;
}
// Other methods
/**
* Sets the small icon of the notification which is also shown in the system status bar.
*
* @see NotificationCompat.Builder#setSmallIcon(int)
* @param smallIconResourceId The resource id of the small icon.
*/
public final void setSmallIcon(@DrawableRes int smallIconResourceId) {
this.smallIconResourceId = smallIconResourceId;
}
/**
* Returns the ordered list of {@linkplain CommandButton command buttons} to be used to build the
* notification.
*
* <p>This method is called each time a new notification is built.
*
* <p>Override this method to customize the buttons on the notification. Commands of the buttons
* returned by this method must be contained in {@link MediaController#getAvailableCommands()}.
*
* <p>By default, the notification shows {@link Player#COMMAND_PLAY_PAUSE} in {@linkplain
* Notification.MediaStyle#setShowActionsInCompactView(int...) compact view}. This can be
* customized by defining the index of the command in compact view of up to 3 commands in their
* extras with key {@link DefaultMediaNotificationProvider#COMMAND_KEY_COMPACT_VIEW_INDEX}.
*
* <p>To make the custom layout and commands work, you need to {@linkplain
* MediaSession#setCustomLayout(List) set the custom layout of commands} and add the custom
* commands to the available commands when a controller {@linkplain
* MediaSession.Callback#onConnect(MediaSession, MediaSession.ControllerInfo) connects to the
* session}. Controllers that connect after you called {@link MediaSession#setCustomLayout(List)}
* need the custom command set in {@link MediaSession.Callback#onPostConnect(MediaSession,
* MediaSession.ControllerInfo)} also.
*
* @param session The media session.
* @param playerCommands The available player commands.
* @param customLayout The {@linkplain MediaSession#setCustomLayout(List) custom layout of
* commands}.
* @param showPauseButton Whether the notification should show a pause button (e.g., because the
* player is currently playing content), otherwise show a play button to start playback.
* @return The ordered list of command buttons to be placed on the notification.
*/
protected ImmutableList<CommandButton> getMediaButtons(
MediaSession session,
Player.Commands playerCommands,
ImmutableList<CommandButton> customLayout,
boolean showPauseButton) {
// Skip to previous action.
ImmutableList.Builder<CommandButton> commandButtons = new ImmutableList.Builder<>();
if (playerCommands.containsAny(COMMAND_SEEK_TO_PREVIOUS, COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)) {
Bundle commandButtonExtras = new Bundle();
commandButtonExtras.putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, INDEX_UNSET);
commandButtons.add(
new CommandButton.Builder()
.setPlayerCommand(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
.setIconResId(R.drawable.media3_notification_seek_to_previous)
.setDisplayName(
context.getString(R.string.media3_controls_seek_to_previous_description))
.setExtras(commandButtonExtras)
.build());
}
if (playerCommands.contains(COMMAND_PLAY_PAUSE)) {
Bundle commandButtonExtras = new Bundle();
commandButtonExtras.putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, INDEX_UNSET);
commandButtons.add(
new CommandButton.Builder()
.setPlayerCommand(COMMAND_PLAY_PAUSE)
.setIconResId(
showPauseButton
? R.drawable.media3_notification_pause
: R.drawable.media3_notification_play)
.setExtras(commandButtonExtras)
.setDisplayName(
showPauseButton
? context.getString(R.string.media3_controls_pause_description)
: context.getString(R.string.media3_controls_play_description))
.build());
}
// Skip to next action.
if (playerCommands.containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)) {
Bundle commandButtonExtras = new Bundle();
commandButtonExtras.putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, INDEX_UNSET);
commandButtons.add(
new CommandButton.Builder()
.setPlayerCommand(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
.setIconResId(R.drawable.media3_notification_seek_to_next)
.setExtras(commandButtonExtras)
.setDisplayName(context.getString(R.string.media3_controls_seek_to_next_description))
.build());
}
for (int i = 0; i < customLayout.size(); i++) {
CommandButton button = customLayout.get(i);
if (button.sessionCommand != null
&& button.sessionCommand.commandCode == SessionCommand.COMMAND_CODE_CUSTOM) {
commandButtons.add(button);
}
}
return commandButtons.build();
}
/**
* Adds the media buttons to the notification builder for the given action factory.
*
* <p>The list of {@code mediaButtons} is the list resulting from {@link #getMediaButtons(
* MediaSession, Player.Commands, ImmutableList, boolean)}.
*
* <p>Override this method to customize how the media buttons {@linkplain
* NotificationCompat.Builder#addAction(NotificationCompat.Action) are added} to the notification
* and define which actions are shown in compact view by returning the indices of the buttons to
* be shown in compact view.
*
* <p>By default, {@link Player#COMMAND_PLAY_PAUSE} is shown in compact view, unless some of the
* buttons are marked with {@link DefaultMediaNotificationProvider#COMMAND_KEY_COMPACT_VIEW_INDEX}
* to declare the index in compact view of the given command button in the button extras.
*
* @param mediaSession The media session to which the actions will be sent.
* @param mediaButtons The command buttons to be included in the notification.
* @param builder The builder to add the actions to.
* @param actionFactory The actions factory to be used to build notifications.
* @return The indices of the buttons to be {@linkplain
* Notification.MediaStyle#setShowActionsInCompactView(int...) used in compact view of the
* notification}.
*/
protected int[] addNotificationActions(
MediaSession mediaSession,
ImmutableList<CommandButton> mediaButtons,
NotificationCompat.Builder builder,
MediaNotification.ActionFactory actionFactory) {
int[] compactViewIndices = new int[3];
Arrays.fill(compactViewIndices, INDEX_UNSET);
int compactViewCommandCount = 0;
for (int i = 0; i < mediaButtons.size(); i++) {
CommandButton commandButton = mediaButtons.get(i);
if (commandButton.sessionCommand != null) {
builder.addAction(
actionFactory.createCustomActionFromCustomCommandButton(mediaSession, commandButton));
} else {
checkState(commandButton.playerCommand != COMMAND_INVALID);
builder.addAction(
actionFactory.createMediaAction(
mediaSession,
IconCompat.createWithResource(context, commandButton.iconResId),
commandButton.displayName,
commandButton.playerCommand));
}
if (compactViewCommandCount == 3) {
continue;
}
int compactViewIndex =
commandButton.extras.getInt(
COMMAND_KEY_COMPACT_VIEW_INDEX, /* defaultValue= */ INDEX_UNSET);
if (compactViewIndex >= 0 && compactViewIndex < compactViewIndices.length) {
compactViewCommandCount++;
compactViewIndices[compactViewIndex] = i;
} else if (commandButton.playerCommand == COMMAND_PLAY_PAUSE
&& compactViewCommandCount == 0) {
// If there is no custom configuration we use the play/pause action in compact view.
compactViewIndices[0] = i;
}
}
for (int i = 0; i < compactViewIndices.length; i++) {
if (compactViewIndices[i] == INDEX_UNSET) {
compactViewIndices = Arrays.copyOf(compactViewIndices, i);
break;
}
}
return compactViewIndices;
}
/**
* Returns the content title to be used to build the notification.
*
* <p>This method is called each time a new notification is built.
*
* <p>Override this method to customize which field of {@link MediaMetadata} is used for content
* title of the notification.
*
* <p>By default, the notification shows {@link MediaMetadata#title} as content title.
*
* @param metadata The media metadata from which content title is fetched.
* @return Notification content title.
*/
@Nullable
protected CharSequence getNotificationContentTitle(MediaMetadata metadata) {
return metadata.title;
}
/**
* Returns the content text to be used to build the notification.
*
* <p>This method is called each time a new notification is built.
*
* <p>Override this method to customize which field of {@link MediaMetadata} is used for content
* text of the notification.
*
* <p>By default, the notification shows {@link MediaMetadata#artist} as content text.
*
* @param metadata The media metadata from which content text is fetched.
* @return Notification content text.
*/
@Nullable
protected CharSequence getNotificationContentText(MediaMetadata metadata) {
return metadata.artist;
}
private void ensureNotificationChannel() {
if (Util.SDK_INT < 26 || notificationManager.getNotificationChannel(channelId) != null) {
return;
}
Api26.createNotificationChannel(
notificationManager, channelId, context.getString(channelNameResourceId));
}
private static long getPlaybackStartTimeEpochMs(Player player) {
// Changing "showWhen" causes notification flicker if SDK_INT < 21.
if (Util.SDK_INT >= 21
&& player.isPlaying()
&& !player.isPlayingAd()
&& !player.isCurrentMediaItemDynamic()
&& player.getPlaybackParameters().speed == 1f) {
return System.currentTimeMillis() - player.getContentPosition();
} else {
return C.TIME_UNSET;
}
}
private static class OnBitmapLoadedFutureCallback implements FutureCallback<Bitmap> {
private final int notificationId;
private final NotificationCompat.Builder builder;
private final Callback onNotificationChangedCallback;
private boolean discarded;
public OnBitmapLoadedFutureCallback(
int notificationId,
NotificationCompat.Builder builder,
Callback onNotificationChangedCallback) {
this.notificationId = notificationId;
this.builder = builder;
this.onNotificationChangedCallback = onNotificationChangedCallback;
}
public void discardIfPending() {
discarded = true;
}
@Override
public void onSuccess(Bitmap result) {
if (!discarded) {
builder.setLargeIcon(result);
onNotificationChangedCallback.onNotificationChanged(
new MediaNotification(notificationId, builder.build()));
}
}
@Override
public void onFailure(Throwable t) {
if (!discarded) {
Log.w(TAG, getBitmapLoadErrorMessage(t));
}
}
}
@RequiresApi(26)
private static class Api26 {
@DoNotInline
public static void createNotificationChannel(
NotificationManager notificationManager, String channelId, String channelName) {
NotificationChannel channel =
new NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW);
if (Util.SDK_INT <= 27) {
// API 28+ will automatically hide the app icon 'badge' for notifications using
// Notification.MediaStyle, but we have to manually hide it for APIs 26 (when badges were
// added) and 27.
channel.setShowBadge(false);
}
notificationManager.createNotificationChannel(channel);
}
}
private static String getBitmapLoadErrorMessage(Throwable throwable) {
return "Failed to load bitmap: " + throwable.getMessage();
}
}