From 62a2d76d00a05afdca68b8410bae395c39fa37cd Mon Sep 17 00:00:00 2001 From: christosts Date: Wed, 20 Jul 2022 11:41:30 +0000 Subject: [PATCH] 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) --- RELEASENOTES.md | 6 + libraries/session/build.gradle | 1 + .../DefaultMediaNotificationProvider.java | 180 +++++++++++++++--- .../media3/session/MediaSessionService.java | 3 +- .../DefaultMediaNotificationProviderTest.java | 123 +++++++++++- 5 files changed, 280 insertions(+), 33 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 9d6aa87817..0a2d83b742 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -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) diff --git a/libraries/session/build.gradle b/libraries/session/build.gradle index 114a98bed6..ec89bc41df 100644 --- a/libraries/session/build.gradle +++ b/libraries/session/build.gradle @@ -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 diff --git a/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java b/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java index 0059a9d99a..4b588cfda7 100644 --- a/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java +++ b/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java @@ -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; *
  • {@code media3_notification_seek_to_previous} - The previous icon. *
  • {@code media3_notification_seek_to_next} - The next icon. *
  • {@code media3_notification_small_icon} - The {@link - * NotificationCompat.Builder#setSmallIcon(int) small icon}. + * NotificationCompat.Builder#setSmallIcon(int) small icon}. A different icon can be set with + * {@link #setSmallIcon(int)}. + * + * + *

    String resources

    + * + * String resources used can be overridden by resources with the same names defined the application. + * These are: + * + * */ @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); } diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java index 680c8834b9..c5ac3fbddc 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java @@ -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( diff --git a/libraries/session/src/test/java/androidx/media3/session/DefaultMediaNotificationProviderTest.java b/libraries/session/src/test/java/androidx/media3/session/DefaultMediaNotificationProviderTest.java index 4cca95092b..b6555098a7 100644 --- a/libraries/session/src/test/java/androidx/media3/session/DefaultMediaNotificationProviderTest.java +++ b/libraries/session/src/test/java/androidx/media3/session/DefaultMediaNotificationProviderTest.java @@ -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 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 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