diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaExtractorCompat.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaExtractorCompat.java new file mode 100644 index 0000000000..b0fc694a98 --- /dev/null +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaExtractorCompat.java @@ -0,0 +1,727 @@ +/* + * 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; + +import static androidx.annotation.VisibleForTesting.NONE; +import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.exoplayer.source.SampleStream.FLAG_OMIT_SAMPLE_DATA; +import static androidx.media3.exoplayer.source.SampleStream.FLAG_PEEK; +import static androidx.media3.exoplayer.source.SampleStream.FLAG_REQUIRE_FORMAT; + +import android.content.Context; +import android.media.MediaExtractor; +import android.media.MediaFormat; +import android.net.Uri; +import android.util.SparseArray; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.media3.common.C; +import androidx.media3.common.Format; +import androidx.media3.common.ParserException; +import androidx.media3.common.util.Assertions; +import androidx.media3.common.util.Log; +import androidx.media3.common.util.MediaFormatUtil; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSourceUtil; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.DefaultDataSource; +import androidx.media3.decoder.DecoderInputBuffer; +import androidx.media3.exoplayer.mediacodec.MediaCodecUtil; +import androidx.media3.exoplayer.source.SampleQueue; +import androidx.media3.exoplayer.source.SampleStream.ReadDataResult; +import androidx.media3.exoplayer.source.SampleStream.ReadFlags; +import androidx.media3.exoplayer.source.UnrecognizedInputFormatException; +import androidx.media3.exoplayer.upstream.Allocator; +import androidx.media3.exoplayer.upstream.DefaultAllocator; +import androidx.media3.extractor.DefaultExtractorInput; +import androidx.media3.extractor.DefaultExtractorsFactory; +import androidx.media3.extractor.DummyTrackOutput; +import androidx.media3.extractor.Extractor; +import androidx.media3.extractor.Extractor.ReadResult; +import androidx.media3.extractor.ExtractorInput; +import androidx.media3.extractor.ExtractorOutput; +import androidx.media3.extractor.ExtractorsFactory; +import androidx.media3.extractor.PositionHolder; +import androidx.media3.extractor.SeekMap; +import androidx.media3.extractor.SeekMap.SeekPoints; +import androidx.media3.extractor.SeekPoint; +import androidx.media3.extractor.TrackOutput; +import androidx.media3.extractor.mp4.Mp4Extractor; +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; +import java.io.EOFException; +import java.io.IOException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.nio.ByteBuffer; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; +import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; + +/** + * A drop-in replacement for {@link MediaExtractor} that provides similar functionality, based on + * the {@code media3.extractor} logic. + */ +@UnstableApi +public final class MediaExtractorCompat { + + /** + * The seeking mode. One of {@link #SEEK_TO_PREVIOUS_SYNC}, {@link #SEEK_TO_NEXT_SYNC}, or {@link + * #SEEK_TO_CLOSEST_SYNC}. + */ + @IntDef({ + SEEK_TO_PREVIOUS_SYNC, + SEEK_TO_NEXT_SYNC, + SEEK_TO_CLOSEST_SYNC, + }) + @Retention(RetentionPolicy.SOURCE) + public @interface SeekMode {} + + /** See {@link MediaExtractor#SEEK_TO_PREVIOUS_SYNC}. */ + public static final int SEEK_TO_PREVIOUS_SYNC = MediaExtractor.SEEK_TO_PREVIOUS_SYNC; + + /** See {@link MediaExtractor#SEEK_TO_NEXT_SYNC}. */ + public static final int SEEK_TO_NEXT_SYNC = MediaExtractor.SEEK_TO_NEXT_SYNC; + + /** See {@link MediaExtractor#SEEK_TO_CLOSEST_SYNC}. */ + public static final int SEEK_TO_CLOSEST_SYNC = MediaExtractor.SEEK_TO_CLOSEST_SYNC; + + private static final String TAG = "MediaExtractorCompat"; + + private final ExtractorsFactory extractorsFactory; + private final DataSource.Factory dataSourceFactory; + private final PositionHolder positionHolder; + private final Allocator allocator; + private final ArrayList tracks; + private final SparseArray sampleQueues; + private final ArrayDeque trackIndicesPerSampleInQueuedOrder; + private final FormatHolder formatHolder; + private final DecoderInputBuffer sampleHolder; + private final DecoderInputBuffer noDataBuffer; + private final Set selectedTrackIndices; + + private boolean hasBeenPrepared; + private long offsetInCurrentFile; + @Nullable private Extractor currentExtractor; + @Nullable private ExtractorInput currentExtractorInput; + @Nullable private DataSource currentDataSource; + @Nullable private SeekPoint pendingSeek; + + @Nullable private SeekMap seekMap; + private boolean tracksEnded; + private int upstreamFormatsCount; + + /** Creates a new instance. */ + public MediaExtractorCompat(Context context) { + this(new DefaultExtractorsFactory(), new DefaultDataSource.Factory(context)); + } + + /** + * Creates a new instance using the given {@link ExtractorsFactory extractorsFactory} to create + * the {@link Extractor extractors} to use for obtaining media samples from a DataSource generated + * by the given {@link DataSource.Factory dataSourceFactory}. + */ + public MediaExtractorCompat( + ExtractorsFactory extractorsFactory, DataSource.Factory dataSourceFactory) { + this.extractorsFactory = extractorsFactory; + this.dataSourceFactory = dataSourceFactory; + positionHolder = new PositionHolder(); + allocator = new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE); + tracks = new ArrayList<>(); + sampleQueues = new SparseArray<>(); + trackIndicesPerSampleInQueuedOrder = new ArrayDeque<>(); + formatHolder = new FormatHolder(); + sampleHolder = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DISABLED); + noDataBuffer = DecoderInputBuffer.newNoDataInstance(); + selectedTrackIndices = new HashSet<>(); + } + + /** + * Initializes the internal state with the media stream obtained from the given {@code uri} at the + * given {@code offset}. + * + * @param uri The content {@link Uri} to extract from. + * @param offset The offset into the file where the data to be extracted starts, in bytes. + * @throws IOException If an error occurs while extracting the media. + * @throws UnrecognizedInputFormatException If none of the available extractors successfully + * sniffs the input. + * @throws IllegalStateException If this method is called twice on the same instance. + */ + public void setDataSource(Uri uri, long offset) throws IOException { + // Assert that this instance is not being re-prepared, which is not currently supported. + Assertions.checkState(!hasBeenPrepared); + hasBeenPrepared = true; + offsetInCurrentFile = offset; + DataSpec dataSpec = buildDataSpec(uri, /* position= */ offsetInCurrentFile); + + currentDataSource = dataSourceFactory.createDataSource(); + long length = currentDataSource.open(dataSpec); + ExtractorInput currentExtractorInput = + new DefaultExtractorInput(currentDataSource, /* position= */ 0, length); + Extractor currentExtractor = selectExtractor(currentExtractorInput); + currentExtractor.init(new ExtractorOutputImpl()); + + boolean preparing = true; + Throwable error = null; + while (preparing) { + int result; + try { + result = currentExtractor.read(currentExtractorInput, positionHolder); + } catch (Exception | OutOfMemoryError e) { + // This value is ignored but initializes result to avoid static analysis errors. + result = Extractor.RESULT_END_OF_INPUT; + error = e; + } + preparing = !tracksEnded || upstreamFormatsCount < sampleQueues.size() || seekMap == null; + if (error != null || (preparing && result == Extractor.RESULT_END_OF_INPUT)) { + // TODO(b/178501820): Support files with incomplete track information. + release(); // Release resources as soon as possible, in case we are low on memory. + String message = + error != null + ? "Exception encountered while parsing input media." + : "Reached end of input before preparation completed."; + throw ParserException.createForMalformedContainer(message, /* cause= */ error); + } else if (result == Extractor.RESULT_SEEK) { + currentExtractorInput = reopenCurrentDataSource(positionHolder.position); + } + } + this.currentExtractorInput = currentExtractorInput; + this.currentExtractor = currentExtractor; + // At this point, we know how many tracks we have, and their format. + } + + /** + * Releases any resources held by this instance. + * + *

Note: Make sure you call this when you're done to free up any resources instead of relying + * on the garbage collector to do this for you at some point in the future. + */ + public void release() { + for (int i = 0; i < sampleQueues.size(); i++) { + sampleQueues.valueAt(i).release(); + } + sampleQueues.clear(); + if (currentExtractor != null) { + currentExtractor.release(); + currentExtractor = null; + } + currentExtractorInput = null; + pendingSeek = null; + DataSourceUtil.closeQuietly(currentDataSource); + currentDataSource = null; + } + + /** Returns the number of tracks found in the data source. */ + public int getTrackCount() { + return tracks.size(); + } + + /** Returns the track {@link MediaFormat} at the specified {@code trackIndex}. */ + public MediaFormat getTrackFormat(int trackIndex) { + return tracks.get(trackIndex).createDownstreamMediaFormat(formatHolder, noDataBuffer); + } + + /** + * Selects a track at the specified {@code trackIndex}. + * + *

Subsequent calls to {@link #readSampleData}, {@link #getSampleTrackIndex} and {@link + * #getSampleTime} only retrieve information for the subset of tracks selected. + * + *

Note: Selecting the same track multiple times has no effect, the track is only selected + * once. + */ + public void selectTrack(int trackIndex) { + selectedTrackIndices.add(trackIndex); + } + + /** + * Unselects the track at the specified {@code trackIndex}. + * + *

Subsequent calls to {@link #readSampleData}, {@link #getSampleTrackIndex} and {@link + * #getSampleTime} only retrieve information for the subset of tracks selected. + */ + public void unselectTrack(int trackIndex) { + selectedTrackIndices.remove(trackIndex); + } + + /** + * All selected tracks seek near the requested {@code timeUs} according to the specified {@code + * mode}. + */ + public void seekTo(long timeUs, @SeekMode int mode) { + if (seekMap == null) { + return; + } + + SeekPoints seekPoints; + if (selectedTrackIndices.size() == 1 && currentExtractor instanceof Mp4Extractor) { + // Mp4Extractor supports seeking within a specific track. This helps with poorly interleaved + // tracks. See b/223910395. + seekPoints = + ((Mp4Extractor) currentExtractor) + .getSeekPoints( + timeUs, tracks.get(selectedTrackIndices.iterator().next()).getIdOfBackingTrack()); + } else { + seekPoints = seekMap.getSeekPoints(timeUs); + } + SeekPoint seekPoint; + switch (mode) { + case SEEK_TO_CLOSEST_SYNC: + seekPoint = + Math.abs(timeUs - seekPoints.second.timeUs) < Math.abs(timeUs - seekPoints.first.timeUs) + ? seekPoints.second + : seekPoints.first; + break; + case SEEK_TO_NEXT_SYNC: + seekPoint = seekPoints.second; + break; + case SEEK_TO_PREVIOUS_SYNC: + seekPoint = seekPoints.first; + break; + default: + // Should never happen. + throw new IllegalArgumentException(); + } + trackIndicesPerSampleInQueuedOrder.clear(); + for (int i = 0; i < sampleQueues.size(); i++) { + sampleQueues.valueAt(i).reset(); + } + pendingSeek = seekPoint; + } + + /** + * Advances to the next sample. Returns {@code false} if no more sample data is available (i.e., + * end of stream), or {@code true} otherwise. + * + *

Note: When extracting from a local file, the behavior of {@link #advance} and {@link + * #readSampleData} is undefined if there are concurrent writes to the same file. This may result + * in an unexpected end of stream being signaled. + */ + public boolean advance() { + // Ensure there is a sample to discard. + if (!advanceToSampleOrEndOfInput()) { + // The end of input has been reached. + return false; + } + skipOneSample(); + return advanceToSampleOrEndOfInput(); + } + + /** + * Retrieves the current encoded sample and stores it in the byte {@code buffer} starting at the + * given {@code offset}. + * + *

Note:On success, the position and limit of {@code buffer} is updated to point to the + * data just read. + * + * @param buffer the destination byte buffer. + * @param offset The offset into the byte buffer at which to write. + * @return the sample size, or -1 if no more samples are available. + */ + public int readSampleData(ByteBuffer buffer, int offset) { + if (!advanceToSampleOrEndOfInput()) { + return -1; + } + // The platform media extractor implementation ignores the buffer's input position and limit. + buffer.position(offset); + buffer.limit(buffer.capacity()); + sampleHolder.data = buffer; + peekNextSelectedTrackSample(sampleHolder, /* omitSampleData= */ false); + buffer.flip(); + buffer.position(offset); + sampleHolder.data = null; + return buffer.remaining(); + } + + /** + * Returns the track index the current sample originates from, or -1 if no more samples are + * available. + */ + public int getSampleTrackIndex() { + if (!advanceToSampleOrEndOfInput()) { + return -1; + } + return trackIndicesPerSampleInQueuedOrder.peekFirst(); + } + + /** + * Returns the current sample's presentation time in microseconds, or -1 if no more samples are + * available. + */ + public long getSampleTime() { + if (!advanceToSampleOrEndOfInput()) { + return -1; + } + peekNextSelectedTrackSample(noDataBuffer, /* omitSampleData= */ true); + return noDataBuffer.timeUs; + } + + /** Returns the current sample's flags. */ + public int getSampleFlags() { + if (!advanceToSampleOrEndOfInput()) { + return -1; + } + peekNextSelectedTrackSample(noDataBuffer, /* omitSampleData= */ true); + int flags = 0; + flags |= noDataBuffer.isEncrypted() ? MediaExtractor.SAMPLE_FLAG_ENCRYPTED : 0; + flags |= noDataBuffer.isKeyFrame() ? MediaExtractor.SAMPLE_FLAG_SYNC : 0; + return flags; + } + + @VisibleForTesting(otherwise = NONE) + public Allocator getAllocator() { + return allocator; + } + + /** + * Peeks a sample from the front of the given {@link SampleQueue}, discarding the downstream + * {@link Format} first, if necessary. + * + * @param decoderInputBuffer The buffer to populate. + * @param omitSampleData Whether to omit the sample's data. + * @throws IllegalStateException If a sample is not peeked as a result of calling this method. + */ + private void peekNextSelectedTrackSample( + DecoderInputBuffer decoderInputBuffer, boolean omitSampleData) { + MediaExtractorTrack trackOfSample = + tracks.get(checkNotNull(trackIndicesPerSampleInQueuedOrder.peekFirst())); + SampleQueue sampleQueue = trackOfSample.sampleQueue; + @ReadFlags int readFlags = FLAG_PEEK | (omitSampleData ? FLAG_OMIT_SAMPLE_DATA : 0); + @ReadDataResult + int result = + sampleQueue.read(formatHolder, decoderInputBuffer, readFlags, /* loadingFinished= */ false); + if (result == C.RESULT_FORMAT_READ) { + // We've consumed a downstream format. Now consume the actual sample. + result = + sampleQueue.read( + formatHolder, decoderInputBuffer, readFlags, /* loadingFinished= */ false); + } + formatHolder.clear(); + // Additional logging is added to debug b/241321832. + if (result != C.RESULT_BUFFER_READ) { + // This method should only be called when there is a sample available for reading. + throw new IllegalStateException( + Util.formatInvariant( + "Sample read result: %s\n" + + "Track sample: %s\n" + + "TrackIndicesPerSampleInQueuedOrder: %s\n" + + "Tracks added: %s\n", + result, trackOfSample, trackIndicesPerSampleInQueuedOrder, tracks)); + } + } + + /** + * Returns the extractor to use for extracting samples from the given {@code input}. + * + * @throws IOException If an error occurs while extracting the media. + * @throws UnrecognizedInputFormatException If none of the available extractors successfully + * sniffs the input. + */ + private Extractor selectExtractor(ExtractorInput input) throws IOException { + Extractor[] extractors = extractorsFactory.createExtractors(); + Extractor result = null; + for (Extractor extractor : extractors) { + try { + if (extractor.sniff(input)) { + result = extractor; + break; + } + } catch (EOFException e) { + // We reached the end of input without recognizing the input format. Do nothing to let the + // next extractor sniff the content. + } finally { + input.resetPeekPosition(); + } + } + if (result == null) { + throw new UnrecognizedInputFormatException( + "None of the available extractors (" + + Joiner.on(", ") + .join( + Lists.transform( + ImmutableList.copyOf(extractors), + extractor -> + extractor.getUnderlyingImplementation().getClass().getSimpleName())) + + ") could read the stream.", + checkNotNull(checkNotNull(currentDataSource).getUri()), + ImmutableList.of()); + } + return result; + } + + /** + * Advances extraction until there is a queued sample from a selected track, or the end of the + * input is found. + * + *

Handles I/O errors (for example, network connection loss) and parsing errors (for example, a + * truncated file) in the same way as {@link MediaExtractor}, treating them as the end of input. + * + * @return Whether a sample from a selected track is available. + */ + @EnsuresNonNullIf(expression = "trackIndicesPerSampleInQueuedOrder.peekFirst()", result = true) + private boolean advanceToSampleOrEndOfInput() { + try { + maybeResolvePendingSeek(); + } catch (IOException e) { + Log.w(TAG, "Treating exception as the end of input.", e); + return false; + } + + boolean seenEndOfInput = false; + while (true) { + if (!trackIndicesPerSampleInQueuedOrder.isEmpty()) { + // By default, tracks are unselected. + if (selectedTrackIndices.contains(trackIndicesPerSampleInQueuedOrder.peekFirst())) { + return true; + } else { + // There is a queued sample, but its track is unselected. We skip the sample. + skipOneSample(); + } + } else if (!seenEndOfInput) { + // There are no queued samples for the selected tracks, but we can feed more data to the + // extractor and see if more samples are produced. + try { + @ReadResult + int result = + checkNotNull(currentExtractor) + .read(checkNotNull(currentExtractorInput), positionHolder); + if (result == Extractor.RESULT_END_OF_INPUT) { + seenEndOfInput = true; + } else if (result == Extractor.RESULT_SEEK) { + this.currentExtractorInput = reopenCurrentDataSource(positionHolder.position); + } + } catch (Exception | OutOfMemoryError e) { + Log.w(TAG, "Treating exception as the end of input.", e); + seenEndOfInput = true; + } + } else { + // No queued samples for selected tracks, and we've parsed all the file. Nothing else to do. + return false; + } + } + } + + private void skipOneSample() { + int trackIndex = trackIndicesPerSampleInQueuedOrder.removeFirst(); + MediaExtractorTrack track = tracks.get(trackIndex); + if (!track.isCompatibilityTrack) { + // We also need to skip the sample data. + track.discardFrontSample(); + } + } + + private ExtractorInput reopenCurrentDataSource(long newPositionInStream) throws IOException { + DataSource currentDataSource = checkNotNull(this.currentDataSource); + Uri currentUri = checkNotNull(currentDataSource.getUri()); + DataSourceUtil.closeQuietly(currentDataSource); + long length = + currentDataSource.open( + buildDataSpec(currentUri, offsetInCurrentFile + newPositionInStream)); + if (length != C.LENGTH_UNSET) { + length += newPositionInStream; + } + return new DefaultExtractorInput(currentDataSource, newPositionInStream, length); + } + + private void onSampleQueueFormatInitialized( + MediaExtractorSampleQueue mediaExtractorSampleQueue, Format newUpstreamFormat) { + upstreamFormatsCount++; + mediaExtractorSampleQueue.setMainTrackIndex(tracks.size()); + tracks.add( + new MediaExtractorTrack( + mediaExtractorSampleQueue, + /* isCompatibilityTrack= */ false, + /* compatibilityTrackMimeType= */ null)); + @Nullable + String compatibilityTrackMimeType = + MediaCodecUtil.getAlternativeCodecMimeType(newUpstreamFormat); + if (compatibilityTrackMimeType != null) { + mediaExtractorSampleQueue.setCompatibilityTrackIndex(tracks.size()); + tracks.add( + new MediaExtractorTrack( + mediaExtractorSampleQueue, + /* isCompatibilityTrack= */ true, + compatibilityTrackMimeType)); + } + } + + private void maybeResolvePendingSeek() throws IOException { + if (this.pendingSeek == null) { + return; // Nothing to do. + } + SeekPoint pendingSeek = checkNotNull(this.pendingSeek); + checkNotNull(currentExtractor).seek(pendingSeek.position, pendingSeek.timeUs); + this.currentExtractorInput = reopenCurrentDataSource(pendingSeek.position); + this.pendingSeek = null; + } + + /** + * Create a new {@link DataSpec} with the given data. + * + *

The created {@link DataSpec} disables caching if the content length cannot be resolved, + * since this is indicative of a progressive live stream. + */ + private static DataSpec buildDataSpec(Uri uri, long position) { + return new DataSpec.Builder() + .setUri(uri) + .setPosition(position) + .setFlags( + DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN | DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION) + .build(); + } + + private final class ExtractorOutputImpl implements ExtractorOutput { + + @Override + public TrackOutput track(int id, int type) { + MediaExtractorSampleQueue sampleQueue = sampleQueues.get(id); + if (sampleQueue != null) { + // This track has already been declared. We return the sample queue that corresponds to this + // id. + return sampleQueue; + } + if (tracksEnded) { + // The id is new and the extractor has ended the tracks. Discard. + return new DummyTrackOutput(); + } + + sampleQueue = new MediaExtractorSampleQueue(allocator, id); + sampleQueues.put(id, sampleQueue); + return sampleQueue; + } + + @Override + public void endTracks() { + tracksEnded = true; + } + + @Override + public void seekMap(SeekMap seekMap) { + MediaExtractorCompat.this.seekMap = seekMap; + } + } + + private static final class MediaExtractorTrack { + + public final MediaExtractorSampleQueue sampleQueue; + public final boolean isCompatibilityTrack; + @Nullable public final String compatibilityTrackMimeType; + + private MediaExtractorTrack( + MediaExtractorSampleQueue sampleQueue, + boolean isCompatibilityTrack, + @Nullable String compatibilityTrackMimeType) { + this.sampleQueue = sampleQueue; + this.isCompatibilityTrack = isCompatibilityTrack; + this.compatibilityTrackMimeType = compatibilityTrackMimeType; + } + + public MediaFormat createDownstreamMediaFormat( + FormatHolder scratchFormatHolder, DecoderInputBuffer scratchNoDataDecoderInputBuffer) { + scratchFormatHolder.clear(); + sampleQueue.read( + scratchFormatHolder, + scratchNoDataDecoderInputBuffer, + FLAG_REQUIRE_FORMAT, + /* loadingFinished= */ false); + Format result = checkNotNull(scratchFormatHolder.format); + MediaFormat mediaFormatResult = MediaFormatUtil.createMediaFormatFromFormat(result); + scratchFormatHolder.clear(); + if (compatibilityTrackMimeType != null) { + mediaFormatResult.setString(MediaFormat.KEY_CODECS_STRING, null); + mediaFormatResult.setString(MediaFormat.KEY_MIME, compatibilityTrackMimeType); + } + return mediaFormatResult; + } + + public void discardFrontSample() { + sampleQueue.skip(/* count= */ 1); + sampleQueue.discardToRead(); + } + + public int getIdOfBackingTrack() { + return sampleQueue.trackId; + } + + @Override + public String toString() { + return String.format( + "MediaExtractorSampleQueue: %s, isCompatibilityTrack: %s, compatibilityTrackMimeType: %s", + sampleQueue, isCompatibilityTrack, compatibilityTrackMimeType); + } + } + + private final class MediaExtractorSampleQueue extends SampleQueue { + + public final int trackId; + private int mainTrackIndex; + private int compatibilityTrackIndex; + + public MediaExtractorSampleQueue(Allocator allocator, int trackId) { + // We do not need the sample queue to acquire keys for encrypted samples, so we pass null + // values for DRM-related arguments. + super(allocator, /* drmSessionManager= */ null, /* drmEventDispatcher= */ null); + this.trackId = trackId; + mainTrackIndex = C.INDEX_UNSET; + compatibilityTrackIndex = C.INDEX_UNSET; + } + + public void setMainTrackIndex(int mainTrackIndex) { + this.mainTrackIndex = mainTrackIndex; + } + + public void setCompatibilityTrackIndex(int compatibilityTrackIndex) { + this.compatibilityTrackIndex = compatibilityTrackIndex; + } + + // SampleQueue implementation. + + @Override + public Format getAdjustedUpstreamFormat(Format format) { + if (getUpstreamFormat() == null) { + onSampleQueueFormatInitialized(this, format); + } + return super.getAdjustedUpstreamFormat(format); + } + + @Override + public void sampleMetadata( + long timeUs, int flags, int size, int offset, @Nullable CryptoData cryptoData) { + // Disable BUFFER_FLAG_LAST_SAMPLE to prevent the sample queue from ignoring + // FLAG_REQUIRE_FORMAT. See b/191518632. + flags &= ~C.BUFFER_FLAG_LAST_SAMPLE; + if (compatibilityTrackIndex != C.INDEX_UNSET) { + trackIndicesPerSampleInQueuedOrder.addLast(compatibilityTrackIndex); + } + Assertions.checkState(mainTrackIndex != C.INDEX_UNSET); + trackIndicesPerSampleInQueuedOrder.addLast(mainTrackIndex); + super.sampleMetadata(timeUs, flags, size, offset, cryptoData); + } + + @Override + public String toString() { + return String.format( + "trackId: %s, mainTrackIndex: %s, compatibilityTrackIndex: %s", + trackId, mainTrackIndex, compatibilityTrackIndex); + } + } +} diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaExtractorCompatTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaExtractorCompatTest.java new file mode 100644 index 0000000000..b60f88485a --- /dev/null +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaExtractorCompatTest.java @@ -0,0 +1,602 @@ +/* + * 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; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import android.media.MediaFormat; +import android.net.Uri; +import androidx.media3.common.C; +import androidx.media3.common.Format; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.ParserException; +import androidx.media3.common.util.ParsableByteArray; +import androidx.media3.datasource.DefaultDataSource; +import androidx.media3.exoplayer.upstream.Allocator; +import androidx.media3.extractor.Extractor; +import androidx.media3.extractor.ExtractorInput; +import androidx.media3.extractor.ExtractorOutput; +import androidx.media3.extractor.ExtractorsFactory; +import androidx.media3.extractor.PositionHolder; +import androidx.media3.extractor.SeekMap; +import androidx.media3.extractor.SeekMap.SeekPoints; +import androidx.media3.extractor.SeekPoint; +import androidx.media3.extractor.TrackOutput; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Function; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests for {@link MediaExtractorCompat}. */ +@RunWith(AndroidJUnit4.class) +public class MediaExtractorCompatTest { + + /** + * Placeholder data URI which saves us from mocking the data source which MediaExtractorCompat + * uses. + * + *

Note: The created data source will be opened, but no data will be read from it, so the + * contents are irrelevant. + */ + private static final Uri PLACEHOLDER_URI = Uri.parse("data:,0123456789"); + + private static final Format PLACEHOLDER_FORMAT_AUDIO = + new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).build(); + private static final Format PLACEHOLDER_FORMAT_VIDEO = + new Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H264).build(); + + private FakeExtractor fakeExtractor; + private SeekMap fakeSeekMap; + private MediaExtractorCompat mediaExtractorCompat; + private ExtractorOutput extractorOutput; + + @Before + public void setUp() { + fakeExtractor = new FakeExtractor(); + fakeSeekMap = new FakeSeekMap(); + ExtractorsFactory factory = () -> new Extractor[] {fakeExtractor}; + mediaExtractorCompat = + new MediaExtractorCompat( + factory, new DefaultDataSource.Factory(ApplicationProvider.getApplicationContext())); + } + + @Test + public void setDataSource_forEmptyContainerFile_producesZeroTracks() throws IOException { + fakeExtractor.addReadAction( + (input, seekPosition) -> { + extractorOutput.endTracks(); + return Extractor.RESULT_END_OF_INPUT; + }); + mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0); + assertThat(mediaExtractorCompat.getTrackCount()).isEqualTo(0); + } + + @Test + public void setDataSource_doesNotPerformMoreReadsThanNecessary() throws IOException { + TrackOutput[] trackOutputs = new TrackOutput[2]; + fakeExtractor.addReadAction( + (input, seekPosition) -> { + trackOutputs[0] = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_AUDIO); + return Extractor.RESULT_CONTINUE; + }); + fakeExtractor.addReadAction( + (input, seekPosition) -> { + trackOutputs[1] = extractorOutput.track(/* id= */ 1, C.TRACK_TYPE_VIDEO); + extractorOutput.endTracks(); + // Should be ignored. Tracks have ended and the id doesn't exist. + extractorOutput.track(/* id= */ 2, C.TRACK_TYPE_TEXT); + return Extractor.RESULT_CONTINUE; + }); + fakeExtractor.addReadAction( + (input, seekPosition) -> { + trackOutputs[0].format(PLACEHOLDER_FORMAT_AUDIO); + return Extractor.RESULT_CONTINUE; + }); + fakeExtractor.addReadAction( + (input, seekPosition) -> { + // After this read call, the extractor should have enough to finish preparation: + // formats, seek map, and the tracks have ended. + trackOutputs[1].format(PLACEHOLDER_FORMAT_VIDEO); + return Extractor.RESULT_CONTINUE; + }); + fakeExtractor.addReadAction(MediaExtractorCompatTest::assertionFailureReadAction); + + mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0); + + assertThat(mediaExtractorCompat.getTrackCount()).isEqualTo(2); + assertThat(mediaExtractorCompat.getTrackFormat(0).getString(MediaFormat.KEY_MIME)) + .isEqualTo(PLACEHOLDER_FORMAT_AUDIO.sampleMimeType); + assertThat(mediaExtractorCompat.getTrackFormat(1).getString(MediaFormat.KEY_MIME)) + .isEqualTo(PLACEHOLDER_FORMAT_VIDEO.sampleMimeType); + } + + @Test + public void setDataSource_withTrackIdReuse_reusesTrackOutput() throws IOException { + fakeExtractor.addReadAction( + (input, seekPosition) -> { + TrackOutput trackOutput1 = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_AUDIO); + TrackOutput trackOutput2 = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_AUDIO); + assertThat(trackOutput1).isSameInstanceAs(trackOutput2); + trackOutput1.format(PLACEHOLDER_FORMAT_AUDIO); + extractorOutput.endTracks(); + return Extractor.RESULT_END_OF_INPUT; + }); + mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0); + } + + @Test + public void readSampleData_forInterleavedInputSamples_producesExpectedSamples() + throws IOException { + TrackOutput[] outputs = new TrackOutput[2]; + fakeExtractor.addReadAction( + (input, seekPosition) -> { + outputs[0] = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO); + outputs[0].format(PLACEHOLDER_FORMAT_VIDEO); + outputs[1] = extractorOutput.track(/* id= */ 1, C.TRACK_TYPE_AUDIO); + outputs[1].format(PLACEHOLDER_FORMAT_AUDIO); + extractorOutput.endTracks(); + return Extractor.RESULT_CONTINUE; + }); + fakeExtractor.addReadAction( + (input, seekPosition) -> { + outputSampleData(outputs[0], /* sampleData...= */ (byte) 1, (byte) 2, (byte) 3); + outputSampleData(outputs[1], /* sampleData...= */ (byte) 4, (byte) 5, (byte) 6); + return Extractor.RESULT_CONTINUE; + }); + fakeExtractor.addReadAction( + (input, seekPosition) -> { + outputSample(outputs[0], /* timeUs= */ 4, /* size= */ 1, /* offset= */ 2); + outputSample(outputs[1], /* timeUs= */ 3, /* size= */ 1, /* offset= */ 2); + return Extractor.RESULT_CONTINUE; + }); + fakeExtractor.addReadAction( + (input, seekPosition) -> { + outputSample(outputs[0], /* timeUs= */ 2, /* size= */ 2, /* offset= */ 0); + outputSample(outputs[1], /* timeUs= */ 1, /* size= */ 2, /* offset= */ 0); + return Extractor.RESULT_CONTINUE; + }); + fakeExtractor.addReadAction(MediaExtractorCompatTest::assertionFailureReadAction); + + mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0); + + assertThat(mediaExtractorCompat.getTrackCount()).isEqualTo(2); + mediaExtractorCompat.selectTrack(0); + mediaExtractorCompat.selectTrack(1); + assertReadSample(/* trackIndex= */ 0, /* timeUs= */ 4, /* sampleData...= */ (byte) 1); + mediaExtractorCompat.advance(); + assertReadSample(/* trackIndex= */ 1, /* timeUs= */ 3, /* sampleData...= */ (byte) 4); + mediaExtractorCompat.advance(); + assertReadSample(/* trackIndex= */ 0, /* timeUs= */ 2, /* sampleData...= */ (byte) 2, (byte) 3); + mediaExtractorCompat.advance(); + assertReadSample(/* trackIndex= */ 1, /* timeUs= */ 1, /* sampleData...= */ (byte) 5, (byte) 6); + } + + @Test + public void getTrackFormat_atEndOfStreamWithFlagLastSample_producesATrackFormat() + throws IOException { + // This is a regression test for b/191518632. + TrackOutput[] outputs = new TrackOutput[1]; + fakeExtractor.addReadAction( + (input, seekPosition) -> { + outputs[0] = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO); + outputs[0].format(PLACEHOLDER_FORMAT_VIDEO); + extractorOutput.endTracks(); + return Extractor.RESULT_CONTINUE; + }); + fakeExtractor.addReadAction( + (input, seekPosition) -> { + outputSampleData(outputs[0], /* sampleData...= */ (byte) 1, (byte) 2, (byte) 3); + outputs[0].sampleMetadata( + /* timeUs= */ 0, + /* flags= */ C.BUFFER_FLAG_KEY_FRAME | C.BUFFER_FLAG_LAST_SAMPLE, + /* size= */ 3, + /* offset= */ 0, + /* cryptoData= */ null); + return Extractor.RESULT_END_OF_INPUT; + }); + + mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0); + + assertThat(mediaExtractorCompat.getTrackCount()).isEqualTo(1); + mediaExtractorCompat.selectTrack(0); + mediaExtractorCompat.advance(); + // After skipping the only sample, there should be none left, and getSampleTime should return + // -1. + assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(-1); + assertThat(mediaExtractorCompat.getTrackFormat(0).getString(MediaFormat.KEY_MIME)) + .isEqualTo(PLACEHOLDER_FORMAT_VIDEO.sampleMimeType); + } + + @Test + public void setDataSource_withOutOfMemoryError_wrapsError() throws IOException { + fakeExtractor.addReadAction( + (input, seekPosition) -> { + throw new OutOfMemoryError(); + }); + ParserException exception = + assertThrows( + ParserException.class, + () -> mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0)); + assertThat(exception).hasCauseThat().isInstanceOf(OutOfMemoryError.class); + } + + @Test + public void getSampleTime_withOutOfMemoryError_producesEndOfInput() throws IOException { + // This boolean guarantees that this test remains useful. The throwing read action is being + // called as a result of an implementation detail (trying to parse a container file with no + // tracks) that could change in the future. Counting on this implementation detail simplifies + // the test. + AtomicBoolean outOfMemoryErrorWasThrown = new AtomicBoolean(false); + fakeExtractor.addReadAction( + (input, seekPosition) -> { + extractorOutput.endTracks(); + return Extractor.RESULT_CONTINUE; + }); + fakeExtractor.addReadAction( + (input, seekPosition) -> { + outOfMemoryErrorWasThrown.set(true); + throw new OutOfMemoryError(); + }); + mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0); + assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(-1); + assertThat(outOfMemoryErrorWasThrown.get()).isTrue(); + } + + @Test + public void setDataSource_withDolbyVision_generatesCompatibilityTrack() throws IOException { + TrackOutput[] outputs = new TrackOutput[1]; + byte[] sampleData = new byte[] {(byte) 1, (byte) 2, (byte) 3}; + fakeExtractor.addReadAction( + (input, seekPosition) -> { + outputs[0] = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO); + extractorOutput.endTracks(); + outputs[0].format( + new Format.Builder() + .setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION) + .setCodecs("hev1.08.10") + .build()); + return Extractor.RESULT_CONTINUE; + }); + fakeExtractor.addReadAction( + (input, seekPosition) -> { + outputSampleData(outputs[0], sampleData); + outputSample(outputs[0], /* timeUs= */ 7, /* size= */ 3, /* offset= */ 0); + return Extractor.RESULT_END_OF_INPUT; + }); + mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0); + assertThat(mediaExtractorCompat.getTrackCount()).isEqualTo(2); + assertThat(mediaExtractorCompat.getTrackFormat(0).getString(MediaFormat.KEY_MIME)) + .isEqualTo(MimeTypes.VIDEO_DOLBY_VISION); + assertThat(mediaExtractorCompat.getTrackFormat(1).getString(MediaFormat.KEY_MIME)) + .isEqualTo(MimeTypes.VIDEO_H265); + ByteBuffer scratchBuffer = ByteBuffer.allocate(3); + + mediaExtractorCompat.selectTrack(0); + mediaExtractorCompat.selectTrack(1); + + assertThat(mediaExtractorCompat.getSampleTrackIndex()).isEqualTo(1); + assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(7); + assertThat(mediaExtractorCompat.readSampleData(scratchBuffer, /* offset= */ 0)).isEqualTo(3); + assertThat(scratchBuffer.array()).isEqualTo(sampleData); + + assertThat(mediaExtractorCompat.advance()).isTrue(); + + assertThat(mediaExtractorCompat.getSampleTrackIndex()).isEqualTo(0); + assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(7); + assertThat(mediaExtractorCompat.readSampleData(scratchBuffer, /* offset= */ 0)).isEqualTo(3); + assertThat(scratchBuffer.array()).isEqualTo(sampleData); + + assertThat(mediaExtractorCompat.advance()).isFalse(); + } + + @Test + public void seekTo_resetsSampleQueues() throws IOException { + fakeExtractor.setSeekStrategy(fakeExtractor::rewindReadActions); + fakeExtractor.addReadAction( + (input, seekPosition) -> { + TrackOutput output = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO); + extractorOutput.endTracks(); + output.format(PLACEHOLDER_FORMAT_VIDEO); + outputSampleData(output, /* sampleData...= */ (byte) 0); + outputSample(output, /* timeUs= */ 7, /* size= */ 1, /* offset= */ 0); + return Extractor.RESULT_END_OF_INPUT; + }); + mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0); + mediaExtractorCompat.selectTrack(/* trackIndex= */ 0); + // Calling getSampleTime forces the extractor to populate the sample queues with the first + // sample. As a result, to pass this test, the tested implementation must clear the sample + // queues when seekTo is called. + assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(7); + mediaExtractorCompat.seekTo(/* timeUs= */ 0, MediaExtractorCompat.SEEK_TO_PREVIOUS_SYNC); + // Test the same sample (and only that sample) is read after the seek to the start. + assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(7); + assertThat(mediaExtractorCompat.advance()).isFalse(); + } + + @Test + public void readSampleData_usesOffsetArgumentInsteadOfBufferPosition() throws IOException { + byte[] sampleData = + new byte[] { + (byte) 1, (byte) 2, (byte) 3, (byte) 4, (byte) 5, (byte) 6, (byte) 7, (byte) 8, (byte) 9 + }; + fakeExtractor.addReadAction( + (input, seekPosition) -> { + TrackOutput output = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO); + extractorOutput.endTracks(); + output.format(PLACEHOLDER_FORMAT_VIDEO); + outputSampleData(output, sampleData); + outputSample(output, /* timeUs= */ 1, /* size= */ 2, /* offset= */ 7); + outputSample(output, /* timeUs= */ 2, /* size= */ 3, /* offset= */ 4); + outputSample(output, /* timeUs= */ 3, /* size= */ 4, /* offset= */ 0); + return Extractor.RESULT_END_OF_INPUT; + }); + mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0); + mediaExtractorCompat.selectTrack(/* trackIndex= */ 0); + ByteBuffer byteBuffer = ByteBuffer.allocate(9); + // Set the position to the limit to test that the position is ignored, like the platform media + // extractor implementation does. + byteBuffer.position(byteBuffer.limit()); + + assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(1); + assertThat(mediaExtractorCompat.readSampleData(byteBuffer, /* offset= */ 0)).isEqualTo(2); + assertThat(byteBuffer.position()).isEqualTo(0); + assertThat(byteBuffer.limit()).isEqualTo(2); + + mediaExtractorCompat.advance(); + assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(2); + assertThat(mediaExtractorCompat.readSampleData(byteBuffer, /* offset= */ 2)).isEqualTo(3); + assertThat(byteBuffer.position()).isEqualTo(2); + assertThat(byteBuffer.limit()).isEqualTo(5); + + mediaExtractorCompat.advance(); + assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(3); + assertThat(mediaExtractorCompat.readSampleData(byteBuffer, /* offset= */ 5)).isEqualTo(4); + assertThat(byteBuffer.position()).isEqualTo(5); + assertThat(byteBuffer.limit()).isEqualTo(9); + + assertThat(byteBuffer.array()).isEqualTo(sampleData); + assertThat(mediaExtractorCompat.advance()).isFalse(); + } + + @Test + public void advance_releasesMemoryOfSkippedSamples() throws IOException { + // This is a regression test for b/209801945. + Allocator allocator = mediaExtractorCompat.getAllocator(); + int individualAllocationSize = allocator.getIndividualAllocationLength(); + + fakeExtractor.addReadAction( + (input, seekPosition) -> { + TrackOutput output = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO); + extractorOutput.endTracks(); + output.format(PLACEHOLDER_FORMAT_VIDEO); + outputSampleData(output, new byte[individualAllocationSize]); + outputSample( + output, /* timeUs= */ 1, /* size= */ individualAllocationSize, /* offset= */ 0); + return Extractor.RESULT_CONTINUE; + }); + fakeExtractor.addReadAction( + (input, seekPosition) -> { + TrackOutput output = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO); + outputSampleData(output, new byte[individualAllocationSize * 2]); + outputSample( + output, + /* timeUs= */ 2, + /* size= */ individualAllocationSize, + /* offset= */ individualAllocationSize); + outputSample( + output, /* timeUs= */ 3, /* size= */ individualAllocationSize, /* offset= */ 0); + return Extractor.RESULT_END_OF_INPUT; + }); + assertThat(allocator.getTotalBytesAllocated()).isEqualTo(0); + + mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0); + mediaExtractorCompat.selectTrack(/* trackIndex= */ 0); + + assertThat(allocator.getTotalBytesAllocated()).isEqualTo(individualAllocationSize); + mediaExtractorCompat.advance(); + assertThat(allocator.getTotalBytesAllocated()).isEqualTo(individualAllocationSize * 2); + mediaExtractorCompat.advance(); + assertThat(allocator.getTotalBytesAllocated()).isEqualTo(individualAllocationSize); + mediaExtractorCompat.advance(); + assertThat(allocator.getTotalBytesAllocated()).isEqualTo(0); + } + + // Test for b/223910395. + @Test + public void seek_withUninterleavedFile_seeksToTheRightPosition() throws IOException { + // We don't use the global mediaExtractorCompat because we want to use a real extractor in this + // case, which is the Mp4 extractor. + MediaExtractorCompat mediaExtractorCompat = + new MediaExtractorCompat(ApplicationProvider.getApplicationContext()); + // The asset is an uninterleaved mp4 file. + mediaExtractorCompat.setDataSource( + Uri.parse("asset:///media/mp4/mv_with_2_top_shots.mp4"), /* offset= */ 0); + mediaExtractorCompat.selectTrack(1); + mediaExtractorCompat.seekTo(1773911, MediaExtractorCompat.SEEK_TO_PREVIOUS_SYNC); + assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(1773911); + } + + // Test for b/233756471. + @Test + public void seek_withException_producesEndOfInput() throws IOException { + Function seekPointsFunction = + (timesUs) -> { + // For the mid seek point we use an invalid position which will cause an IOException. We + // expect that exception to be treated as the end of input. + SeekPoint midSeekPoint = new SeekPoint(/* timeUs= */ 14, /* position= */ 1000); + return timesUs < 14 + ? new SeekPoints(SeekPoint.START, midSeekPoint) + : new SeekPoints(midSeekPoint); + }; + + fakeExtractor.setSeekStrategy(fakeExtractor::rewindReadActions); + fakeExtractor.addReadAction( + (input, seekPosition) -> { + TrackOutput output = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO); + extractorOutput.endTracks(); + output.format(PLACEHOLDER_FORMAT_VIDEO); + extractorOutput.seekMap(new FakeSeekMap(/* durationUs= */ 28, seekPointsFunction)); + outputSampleData(output, /* sampleData...= */ (byte) 0, (byte) 1, (byte) 2); + outputSample(output, /* timeUs= */ 7, /* size= */ 1, /* offset= */ 2); + outputSample(output, /* timeUs= */ 14, /* size= */ 1, /* offset= */ 1); + outputSample(output, /* timeUs= */ 21, /* size= */ 1, /* offset= */ 0); + return Extractor.RESULT_END_OF_INPUT; + }); + mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0); + mediaExtractorCompat.selectTrack(/* trackIndex= */ 0); + assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(7); + mediaExtractorCompat.advance(); + assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(14); + mediaExtractorCompat.advance(); + assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(21); + mediaExtractorCompat.advance(); + assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(-1); + + // This seek will cause the target position to be invalid, causing an IOException which should + // be treated as the end of input. + mediaExtractorCompat.seekTo(/* timeUs= */ 14, MediaExtractorCompat.SEEK_TO_CLOSEST_SYNC); + assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(-1); + + // This seek should go to position 0, which should be handled correctly again. + mediaExtractorCompat.seekTo(/* timeUs= */ 0, MediaExtractorCompat.SEEK_TO_CLOSEST_SYNC); + assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(7); + } + + // Internal methods. + + private void assertReadSample(int trackIndex, long timeUs, byte... sampleData) { + assertThat(mediaExtractorCompat.getSampleTrackIndex()).isEqualTo(trackIndex); + assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(timeUs); + ByteBuffer buffer = ByteBuffer.allocate(100); + assertThat(mediaExtractorCompat.readSampleData(buffer, /* offset= */ 0)) + .isEqualTo(sampleData.length); + for (int i = 0; i < buffer.remaining(); i++) { + assertThat(buffer.get()).isEqualTo(sampleData[i]); + } + } + + private static void outputSampleData(TrackOutput trackOutput, byte... sampleData) { + trackOutput.sampleData(new ParsableByteArray(sampleData), sampleData.length); + } + + private static void outputSample(TrackOutput trackOutput, long timeUs, int size, int offset) { + trackOutput.sampleMetadata( + timeUs, C.BUFFER_FLAG_KEY_FRAME, size, offset, /* cryptoData= */ null); + } + + /** + * Read action to verify that {@link MediaExtractorCompat} does not read more data than expected. + */ + private static int assertionFailureReadAction(ExtractorInput input, PositionHolder holder) { + throw new AssertionError("ExoPlayerBackedMediaExtractorProxy read more data than needed."); + } + + // Internal classes. + + private static class FakeSeekMap implements SeekMap { + + private final long durationUs; + private final Function seekPointsFunction; + + public FakeSeekMap() { + this(C.TIME_UNSET, (timeUs) -> new SeekPoints(SeekPoint.START)); + } + + public FakeSeekMap(long durationUs, Function seekPointsFunction) { + this.durationUs = durationUs; + this.seekPointsFunction = seekPointsFunction; + } + + @Override + public boolean isSeekable() { + return true; + } + + @Override + public long getDurationUs() { + return durationUs; + } + + @Override + public SeekPoints getSeekPoints(long timeUs) { + return seekPointsFunction.apply(timeUs); + } + } + + private class FakeExtractor implements Extractor { + + private final ArrayList> readActions; + private BiConsumer seekStrategy; + private int nextReadActionIndex; + + public FakeExtractor() { + readActions = new ArrayList<>(); + nextReadActionIndex = 0; + seekStrategy = (arg1, arg2) -> {}; + } + + public void addReadAction(BiFunction readAction) { + readActions.add(readAction); + } + + public void setSeekStrategy(BiConsumer seekStrategy) { + this.seekStrategy = seekStrategy; + } + + public void rewindReadActions(long position, long timeUs) { + nextReadActionIndex = 0; + } + + // Extractor implementation. + + @Override + public boolean sniff(ExtractorInput input) { + return true; + } + + @Override + public void init(ExtractorOutput extractorOutput) { + MediaExtractorCompatTest.this.extractorOutput = extractorOutput; + extractorOutput.seekMap(fakeSeekMap); + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) throws IOException { + if (nextReadActionIndex >= readActions.size()) { + return C.RESULT_END_OF_INPUT; + } else { + return readActions.get(nextReadActionIndex++).apply(input, seekPosition); + } + } + + @Override + public void seek(long position, long timeUs) { + seekStrategy.accept(position, timeUs); + } + + @Override + public void release() {} + } +} diff --git a/libraries/test_data/src/test/assets/media/mp4/mv_with_2_top_shots.mp4 b/libraries/test_data/src/test/assets/media/mp4/mv_with_2_top_shots.mp4 new file mode 100644 index 0000000000..ba7671c1eb Binary files /dev/null and b/libraries/test_data/src/test/assets/media/mp4/mv_with_2_top_shots.mp4 differ