Make DefaultMediaNotificationProvider more configurable

Add a Builder to constructor DefaultMediaNotificationProvider. The
Builder can also set the provider's:
- notification ID
- notification channel ID
- notification channel name

The change adds an API for apps to set the small icon in notifications.

#minor-release
Issue: androidx/media#104
PiperOrigin-RevId: 462111536
(cherry picked from commit 436ff6d86a4b8de5324d3b3b08bb655b75ca6632)
This commit is contained in:
christosts 2022-07-20 11:41:30 +00:00 committed by microkatz
parent eb823a9ab7
commit 62a2d76d00
5 changed files with 280 additions and 33 deletions

View File

@ -19,6 +19,12 @@
* Ensure commands are always executed in the correct order even if some
require asynchronous resolution
([#85](https://github.com/androidx/media/issues/85)).
* Add `DefaultNotificationProvider.Builder` to build
`DefaultNotificationProvider` instances. The builder can configure the
notification ID, the notification channel ID and the notification
channel name used by the provider. Also, add method
`DefaultNotificationProvider.setSmallIcon(int)` to set the notifications
small icon ([#104](https://github.com/androidx/media/issues/104)).
### 1.0.0-beta02 (2022-07-15)

View File

@ -32,6 +32,7 @@ android {
}
dependencies {
api project(modulePrefix + 'lib-common')
compileOnly 'com.google.errorprone:error_prone_annotations:' + errorProneVersion
compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion
implementation 'androidx.collection:collection:' + androidxCollectionVersion

View File

@ -36,7 +36,9 @@ import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import androidx.annotation.DrawableRes;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.app.NotificationCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.media.app.NotificationCompat.MediaStyle;
@ -51,6 +53,7 @@ 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.ArrayList;
import java.util.Arrays;
import java.util.List;
@ -89,12 +92,115 @@ import java.util.concurrent.ExecutionException;
* <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}.
* 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 names defined the application.
* These 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 int notificationId;
private String channelId;
@StringRes private int channelNameResourceId;
private BitmapLoader bitmapLoader;
private boolean built;
/**
* Creates a builder.
*
* @param context Any {@link Context}.
*/
public Builder(Context context) {
this.context = context;
notificationId = DEFAULT_NOTIFICATION_ID;
channelId = DEFAULT_CHANNEL_ID;
channelNameResourceId = DEFAULT_CHANNEL_NAME_RESOURCE_ID;
bitmapLoader = new SimpleBitmapLoader();
}
/**
* Sets the {@link MediaNotification#notificationId} used for the created notifications. By
* default this is set to {@link #DEFAULT_NOTIFICATION_ID}.
*
* @param notificationId The notification ID.
* @return This builder.
*/
@CanIgnoreReturnValue
public Builder setNotificationId(int notificationId) {
this.notificationId = notificationId;
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;
}
/**
* Sets the {@link BitmapLoader} used load artwork. By default, a {@link SimpleBitmapLoader}
* will be used.
*
* @param bitmapLoader The bitmap loader.
* @return This builder.
*/
@CanIgnoreReturnValue
public Builder setBitmapLoader(BitmapLoader bitmapLoader) {
this.bitmapLoader = bitmapLoader;
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;
}
}
/**
* An extras key that can be used to define the index of a {@link CommandButton} in {@linkplain
* Notification.MediaStyle#setShowActionsInCompactView(int...) compact view}.
@ -102,12 +208,27 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
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 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 int notificationId;
private final String channelId;
@StringRes private final int channelNameResourceId;
private final NotificationManager notificationManager;
private final BitmapLoader bitmapLoader;
// Cache the last loaded bitmap to avoid reloading the bitmap again, particularly useful when
@ -116,24 +237,25 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
private final Handler mainHandler;
private OnBitmapLoadedFutureCallback pendingOnBitmapLoadedFutureCallback;
@DrawableRes private int smallIconResourceId;
/** Creates an instance that uses a {@link SimpleBitmapLoader} for loading artwork images. */
public DefaultMediaNotificationProvider(Context context) {
this(context, new SimpleBitmapLoader());
}
/** Creates an instance that uses the {@code bitmapLoader} for loading artwork images. */
public DefaultMediaNotificationProvider(Context context, BitmapLoader bitmapLoader) {
this.context = context.getApplicationContext();
this.bitmapLoader = bitmapLoader;
lastLoadedBitmapInfo = new LoadedBitmapInfo();
mainHandler = new Handler(Looper.getMainLooper());
private DefaultMediaNotificationProvider(Builder builder) {
this.context = builder.context;
this.notificationId = builder.notificationId;
this.channelId = builder.channelId;
this.channelNameResourceId = builder.channelNameResourceId;
this.bitmapLoader = builder.bitmapLoader;
notificationManager =
checkStateNotNull(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE));
lastLoadedBitmapInfo = new LoadedBitmapInfo();
mainHandler = new Handler(Looper.getMainLooper());
pendingOnBitmapLoadedFutureCallback = new OnBitmapLoadedFutureCallback(bitmap -> {});
smallIconResourceId = R.drawable.media3_notification_small_icon;
}
// MediaNotification.Provider implementation
@Override
public final MediaNotification createNotification(
MediaSession mediaSession,
@ -143,8 +265,7 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
ensureNotificationChannel();
Player player = mediaSession.getPlayer();
NotificationCompat.Builder builder =
new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId);
MediaStyle mediaStyle = new MediaStyle();
int[] compactViewIndices =
@ -173,7 +294,7 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
bitmap -> {
builder.setLargeIcon(bitmap);
onNotificationChangedCallback.onNotificationChanged(
new MediaNotification(NOTIFICATION_ID, builder.build()));
new MediaNotification(notificationId, builder.build()));
}),
// This callback must be executed on the next looper iteration, after this method has
// returned a media notification.
@ -200,12 +321,12 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
.setDeleteIntent(
actionFactory.createMediaActionPendingIntent(mediaSession, COMMAND_STOP))
.setOnlyAlertOnce(true)
.setSmallIcon(R.drawable.media3_notification_small_icon)
.setSmallIcon(smallIconResourceId)
.setStyle(mediaStyle)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(false)
.build();
return new MediaNotification(NOTIFICATION_ID, notification);
return new MediaNotification(notificationId, notification);
}
@Override
@ -214,6 +335,18 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
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.
@ -367,13 +500,14 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
}
private void ensureNotificationChannel() {
if (Util.SDK_INT < 26
|| notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null) {
if (Util.SDK_INT < 26 || notificationManager.getNotificationChannel(channelId) != null) {
return;
}
NotificationChannel channel =
new NotificationChannel(
NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW);
channelId,
context.getString(channelNameResourceId),
NotificationManager.IMPORTANCE_LOW);
notificationManager.createNotificationChannel(channel);
}

