diff --git a/libraries/session/src/androidTest/java/androidx/media3/session/CacheBitmapLoaderTest.java b/libraries/session/src/androidTest/java/androidx/media3/session/CacheBitmapLoaderTest.java
new file mode 100644
index 0000000000..d2f8847519
--- /dev/null
+++ b/libraries/session/src/androidTest/java/androidx/media3/session/CacheBitmapLoaderTest.java
@@ -0,0 +1,207 @@
+/*
+ * 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
+ *
+ * https://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 java.util.concurrent.TimeUnit.SECONDS;
+import static org.junit.Assert.assertThrows;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+import androidx.media3.test.utils.TestUtil;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.io.IOException;
+import java.util.concurrent.ExecutionException;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okio.Buffer;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+
+/**
+ * Tests for {@link CacheBitmapLoader}.
+ *
+ *
This test needs to run as an androidTest because robolectric's {@link BitmapFactory} is not
+ * fully functional.
+ */
+@RunWith(AndroidJUnit4.class)
+public class CacheBitmapLoaderTest {
+
+ private static final String TEST_IMAGE_PATH = "media/jpeg/non-motion-photo-shortened.jpg";
+
+ private static final String SECOND_TEST_IMAGE_PATH = "media/jpeg/ss-motion-photo-shortened.jpg";
+
+ @Rule public final TemporaryFolder tempFolder = new TemporaryFolder();
+
+ @Test
+ public void decodeBitmap_requestWithSameDataTwice_success() throws Exception {
+ CacheBitmapLoader cacheBitmapLoader = new CacheBitmapLoader(new SimpleBitmapLoader());
+ byte[] imageData =
+ TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TEST_IMAGE_PATH);
+
+ // First request, no cached bitmap load request.
+ ListenableFuture future1 = cacheBitmapLoader.decodeBitmap(imageData);
+
+ assertThat(
+ future1
+ .get(10, SECONDS)
+ .sameAs(
+ BitmapFactory.decodeByteArray(imageData, /* offset= */ 0, imageData.length)))
+ .isTrue();
+
+ // Second request, has cached bitmap load request.
+ ListenableFuture future2 = cacheBitmapLoader.decodeBitmap(imageData);
+
+ assertThat(future1).isSameInstanceAs(future2);
+ }
+
+ @Test
+ public void decodeBitmap_requestWithDifferentData_success() throws Exception {
+ CacheBitmapLoader cacheBitmapLoader = new CacheBitmapLoader(new SimpleBitmapLoader());
+ byte[] imageData1 =
+ TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TEST_IMAGE_PATH);
+ byte[] imageData2 =
+ TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), SECOND_TEST_IMAGE_PATH);
+
+ // First request.
+ ListenableFuture future1 = cacheBitmapLoader.decodeBitmap(imageData1);
+
+ assertThat(
+ future1
+ .get(10, SECONDS)
+ .sameAs(
+ BitmapFactory.decodeByteArray(imageData1, /* offset= */ 0, imageData1.length)))
+ .isTrue();
+
+ // Second request.
+ ListenableFuture future2 = cacheBitmapLoader.decodeBitmap(imageData2);
+
+ assertThat(
+ future2
+ .get(10, SECONDS)
+ .sameAs(
+ BitmapFactory.decodeByteArray(imageData2, /* offset= */ 0, imageData2.length)))
+ .isTrue();
+ assertThat(future1).isNotSameInstanceAs(future2);
+ }
+
+ @Test
+ public void decodeBitmap_requestWithSameDataTwice_throwsException() {
+ CacheBitmapLoader cacheBitmapLoader = new CacheBitmapLoader(new SimpleBitmapLoader());
+
+ // First request, no cached bitmap load request.
+ ListenableFuture future1 = cacheBitmapLoader.decodeBitmap(new byte[0]);
+
+ // Second request, has cached bitmap load request.
+ ListenableFuture future2 = cacheBitmapLoader.decodeBitmap(new byte[0]);
+
+ assertThat(future1).isSameInstanceAs(future2);
+ ExecutionException executionException =
+ assertThrows(ExecutionException.class, () -> future1.get(10, SECONDS));
+ assertThat(executionException).hasCauseThat().isInstanceOf(IllegalArgumentException.class);
+ assertThat(executionException).hasMessageThat().contains("Could not decode image data");
+ }
+
+ @Test
+ public void loadBitmap_httpUri_requestWithSameUriTwice_success() throws Exception {
+ CacheBitmapLoader cacheBitmapLoader = new CacheBitmapLoader(new SimpleBitmapLoader());
+ byte[] imageData =
+ TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TEST_IMAGE_PATH);
+ Buffer responseBody = new Buffer().write(imageData);
+ MockWebServer mockWebServer = new MockWebServer();
+ mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(responseBody));
+ Uri uri = Uri.parse(mockWebServer.url("test_path").toString());
+
+ // First request, no cached bitmap load request.
+ Bitmap bitmap = cacheBitmapLoader.loadBitmap(uri).get(10, SECONDS);
+
+ assertThat(
+ bitmap.sameAs(
+ BitmapFactory.decodeByteArray(imageData, /* offset= */ 0, imageData.length)))
+ .isTrue();
+
+ // Second request, has cached bitmap load request.
+ bitmap = cacheBitmapLoader.loadBitmap(uri).get(10, SECONDS);
+
+ assertThat(
+ bitmap.sameAs(
+ BitmapFactory.decodeByteArray(imageData, /* offset= */ 0, imageData.length)))
+ .isTrue();
+ assertThat(mockWebServer.getRequestCount()).isEqualTo(1);
+ }
+
+ @Test
+ public void loadBitmap_httpUri_requestWithDifferentUri_success() throws Exception {
+ CacheBitmapLoader cacheBitmapLoader = new CacheBitmapLoader(new SimpleBitmapLoader());
+ byte[] imageData1 =
+ TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TEST_IMAGE_PATH);
+ byte[] imageData2 =
+ TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), SECOND_TEST_IMAGE_PATH);
+ Buffer responseBody1 = new Buffer().write(imageData1);
+ Buffer responseBody2 = new Buffer().write(imageData2);
+ MockWebServer mockWebServer = new MockWebServer();
+ mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(responseBody1));
+ mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(responseBody2));
+ Uri uri1 = Uri.parse(mockWebServer.url("test_path_1").toString());
+ Uri uri2 = Uri.parse(mockWebServer.url("test_path_2").toString());
+
+ // First request.
+ Bitmap bitmap = cacheBitmapLoader.loadBitmap(uri1).get(10, SECONDS);
+
+ assertThat(
+ bitmap.sameAs(
+ BitmapFactory.decodeByteArray(imageData1, /* offset= */ 0, imageData1.length)))
+ .isTrue();
+
+ // Second request.
+ bitmap = cacheBitmapLoader.loadBitmap(uri2).get(10, SECONDS);
+
+ assertThat(
+ bitmap.sameAs(
+ BitmapFactory.decodeByteArray(imageData2, /* offset= */ 0, imageData2.length)))
+ .isTrue();
+ assertThat(mockWebServer.getRequestCount()).isEqualTo(2);
+ }
+
+ @Test
+ public void loadBitmap_httpUri_requestWithSameUriTwice_throwsException() throws Exception {
+ CacheBitmapLoader cacheBitmapLoader = new CacheBitmapLoader(new SimpleBitmapLoader());
+ MockWebServer mockWebServer = new MockWebServer();
+ mockWebServer.enqueue(new MockResponse().setResponseCode(404));
+ Uri uri = Uri.parse(mockWebServer.url("test_path").toString());
+
+ // First request, no cached bitmap load request.
+ ListenableFuture future1 = cacheBitmapLoader.loadBitmap(uri);
+
+ // Second request, has cached bitmap load request.
+ ListenableFuture future2 = cacheBitmapLoader.loadBitmap(uri);
+
+ ExecutionException executionException1 =
+ assertThrows(ExecutionException.class, () -> future1.get(10, SECONDS));
+ ExecutionException executionException2 =
+ assertThrows(ExecutionException.class, () -> future2.get(10, SECONDS));
+ assertThat(executionException1).hasCauseThat().isInstanceOf(IOException.class);
+ assertThat(executionException2).hasCauseThat().isInstanceOf(IOException.class);
+ assertThat(executionException1).hasMessageThat().contains("Invalid response status");
+ assertThat(executionException2).hasMessageThat().contains("Invalid response status");
+ assertThat(mockWebServer.getRequestCount()).isEqualTo(1);
+ }
+}
diff --git a/libraries/session/src/main/java/androidx/media3/session/CacheBitmapLoader.java b/libraries/session/src/main/java/androidx/media3/session/CacheBitmapLoader.java
new file mode 100644
index 0000000000..5c99bd1256
--- /dev/null
+++ b/libraries/session/src/main/java/androidx/media3/session/CacheBitmapLoader.java
@@ -0,0 +1,106 @@
+/*
+ * 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
+ *
+ * https://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.graphics.Bitmap;
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import androidx.media3.common.util.UnstableApi;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.util.Arrays;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+
+/**
+ * A {@link BitmapLoader} that caches the result of the last {@link #decodeBitmap(byte[])} or {@link
+ * #loadBitmap(Uri)} request. Requests are fulfilled from the last bitmap load request when the last
+ * bitmap is requested from the same {@code data} or the last bitmap is requested from the same
+ * {@code uri}. If it's not the above two cases, the request is forwarded to the provided {@link
+ * BitmapLoader} and the result is cached.
+ */
+@UnstableApi
+public final class CacheBitmapLoader implements BitmapLoader {
+
+ private final BitmapLoader bitmapLoader;
+
+ private @MonotonicNonNull BitmapLoadRequest lastBitmapLoadRequest;
+
+ /**
+ * Creates an instance that is able to cache the last bitmap load request to the given bitmap
+ * loader.
+ */
+ public CacheBitmapLoader(BitmapLoader bitmapLoader) {
+ this.bitmapLoader = bitmapLoader;
+ }
+
+ @Override
+ public ListenableFuture decodeBitmap(byte[] data) {
+ if (lastBitmapLoadRequest != null && lastBitmapLoadRequest.matches(data)) {
+ return lastBitmapLoadRequest.getFuture();
+ }
+ ListenableFuture future = bitmapLoader.decodeBitmap(data);
+ lastBitmapLoadRequest = new BitmapLoadRequest(data, future);
+ return future;
+ }
+
+ @Override
+ public ListenableFuture loadBitmap(Uri uri) {
+ if (lastBitmapLoadRequest != null && lastBitmapLoadRequest.matches(uri)) {
+ return lastBitmapLoadRequest.getFuture();
+ }
+ ListenableFuture future = bitmapLoader.loadBitmap(uri);
+ lastBitmapLoadRequest = new BitmapLoadRequest(uri, future);
+ return future;
+ }
+
+ /**
+ * Stores the result of a bitmap load request. Requests are identified either by a byte array, if
+ * the bitmap is loaded from compressed data, or a URI, if the bitmap was loaded from a URI.
+ */
+ private static class BitmapLoadRequest {
+ @Nullable private final byte[] data;
+ @Nullable private final Uri uri;
+ @Nullable private final ListenableFuture future;
+
+ public BitmapLoadRequest(byte[] data, ListenableFuture future) {
+ this.data = data;
+ this.uri = null;
+ this.future = future;
+ }
+
+ public BitmapLoadRequest(Uri uri, ListenableFuture future) {
+ this.data = null;
+ this.uri = uri;
+ this.future = future;
+ }
+
+ /** Whether the bitmap load request was performed for {@code data}. */
+ public boolean matches(@Nullable byte[] data) {
+ return this.data != null && Arrays.equals(this.data, data);
+ }
+
+ /** Whether the bitmap load request was performed for {@code uri}. */
+ public boolean matches(@Nullable Uri uri) {
+ return this.uri != null && this.uri.equals(uri);
+ }
+
+ /** Returns the future that set for the bitmap load request. */
+ public ListenableFuture getFuture() {
+ return checkStateNotNull(future);
+ }
+ }
+}
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 29639baff4..21d61d6e30 100644
--- a/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java
+++ b/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java
@@ -32,7 +32,6 @@ import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import android.graphics.Bitmap;
-import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
@@ -196,15 +195,15 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
}
/**
- * Sets the {@link BitmapLoader} used load artwork. By default, a {@link SimpleBitmapLoader}
- * will be used.
+ * Sets the {@link BitmapLoader} used load artwork. By default, a {@link CacheBitmapLoader} with
+ * a {@link SimpleBitmapLoader} inside will be used.
*
* @param bitmapLoader The bitmap loader.
* @return This builder.
*/
@CanIgnoreReturnValue
public Builder setBitmapLoader(BitmapLoader bitmapLoader) {
- this.bitmapLoader = bitmapLoader;
+ this.bitmapLoader = new CacheBitmapLoader(bitmapLoader);
return this;
}
@@ -261,7 +260,6 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
private final BitmapLoader bitmapLoader;
// Cache the last bitmap load request to avoid reloading the bitmap again, particularly useful
// when showing a notification for the same item (e.g. when switching from playing to paused).
- private final BitmapLoadRequest lastBitmapLoadRequest;
private final Handler mainHandler;
private @MonotonicNonNull OnBitmapLoadedFutureCallback pendingOnBitmapLoadedFutureCallback;
@@ -272,11 +270,10 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
this.notificationIdProvider = builder.notificationIdProvider;
this.channelId = builder.channelId;
this.channelNameResourceId = builder.channelNameResourceId;
- this.bitmapLoader = builder.bitmapLoader;
+ this.bitmapLoader = new CacheBitmapLoader(builder.bitmapLoader);
notificationManager =
checkStateNotNull(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE));
- lastBitmapLoadRequest = new BitmapLoadRequest();
mainHandler = new Handler(Looper.getMainLooper());
smallIconResourceId = R.drawable.media3_notification_small_icon;
}
@@ -590,15 +587,10 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
@Nullable
private ListenableFuture loadArtworkBitmap(MediaMetadata metadata) {
@Nullable ListenableFuture future;
- if (lastBitmapLoadRequest.matches(metadata.artworkData)
- || lastBitmapLoadRequest.matches(metadata.artworkUri)) {
- future = lastBitmapLoadRequest.getFuture();
- } else if (metadata.artworkData != null) {
+ if (metadata.artworkData != null) {
future = bitmapLoader.decodeBitmap(metadata.artworkData);
- lastBitmapLoadRequest.setBitmapFuture(metadata.artworkData, future);
} else if (metadata.artworkUri != null) {
future = bitmapLoader.loadBitmap(metadata.artworkUri);
- lastBitmapLoadRequest.setBitmapFuture(metadata.artworkUri, future);
} else {
future = null;
}
@@ -654,54 +646,4 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
}
}
}
-
- /**
- * Stores the result of a bitmap load request. Requests are identified either by a byte array, if
- * the bitmap is loaded from compressed data, or a URI, if the bitmap was loaded from a URI.
- */
- private static class BitmapLoadRequest {
- @Nullable private byte[] data;
- @Nullable private Uri uri;
- @Nullable private ListenableFuture bitmapFuture;
-
- /** Whether the bitmap load request was performed for {@code data}. */
- public boolean matches(@Nullable byte[] data) {
- return this.data != null && data != null && Arrays.equals(this.data, data);
- }
-
- /** Whether the bitmap load request was performed for {@code uri}. */
- public boolean matches(@Nullable Uri uri) {
- return this.uri != null && this.uri.equals(uri);
- }
-
- /**
- * Returns the future that set for the bitmap load request.
- *
- * @see #setBitmapFuture(Uri, ListenableFuture)
- * @see #setBitmapFuture(byte[], ListenableFuture)
- */
- public ListenableFuture getFuture() {
- return checkStateNotNull(bitmapFuture);
- }
-
- /**
- * Sets the future result of requesting to {@linkplain BitmapLoader#decodeBitmap(byte[]) decode}
- * a bitmap from {@code data}.
- */
- public void setBitmapFuture(byte[] data, ListenableFuture bitmapFuture) {
- this.data = data;
- this.bitmapFuture = bitmapFuture;
- this.uri = null;
- }
-
- /**
- * Sets the future result of requesting {@linkplain BitmapLoader#loadBitmap(Uri) load} a bitmap
- * from {@code uri}.
- */
- public void setBitmapFuture(Uri uri, ListenableFuture bitmapFuture) {
- this.uri = uri;
- this.bitmapFuture = bitmapFuture;
- this.data = null;
- }
- }
}
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 7a1d8f4211..23b15cb7fe 100644
--- a/libraries/session/src/test/java/androidx/media3/session/DefaultMediaNotificationProviderTest.java
+++ b/libraries/session/src/test/java/androidx/media3/session/DefaultMediaNotificationProviderTest.java
@@ -399,11 +399,12 @@ public class DefaultMediaNotificationProviderTest {
}
/**
- * Tests that the {@link DefaultMediaNotificationProvider} will not request to load the same
- * artwork bitmap again, if the same bitmap has been requested already.
+ * Tests that the {@link DefaultMediaNotificationProvider} will discard the pending {@link
+ * MediaNotification.Provider.Callback#onNotificationChanged(MediaNotification)}, if there is a
+ * new request.
*/
@Test
- public void requestsSameBitmap_withPendingRequest_oneRequestOnly() {
+ public void createNotification_withNewRequest_discardPendingCallback() {
// We will advance the main looper manually in the test.
shadowOf(Looper.getMainLooper()).pause();
// Create a MediaSession whose player returns non-null media metadata so that the
@@ -434,7 +435,6 @@ public class DefaultMediaNotificationProviderTest {
defaultActionFactory,
mockOnNotificationChangedCallback1);
ShadowLooper.idleMainLooper();
- verify(mockBitmapLoader).loadBitmap(Uri.parse("http://example.test/image.jpg"));
verifyNoInteractions(mockOnNotificationChangedCallback1);
MediaNotification.Provider.Callback mockOnNotificationChangedCallback2 =
mock(MediaNotification.Provider.Callback.class);
@@ -446,8 +446,6 @@ public class DefaultMediaNotificationProviderTest {
// The bitmap has arrived.
bitmapFuture.set(Bitmap.createBitmap(/* width= */ 8, /* height= */ 8, Bitmap.Config.RGB_565));
ShadowLooper.idleMainLooper();
-
- verifyNoMoreInteractions(mockBitmapLoader);
verify(mockOnNotificationChangedCallback2).onNotificationChanged(any());
verifyNoInteractions(mockOnNotificationChangedCallback1);
}