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
This commit is contained in:
christosts 2022-02-16 12:10:37 +00:00 committed by Ian Baker
parent b8687a3111
commit b59869ede3
6 changed files with 477 additions and 10 deletions

View File

@ -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
}

View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="androidx.media3.session.test">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-sdk/>
<application
android:allowBackup="false"
tools:ignore="MissingApplicationIcon,HardcodedDebugMode"
android:usesCleartextTraffic="true"/>
<instrumentation
android:targetPackage="androidx.media3.session.test"
android:name="androidx.test.runner.AndroidJUnitRunner"/>
</manifest>

View File

@ -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}.
*
* <p>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<Bitmap> 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<Bitmap> 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);
}
}

View File

@ -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<Bitmap> decodeBitmap(byte[] data);
/** Loads an image from {@code uri}. */
ListenableFuture<Bitmap> loadBitmap(Uri uri);
}

View File

@ -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;
* </ul>
*/
@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<Bitmap> 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<Bitmap> loadArtworkBitmap(MediaMetadata metadata) {
if (lastLoadedBitmapInfo.matches(metadata.artworkData)
|| lastLoadedBitmapInfo.matches(metadata.artworkUri)) {
return Futures.immediateFuture(lastLoadedBitmapInfo.getBitmap());
}
ListenableFuture<Bitmap> future;
Consumer<Bitmap> 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<Bitmap> {
private final Consumer<Bitmap> consumer;
private boolean discarded;
private OnBitmapLoadedFutureCallback(Consumer<Bitmap> 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;
}
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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<ListeningExecutorService> 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<Bitmap> decodeBitmap(byte[] data) {
return executorService.submit(() -> decode(data));
}
@Override
public ListenableFuture<Bitmap> 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));
}
}
}