From bf6e9c6144d879dcdf6345b1b4ea4c2998c30220 Mon Sep 17 00:00:00 2001 From: christosts Date: Tue, 17 Dec 2019 10:51:44 +0000 Subject: [PATCH] Add MultiLockAsyncMediaCodecAdapter MultiLockAsyncMediaCodecAdapter is an implementation of the MediaCodecAdapter that uses multiple locks to synchronize access to its data compared to the single-lock approach used in DedicatedThreadAsyncMediaCodecAdapter. PiperOrigin-RevId: 285944702 --- .../mediacodec/MediaCodecRenderer.java | 16 +- .../MultiLockAsynchMediaCodecAdapter.java | 344 ++++++++++++++ .../MultiLockAsyncMediaCodecAdapterTest.java | 437 ++++++++++++++++++ 3 files changed, 796 insertions(+), 1 deletion(-) create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MultiLockAsynchMediaCodecAdapter.java create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MultiLockAsyncMediaCodecAdapterTest.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index ae6b6304af..de975b1536 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -197,7 +197,8 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @IntDef({ MediaCodecOperationMode.SYNCHRONOUS, MediaCodecOperationMode.ASYNCHRONOUS_PLAYBACK_THREAD, - MediaCodecOperationMode.ASYNCHRONOUS_DEDICATED_THREAD + MediaCodecOperationMode.ASYNCHRONOUS_DEDICATED_THREAD, + MediaCodecOperationMode.ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK }) public @interface MediaCodecOperationMode { @@ -213,6 +214,11 @@ public abstract class MediaCodecRenderer extends BaseRenderer { * callbacks to a dedicated Thread. */ int ASYNCHRONOUS_DEDICATED_THREAD = 2; + /** + * Operates the {@link MediaCodec} in asynchronous mode and routes {@link MediaCodec.Callback} + * callbacks to a dedicated Thread. Uses granular locking for input and output buffers. + */ + int ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK = 3; } /** Indicates no codec operating rate should be set. */ @@ -481,6 +487,9 @@ public abstract class MediaCodecRenderer extends BaseRenderer { * routed to a dedicated Thread. This mode requires API level ≥ 23; if the API level * is ≤ 22, the operation mode will be set to {@link * MediaCodecOperationMode#SYNCHRONOUS}. + *
  • {@link MediaCodecOperationMode#ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK}: Same as + * {@link MediaCodecOperationMode#ASYNCHRONOUS_DEDICATED_THREAD} but it will internally + * use a finer grained locking mechanism for increased performance. * * By default, the operation mode is set to {@link MediaCodecOperationMode#SYNCHRONOUS}. */ @@ -984,6 +993,11 @@ public abstract class MediaCodecRenderer extends BaseRenderer { && Util.SDK_INT >= 23) { codecAdapter = new DedicatedThreadAsyncMediaCodecAdapter(codec, getTrackType()); ((DedicatedThreadAsyncMediaCodecAdapter) codecAdapter).start(); + } else if (mediaCodecOperationMode + == MediaCodecOperationMode.ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK + && Util.SDK_INT >= 23) { + codecAdapter = new MultiLockAsynchMediaCodecAdapter(codec, getTrackType()); + ((MultiLockAsynchMediaCodecAdapter) codecAdapter).start(); } else { codecAdapter = new SynchronousMediaCodecAdapter(codec, getDequeueOutputBufferTimeoutUs()); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MultiLockAsynchMediaCodecAdapter.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MultiLockAsynchMediaCodecAdapter.java new file mode 100644 index 0000000000..2fa40545d5 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MultiLockAsynchMediaCodecAdapter.java @@ -0,0 +1,344 @@ +/* + * Copyright (C) 2019 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 com.google.android.exoplayer2.mediacodec; + +import android.media.MediaCodec; +import android.media.MediaFormat; +import android.os.Handler; +import android.os.HandlerThread; +import androidx.annotation.GuardedBy; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.VisibleForTesting; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.IntArrayQueue; +import com.google.android.exoplayer2.util.Util; +import java.util.ArrayDeque; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** + * A {@link MediaCodecAdapter} that operates the underlying {@link MediaCodec} in asynchronous mode + * and routes {@link MediaCodec.Callback} callbacks on a dedicated Thread that is managed + * internally. + * + *

    The main difference of this class compared to the {@link + * DedicatedThreadAsyncMediaCodecAdapter} is that its internal implementation applies finer-grained + * locking. The {@link DedicatedThreadAsyncMediaCodecAdapter} uses a single lock to synchronize + * access, whereas this class uses a different lock to access the available input and available + * output buffer indexes returned from the {@link MediaCodec}. This class assumes that the {@link + * MediaCodecAdapter} methods will be accessed by the Playback Thread and the {@link + * MediaCodec.Callback} methods will be accessed by the internal Thread. This class is + * NOT generally thread-safe in the sense that its public methods cannot be called + * by any thread. + * + *

    After creating an instance, you need to call {@link #start()} to start the internal Thread. + */ +@RequiresApi(23) +/* package */ final class MultiLockAsynchMediaCodecAdapter extends MediaCodec.Callback + implements MediaCodecAdapter { + + @IntDef({State.CREATED, State.STARTED, State.SHUT_DOWN}) + private @interface State { + int CREATED = 0; + int STARTED = 1; + int SHUT_DOWN = 2; + } + + private final MediaCodec codec; + private final Object inputBufferLock; + private final Object outputBufferLock; + private final Object objectStateLock; + + @GuardedBy("inputBufferLock") + private final IntArrayQueue availableInputBuffers; + + @GuardedBy("outputBufferLock") + private final IntArrayQueue availableOutputBuffers; + + @GuardedBy("outputBufferLock") + private final ArrayDeque bufferInfos; + + @GuardedBy("outputBufferLock") + private final ArrayDeque formats; + + @GuardedBy("objectStateLock") + @MonotonicNonNull + private MediaFormat currentFormat; + + @GuardedBy("objectStateLock") + private long pendingFlush; + + @GuardedBy("objectStateLock") + @Nullable + private IllegalStateException codecException; + + @GuardedBy("objectStateLock") + private @State int state; + + private final HandlerThread handlerThread; + @MonotonicNonNull private Handler handler; + private Runnable onCodecStart; + + /** Creates a new instance that wraps the specified {@link MediaCodec}. */ + /* package */ MultiLockAsynchMediaCodecAdapter(MediaCodec codec, int trackType) { + this(codec, new HandlerThread(createThreadLabel(trackType))); + } + + @VisibleForTesting + /* package */ MultiLockAsynchMediaCodecAdapter(MediaCodec codec, HandlerThread handlerThread) { + this.codec = codec; + inputBufferLock = new Object(); + outputBufferLock = new Object(); + objectStateLock = new Object(); + availableInputBuffers = new IntArrayQueue(); + availableOutputBuffers = new IntArrayQueue(); + bufferInfos = new ArrayDeque<>(); + formats = new ArrayDeque<>(); + codecException = null; + state = State.CREATED; + this.handlerThread = handlerThread; + onCodecStart = codec::start; + } + + /** + * Starts the operation of this instance. + * + *

    After a call to this method, make sure to call {@link #shutdown()} to terminate the internal + * Thread. You can only call this method once during the lifetime of an instance; calling this + * method again will throw an {@link IllegalStateException}. + * + * @throws IllegalStateException If this method has been called already. + */ + public void start() { + synchronized (objectStateLock) { + Assertions.checkState(state == State.CREATED); + + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()); + codec.setCallback(this, handler); + state = State.STARTED; + } + } + + @Override + public int dequeueInputBufferIndex() { + synchronized (objectStateLock) { + Assertions.checkState(state == State.STARTED); + + if (isFlushing()) { + return MediaCodec.INFO_TRY_AGAIN_LATER; + } else { + maybeThrowException(); + return dequeueAvailableInputBufferIndex(); + } + } + } + + @Override + public int dequeueOutputBufferIndex(MediaCodec.BufferInfo bufferInfo) { + synchronized (objectStateLock) { + Assertions.checkState(state == State.STARTED); + + if (isFlushing()) { + return MediaCodec.INFO_TRY_AGAIN_LATER; + } else { + maybeThrowException(); + return dequeueAvailableOutputBufferIndex(bufferInfo); + } + } + } + + @Override + public MediaFormat getOutputFormat() { + synchronized (objectStateLock) { + Assertions.checkState(state == State.STARTED); + + if (currentFormat == null) { + throw new IllegalStateException(); + } + + return currentFormat; + } + } + + @Override + public void flush() { + synchronized (objectStateLock) { + Assertions.checkState(state == State.STARTED); + + codec.flush(); + pendingFlush++; + Util.castNonNull(handler).post(this::onFlushComplete); + } + } + + @Override + public void shutdown() { + synchronized (objectStateLock) { + if (state == State.STARTED) { + handlerThread.quit(); + } + state = State.SHUT_DOWN; + } + } + + @VisibleForTesting + /* package */ void setOnCodecStart(Runnable onCodecStart) { + this.onCodecStart = onCodecStart; + } + + private int dequeueAvailableInputBufferIndex() { + synchronized (inputBufferLock) { + return availableInputBuffers.isEmpty() + ? MediaCodec.INFO_TRY_AGAIN_LATER + : availableInputBuffers.remove(); + } + } + + @GuardedBy("objectStateLock") + private int dequeueAvailableOutputBufferIndex(MediaCodec.BufferInfo bufferInfo) { + int bufferIndex; + synchronized (outputBufferLock) { + if (availableOutputBuffers.isEmpty()) { + bufferIndex = MediaCodec.INFO_TRY_AGAIN_LATER; + } else { + bufferIndex = availableOutputBuffers.remove(); + if (bufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + currentFormat = formats.remove(); + } else if (bufferIndex >= 0) { + MediaCodec.BufferInfo outBufferInfo = bufferInfos.remove(); + bufferInfo.set( + outBufferInfo.offset, + outBufferInfo.size, + outBufferInfo.presentationTimeUs, + outBufferInfo.flags); + } + } + } + return bufferIndex; + } + + @GuardedBy("objectStateLock") + private boolean isFlushing() { + return pendingFlush > 0; + } + + @GuardedBy("objectStateLock") + private void maybeThrowException() { + @Nullable IllegalStateException exception = codecException; + if (exception != null) { + codecException = null; + throw exception; + } + } + + // Called by the internal Thread. + + @Override + public void onInputBufferAvailable(@NonNull MediaCodec codec, int index) { + synchronized (inputBufferLock) { + availableInputBuffers.add(index); + } + } + + @Override + public void onOutputBufferAvailable( + @NonNull MediaCodec codec, int index, @NonNull MediaCodec.BufferInfo info) { + synchronized (outputBufferLock) { + availableOutputBuffers.add(index); + bufferInfos.add(info); + } + } + + @Override + public void onError(@NonNull MediaCodec codec, @NonNull MediaCodec.CodecException e) { + onMediaCodecError(e); + } + + @Override + public void onOutputFormatChanged(@NonNull MediaCodec codec, @NonNull MediaFormat format) { + synchronized (outputBufferLock) { + availableOutputBuffers.add(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); + formats.add(format); + } + } + + @VisibleForTesting + /* package */ void onMediaCodecError(IllegalStateException e) { + synchronized (objectStateLock) { + codecException = e; + } + } + + private void onFlushComplete() { + synchronized (objectStateLock) { + if (state == State.SHUT_DOWN) { + return; + } + + --pendingFlush; + if (pendingFlush > 0) { + // Another flush() has been called. + return; + } else if (pendingFlush < 0) { + // This should never happen. + codecException = new IllegalStateException(); + return; + } + + clearAvailableInput(); + clearAvailableOutput(); + codecException = null; + try { + onCodecStart.run(); + } catch (IllegalStateException e) { + codecException = e; + } catch (Exception e) { + codecException = new IllegalStateException(e); + } + } + } + + private void clearAvailableInput() { + synchronized (inputBufferLock) { + availableInputBuffers.clear(); + } + } + + private void clearAvailableOutput() { + synchronized (outputBufferLock) { + availableOutputBuffers.clear(); + bufferInfos.clear(); + formats.clear(); + } + } + + private static String createThreadLabel(int trackType) { + StringBuilder labelBuilder = new StringBuilder("MediaCodecAsyncAdapter:"); + if (trackType == C.TRACK_TYPE_AUDIO) { + labelBuilder.append("Audio"); + } else if (trackType == C.TRACK_TYPE_VIDEO) { + labelBuilder.append("Video"); + } else { + labelBuilder.append("Unknown(").append(trackType).append(")"); + } + return labelBuilder.toString(); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MultiLockAsyncMediaCodecAdapterTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MultiLockAsyncMediaCodecAdapterTest.java new file mode 100644 index 0000000000..815d6ab3da --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MultiLockAsyncMediaCodecAdapterTest.java @@ -0,0 +1,437 @@ +/* + * Copyright (C) 2019 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 com.google.android.exoplayer2.mediacodec; + +import static com.google.android.exoplayer2.mediacodec.MediaCodecTestUtils.areEqual; +import static com.google.android.exoplayer2.mediacodec.MediaCodecTestUtils.waitUntilAllEventsAreExecuted; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; +import static org.robolectric.Shadows.shadowOf; + +import android.media.MediaCodec; +import android.media.MediaFormat; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.io.IOException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.shadows.ShadowLooper; + +/** Unit tests for {@link MultiLockAsynchMediaCodecAdapter}. */ +@RunWith(AndroidJUnit4.class) +public class MultiLockAsyncMediaCodecAdapterTest { + private MultiLockAsynchMediaCodecAdapter adapter; + private MediaCodec codec; + private MediaCodec.BufferInfo bufferInfo = null; + private MediaCodecAsyncCallback mediaCodecAsyncCallbackSpy; + private TestHandlerThread handlerThread; + + @Before + public void setup() throws IOException { + codec = MediaCodec.createByCodecName("h264"); + handlerThread = new TestHandlerThread("TestHandlerThread"); + adapter = new MultiLockAsynchMediaCodecAdapter(codec, handlerThread); + bufferInfo = new MediaCodec.BufferInfo(); + } + + @After + public void tearDown() { + adapter.shutdown(); + assertThat(TestHandlerThread.INSTANCES_STARTED.get()).isEqualTo(0); + } + + @Test + public void startAndShutdown_works() { + adapter.start(); + adapter.shutdown(); + } + + @Test + public void start_calledTwice_throwsException() { + adapter.start(); + try { + adapter.start(); + fail(); + } catch (IllegalStateException expected) { + } + } + + @Test + public void dequeueInputBufferIndex_withoutStart_throwsException() { + try { + adapter.dequeueInputBufferIndex(); + fail(); + } catch (IllegalStateException expected) { + } + } + + @Test + public void dequeueInputBufferIndex_afterShutdown_throwsException() { + adapter.start(); + adapter.shutdown(); + try { + adapter.dequeueInputBufferIndex(); + fail(); + } catch (IllegalStateException expected) { + } + } + + @Test + public void dequeueInputBufferIndex_withAfterFlushFailed_throwsException() + throws InterruptedException { + adapter.setOnCodecStart( + () -> { + throw new IllegalStateException("codec#start() exception"); + }); + adapter.start(); + adapter.flush(); + + assertThat( + waitUntilAllEventsAreExecuted( + handlerThread.getLooper(), /* time= */ 5, TimeUnit.SECONDS)) + .isTrue(); + try { + adapter.dequeueInputBufferIndex(); + fail(); + } catch (IllegalStateException expected) { + } + } + + @Test + public void dequeueInputBufferIndex_withoutInputBuffer_returnsTryAgainLater() { + adapter.start(); + + assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); + } + + @Test + public void dequeueInputBufferIndex_withInputBuffer_returnsInputBuffer() { + adapter.start(); + adapter.onInputBufferAvailable(codec, 0); + + assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(0); + } + + @Test + public void dequeueInputBufferIndex_withPendingFlush_returnsTryAgainLater() { + adapter.start(); + adapter.onInputBufferAvailable(codec, 0); + adapter.flush(); + + assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); + } + + @Test + public void dequeueInputBufferIndex_withFlushCompletedAndInputBuffer_returnsInputBuffer() + throws InterruptedException { + // Disable calling codec.start() after flush to avoid receiving buffers from the + // shadow codec impl + adapter.setOnCodecStart(() -> {}); + adapter.start(); + Looper looper = handlerThread.getLooper(); + Handler handler = new Handler(looper); + // Enqueue 10 callbacks from codec + for (int i = 0; i < 10; i++) { + int bufferIndex = i; + handler.post(() -> adapter.onInputBufferAvailable(codec, bufferIndex)); + } + adapter.flush(); // Enqueues a flush event after the onInputBufferAvailable callbacks + // Enqueue another onInputBufferAvailable after the flush event + handler.post(() -> adapter.onInputBufferAvailable(codec, 10)); + + // Wait until all tasks have been handled + assertThat(waitUntilAllEventsAreExecuted(looper, /* time= */ 5, TimeUnit.SECONDS)).isTrue(); + assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(10); + } + + @Test + public void dequeueInputBufferIndex_withMediaCodecError_throwsException() { + adapter.start(); + adapter.onMediaCodecError(new IllegalStateException("error from codec")); + + try { + adapter.dequeueInputBufferIndex(); + fail(); + } catch (IllegalStateException expected) { + } + } + + @Test + public void dequeueOutputBufferIndex_withoutStart_throwsException() { + try { + adapter.dequeueOutputBufferIndex(bufferInfo); + fail(); + } catch (IllegalStateException expected) { + } + } + + @Test + public void dequeueOutputBufferIndex_afterShutdown_throwsException() { + adapter.start(); + adapter.shutdown(); + try { + adapter.dequeueOutputBufferIndex(bufferInfo); + fail(); + } catch (IllegalStateException expected) { + } + } + + @Test + public void dequeueOutputBufferIndex_withInternalException_throwsException() + throws InterruptedException { + adapter.setOnCodecStart( + () -> { + throw new RuntimeException("codec#start() exception"); + }); + adapter.start(); + adapter.flush(); + + assertThat( + waitUntilAllEventsAreExecuted( + handlerThread.getLooper(), /* time= */ 5, TimeUnit.SECONDS)) + .isTrue(); + try { + adapter.dequeueOutputBufferIndex(bufferInfo); + fail(); + } catch (IllegalStateException expected) { + } + } + + @Test + public void dequeueOutputBufferIndex_withoutInputBuffer_returnsTryAgainLater() { + adapter.start(); + + assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) + .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); + } + + @Test + public void dequeueOutputBufferIndex_withOutputBuffer_returnsOutputBuffer() { + adapter.start(); + MediaCodec.BufferInfo enqueuedBufferInfo = new MediaCodec.BufferInfo(); + adapter.onOutputBufferAvailable(codec, 0, enqueuedBufferInfo); + + assertThat(adapter.dequeueOutputBufferIndex((bufferInfo))).isEqualTo(0); + assertThat(areEqual(bufferInfo, enqueuedBufferInfo)).isTrue(); + } + + @Test + public void dequeueOutputBufferIndex_withPendingFlush_returnsTryAgainLater() { + adapter.start(); + adapter.dequeueOutputBufferIndex(bufferInfo); + adapter.flush(); + + assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) + .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); + } + + @Test + public void dequeueOutputBufferIndex_withFlushCompletedAndOutputBuffer_returnsOutputBuffer() + throws InterruptedException { + adapter.start(); + Looper looper = handlerThread.getLooper(); + Handler handler = new Handler(looper); + // Enqueue 10 callbacks from codec + for (int i = 0; i < 10; i++) { + int bufferIndex = i; + MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); + outBufferInfo.presentationTimeUs = i; + handler.post(() -> adapter.onOutputBufferAvailable(codec, bufferIndex, outBufferInfo)); + } + adapter.flush(); // Enqueues a flush event after the onOutputBufferAvailable callbacks + // Enqueue another onOutputBufferAvailable after the flush event + MediaCodec.BufferInfo lastBufferInfo = new MediaCodec.BufferInfo(); + lastBufferInfo.presentationTimeUs = 10; + handler.post(() -> adapter.onOutputBufferAvailable(codec, 10, lastBufferInfo)); + + // Wait until all tasks have been handled + assertThat(waitUntilAllEventsAreExecuted(looper, /* time= */ 5, TimeUnit.SECONDS)).isTrue(); + assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)).isEqualTo(10); + assertThat(areEqual(bufferInfo, lastBufferInfo)).isTrue(); + } + + @Test + public void dequeueOutputBufferIndex_withMediaCodecError_throwsException() { + adapter.start(); + adapter.onMediaCodecError(new IllegalStateException("error from codec")); + + try { + adapter.dequeueOutputBufferIndex(bufferInfo); + fail(); + } catch (IllegalStateException expected) { + } + } + + @Test + public void getOutputFormat_withoutStart_throwsException() { + try { + adapter.getOutputFormat(); + fail(); + } catch (IllegalStateException expected) { + } + } + + @Test + public void getOutputFormat_afterShutdown_throwsException() { + adapter.start(); + adapter.shutdown(); + try { + adapter.getOutputFormat(); + fail(); + } catch (IllegalStateException expected) { + } + } + + @Test + public void getOutputFormat_withoutFormatReceived_throwsException() { + adapter.start(); + + try { + adapter.getOutputFormat(); + fail(); + } catch (IllegalStateException expected) { + } + } + + @Test + public void getOutputFormat_withMultipleFormats_returnsCorrectFormat() { + adapter.start(); + MediaFormat[] formats = new MediaFormat[10]; + for (int i = 0; i < formats.length; i++) { + formats[i] = new MediaFormat(); + adapter.onOutputFormatChanged(codec, formats[i]); + } + + for (int i = 0; i < 10; i++) { + assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) + .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); + assertThat(adapter.getOutputFormat()).isEqualTo(formats[i]); + // A subsequent call to getOutputFormat() should return the previously fetched format + assertThat(adapter.getOutputFormat()).isEqualTo(formats[i]); + } + assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) + .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); + } + + @Test + public void getOutputFormat_afterFlush_returnsPreviousFormat() throws InterruptedException { + MediaFormat format = new MediaFormat(); + adapter.start(); + adapter.onOutputFormatChanged(codec, format); + + assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) + .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); + assertThat(adapter.getOutputFormat()).isEqualTo(format); + + adapter.flush(); + assertThat( + waitUntilAllEventsAreExecuted( + handlerThread.getLooper(), /* time= */ 5, TimeUnit.SECONDS)) + .isTrue(); + assertThat(adapter.getOutputFormat()).isEqualTo(format); + } + + @Test + public void flush_withoutStarted_throwsException() { + try { + adapter.flush(); + } catch (IllegalStateException expected) { + } + } + + @Test + public void flush_afterShutdown_throwsException() { + adapter.start(); + adapter.shutdown(); + try { + adapter.flush(); + } catch (IllegalStateException expected) { + } + } + + @Test + public void flush_multipleTimes_onlyLastFlushExecutes() throws InterruptedException { + AtomicInteger onCodecStartCount = new AtomicInteger(0); + adapter.setOnCodecStart(() -> onCodecStartCount.incrementAndGet()); + adapter.start(); + Looper looper = handlerThread.getLooper(); + Handler handler = new Handler(looper); + handler.post(() -> adapter.onInputBufferAvailable(codec, 0)); + adapter.flush(); // Enqueues a flush event + handler.post(() -> adapter.onInputBufferAvailable(codec, 2)); + AtomicInteger milestoneCount = new AtomicInteger(0); + handler.post(() -> milestoneCount.incrementAndGet()); + adapter.flush(); // Enqueues a second flush event + handler.post(() -> adapter.onInputBufferAvailable(codec, 3)); + + // Progress the looper until the milestoneCount is increased - first flush event + // should have been a no-op + ShadowLooper shadowLooper = shadowOf(looper); + while (milestoneCount.get() < 1) { + shadowLooper.runOneTask(); + } + assertThat(onCodecStartCount.get()).isEqualTo(0); + + assertThat(waitUntilAllEventsAreExecuted(looper, /* time= */ 5, TimeUnit.SECONDS)).isTrue(); + assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(3); + assertThat(onCodecStartCount.get()).isEqualTo(1); + } + + @Test + public void flush_andImmediatelyShutdown_flushIsNoOp() throws InterruptedException { + AtomicInteger onCodecStartCount = new AtomicInteger(0); + adapter.setOnCodecStart(() -> onCodecStartCount.incrementAndGet()); + adapter.start(); + // Obtain looper when adapter is started. + Looper looper = handlerThread.getLooper(); + adapter.flush(); + adapter.shutdown(); + + assertThat(waitUntilAllEventsAreExecuted(looper, 5, TimeUnit.SECONDS)).isTrue(); + // Only shutdown flushes the MediaCodecAsync handler. + assertThat(onCodecStartCount.get()).isEqualTo(0); + } + + private static class TestHandlerThread extends HandlerThread { + + private static final AtomicLong INSTANCES_STARTED = new AtomicLong(0); + + public TestHandlerThread(String name) { + super(name); + } + + @Override + public synchronized void start() { + super.start(); + INSTANCES_STARTED.incrementAndGet(); + } + + @Override + public boolean quit() { + boolean quit = super.quit(); + INSTANCES_STARTED.decrementAndGet(); + return quit; + } + } +}