From b59869ede3b2a7f403963ab397081476a53cdbb9 Mon Sep 17 00:00:00 2001 From: christosts Date: Wed, 16 Feb 2022 12:10:37 +0000 Subject: [PATCH] Create BitmapLoader Create BitmapLoader component for loading artwork images. Add the SimpleBitmapLoader which fetches images from HTTP/HTTPS endpoints. Integrate BitmapLoader in DefaultMediaNotificationProvider. PiperOrigin-RevId: 429010249 --- libraries/session/build.gradle | 17 +- .../src/androidTest/AndroidManifest.xml | 34 ++++ .../session/SimpleBitmapLoaderTest.java | 141 ++++++++++++++++ .../androidx/media3/session/BitmapLoader.java | 30 ++++ .../DefaultMediaNotificationProvider.java | 159 +++++++++++++++++- .../media3/session/SimpleBitmapLoader.java | 106 ++++++++++++ 6 files changed, 477 insertions(+), 10 deletions(-) create mode 100644 libraries/session/src/androidTest/AndroidManifest.xml create mode 100644 libraries/session/src/androidTest/java/androidx/media3/session/SimpleBitmapLoaderTest.java create mode 100644 libraries/session/src/main/java/androidx/media3/session/BitmapLoader.java create mode 100644 libraries/session/src/main/java/androidx/media3/session/SimpleBitmapLoader.java 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 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)); + } + } +}