diff --git a/libraries/datasource/src/androidTest/java/androidx/media3/datasource/DataSourceBitmapLoaderTest.java b/libraries/datasource/src/androidTest/java/androidx/media3/datasource/DataSourceBitmapLoaderTest.java
new file mode 100644
index 0000000000..c67a047088
--- /dev/null
+++ b/libraries/datasource/src/androidTest/java/androidx/media3/datasource/DataSourceBitmapLoaderTest.java
@@ -0,0 +1,243 @@
+/*
+ * 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.datasource;
+
+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.common.MediaMetadata;
+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.File;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.concurrent.ExecutionException;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okio.Buffer;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.function.ThrowingRunnable;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+
+/**
+ * Tests for {@link DataSourceBitmapLoader}.
+ *
+ *
This test needs to run as an androidTest because robolectric's BitmapFactory is not fully
+ * functional.
+ */
+@RunWith(AndroidJUnit4.class)
+public class DataSourceBitmapLoaderTest {
+
+ @Rule public final TemporaryFolder tempFolder = new TemporaryFolder();
+
+ private static final String TEST_IMAGE_PATH = "media/jpeg/non-motion-photo-shortened.jpg";
+
+ private DataSource.Factory dataSourceFactory;
+
+ @Before
+ public void setUp() {
+ dataSourceFactory = new DefaultDataSource.Factory(ApplicationProvider.getApplicationContext());
+ }
+
+ @Test
+ public void decodeBitmap_withValidData_loadsCorrectData() throws Exception {
+ DataSourceBitmapLoader bitmapLoader =
+ new DataSourceBitmapLoader(MoreExecutors.newDirectExecutorService(), dataSourceFactory);
+ 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 decodeBitmap_withInvalidData_throws() {
+ DataSourceBitmapLoader bitmapLoader =
+ new DataSourceBitmapLoader(MoreExecutors.newDirectExecutorService(), dataSourceFactory);
+
+ ListenableFuture future = bitmapLoader.decodeBitmap(new byte[0]);
+
+ assertException(
+ future::get,
+ IllegalArgumentException.class,
+ /* messagePart= */ "Could not decode image data");
+ }
+
+ @Test
+ public void loadBitmap_withHttpUri_loadsCorrectData() throws Exception {
+ DataSourceBitmapLoader bitmapLoader =
+ new DataSourceBitmapLoader(MoreExecutors.newDirectExecutorService(), dataSourceFactory);
+ 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 loadBitmap_httpUriAndServerError_throws() {
+ DataSourceBitmapLoader bitmapLoader =
+ new DataSourceBitmapLoader(MoreExecutors.newDirectExecutorService(), dataSourceFactory);
+ MockWebServer mockWebServer = new MockWebServer();
+ mockWebServer.enqueue(new MockResponse().setResponseCode(404));
+
+ ListenableFuture future =
+ bitmapLoader.loadBitmap(Uri.parse(mockWebServer.url("test_path").toString()));
+
+ assertException(
+ future::get, HttpDataSource.InvalidResponseCodeException.class, /* messagePart= */ "404");
+ }
+
+ @Test
+ public void loadBitmap_assetUri_loadsCorrectData() throws Exception {
+ DataSourceBitmapLoader bitmapLoader =
+ new DataSourceBitmapLoader(MoreExecutors.newDirectExecutorService(), dataSourceFactory);
+ byte[] imageData =
+ TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TEST_IMAGE_PATH);
+
+ Bitmap bitmap = bitmapLoader.loadBitmap(Uri.parse("asset:///" + TEST_IMAGE_PATH)).get();
+
+ assertThat(
+ bitmap.sameAs(
+ BitmapFactory.decodeByteArray(imageData, /* offset= */ 0, imageData.length)))
+ .isTrue();
+ }
+
+ @Test
+ public void loadBitmap_assetUriWithAssetNotExisting_throws() throws Exception {
+ DataSourceBitmapLoader bitmapLoader =
+ new DataSourceBitmapLoader(MoreExecutors.newDirectExecutorService(), dataSourceFactory);
+
+ assertException(
+ () -> bitmapLoader.loadBitmap(Uri.parse("asset:///not_valid/path/image.bmp")).get(),
+ AssetDataSource.AssetDataSourceException.class,
+ /* messagePart= */ "");
+ }
+
+ @Test
+ public void loadBitmap_withFileUri_loadsCorrectData() throws Exception {
+ byte[] imageData =
+ TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TEST_IMAGE_PATH);
+ File file = tempFolder.newFile();
+ Files.write(Paths.get(file.getAbsolutePath()), imageData);
+ Uri uri = Uri.fromFile(file);
+ DataSourceBitmapLoader bitmapLoader =
+ new DataSourceBitmapLoader(MoreExecutors.newDirectExecutorService(), dataSourceFactory);
+
+ Bitmap bitmap = bitmapLoader.loadBitmap(uri).get();
+
+ assertThat(
+ bitmap.sameAs(
+ BitmapFactory.decodeByteArray(imageData, /* offset= */ 0, imageData.length)))
+ .isTrue();
+ }
+
+ @Test
+ public void loadBitmap_fileUriWithFileNotExisting_throws() throws Exception {
+ DataSourceBitmapLoader bitmapLoader =
+ new DataSourceBitmapLoader(MoreExecutors.newDirectExecutorService(), dataSourceFactory);
+
+ assertException(
+ () -> bitmapLoader.loadBitmap(Uri.parse("file:///not_valid/path/image.bmp")).get(),
+ FileDataSource.FileDataSourceException.class,
+ /* messagePart= */ "No such file or directory");
+ }
+
+ @Test
+ public void loadBitmapFromMetadata_withArtworkDataAndArtworkUriSet_decodeFromArtworkData()
+ throws Exception {
+ byte[] imageData =
+ TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TEST_IMAGE_PATH);
+ MockWebServer mockWebServer = new MockWebServer();
+ Uri uri = Uri.parse(mockWebServer.url("test_path").toString());
+ MediaMetadata metadata =
+ new MediaMetadata.Builder()
+ .setArtworkData(imageData, MediaMetadata.PICTURE_TYPE_FRONT_COVER)
+ .setArtworkUri(uri)
+ .build();
+ DataSourceBitmapLoader bitmapLoader =
+ new DataSourceBitmapLoader(MoreExecutors.newDirectExecutorService(), dataSourceFactory);
+
+ Bitmap bitmap = bitmapLoader.loadBitmapFromMetadata(metadata).get();
+
+ assertThat(
+ bitmap.sameAs(
+ BitmapFactory.decodeByteArray(imageData, /* offset= */ 0, imageData.length)))
+ .isTrue();
+ assertThat(mockWebServer.getRequestCount()).isEqualTo(0);
+ }
+
+ @Test
+ public void loadBitmapFromMetadata_withArtworkUriSet_loadFromArtworkUri() throws Exception {
+ byte[] imageData =
+ TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TEST_IMAGE_PATH);
+ MockWebServer mockWebServer = new MockWebServer();
+ Buffer responseBody = new Buffer().write(imageData);
+ mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(responseBody));
+ Uri uri = Uri.parse(mockWebServer.url("test_path").toString());
+ MediaMetadata metadata = new MediaMetadata.Builder().setArtworkUri(uri).build();
+ DataSourceBitmapLoader bitmapLoader =
+ new DataSourceBitmapLoader(MoreExecutors.newDirectExecutorService(), dataSourceFactory);
+
+ Bitmap bitmap = bitmapLoader.loadBitmapFromMetadata(metadata).get();
+
+ assertThat(
+ bitmap.sameAs(
+ BitmapFactory.decodeByteArray(imageData, /* offset= */ 0, imageData.length)))
+ .isTrue();
+ assertThat(mockWebServer.getRequestCount()).isEqualTo(1);
+ }
+
+ @Test
+ public void loadBitmapFromMetadata_withArtworkDataAndArtworkUriUnset_returnNull()
+ throws Exception {
+ MediaMetadata metadata = new MediaMetadata.Builder().build();
+ DataSourceBitmapLoader bitmapLoader =
+ new DataSourceBitmapLoader(MoreExecutors.newDirectExecutorService(), dataSourceFactory);
+
+ ListenableFuture bitmapFuture = bitmapLoader.loadBitmapFromMetadata(metadata);
+
+ assertThat(bitmapFuture).isNull();
+ }
+
+ 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/datasource/src/main/java/androidx/media3/datasource/DataSourceBitmapLoader.java b/libraries/datasource/src/main/java/androidx/media3/datasource/DataSourceBitmapLoader.java
new file mode 100644
index 0000000000..9d0723a8b1
--- /dev/null
+++ b/libraries/datasource/src/main/java/androidx/media3/datasource/DataSourceBitmapLoader.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2023 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.datasource;
+
+import static androidx.media3.common.util.Assertions.checkArgument;
+import static androidx.media3.common.util.Assertions.checkStateNotNull;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import androidx.media3.common.util.BitmapLoader;
+import androidx.media3.common.util.UnstableApi;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+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.util.concurrent.Executors;
+
+/**
+ * A {@link BitmapLoader} implementation that uses a {@link DataSource} to support fetching images
+ * from URIs.
+ *
+ * Loading tasks are delegated to a {@link ListeningExecutorService} defined during construction.
+ * If no executor service is passed, all tasks are delegated to a single-thread executor service
+ * that is shared between instances of this class.
+ */
+@UnstableApi
+public final class DataSourceBitmapLoader implements BitmapLoader {
+
+ public static final Supplier DEFAULT_EXECUTOR_SERVICE =
+ Suppliers.memoize(
+ () -> MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()));
+
+ private final ListeningExecutorService listeningExecutorService;
+ private final DataSource.Factory dataSourceFactory;
+
+ /**
+ * Creates an instance that uses a {@link DefaultHttpDataSource} for image loading and delegates
+ * loading tasks to a {@link Executors#newSingleThreadExecutor()}.
+ */
+ public DataSourceBitmapLoader(Context context) {
+ this(checkStateNotNull(DEFAULT_EXECUTOR_SERVICE.get()), new DefaultDataSource.Factory(context));
+ }
+
+ /**
+ * Creates an instance that delegates loading tasks to the {@link ListeningExecutorService}.
+ *
+ * @param listeningExecutorService The {@link ListeningExecutorService}.
+ * @param dataSourceFactory The {@link DataSource.Factory} that creates the {@link DataSource}
+ * used to load the image.
+ */
+ public DataSourceBitmapLoader(
+ ListeningExecutorService listeningExecutorService, DataSource.Factory dataSourceFactory) {
+ this.listeningExecutorService = listeningExecutorService;
+ this.dataSourceFactory = dataSourceFactory;
+ }
+
+ @Override
+ public ListenableFuture decodeBitmap(byte[] data) {
+ return listeningExecutorService.submit(() -> decode(data));
+ }
+
+ /** Loads an image from a {@link Uri}. */
+ @Override
+ public ListenableFuture loadBitmap(Uri uri) {
+ return listeningExecutorService.submit(() -> load(dataSourceFactory.createDataSource(), uri));
+ }
+
+ private static Bitmap decode(byte[] data) {
+ @Nullable Bitmap bitmap = BitmapFactory.decodeByteArray(data, /* offset= */ 0, data.length);
+ checkArgument(bitmap != null, "Could not decode image data");
+ return bitmap;
+ }
+
+ private static Bitmap load(DataSource dataSource, Uri uri) throws IOException {
+ DataSpec dataSpec = new DataSpec(uri);
+ dataSource.open(dataSpec);
+ byte[] readData = DataSourceUtil.readToEnd(dataSource);
+ return decode(readData);
+ }
+}