diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 1db04963df..bdbc9193bc 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -63,6 +63,8 @@ values. * Metadata: * Image: + * Add `ExternallyLoadedImageDecoder` for simplified integration with + external image loading libraries like Glide or Coil. * DataSource: * Add `FileDescriptorDataSource`, a new `DataSource` that can be used to read from a `FileDescriptor` diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java index 266ed047d8..5894061c9d 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java @@ -623,7 +623,7 @@ public class DefaultRenderersFactory implements RenderersFactory { * @param out An array to which the built renderers should be appended. */ protected void buildImageRenderers(ArrayList out) { - out.add(new ImageRenderer(ImageDecoder.Factory.DEFAULT, /* imageOutput= */ null)); + out.add(new ImageRenderer(getImageDecoderFactory(), /* imageOutput= */ null)); } /** @@ -669,4 +669,9 @@ public class DefaultRenderersFactory implements RenderersFactory { protected MediaCodecAdapter.Factory getCodecAdapterFactory() { return codecAdapterFactory; } + + /** Returns the {@link ImageDecoder.Factory} used to build the image renderer. */ + protected ImageDecoder.Factory getImageDecoderFactory() { + return ImageDecoder.Factory.DEFAULT; + } } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/image/ExternallyLoadedImageDecoder.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/image/ExternallyLoadedImageDecoder.java new file mode 100644 index 0000000000..9b7d2e43bf --- /dev/null +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/image/ExternallyLoadedImageDecoder.java @@ -0,0 +1,204 @@ +/* + * Copyright 2024 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.exoplayer.image; + +import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.common.util.Assertions.checkState; + +import android.graphics.Bitmap; +import android.net.Uri; +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.common.Format; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.decoder.DecoderInputBuffer; +import androidx.media3.exoplayer.RendererCapabilities; +import com.google.common.base.Charsets; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import java.nio.ByteBuffer; +import java.util.Objects; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; + +/** + * An {@link ImageDecoder} for externally loaded images. + * + * @see MimeTypes#APPLICATION_EXTERNALLY_LOADED_IMAGE + */ +@UnstableApi +public final class ExternallyLoadedImageDecoder implements ImageDecoder { + + /** A data class providing information about the external image request. */ + public static final class ExternalImageRequest { + + /** The {@link Uri} for the external image. */ + public final Uri uri; + + /** + * Creates an instance. + * + * @param uri The {@link Uri} for the external image. + */ + public ExternalImageRequest(Uri uri) { + this.uri = uri; + } + } + + /** The resolver that resolves an external image request to a Bitmap. */ + public interface BitmapResolver { + + /** + * Returns a {@link ListenableFuture} for the Bitmap referenced by the given {@link + * ExternalImageRequest}. + * + * @param request The {@link ExternalImageRequest}. + * @return A {@link ListenableFuture} returning the {@link Bitmap} for the request. + */ + ListenableFuture resolve(ExternalImageRequest request); + } + + /** A {@link ImageDecoder.Factory} for {@link ExternallyLoadedImageDecoder} instances. */ + public static final class Factory implements ImageDecoder.Factory { + + private final BitmapResolver bitmapResolver; + + /** + * Creates the factory. + * + * @param bitmapResolver The {@link BitmapResolver} to resolve the {@link ExternalImageRequest} + * to a {@link Bitmap}. + */ + public Factory(BitmapResolver bitmapResolver) { + this.bitmapResolver = bitmapResolver; + } + + @Override + public @RendererCapabilities.Capabilities int supportsFormat(Format format) { + boolean isExternallyLoadedImage = + Objects.equals(format.sampleMimeType, MimeTypes.APPLICATION_EXTERNALLY_LOADED_IMAGE); + return RendererCapabilities.create( + isExternallyLoadedImage + ? C.FORMAT_HANDLED + : MimeTypes.isImage(format.sampleMimeType) + ? C.FORMAT_UNSUPPORTED_SUBTYPE + : C.FORMAT_UNSUPPORTED_TYPE); + } + + @Override + public ExternallyLoadedImageDecoder createImageDecoder() { + return new ExternallyLoadedImageDecoder(bitmapResolver); + } + } + + private final BitmapResolver bitmapResolver; + private final DecoderInputBuffer inputBuffer; + private final ImageOutputBuffer outputBuffer; + + @Nullable private ListenableFuture pendingDecode; + private long pendingDecodeTimeUs; + private boolean pendingEndOfStream; + + private ExternallyLoadedImageDecoder(BitmapResolver bitmapResolver) { + this.bitmapResolver = bitmapResolver; + this.inputBuffer = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); + this.outputBuffer = + new ImageOutputBuffer() { + @Override + public void release() { + clear(); + } + }; + } + + @Override + public String getName() { + return "externallyLoadedImageDecoder"; + } + + @Override + public void setOutputStartTimeUs(long outputStartTimeUs) { + // Intentionally unused to render images that start earlier than the intended start time. + } + + @Nullable + @Override + public DecoderInputBuffer dequeueInputBuffer() { + return pendingDecode == null ? inputBuffer : null; + } + + @Override + public void queueInputBuffer(DecoderInputBuffer inputBuffer) { + if (inputBuffer.isEndOfStream()) { + pendingEndOfStream = true; + inputBuffer.clear(); + return; + } + ByteBuffer inputData = checkNotNull(inputBuffer.data); + checkState(inputData.hasArray()); + Uri imageUri = + Uri.parse( + new String( + inputData.array(), inputData.arrayOffset(), inputData.remaining(), Charsets.UTF_8)); + pendingDecode = bitmapResolver.resolve(new ExternalImageRequest(imageUri)); + pendingDecodeTimeUs = inputBuffer.timeUs; + inputBuffer.clear(); + } + + @Nullable + @Override + public ImageOutputBuffer dequeueOutputBuffer() throws ImageDecoderException { + if (pendingEndOfStream) { + outputBuffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM); + pendingEndOfStream = false; + return outputBuffer; + } + if (pendingDecode == null || !pendingDecode.isDone()) { + return null; + } + try { + outputBuffer.bitmap = Futures.getDone(pendingDecode); + outputBuffer.timeUs = pendingDecodeTimeUs; + return outputBuffer; + } catch (ExecutionException e) { + throw new ImageDecoderException(e.getCause()); + } catch (CancellationException e) { + throw new ImageDecoderException(e); + } finally { + pendingDecode = null; + } + } + + @Override + public void flush() { + resetState(); + } + + @Override + public void release() { + resetState(); + } + + private void resetState() { + if (pendingDecode != null) { + pendingDecode.cancel(/* mayInterruptIfRunning= */ false); + pendingDecode = null; + } + pendingEndOfStream = false; + outputBuffer.release(); + } +} diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/image/ImageOutputBuffer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/image/ImageOutputBuffer.java index 52931bf0b6..37c4dacee2 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/image/ImageOutputBuffer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/image/ImageOutputBuffer.java @@ -16,13 +16,22 @@ package androidx.media3.exoplayer.image; import android.graphics.Bitmap; +import androidx.annotation.CallSuper; import androidx.annotation.Nullable; import androidx.media3.common.util.UnstableApi; import androidx.media3.decoder.DecoderOutputBuffer; -/** Output buffer for {@link ImageDecoder}s. */ +/** Output buffer for {@link ImageDecoder} instances. */ @UnstableApi public abstract class ImageOutputBuffer extends DecoderOutputBuffer { + /** The decoded {@link Bitmap}. */ @Nullable public Bitmap bitmap; + + @Override + @CallSuper + public void clear() { + bitmap = null; + super.clear(); + } } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/image/ImageRenderer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/image/ImageRenderer.java index 344eb6d2da..afe6a1acda 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/image/ImageRenderer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/image/ImageRenderer.java @@ -458,7 +458,7 @@ public class ImageRenderer extends BaseRenderer { // Input buffers with no data that are also non-EOS, only carry the timestamp for a grid // tile. These buffers are not queued. boolean shouldQueueBuffer = - checkStateNotNull(inputBuffer.data).remaining() > 0 + (inputBuffer.data != null && inputBuffer.data.remaining() > 0) || checkStateNotNull(inputBuffer).isEndOfStream(); if (shouldQueueBuffer) { checkStateNotNull(decoder).queueInputBuffer(checkStateNotNull(inputBuffer)); diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/ExternallyLoadedImagePlaybackTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/ExternallyLoadedImagePlaybackTest.java index dd04be12af..da55f85540 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/ExternallyLoadedImagePlaybackTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/ExternallyLoadedImagePlaybackTest.java @@ -24,9 +24,9 @@ import android.graphics.BitmapFactory; import android.net.Uri; import androidx.annotation.Nullable; import androidx.media3.common.C; -import androidx.media3.common.Format; import androidx.media3.common.MediaItem; import androidx.media3.common.MimeTypes; +import androidx.media3.common.PlaybackException; import androidx.media3.common.Player; import androidx.media3.common.util.Clock; import androidx.media3.common.util.ConditionVariable; @@ -35,9 +35,7 @@ import androidx.media3.datasource.DataSourceUtil; import androidx.media3.datasource.DataSpec; import androidx.media3.exoplayer.ExoPlaybackException; import androidx.media3.exoplayer.ExoPlayer; -import androidx.media3.exoplayer.RendererCapabilities; -import androidx.media3.exoplayer.image.BitmapFactoryImageDecoder; -import androidx.media3.exoplayer.image.ImageDecoder; +import androidx.media3.exoplayer.image.ExternallyLoadedImageDecoder; import androidx.media3.exoplayer.image.ImageDecoderException; import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; import androidx.media3.exoplayer.source.MediaSource; @@ -48,12 +46,11 @@ import androidx.media3.test.utils.robolectric.PlaybackOutput; import androidx.media3.test.utils.robolectric.TestPlayerRunHelper; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.common.base.Charsets; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; import java.io.IOException; +import java.util.ArrayList; import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicInteger; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.annotation.GraphicsMode; @@ -69,18 +66,20 @@ public final class ExternallyLoadedImagePlaybackTest { public void imagePlayback_validExternalLoader_callsLoadOnceAndPlaysSuccessfully() throws Exception { Context applicationContext = ApplicationProvider.getApplicationContext(); - CapturingRenderersFactory renderersFactory = - new CapturingRenderersFactory(applicationContext) - .setImageDecoderFactory(new CustomImageDecoderFactory()); - Clock clock = new FakeClock(/* isAutoAdvancing= */ true); - AtomicInteger externalLoaderCallCount = new AtomicInteger(); ListeningExecutorService listeningExecutorService = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()); + CapturingRenderersFactory renderersFactory = + new CapturingRenderersFactory(applicationContext) + .setImageDecoderFactory( + new ExternallyLoadedImageDecoder.Factory( + request -> listeningExecutorService.submit(() -> decode(request.uri)))); + Clock clock = new FakeClock(/* isAutoAdvancing= */ true); + ArrayList externalLoaderUris = new ArrayList<>(); MediaSource.Factory mediaSourceFactory = new DefaultMediaSourceFactory(applicationContext) .setExternalImageLoader( - unused -> - listeningExecutorService.submit(externalLoaderCallCount::getAndIncrement)); + request -> + listeningExecutorService.submit(() -> externalLoaderUris.add(request.uri))); ExoPlayer player = new ExoPlayer.Builder(applicationContext, renderersFactory) .setClock(clock) @@ -88,9 +87,10 @@ public final class ExternallyLoadedImagePlaybackTest { .build(); PlaybackOutput playbackOutput = PlaybackOutput.register(player, renderersFactory); long durationMs = 5 * C.MILLIS_PER_SECOND; + Uri uri = Uri.parse("asset:///media/" + INPUT_FILE); player.setMediaItem( new MediaItem.Builder() - .setUri("asset:///media/" + INPUT_FILE) + .setUri(uri) .setImageDurationMs(durationMs) .setMimeType(MimeTypes.APPLICATION_EXTERNALLY_LOADED_IMAGE) .build()); @@ -103,7 +103,7 @@ public final class ExternallyLoadedImagePlaybackTest { long playbackDurationMs = clock.elapsedRealtime() - playerStartedMs; player.release(); - assertThat(externalLoaderCallCount.get()).isEqualTo(1); + assertThat(externalLoaderUris).containsExactly(uri); assertThat(playbackDurationMs).isAtLeast(durationMs); DumpFileAsserts.assertOutput( applicationContext, playbackOutput, "playbackdumps/" + INPUT_FILE + ".dump"); @@ -112,18 +112,21 @@ public final class ExternallyLoadedImagePlaybackTest { @Test public void imagePlayback_externalLoaderFutureFails_propagatesFailure() throws Exception { Context applicationContext = ApplicationProvider.getApplicationContext(); - CapturingRenderersFactory renderersFactory = - new CapturingRenderersFactory(applicationContext) - .setImageDecoderFactory(new CustomImageDecoderFactory()); ListeningExecutorService listeningExecutorService = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()); + CapturingRenderersFactory renderersFactory = + new CapturingRenderersFactory(applicationContext) + .setImageDecoderFactory( + new ExternallyLoadedImageDecoder.Factory( + request -> listeningExecutorService.submit(() -> decode(request.uri)))); + RuntimeException exception = new RuntimeException("My Exception"); MediaSource.Factory mediaSourceFactory = new DefaultMediaSourceFactory(applicationContext) .setExternalImageLoader( unused -> listeningExecutorService.submit( () -> { - throw new RuntimeException("My Exception"); + throw exception; })); ExoPlayer player = new ExoPlayer.Builder(applicationContext, renderersFactory) @@ -139,17 +142,21 @@ public final class ExternallyLoadedImagePlaybackTest { player.prepare(); ExoPlaybackException error = TestPlayerRunHelper.runUntilError(player); - assertThat(error).isNotNull(); + + assertThat(error.errorCode).isEqualTo(PlaybackException.ERROR_CODE_IO_UNSPECIFIED); + assertThat(error.getSourceException()).hasCauseThat().isEqualTo(exception); } @Test public void imagePlayback_loadingCompletedWhenFutureCompletes() throws Exception { Context applicationContext = ApplicationProvider.getApplicationContext(); - CapturingRenderersFactory renderersFactory = - new CapturingRenderersFactory(applicationContext) - .setImageDecoderFactory(new CustomImageDecoderFactory()); ListeningExecutorService listeningExecutorService = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()); + CapturingRenderersFactory renderersFactory = + new CapturingRenderersFactory(applicationContext) + .setImageDecoderFactory( + new ExternallyLoadedImageDecoder.Factory( + request -> listeningExecutorService.submit(() -> decode(request.uri)))); ConditionVariable loadingComplete = new ConditionVariable(); MediaSource.Factory mediaSourceFactory = new DefaultMediaSourceFactory(applicationContext) @@ -177,27 +184,10 @@ public final class ExternallyLoadedImagePlaybackTest { TestPlayerRunHelper.runUntilIsLoading(player, /* expectedIsLoading= */ false); } - private static final class CustomImageDecoderFactory implements ImageDecoder.Factory { - - @Override - public @RendererCapabilities.Capabilities int supportsFormat(Format format) { - return format.sampleMimeType.equals(MimeTypes.APPLICATION_EXTERNALLY_LOADED_IMAGE) - ? RendererCapabilities.create(C.FORMAT_HANDLED) - : RendererCapabilities.create(C.FORMAT_UNSUPPORTED_TYPE); - } - - @Override - public ImageDecoder createImageDecoder() { - return new BitmapFactoryImageDecoder.Factory(ExternallyLoadedImagePlaybackTest::decode) - .createImageDecoder(); - } - } - - private static Bitmap decode(byte[] data, int length) throws ImageDecoderException { - String uriString = new String(data, Charsets.UTF_8); + private static Bitmap decode(Uri uri) throws ImageDecoderException { AssetDataSource assetDataSource = new AssetDataSource(ApplicationProvider.getApplicationContext()); - DataSpec dataSpec = new DataSpec(Uri.parse(uriString)); + DataSpec dataSpec = new DataSpec(uri); @Nullable Bitmap bitmap; try { @@ -209,8 +199,7 @@ public final class ExternallyLoadedImagePlaybackTest { } if (bitmap == null) { throw new ImageDecoderException( - "Could not decode image data with BitmapFactory. uriString decoded from data = " - + uriString); + "Could not decode image data with BitmapFactory. uriString decoded from data = " + uri); } return bitmap; } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/image/ExternallyLoadedImageDecoderTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/image/ExternallyLoadedImageDecoderTest.java new file mode 100644 index 0000000000..28a6485d2e --- /dev/null +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/image/ExternallyLoadedImageDecoderTest.java @@ -0,0 +1,343 @@ +/* + * Copyright 2024 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.exoplayer.image; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.util.concurrent.Futures.immediateCancelledFuture; +import static com.google.common.util.concurrent.Futures.immediateFuture; +import static org.junit.Assert.assertThrows; + +import android.graphics.Bitmap; +import android.net.Uri; +import androidx.media3.common.C; +import androidx.media3.common.Format; +import androidx.media3.common.MimeTypes; +import androidx.media3.decoder.DecoderInputBuffer; +import androidx.media3.exoplayer.RendererCapabilities; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.base.Charsets; +import com.google.common.util.concurrent.SettableFuture; +import java.util.concurrent.CancellationException; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link ExternallyLoadedImageDecoder}. */ +@RunWith(AndroidJUnit4.class) +public class ExternallyLoadedImageDecoderTest { + + @Test + public void factorySupportsFormat_externallyLoadedImage_returnsFormatSupported() { + ExternallyLoadedImageDecoder.Factory factory = + new ExternallyLoadedImageDecoder.Factory(request -> immediateCancelledFuture()); + Format format = + new Format.Builder() + .setSampleMimeType(MimeTypes.APPLICATION_EXTERNALLY_LOADED_IMAGE) + .build(); + + assertThat(factory.supportsFormat(format)) + .isEqualTo(RendererCapabilities.create(C.FORMAT_HANDLED)); + } + + @Test + public void factorySupportsFormat_noSampleMimeType_returnsUnsupportedType() { + ExternallyLoadedImageDecoder.Factory factory = + new ExternallyLoadedImageDecoder.Factory(request -> immediateCancelledFuture()); + Format format = new Format.Builder().build(); + + assertThat(factory.supportsFormat(format)) + .isEqualTo(RendererCapabilities.create(C.FORMAT_UNSUPPORTED_TYPE)); + } + + @Test + public void factorySupportsFormat_nonImageMimeType_returnsUnsupportedType() { + ExternallyLoadedImageDecoder.Factory factory = + new ExternallyLoadedImageDecoder.Factory(request -> immediateCancelledFuture()); + Format format = new Format.Builder().setSampleMimeType(MimeTypes.VIDEO_AV1).build(); + + assertThat(factory.supportsFormat(format)) + .isEqualTo(RendererCapabilities.create(C.FORMAT_UNSUPPORTED_TYPE)); + } + + @Test + public void factorySupportsFormat_unsupportedImageMimeType_returnsUnsupportedSubType() { + ExternallyLoadedImageDecoder.Factory factory = + new ExternallyLoadedImageDecoder.Factory(request -> immediateCancelledFuture()); + Format format = new Format.Builder().setSampleMimeType("image/custom").build(); + + assertThat(factory.supportsFormat(format)) + .isEqualTo(RendererCapabilities.create(C.FORMAT_UNSUPPORTED_SUBTYPE)); + } + + @Test + public void decoding_withMultipleBuffersAndEndOfStream_producesExpectedOutput() throws Exception { + Uri uri1 = Uri.parse("https://image1_longer_name_than_image2.test"); + Uri uri2 = Uri.parse("https://image2.test"); + byte[] uri1Bytes = uri1.toString().getBytes(Charsets.UTF_8); + byte[] uri2Bytes = uri2.toString().getBytes(Charsets.UTF_8); + Bitmap bitmap1 = Bitmap.createBitmap(/* width= */ 5, /* height= */ 5, Bitmap.Config.ARGB_8888); + Bitmap bitmap2 = Bitmap.createBitmap(/* width= */ 7, /* height= */ 7, Bitmap.Config.ARGB_8888); + ExternallyLoadedImageDecoder decoder = + new ExternallyLoadedImageDecoder.Factory( + request -> immediateFuture(request.uri.equals(uri1) ? bitmap1 : bitmap2)) + .createImageDecoder(); + + DecoderInputBuffer inputBuffer1 = decoder.dequeueInputBuffer(); + inputBuffer1.timeUs = 555; + inputBuffer1.ensureSpaceForWrite(uri1Bytes.length); + inputBuffer1.data.put(uri1Bytes); + inputBuffer1.data.flip(); + decoder.queueInputBuffer(inputBuffer1); + ImageOutputBuffer outputBuffer1 = decoder.dequeueOutputBuffer(); + + assertThat(outputBuffer1.timeUs).isEqualTo(555); + assertThat(outputBuffer1.isEndOfStream()).isFalse(); + assertThat(outputBuffer1.bitmap).isEqualTo(bitmap1); + + outputBuffer1.release(); + DecoderInputBuffer inputBuffer2 = decoder.dequeueInputBuffer(); + inputBuffer2.timeUs = 777; + inputBuffer2.ensureSpaceForWrite(uri2Bytes.length); + inputBuffer2.data.put(uri2Bytes); + inputBuffer2.data.flip(); + decoder.queueInputBuffer(inputBuffer2); + ImageOutputBuffer outputBuffer2 = decoder.dequeueOutputBuffer(); + + assertThat(outputBuffer2.timeUs).isEqualTo(777); + assertThat(outputBuffer2.isEndOfStream()).isFalse(); + assertThat(outputBuffer2.bitmap).isEqualTo(bitmap2); + + outputBuffer2.release(); + DecoderInputBuffer inputBufferEos = decoder.dequeueInputBuffer(); + inputBufferEos.setFlags(C.BUFFER_FLAG_END_OF_STREAM); + decoder.queueInputBuffer(inputBufferEos); + ImageOutputBuffer outputBufferEos = decoder.dequeueOutputBuffer(); + + assertThat(outputBufferEos.isEndOfStream()).isTrue(); + } + + @Test + public void dequeueOutputBuffer_withDelayedBitmap_onlyReturnsOutputWhenReady() throws Exception { + Uri uri = Uri.parse("https://image.test"); + byte[] uriBytes = uri.toString().getBytes(Charsets.UTF_8); + Bitmap bitmap = Bitmap.createBitmap(/* width= */ 5, /* height= */ 5, Bitmap.Config.ARGB_8888); + SettableFuture settableFuture = SettableFuture.create(); + ExternallyLoadedImageDecoder decoder = + new ExternallyLoadedImageDecoder.Factory(request -> settableFuture).createImageDecoder(); + DecoderInputBuffer inputBuffer = decoder.dequeueInputBuffer(); + inputBuffer.timeUs = 555; + inputBuffer.ensureSpaceForWrite(uriBytes.length); + inputBuffer.data.put(uriBytes); + inputBuffer.data.flip(); + decoder.queueInputBuffer(inputBuffer); + + assertThat(decoder.dequeueOutputBuffer()).isNull(); + settableFuture.set(bitmap); + assertThat(decoder.dequeueOutputBuffer()).isNotNull(); + } + + @Test + public void dequeueOutputBuffer_withFailedFuture_throwsExceptionWithOriginalCause() + throws Exception { + Uri uri = Uri.parse("https://image.test"); + byte[] uriBytes = uri.toString().getBytes(Charsets.UTF_8); + Throwable testThrowable = new Throwable(); + SettableFuture settableFuture = SettableFuture.create(); + ExternallyLoadedImageDecoder decoder = + new ExternallyLoadedImageDecoder.Factory(request -> settableFuture).createImageDecoder(); + DecoderInputBuffer inputBuffer = decoder.dequeueInputBuffer(); + inputBuffer.timeUs = 555; + inputBuffer.ensureSpaceForWrite(uriBytes.length); + inputBuffer.data.put(uriBytes); + inputBuffer.data.flip(); + decoder.queueInputBuffer(inputBuffer); + + assertThat(decoder.dequeueOutputBuffer()).isNull(); + settableFuture.setException(testThrowable); + ImageDecoderException exception = + assertThrows(ImageDecoderException.class, decoder::dequeueOutputBuffer); + assertThat(exception).hasCauseThat().isEqualTo(testThrowable); + } + + @Test + public void + dequeueOutputBuffer_withCancelledFuture_throwsExceptionWithCancellationExceptionCause() + throws Exception { + Uri uri = Uri.parse("https://image.test"); + byte[] uriBytes = uri.toString().getBytes(Charsets.UTF_8); + SettableFuture settableFuture = SettableFuture.create(); + ExternallyLoadedImageDecoder decoder = + new ExternallyLoadedImageDecoder.Factory(request -> settableFuture).createImageDecoder(); + DecoderInputBuffer inputBuffer = decoder.dequeueInputBuffer(); + inputBuffer.timeUs = 555; + inputBuffer.ensureSpaceForWrite(uriBytes.length); + inputBuffer.data.put(uriBytes); + inputBuffer.data.flip(); + decoder.queueInputBuffer(inputBuffer); + + assertThat(decoder.dequeueOutputBuffer()).isNull(); + settableFuture.cancel(/* mayInterruptIfRunning= */ false); + ImageDecoderException exception = + assertThrows(ImageDecoderException.class, decoder::dequeueOutputBuffer); + assertThat(exception).hasCauseThat().isInstanceOf(CancellationException.class); + } + + @Test + public void flush_beforeFirstBuffer_allowsToQueueNextBuffer() throws Exception { + Uri uri = Uri.parse("https://image.test"); + byte[] uriBytes = uri.toString().getBytes(Charsets.UTF_8); + Bitmap bitmap = Bitmap.createBitmap(/* width= */ 5, /* height= */ 5, Bitmap.Config.ARGB_8888); + ExternallyLoadedImageDecoder decoder = + new ExternallyLoadedImageDecoder.Factory(request -> immediateFuture(bitmap)) + .createImageDecoder(); + + decoder.flush(); + DecoderInputBuffer inputBuffer = decoder.dequeueInputBuffer(); + inputBuffer.timeUs = 555; + inputBuffer.ensureSpaceForWrite(uriBytes.length); + inputBuffer.data.put(uriBytes); + inputBuffer.data.flip(); + decoder.queueInputBuffer(inputBuffer); + ImageOutputBuffer outputBuffer = decoder.dequeueOutputBuffer(); + + assertThat(outputBuffer.timeUs).isEqualTo(555); + assertThat(outputBuffer.isEndOfStream()).isFalse(); + assertThat(outputBuffer.bitmap).isEqualTo(bitmap); + } + + @Test + public void flush_duringDecoding_cancelsPendingDecodeAndAllowsToQueueNextBuffer() + throws Exception { + Uri uri1 = Uri.parse("https://image1.test"); + Uri uri2 = Uri.parse("https://image2.test"); + byte[] uri1Bytes = uri1.toString().getBytes(Charsets.UTF_8); + byte[] uri2Bytes = uri2.toString().getBytes(Charsets.UTF_8); + Bitmap bitmap2 = Bitmap.createBitmap(/* width= */ 7, /* height= */ 7, Bitmap.Config.ARGB_8888); + SettableFuture settableFuture = SettableFuture.create(); + ExternallyLoadedImageDecoder decoder = + new ExternallyLoadedImageDecoder.Factory( + request -> request.uri.equals(uri1) ? settableFuture : immediateFuture(bitmap2)) + .createImageDecoder(); + DecoderInputBuffer inputBuffer = decoder.dequeueInputBuffer(); + inputBuffer.timeUs = 111; + inputBuffer.ensureSpaceForWrite(uri1Bytes.length); + inputBuffer.data.put(uri1Bytes); + inputBuffer.data.flip(); + decoder.queueInputBuffer(inputBuffer); + + decoder.flush(); + DecoderInputBuffer newInputBuffer = decoder.dequeueInputBuffer(); + newInputBuffer.timeUs = 555; + newInputBuffer.ensureSpaceForWrite(uri2Bytes.length); + newInputBuffer.data.put(uri2Bytes); + newInputBuffer.data.flip(); + decoder.queueInputBuffer(newInputBuffer); + ImageOutputBuffer newOutputBuffer = decoder.dequeueOutputBuffer(); + + assertThat(settableFuture.isCancelled()).isTrue(); + assertThat(newOutputBuffer.timeUs).isEqualTo(555); + assertThat(newOutputBuffer.isEndOfStream()).isFalse(); + assertThat(newOutputBuffer.bitmap).isEqualTo(bitmap2); + } + + @Test + public void flush_afterDecoding_allowsToQueueNextBuffer() throws Exception { + Uri uri1 = Uri.parse("https://image1.test"); + Uri uri2 = Uri.parse("https://image2.test"); + byte[] uri1Bytes = uri1.toString().getBytes(Charsets.UTF_8); + byte[] uri2Bytes = uri2.toString().getBytes(Charsets.UTF_8); + Bitmap bitmap1 = Bitmap.createBitmap(/* width= */ 5, /* height= */ 5, Bitmap.Config.ARGB_8888); + Bitmap bitmap2 = Bitmap.createBitmap(/* width= */ 7, /* height= */ 7, Bitmap.Config.ARGB_8888); + ExternallyLoadedImageDecoder decoder = + new ExternallyLoadedImageDecoder.Factory( + request -> immediateFuture(request.uri.equals(uri1) ? bitmap1 : bitmap2)) + .createImageDecoder(); + DecoderInputBuffer inputBuffer = decoder.dequeueInputBuffer(); + inputBuffer.timeUs = 111; + inputBuffer.ensureSpaceForWrite(uri1Bytes.length); + inputBuffer.data.put(uri1Bytes); + inputBuffer.data.flip(); + decoder.queueInputBuffer(inputBuffer); + ImageOutputBuffer outputBuffer = decoder.dequeueOutputBuffer(); + outputBuffer.release(); + + decoder.flush(); + DecoderInputBuffer newInputBuffer = decoder.dequeueInputBuffer(); + newInputBuffer.timeUs = 555; + newInputBuffer.ensureSpaceForWrite(uri2Bytes.length); + newInputBuffer.data.put(uri2Bytes); + newInputBuffer.data.flip(); + decoder.queueInputBuffer(newInputBuffer); + ImageOutputBuffer newOutputBuffer = decoder.dequeueOutputBuffer(); + + assertThat(newOutputBuffer.timeUs).isEqualTo(555); + assertThat(newOutputBuffer.isEndOfStream()).isFalse(); + assertThat(newOutputBuffer.bitmap).isEqualTo(bitmap2); + } + + @Test + public void flush_duringEndOfStreamSample_allowsToQueueNextBuffer() throws Exception { + Uri uri = Uri.parse("https://image1.test"); + byte[] uriBytes = uri.toString().getBytes(Charsets.UTF_8); + Bitmap bitmap = Bitmap.createBitmap(/* width= */ 5, /* height= */ 5, Bitmap.Config.ARGB_8888); + ExternallyLoadedImageDecoder decoder = + new ExternallyLoadedImageDecoder.Factory(request -> immediateFuture(bitmap)) + .createImageDecoder(); + DecoderInputBuffer inputBuffer = decoder.dequeueInputBuffer(); + inputBuffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); + decoder.queueInputBuffer(inputBuffer); + + decoder.flush(); + DecoderInputBuffer newInputBuffer = decoder.dequeueInputBuffer(); + newInputBuffer.timeUs = 555; + newInputBuffer.ensureSpaceForWrite(uriBytes.length); + newInputBuffer.data.put(uriBytes); + newInputBuffer.data.flip(); + decoder.queueInputBuffer(newInputBuffer); + ImageOutputBuffer newOutputBuffer = decoder.dequeueOutputBuffer(); + + assertThat(newOutputBuffer.timeUs).isEqualTo(555); + assertThat(newOutputBuffer.isEndOfStream()).isFalse(); + assertThat(newOutputBuffer.bitmap).isEqualTo(bitmap); + } + + @Test + public void flush_afterEndOfStreamSample_allowsToQueueNextBuffer() throws Exception { + Uri uri = Uri.parse("https://image1.test"); + byte[] uriBytes = uri.toString().getBytes(Charsets.UTF_8); + Bitmap bitmap = Bitmap.createBitmap(/* width= */ 5, /* height= */ 5, Bitmap.Config.ARGB_8888); + ExternallyLoadedImageDecoder decoder = + new ExternallyLoadedImageDecoder.Factory(request -> immediateFuture(bitmap)) + .createImageDecoder(); + DecoderInputBuffer inputBuffer = decoder.dequeueInputBuffer(); + inputBuffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); + decoder.queueInputBuffer(inputBuffer); + ImageOutputBuffer outputBuffer = decoder.dequeueOutputBuffer(); + outputBuffer.release(); + + decoder.flush(); + DecoderInputBuffer newInputBuffer = decoder.dequeueInputBuffer(); + newInputBuffer.timeUs = 555; + newInputBuffer.ensureSpaceForWrite(uriBytes.length); + newInputBuffer.data.put(uriBytes); + newInputBuffer.data.flip(); + decoder.queueInputBuffer(newInputBuffer); + ImageOutputBuffer newOutputBuffer = decoder.dequeueOutputBuffer(); + + assertThat(newOutputBuffer.timeUs).isEqualTo(555); + assertThat(newOutputBuffer.isEndOfStream()).isFalse(); + assertThat(newOutputBuffer.bitmap).isEqualTo(bitmap); + } +}