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 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); + } +}