View File

@ -433,7 +433,8 @@ public abstract class MediaSessionService extends Service {
synchronized (lock) {
if (mediaNotificationManager == null) {
if (mediaNotificationProvider == null) {
mediaNotificationProvider = new DefaultMediaNotificationProvider(getApplicationContext());
mediaNotificationProvider =
new DefaultMediaNotificationProvider.Builder(getApplicationContext()).build();
}
mediaNotificationManager =
new MediaNotificationManager(

View File

@ -24,10 +24,14 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Player;
import androidx.media3.common.Player.Commands;
import androidx.test.core.app.ApplicationProvider;
@ -40,6 +44,8 @@ import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mockito;
import org.robolectric.Robolectric;
import org.robolectric.Shadows;
import org.robolectric.shadows.ShadowNotificationManager;
/** Tests for {@link DefaultMediaNotificationProvider}. */
@RunWith(AndroidJUnit4.class)
@ -48,7 +54,8 @@ public class DefaultMediaNotificationProviderTest {
@Test
public void getMediaButtons_playWhenReadyTrueOrFalse_correctPlayPauseResources() {
DefaultMediaNotificationProvider defaultMediaNotificationProvider =
new DefaultMediaNotificationProvider(ApplicationProvider.getApplicationContext());
new DefaultMediaNotificationProvider.Builder(ApplicationProvider.getApplicationContext())
.build();
Commands commands = new Commands.Builder().addAllCommands().build();
List<CommandButton> mediaButtonsWhenPlaying =
@ -73,7 +80,8 @@ public class DefaultMediaNotificationProviderTest {
@Test
public void getMediaButtons_allCommandsAvailable_createsPauseSkipNextSkipPreviousButtons() {
DefaultMediaNotificationProvider defaultMediaNotificationProvider =
new DefaultMediaNotificationProvider(ApplicationProvider.getApplicationContext());
new DefaultMediaNotificationProvider.Builder(ApplicationProvider.getApplicationContext())
.build();
Commands commands = new Commands.Builder().addAllCommands().build();
SessionCommand customSessionCommand = new SessionCommand("", Bundle.EMPTY);
CommandButton customCommandButton =
@ -98,7 +106,8 @@ public class DefaultMediaNotificationProviderTest {
@Test
public void getMediaButtons_noPlayerCommandsAvailable_onlyCustomLayoutButtons() {
DefaultMediaNotificationProvider defaultMediaNotificationProvider =
new DefaultMediaNotificationProvider(ApplicationProvider.getApplicationContext());
new DefaultMediaNotificationProvider.Builder(ApplicationProvider.getApplicationContext())
.build();
Commands commands = new Commands.Builder().build();
SessionCommand customSessionCommand = new SessionCommand("action1", Bundle.EMPTY);
CommandButton customCommandButton =
@ -118,7 +127,8 @@ public class DefaultMediaNotificationProviderTest {
@Test
public void addNotificationActions_customCompactViewDeclarations_correctCompactViewIndices() {
DefaultMediaNotificationProvider defaultMediaNotificationProvider =
new DefaultMediaNotificationProvider(ApplicationProvider.getApplicationContext());
new DefaultMediaNotificationProvider.Builder(ApplicationProvider.getApplicationContext())
.build();
NotificationCompat.Builder mockNotificationBuilder = mock(NotificationCompat.Builder.class);
MediaNotification.ActionFactory mockActionFactory = mock(MediaNotification.ActionFactory.class);
MediaSession mockMediaSession = mock(MediaSession.class);
@ -185,7 +195,8 @@ public class DefaultMediaNotificationProviderTest {
@Test
public void addNotificationActions_playPauseCommandNoCustomDeclaration_playPauseInCompactView() {
DefaultMediaNotificationProvider defaultMediaNotificationProvider =
new DefaultMediaNotificationProvider(ApplicationProvider.getApplicationContext());
new DefaultMediaNotificationProvider.Builder(ApplicationProvider.getApplicationContext())
.build();
NotificationCompat.Builder mockNotificationBuilder = mock(NotificationCompat.Builder.class);
MediaNotification.ActionFactory mockActionFactory = mock(MediaNotification.ActionFactory.class);
MediaSession mockMediaSession = mock(MediaSession.class);
@ -230,7 +241,8 @@ public class DefaultMediaNotificationProviderTest {
public void
addNotificationActions_noPlayPauseCommandNoCustomDeclaration_emptyCompactViewIndices() {
DefaultMediaNotificationProvider defaultMediaNotificationProvider =
new DefaultMediaNotificationProvider(ApplicationProvider.getApplicationContext());
new DefaultMediaNotificationProvider.Builder(ApplicationProvider.getApplicationContext())
.build();
NotificationCompat.Builder mockNotificationBuilder = mock(NotificationCompat.Builder.class);
MediaNotification.ActionFactory mockActionFactory = mock(MediaNotification.ActionFactory.class);
MediaSession mockMediaSession = mock(MediaSession.class);
@ -259,7 +271,8 @@ public class DefaultMediaNotificationProviderTest {
@Test
public void addNotificationActions_outOfBoundsCompactViewIndices_ignored() {
DefaultMediaNotificationProvider defaultMediaNotificationProvider =
new DefaultMediaNotificationProvider(ApplicationProvider.getApplicationContext());
new DefaultMediaNotificationProvider.Builder(ApplicationProvider.getApplicationContext())
.build();
NotificationCompat.Builder mockNotificationBuilder = mock(NotificationCompat.Builder.class);
MediaNotification.ActionFactory mockActionFactory = mock(MediaNotification.ActionFactory.class);
MediaSession mockMediaSession = mock(MediaSession.class);
@ -304,7 +317,8 @@ public class DefaultMediaNotificationProviderTest {
@Test
public void addNotificationActions_unsetLeadingArrayFields_cropped() {
DefaultMediaNotificationProvider defaultMediaNotificationProvider =
new DefaultMediaNotificationProvider(ApplicationProvider.getApplicationContext());
new DefaultMediaNotificationProvider.Builder(ApplicationProvider.getApplicationContext())
.build();
NotificationCompat.Builder mockNotificationBuilder = mock(NotificationCompat.Builder.class);
MediaNotification.ActionFactory mockActionFactory = mock(MediaNotification.ActionFactory.class);
MediaSession mockMediaSession = mock(MediaSession.class);
@ -337,7 +351,8 @@ public class DefaultMediaNotificationProviderTest {
@Test
public void addNotificationActions_correctNotificationActionAttributes() {
DefaultMediaNotificationProvider defaultMediaNotificationProvider =
new DefaultMediaNotificationProvider(ApplicationProvider.getApplicationContext());
new DefaultMediaNotificationProvider.Builder(ApplicationProvider.getApplicationContext())
.build();
NotificationCompat.Builder mockNotificationBuilder = mock(NotificationCompat.Builder.class);
DefaultActionFactory defaultActionFactory =
new DefaultActionFactory(Robolectric.setupService(TestService.class));
@ -374,6 +389,96 @@ public class DefaultMediaNotificationProviderTest {
assertThat(actions.get(0).getExtras().size()).isEqualTo(0);
}
@Test
public void provider_withCustomIds_notificationsUseCustomIds() {
Context context = ApplicationProvider.getApplicationContext();
DefaultMediaNotificationProvider defaultMediaNotificationProvider =
new DefaultMediaNotificationProvider.Builder(context)
.setNotificationId(/* notificationId= */ 2)
.setChannelId(/* channelId= */ "customChannelId")
.setChannelName(/* channelNameResourceId= */ R.string.media3_controls_play_description)
.build();
MediaSession mockMediaSession = createMockMediaSessionForNotification();
DefaultActionFactory defaultActionFactory =
new DefaultActionFactory(Robolectric.setupService(TestService.class));
MediaNotification notification =
defaultMediaNotificationProvider.createNotification(
mockMediaSession,
ImmutableList.of(),
defaultActionFactory,
mock(MediaNotification.Provider.Callback.class));
assertThat(notification.notificationId).isEqualTo(2);
assertThat(notification.notification.getChannelId()).isEqualTo("customChannelId");
ShadowNotificationManager shadowNotificationManager =
Shadows.shadowOf(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE));
assertHasNotificationChannel(
shadowNotificationManager.getNotificationChannels(),
/* channelId= */ "customChannelId",
/* channelName= */ context.getString(R.string.media3_controls_play_description));
}
@Test
public void setCustomSmallIcon_notificationUsesCustomSmallIcon() {
Context context = ApplicationProvider.getApplicationContext();
DefaultMediaNotificationProvider defaultMediaNotificationProvider =
new DefaultMediaNotificationProvider.Builder(context).build();
DefaultActionFactory defaultActionFactory =
new DefaultActionFactory(Robolectric.setupService(TestService.class));
MediaSession mockMediaSession = createMockMediaSessionForNotification();
MediaNotification notification =
defaultMediaNotificationProvider.createNotification(
mockMediaSession,
ImmutableList.of(),
defaultActionFactory,
mock(MediaNotification.Provider.Callback.class));
// Change the small icon.
defaultMediaNotificationProvider.setSmallIcon(R.drawable.media3_icon_circular_play);
MediaNotification notificationWithSmallIcon =
defaultMediaNotificationProvider.createNotification(
mockMediaSession,
ImmutableList.of(),
defaultActionFactory,
mock(MediaNotification.Provider.Callback.class));
assertThat(notification.notification.getSmallIcon().getResId())
.isEqualTo(R.drawable.media3_notification_small_icon);
assertThat(notificationWithSmallIcon.notification.getSmallIcon().getResId())
.isEqualTo(R.drawable.media3_icon_circular_play);
}
private static void assertHasNotificationChannel(
List<Object> notificationChannels, String channelId, String channelName) {
boolean found = false;
for (int i = 0; i < notificationChannels.size(); i++) {
NotificationChannel notificationChannel = (NotificationChannel) notificationChannels.get(i);
found =
notificationChannel.getId().equals(channelId)
// NotificationChannel.getName() is CharSequence. Use String#contentEquals instead
// because CharSequence.equals() has undefined behavior.
&& channelName.contentEquals(notificationChannel.getName());
if (found) {
break;
}
}
assertThat(found).isTrue();
}
private static MediaSession createMockMediaSessionForNotification() {
Player mockPlayer = mock(Player.class);
when(mockPlayer.getAvailableCommands()).thenReturn(Commands.EMPTY);
when(mockPlayer.getMediaMetadata()).thenReturn(MediaMetadata.EMPTY);
MediaSession mockMediaSession = mock(MediaSession.class);
when(mockMediaSession.getPlayer()).thenReturn(mockPlayer);
MediaSessionImpl mockMediaSessionImpl = mock(MediaSessionImpl.class);
when(mockMediaSession.getImpl()).thenReturn(mockMediaSessionImpl);
when(mockMediaSessionImpl.getUri()).thenReturn(Uri.parse("http://example.com"));
return mockMediaSession;
}
/** A test service for unit tests. */
private static final class TestService extends MediaLibraryService {
@Nullable