diff --git a/libraries/session/build.gradle b/libraries/session/build.gradle
index 1cfc78bcf6..dcfd31f616 100644
--- a/libraries/session/build.gradle
+++ b/libraries/session/build.gradle
@@ -16,14 +16,27 @@ apply from: "$gradle.ext.androidxMediaSettingsDir/common_library_config.gradle"
// TODO(b/178560255): Remove the "group" override after the "group" in build.gradle changed
group 'androidx.media3'
+android {
+ defaultConfig {
+ multiDexEnabled true
+ }
+ buildTypes {
+ debug {
+ testCoverageEnabled = true
+ }
+ }
+ sourceSets.androidTest.assets.srcDir '../test_data/src/test/assets/'
+}
dependencies {
api project(modulePrefix + 'lib-common')
compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion
implementation 'androidx.collection:collection:' + androidxCollectionVersion
implementation 'androidx.media:media:' + androidxMediaVersion
-
- testImplementation project(modulePrefix + 'test-utils')
+ androidTestImplementation 'com.squareup.okhttp3:mockwebserver:' + okhttpVersion
+ androidTestImplementation project(modulePrefix + 'test-utils')
+ androidTestImplementation project(modulePrefix + 'test-data')
+ androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
}
diff --git a/libraries/session/src/androidTest/AndroidManifest.xml b/libraries/session/src/androidTest/AndroidManifest.xml
new file mode 100644
index 0000000000..f96881ea89
--- /dev/null
+++ b/libraries/session/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libraries/session/src/androidTest/java/androidx/media3/session/SimpleBitmapLoaderTest.java b/libraries/session/src/androidTest/java/androidx/media3/session/SimpleBitmapLoaderTest.java
new file mode 100644
index 0000000000..d7ba1d979f
--- /dev/null
+++ b/libraries/session/src/androidTest/java/androidx/media3/session/SimpleBitmapLoaderTest.java
@@ -0,0 +1,141 @@
+/*
+ * 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.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 com.google.common.util.concurrent.MoreExecutors;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.util.concurrent.ExecutionException;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okio.Buffer;
+import org.junit.Test;
+import org.junit.function.ThrowingRunnable;
+import org.junit.runner.RunWith;
+
+/**
+ * Tests for {@link SimpleBitmapLoader}.
+ *
+ *
This test needs to run as an androidTest because robolectric's BitmapFactory is not fully
+ * functional.
+ */
+@RunWith(AndroidJUnit4.class)
+public class SimpleBitmapLoaderTest {
+
+ private static final String TEST_IMAGE_PATH = "media/jpeg/non-motion-photo-shortened.jpg";
+
+ @Test
+ public void loadData() throws Exception {
+ SimpleBitmapLoader bitmapLoader =
+ new SimpleBitmapLoader(MoreExecutors.newDirectExecutorService());
+ byte[] imageData =
+ TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TEST_IMAGE_PATH);
+
+ Bitmap bitmap = bitmapLoader.decodeBitmap(imageData).get();
+
+ assertThat(
+ bitmap.sameAs(
+ BitmapFactory.decodeByteArray(imageData, /* offset= */ 0, imageData.length)))
+ .isTrue();
+ }
+
+ @Test
+ public void loadData_withInvalidData_throwsException() {
+ SimpleBitmapLoader bitmapLoader =
+ new SimpleBitmapLoader(MoreExecutors.newDirectExecutorService());
+
+ ListenableFuture future = bitmapLoader.decodeBitmap(new byte[0]);
+
+ assertException(
+ future::get, IllegalArgumentException.class, /* messagePart= */ "Could not decode bitmap");
+ }
+
+ @Test
+ public void loadUri_loadsImage() throws Exception {
+ SimpleBitmapLoader bitmapLoader =
+ new SimpleBitmapLoader(MoreExecutors.newDirectExecutorService());
+ MockWebServer mockWebServer = new MockWebServer();
+ byte[] imageData =
+ TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TEST_IMAGE_PATH);
+ Buffer responseBody = new Buffer().write(imageData);
+ mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(responseBody));
+
+ Bitmap bitmap =
+ bitmapLoader.loadBitmap(Uri.parse(mockWebServer.url("test_path").toString())).get();
+
+ assertThat(
+ bitmap.sameAs(
+ BitmapFactory.decodeByteArray(imageData, /* offset= */ 0, imageData.length)))
+ .isTrue();
+ }
+
+ @Test
+ public void loadUri_serverError_throwsException() {
+ SimpleBitmapLoader bitmapLoader =
+ new SimpleBitmapLoader(MoreExecutors.newDirectExecutorService());
+ MockWebServer mockWebServer = new MockWebServer();
+ mockWebServer.enqueue(new MockResponse().setResponseCode(404));
+
+ ListenableFuture future =
+ bitmapLoader.loadBitmap(Uri.parse(mockWebServer.url("test_path").toString()));
+
+ assertException(future::get, IOException.class, /* messagePart= */ "Invalid response status");
+ }
+
+ @Test
+ public void loadUri_nonHttpUri_throwsException() {
+ SimpleBitmapLoader bitmapLoader =
+ new SimpleBitmapLoader(MoreExecutors.newDirectExecutorService());
+
+ assertException(
+ () -> bitmapLoader.loadBitmap(Uri.parse("/local/path")).get(),
+ MalformedURLException.class,
+ /* messagePart= */ "no protocol");
+ assertException(
+ () -> bitmapLoader.loadBitmap(Uri.parse("file://local/path")).get(),
+ UnsupportedOperationException.class,
+ /* messagePart= */ "Unsupported scheme");
+ assertException(
+ () -> bitmapLoader.loadBitmap(Uri.parse("asset://asset/path")).get(),
+ MalformedURLException.class,
+ /* messagePart= */ "unknown protocol");
+ assertException(
+ () -> bitmapLoader.loadBitmap(Uri.parse("raw://raw/path")).get(),
+ MalformedURLException.class,
+ /* messagePart= */ "unknown protocol");
+ assertException(
+ () -> bitmapLoader.loadBitmap(Uri.parse("data://data")).get(),
+ MalformedURLException.class,
+ /* messagePart= */ "unknown protocol");
+ }
+
+ private static void assertException(
+ ThrowingRunnable runnable, Class extends Exception> clazz, String messagePart) {
+ ExecutionException executionException = assertThrows(ExecutionException.class, runnable);
+ assertThat(executionException).hasCauseThat().isInstanceOf(clazz);
+ assertThat(executionException).hasMessageThat().contains(messagePart);
+ }
+}
diff --git a/libraries/session/src/main/java/androidx/media3/session/BitmapLoader.java b/libraries/session/src/main/java/androidx/media3/session/BitmapLoader.java
new file mode 100644
index 0000000000..2b88806943
--- /dev/null
+++ b/libraries/session/src/main/java/androidx/media3/session/BitmapLoader.java
@@ -0,0 +1,30 @@
+/*
+ * 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.graphics.Bitmap;
+import android.net.Uri;
+import androidx.media3.common.util.UnstableApi;
+import com.google.common.util.concurrent.ListenableFuture;
+
+/** Loads images. */
+@UnstableApi
+public interface BitmapLoader {
+ /** Decodes an image from compressed binary data. */
+ ListenableFuture decodeBitmap(byte[] data);
+ /** Loads an image from {@code uri}. */
+ ListenableFuture loadBitmap(Uri uri);
+}
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 f253433905..1b308d425a 100644
--- a/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java
+++ b/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java
@@ -16,19 +16,30 @@
package androidx.media3.session;
import static androidx.media3.common.util.Assertions.checkStateNotNull;
+import static androidx.media3.common.util.Util.castNonNull;
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.net.Uri;
import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.media3.common.MediaMetadata;
+import androidx.media3.common.util.Consumer;
+import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.util.Arrays;
+import java.util.concurrent.ExecutionException;
/**
* The default {@link MediaNotification.Provider}.
@@ -60,21 +71,37 @@ import androidx.media3.common.util.Util;
*
*/
@UnstableApi
-/* package */ final class DefaultMediaNotificationProvider implements MediaNotification.Provider {
-
+public final class DefaultMediaNotificationProvider implements MediaNotification.Provider {
+ 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 NotificationManager notificationManager;
+ private final BitmapLoader bitmapLoader;
+ // Cache the last loaded bitmap 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 LoadedBitmapInfo lastLoadedBitmapInfo;
+ private final Handler mainHandler;
- /** Creates an instance. */
+ private OnBitmapLoadedFutureCallback pendingOnBitmapLoadedFutureCallback;
+
+ /** 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());
notificationManager =
checkStateNotNull(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE));
+ pendingOnBitmapLoadedFutureCallback = new OnBitmapLoadedFutureCallback(bitmap -> {});
}
@Override
@@ -118,10 +145,28 @@ import androidx.media3.common.util.Util;
// 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);
+
+ @Nullable ListenableFuture bitmapFuture = loadArtworkBitmap(metadata);
+ if (bitmapFuture != null) {
+ if (bitmapFuture.isDone()) {
+ try {
+ builder.setLargeIcon(Futures.getDone(bitmapFuture));
+ } catch (ExecutionException e) {
+ Log.w(TAG, "Failed to load bitmap", e);
+ }
+ } else {
+ Futures.addCallback(
+ bitmapFuture,
+ new OnBitmapLoadedFutureCallback(
+ bitmap -> {
+ builder.setLargeIcon(bitmap);
+ onNotificationChangedCallback.onNotificationChanged(
+ new MediaNotification(NOTIFICATION_ID, builder.build()));
+ }),
+ // This callback must be executed on the next looper iteration, after this method has
+ // returned a media notification.
+ mainHandler::post);
+ }
}
androidx.media.app.NotificationCompat.MediaStyle mediaStyle =
@@ -162,6 +207,41 @@ import androidx.media3.common.util.Util;
notificationManager.createNotificationChannel(channel);
}
+ /**
+ * Requests from the bitmapLoader to load artwork or returns null if the metadata don't include
+ * artwork.
+ */
+ @Nullable
+ private ListenableFuture loadArtworkBitmap(MediaMetadata metadata) {
+ if (lastLoadedBitmapInfo.matches(metadata.artworkData)
+ || lastLoadedBitmapInfo.matches(metadata.artworkUri)) {
+ return Futures.immediateFuture(lastLoadedBitmapInfo.getBitmap());
+ }
+
+ ListenableFuture future;
+ Consumer onBitmapLoaded;
+ if (metadata.artworkData != null) {
+ future = bitmapLoader.decodeBitmap(metadata.artworkData);
+ onBitmapLoaded =
+ bitmap -> lastLoadedBitmapInfo.setBitmapInfo(castNonNull(metadata.artworkData), bitmap);
+ } else if (metadata.artworkUri != null) {
+ future = bitmapLoader.loadBitmap(metadata.artworkUri);
+ onBitmapLoaded =
+ bitmap -> lastLoadedBitmapInfo.setBitmapInfo(castNonNull(metadata.artworkUri), bitmap);
+ } else {
+ return null;
+ }
+
+ pendingOnBitmapLoadedFutureCallback.discardIfPending();
+ pendingOnBitmapLoadedFutureCallback = new OnBitmapLoadedFutureCallback(onBitmapLoaded);
+ Futures.addCallback(
+ future,
+ pendingOnBitmapLoadedFutureCallback,
+ // It's ok to run this immediately to update the last loaded bitmap.
+ runnable -> Util.postOrRun(mainHandler, runnable));
+ return future;
+ }
+
private static int getSmallIconResId(Context context) {
int appIcon = context.getApplicationInfo().icon;
if (appIcon != 0) {
@@ -170,4 +250,67 @@ import androidx.media3.common.util.Util;
return Util.SDK_INT >= 21 ? R.drawable.media_session_service_notification_ic_music_note : 0;
}
}
+
+ private static class OnBitmapLoadedFutureCallback implements FutureCallback {
+
+ private final Consumer consumer;
+
+ private boolean discarded;
+
+ private OnBitmapLoadedFutureCallback(Consumer consumer) {
+ this.consumer = consumer;
+ }
+
+ public void discardIfPending() {
+ discarded = true;
+ }
+
+ @Override
+ public void onSuccess(Bitmap result) {
+ if (!discarded) {
+ consumer.accept(result);
+ }
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ if (!discarded) {
+ Log.d(TAG, "Failed to load bitmap", t);
+ }
+ }
+ }
+
+ /**
+ * Caches the last loaded bitmap. The key to identify a bitmap is either 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 LoadedBitmapInfo {
+ @Nullable private byte[] data;
+ @Nullable private Uri uri;
+ @Nullable private Bitmap bitmap;
+
+ public boolean matches(@Nullable byte[] data) {
+ return this.data != null && data != null && Arrays.equals(this.data, data);
+ }
+
+ public boolean matches(@Nullable Uri uri) {
+ return this.uri != null && this.uri.equals(uri);
+ }
+
+ public Bitmap getBitmap() {
+ return checkStateNotNull(bitmap);
+ }
+
+ public void setBitmapInfo(byte[] data, Bitmap bitmap) {
+ this.data = data;
+ this.bitmap = bitmap;
+ this.uri = null;
+ }
+
+ public void setBitmapInfo(Uri uri, Bitmap bitmap) {
+ this.uri = uri;
+ this.bitmap = bitmap;
+ this.data = null;
+ }
+ }
}
diff --git a/libraries/session/src/main/java/androidx/media3/session/SimpleBitmapLoader.java b/libraries/session/src/main/java/androidx/media3/session/SimpleBitmapLoader.java
new file mode 100644
index 0000000000..e3ddd87e76
--- /dev/null
+++ b/libraries/session/src/main/java/androidx/media3/session/SimpleBitmapLoader.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
+ *
+ * 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.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import androidx.media3.common.util.UnstableApi;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.io.ByteStreams;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLConnection;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * A simple bitmap loader that delegates all tasks to an executor and supports fetching images from
+ * HTTP/HTTPS endpoints.
+ *
+ * Loading tasks are delegated to an {@link ExecutorService} (or {@link
+ * ListeningExecutorService}) defined during construction. If no executor service is defined, all
+ * tasks are delegated to a single-thread executor service that is shared between instances of this
+ * class.
+ *
+ *
The supported URI scheme is only HTTP/HTTPS and this class reads a resource only when the
+ * endpoint responds with an {@code HTTP 200} after sending the HTTP request.
+ */
+@UnstableApi
+public final class SimpleBitmapLoader implements BitmapLoader {
+
+ private static final Supplier DEFAULT_EXECUTOR_SERVICE =
+ Suppliers.memoize(
+ () -> MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()));
+
+ private final ListeningExecutorService executorService;
+
+ /**
+ * Creates an instance that delegates all load tasks to a single-thread executor service shared
+ * between instances.
+ */
+ public SimpleBitmapLoader() {
+ this(checkStateNotNull(DEFAULT_EXECUTOR_SERVICE.get()));
+ }
+
+ /** Creates an instance that delegates loading tasks to the {@code executorService}. */
+ public SimpleBitmapLoader(ExecutorService executorService) {
+ this.executorService = MoreExecutors.listeningDecorator(executorService);
+ }
+
+ @Override
+ public ListenableFuture decodeBitmap(byte[] data) {
+ return executorService.submit(() -> decode(data));
+ }
+
+ @Override
+ public ListenableFuture loadBitmap(Uri uri) {
+ return executorService.submit(() -> load(uri));
+ }
+
+ private static Bitmap decode(byte[] data) {
+ @Nullable Bitmap bitmap = BitmapFactory.decodeByteArray(data, /* offset= */ 0, data.length);
+ if (bitmap == null) {
+ throw new IllegalArgumentException("Could not decode bitmap");
+ }
+ return bitmap;
+ }
+
+ private static Bitmap load(Uri uri) throws IOException {
+ URLConnection connection = new URL(uri.toString()).openConnection();
+ if (!(connection instanceof HttpURLConnection)) {
+ throw new UnsupportedOperationException("Unsupported scheme: " + uri.getScheme());
+ }
+ HttpURLConnection httpConnection = (HttpURLConnection) connection;
+ httpConnection.connect();
+ int responseCode = httpConnection.getResponseCode();
+ if (responseCode != HttpURLConnection.HTTP_OK) {
+ throw new IOException("Invalid response status code: " + responseCode);
+ }
+ try (InputStream inputStream = httpConnection.getInputStream()) {
+ return decode(ByteStreams.toByteArray(inputStream));
+ }
+ }
+}