diff --git a/build.gradle b/build.gradle index 4cf5e94806..2864587d3f 100644 --- a/build.gradle +++ b/build.gradle @@ -17,9 +17,11 @@ buildscript { repositories { mavenCentral() + jcenter() } dependencies { classpath 'com.android.tools.build:gradle:1.0.0' + classpath 'com.novoda:bintray-release:0.2.7' } } diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/DemoUtil.java b/demo/src/main/java/com/google/android/exoplayer/demo/DemoUtil.java index dae5773100..b021e37683 100644 --- a/demo/src/main/java/com/google/android/exoplayer/demo/DemoUtil.java +++ b/demo/src/main/java/com/google/android/exoplayer/demo/DemoUtil.java @@ -48,6 +48,7 @@ public class DemoUtil { public static final int TYPE_SS = 1; public static final int TYPE_OTHER = 2; public static final int TYPE_HLS = 3; + public static final int TYPE_MP4 = 4; private static final CookieManager defaultCookieManager; diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/EventLogger.java b/demo/src/main/java/com/google/android/exoplayer/demo/EventLogger.java index c9ece110b1..7f4c347706 100644 --- a/demo/src/main/java/com/google/android/exoplayer/demo/EventLogger.java +++ b/demo/src/main/java/com/google/android/exoplayer/demo/EventLogger.java @@ -163,6 +163,12 @@ public class EventLogger implements DemoPlayer.Listener, DemoPlayer.InfoListener printInternalError("cryptoError", e); } + @Override + public void onDecoderInitialized(String decoderName, long elapsedRealtimeMs, + long initializationDurationMs) { + Log.d(TAG, "decoderInitialized [" + getSessionTimeString() + "]"); + } + private void printInternalError(String type, Exception e) { Log.e(TAG, "internalError [" + getSessionTimeString() + ", " + type + "]", e); } diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/PlayerActivity.java b/demo/src/main/java/com/google/android/exoplayer/demo/PlayerActivity.java index e52bb377fd..fcf61fb276 100644 --- a/demo/src/main/java/com/google/android/exoplayer/demo/PlayerActivity.java +++ b/demo/src/main/java/com/google/android/exoplayer/demo/PlayerActivity.java @@ -24,8 +24,11 @@ import com.google.android.exoplayer.demo.player.DefaultRendererBuilder; import com.google.android.exoplayer.demo.player.DemoPlayer; import com.google.android.exoplayer.demo.player.DemoPlayer.RendererBuilder; import com.google.android.exoplayer.demo.player.HlsRendererBuilder; +import com.google.android.exoplayer.demo.player.Mp4RendererBuilder; import com.google.android.exoplayer.demo.player.SmoothStreamingRendererBuilder; import com.google.android.exoplayer.demo.player.UnsupportedDrmException; +import com.google.android.exoplayer.metadata.GeobMetadata; +import com.google.android.exoplayer.metadata.PrivMetadata; import com.google.android.exoplayer.metadata.TxxxMetadata; import com.google.android.exoplayer.text.CaptionStyleCompat; import com.google.android.exoplayer.text.SubtitleView; @@ -213,6 +216,8 @@ public class PlayerActivity extends Activity implements SurfaceHolder.Callback, new WidevineTestMediaDrmCallback(contentId), debugTextView, audioCapabilities); case DemoUtil.TYPE_HLS: return new HlsRendererBuilder(userAgent, contentUri.toString()); + case DemoUtil.TYPE_MP4: + return new Mp4RendererBuilder(contentUri, debugTextView); default: return new DefaultRendererBuilder(this, contentUri, debugTextView); } @@ -446,11 +451,22 @@ public class PlayerActivity extends Activity implements SurfaceHolder.Callback, @Override public void onId3Metadata(Map metadata) { - for (int i = 0; i < metadata.size(); i++) { - if (metadata.containsKey(TxxxMetadata.TYPE)) { - TxxxMetadata txxxMetadata = (TxxxMetadata) metadata.get(TxxxMetadata.TYPE); - Log.i(TAG, String.format("ID3 TimedMetadata: description=%s, value=%s", - txxxMetadata.description, txxxMetadata.value)); + for (Map.Entry entry : metadata.entrySet()) { + if (TxxxMetadata.TYPE.equals(entry.getKey())) { + TxxxMetadata txxxMetadata = (TxxxMetadata) entry.getValue(); + Log.i(TAG, String.format("ID3 TimedMetadata %s: description=%s, value=%s", + TxxxMetadata.TYPE, txxxMetadata.description, txxxMetadata.value)); + } else if (PrivMetadata.TYPE.equals(entry.getKey())) { + PrivMetadata privMetadata = (PrivMetadata) entry.getValue(); + Log.i(TAG, String.format("ID3 TimedMetadata %s: owner=%s", + PrivMetadata.TYPE, privMetadata.owner)); + } else if (GeobMetadata.TYPE.equals(entry.getKey())) { + GeobMetadata geobMetadata = (GeobMetadata) entry.getValue(); + Log.i(TAG, String.format("ID3 TimedMetadata %s: mimeType=%s, filename=%s, description=%s", + GeobMetadata.TYPE, geobMetadata.mimeType, geobMetadata.filename, + geobMetadata.description)); + } else { + Log.i(TAG, String.format("ID3 TimedMetadata %s", entry.getKey())); } } } diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/Samples.java b/demo/src/main/java/com/google/android/exoplayer/demo/Samples.java index a22e825960..d6e16785ff 100644 --- a/demo/src/main/java/com/google/android/exoplayer/demo/Samples.java +++ b/demo/src/main/java/com/google/android/exoplayer/demo/Samples.java @@ -103,7 +103,7 @@ import java.util.Locale; + "as=fmp4_audio_cenc,fmp4_sd_hd_cenc&sparams=ip,ipbits,expire,as&ip=0.0.0.0&ipbits=0" + "&expire=19000000000&signature=61611F115EEEC7BADE5536827343FFFE2D83D14F." + "2FDF4BFA502FB5865C5C86401314BDDEA4799BD0&key=ik0", DemoUtil.TYPE_DASH), - new Sample("WV: 30s license duration", "f9a34cab7b05881a", + new Sample("WV: 30s license duration (fails at ~30s)", "f9a34cab7b05881a", "http://www.youtube.com/api/manifest/dash/id/f9a34cab7b05881a/source/youtube?" + "as=fmp4_audio_cenc,fmp4_sd_hd_cenc&sparams=ip,ipbits,expire,as&ip=0.0.0.0&ipbits=0" + "&expire=19000000000&signature=88DC53943385CED8CF9F37ADD9E9843E3BF621E6." @@ -123,6 +123,8 @@ import java.util.Locale; new Sample("Apple AAC media playlist", "https://devimages.apple.com.edgekey.net/streaming/examples/bipbop_4x3/gear0/" + "prog_index.m3u8", DemoUtil.TYPE_HLS), + new Sample("Apple ID3 metadata", "http://devimages.apple.com/samplecode/adDemo/ad.m3u8", + DemoUtil.TYPE_HLS), }; public static final Sample[] MISC = new Sample[] { @@ -133,6 +135,12 @@ import java.util.Locale; new Sample("Apple AAC 10s", "https://devimages.apple.com.edgekey.net/" + "streaming/examples/bipbop_4x3/gear0/fileSequence0.aac", DemoUtil.TYPE_OTHER), + new Sample("Big Buck Bunny (MP4)", + "http://redirector.c.youtube.com/videoplayback?id=604ed5ce52eda7ee&itag=22&source=youtube" + + "&sparams=ip,ipbits,expire&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=" + + "2E853B992F6CAB9D28CA3BEBD84A6F26709A8A55.94344B0D8BA83A7417AAD24DACC8C71A9A878ECE" + + "&key=ik0", + DemoUtil.TYPE_MP4), }; private Samples() {} diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/player/DemoPlayer.java b/demo/src/main/java/com/google/android/exoplayer/demo/player/DemoPlayer.java index 338ec5b1db..c70b0e2a2f 100644 --- a/demo/src/main/java/com/google/android/exoplayer/demo/player/DemoPlayer.java +++ b/demo/src/main/java/com/google/android/exoplayer/demo/player/DemoPlayer.java @@ -129,6 +129,8 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi void onLoadStarted(int sourceId, String formatId, int trigger, boolean isInitialization, int mediaStartTimeMs, int mediaEndTimeMs, long length); void onLoadCompleted(int sourceId, long bytesLoaded); + void onDecoderInitialized(String decoderName, long elapsedRealtimeMs, + long initializationDurationMs); } /** @@ -477,6 +479,16 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi } } + @Override + public void onDecoderInitialized( + String decoderName, + long elapsedRealtimeMs, + long initializationDurationMs) { + if (infoListener != null) { + infoListener.onDecoderInitialized(decoderName, elapsedRealtimeMs, initializationDurationMs); + } + } + @Override public void onUpstreamError(int sourceId, IOException e) { if (internalErrorListener != null) { diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/player/Mp4RendererBuilder.java b/demo/src/main/java/com/google/android/exoplayer/demo/player/Mp4RendererBuilder.java new file mode 100644 index 0000000000..68fe2b8366 --- /dev/null +++ b/demo/src/main/java/com/google/android/exoplayer/demo/player/Mp4RendererBuilder.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2014 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.exoplayer.demo.player; + +import com.google.android.exoplayer.MediaCodecAudioTrackRenderer; +import com.google.android.exoplayer.MediaCodecVideoTrackRenderer; +import com.google.android.exoplayer.TrackRenderer; +import com.google.android.exoplayer.demo.player.DemoPlayer.RendererBuilder; +import com.google.android.exoplayer.demo.player.DemoPlayer.RendererBuilderCallback; +import com.google.android.exoplayer.source.DefaultSampleSource; +import com.google.android.exoplayer.source.Mp4SampleExtractor; +import com.google.android.exoplayer.upstream.DataSpec; +import com.google.android.exoplayer.upstream.UriDataSource; + +import android.media.MediaCodec; +import android.net.Uri; +import android.widget.TextView; + +/** + * A {@link RendererBuilder} for streams that can be read using {@link Mp4SampleExtractor}. + */ +public class Mp4RendererBuilder implements RendererBuilder { + + private final Uri uri; + private final TextView debugTextView; + + public Mp4RendererBuilder(Uri uri, TextView debugTextView) { + this.uri = uri; + this.debugTextView = debugTextView; + } + + @Override + public void buildRenderers(DemoPlayer player, RendererBuilderCallback callback) { + // Build the video and audio renderers. + DefaultSampleSource sampleSource = new DefaultSampleSource( + new Mp4SampleExtractor(new UriDataSource("exoplayer", null), new DataSpec(uri)), 2); + MediaCodecVideoTrackRenderer videoRenderer = new MediaCodecVideoTrackRenderer(sampleSource, + null, true, MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT, 5000, null, player.getMainHandler(), + player, 50); + MediaCodecAudioTrackRenderer audioRenderer = new MediaCodecAudioTrackRenderer(sampleSource, + null, true, player.getMainHandler(), player); + + // Build the debug renderer. + TrackRenderer debugRenderer = debugTextView != null + ? new DebugTrackRenderer(debugTextView, videoRenderer) + : null; + + // Invoke the callback. + TrackRenderer[] renderers = new TrackRenderer[DemoPlayer.RENDERER_COUNT]; + renderers[DemoPlayer.TYPE_VIDEO] = videoRenderer; + renderers[DemoPlayer.TYPE_AUDIO] = audioRenderer; + renderers[DemoPlayer.TYPE_DEBUG] = debugRenderer; + callback.onRenderers(null, null, renderers); + } + +} diff --git a/library/build.gradle b/library/build.gradle index 7c66a11161..6ba2e1ad6d 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. apply plugin: 'com.android.library' +apply plugin: 'bintray-release' android { compileSdkVersion 21 @@ -47,3 +48,13 @@ android.libraryVariants.all { variant -> task.from variant.javaCompile.destinationDir artifacts.add('archives', task); } + +publish { + repoName = 'exoplayer' + userOrg = 'google' + groupId = 'com.google.android.exoplayer' + artifactId = 'exoplayer' + version = 'r1.2.3' + description = 'The ExoPlayer library.' + website = 'https://github.com/google/ExoPlayer' +} diff --git a/library/src/main/java/com/google/android/exoplayer/Ac3PassthroughAudioTrackRenderer.java b/library/src/main/java/com/google/android/exoplayer/Ac3PassthroughAudioTrackRenderer.java index 44325b44ea..1c8c967963 100644 --- a/library/src/main/java/com/google/android/exoplayer/Ac3PassthroughAudioTrackRenderer.java +++ b/library/src/main/java/com/google/android/exoplayer/Ac3PassthroughAudioTrackRenderer.java @@ -281,7 +281,7 @@ public final class Ac3PassthroughAudioTrackRenderer extends TrackRenderer { protected void onDisabled() { audioSessionId = AudioTrack.SESSION_ID_NOT_SET; shouldReadInputBuffer = true; - audioTrack.reset(); + audioTrack.release(); } @Override diff --git a/library/src/main/java/com/google/android/exoplayer/C.java b/library/src/main/java/com/google/android/exoplayer/C.java index 6bf5e08c42..0c43fd50d0 100644 --- a/library/src/main/java/com/google/android/exoplayer/C.java +++ b/library/src/main/java/com/google/android/exoplayer/C.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer; +import android.media.MediaCodec; import android.media.MediaExtractor; /** @@ -43,11 +44,33 @@ public final class C { public static final String UTF8_NAME = "UTF-8"; /** - * Sample flag that indicates the sample is a synchronization sample. + * @see MediaCodec#CRYPTO_MODE_AES_CTR + */ + @SuppressWarnings("InlinedApi") + public static final int CRYPTO_MODE_AES_CTR = MediaCodec.CRYPTO_MODE_AES_CTR; + + /** + * @see MediaExtractor#SAMPLE_FLAG_SYNC */ @SuppressWarnings("InlinedApi") public static final int SAMPLE_FLAG_SYNC = MediaExtractor.SAMPLE_FLAG_SYNC; + /** + * @see MediaExtractor#SAMPLE_FLAG_ENCRYPTED + */ + @SuppressWarnings("InlinedApi") + public static final int SAMPLE_FLAG_ENCRYPTED = MediaExtractor.SAMPLE_FLAG_ENCRYPTED; + + /** + * Indicates that a sample should be decoded but not rendered. + */ + public static final int SAMPLE_FLAG_DECODE_ONLY = 0x8000000; + + /** + * A return value for methods where the end of an input was encountered. + */ + public static final int RESULT_END_OF_INPUT = -1; + private C() {} } diff --git a/library/src/main/java/com/google/android/exoplayer/ExoPlayer.java b/library/src/main/java/com/google/android/exoplayer/ExoPlayer.java index a5bc989a40..b6ec3c12d1 100644 --- a/library/src/main/java/com/google/android/exoplayer/ExoPlayer.java +++ b/library/src/main/java/com/google/android/exoplayer/ExoPlayer.java @@ -141,14 +141,6 @@ public interface ExoPlayer { return new ExoPlayerImpl(rendererCount, DEFAULT_MIN_BUFFER_MS, DEFAULT_MIN_REBUFFER_MS); } - /** - * @deprecated Please use {@link #newInstance(int, int, int)}. - */ - @Deprecated - public static ExoPlayer newInstance(int rendererCount, int minRebufferMs) { - return new ExoPlayerImpl(rendererCount, DEFAULT_MIN_BUFFER_MS, minRebufferMs); - } - } /** @@ -160,7 +152,8 @@ public interface ExoPlayer { * {@link ExoPlayer#getPlaybackState()} changes. * * @param playWhenReady Whether playback will proceed when ready. - * @param playbackState One of the {@code STATE} constants defined in this class. + * @param playbackState One of the {@code STATE} constants defined in the {@link ExoPlayer} + * interface. */ void onPlayerStateChanged(boolean playWhenReady, int playbackState); /** @@ -256,7 +249,7 @@ public interface ExoPlayer { /** * Returns the current state of the player. * - * @return One of the {@code STATE} constants defined in this class. + * @return One of the {@code STATE} constants defined in this interface. */ public int getPlaybackState(); diff --git a/library/src/main/java/com/google/android/exoplayer/MediaCodecAudioTrackRenderer.java b/library/src/main/java/com/google/android/exoplayer/MediaCodecAudioTrackRenderer.java index a6ff3b0a44..34b76d1a76 100644 --- a/library/src/main/java/com/google/android/exoplayer/MediaCodecAudioTrackRenderer.java +++ b/library/src/main/java/com/google/android/exoplayer/MediaCodecAudioTrackRenderer.java @@ -202,7 +202,7 @@ public class MediaCodecAudioTrackRenderer extends MediaCodecTrackRenderer { protected void onDisabled() { audioSessionId = AudioTrack.SESSION_ID_NOT_SET; try { - audioTrack.reset(); + audioTrack.release(); } finally { super.onDisabled(); } diff --git a/library/src/main/java/com/google/android/exoplayer/MediaCodecTrackRenderer.java b/library/src/main/java/com/google/android/exoplayer/MediaCodecTrackRenderer.java index 61b12e33b1..c7f3406582 100644 --- a/library/src/main/java/com/google/android/exoplayer/MediaCodecTrackRenderer.java +++ b/library/src/main/java/com/google/android/exoplayer/MediaCodecTrackRenderer.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer; import com.google.android.exoplayer.MediaCodecUtil.DecoderQueryException; +import com.google.android.exoplayer.drm.DrmInitData; import com.google.android.exoplayer.drm.DrmSessionManager; import com.google.android.exoplayer.util.Assertions; import com.google.android.exoplayer.util.Util; @@ -25,7 +26,6 @@ import android.media.MediaCodec; import android.media.MediaCodec.CodecException; import android.media.MediaCodec.CryptoException; import android.media.MediaCrypto; -import android.media.MediaExtractor; import android.os.Handler; import android.os.SystemClock; @@ -33,8 +33,6 @@ import java.io.IOException; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.List; -import java.util.Map; -import java.util.UUID; /** * An abstract {@link TrackRenderer} that uses {@link MediaCodec} to decode samples for rendering. @@ -61,6 +59,17 @@ public abstract class MediaCodecTrackRenderer extends TrackRenderer { */ void onCryptoError(CryptoException e); + /** + * Invoked when a decoder is successfully created. + * + * @param decoderName The decoder that was configured and created. + * @param elapsedRealtimeMs {@code elapsedRealtime} timestamp of when the initialization + * finished. + * @param initializationDurationMs Amount of time taken to initialize the decoder. + */ + void onDecoderInitialized(String decoderName, long elapsedRealtimeMs, + long initializationDurationMs); + } /** @@ -151,6 +160,23 @@ public abstract class MediaCodecTrackRenderer extends TrackRenderer { */ private static final int RECONFIGURATION_STATE_QUEUE_PENDING = 2; + /** + * The codec does not need to be re-initialized. + */ + private static final int REINITIALIZATION_STATE_NONE = 0; + /** + * The input format has changed in a way that requires the codec to be re-initialized, but we + * haven't yet signaled an end of stream to the existing codec. We need to do so in order to + * ensure that it outputs any remaining buffers before we release it. + */ + private static final int REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM = 1; + /** + * The input format has changed in a way that requires the codec to be re-initialized, and we've + * signaled an end of stream to the existing codec. We're waiting for the codec to output an end + * of stream signal to indicate that it has output any remaining buffers before we release it. + */ + private static final int REINITIALIZATION_STATE_WAIT_END_OF_STREAM = 2; + public final CodecCounters codecCounters; private final DrmSessionManager drmSessionManager; @@ -164,7 +190,7 @@ public abstract class MediaCodecTrackRenderer extends TrackRenderer { protected final Handler eventHandler; private MediaFormat format; - private Map drmInitData; + private DrmInitData drmInitData; private MediaCodec codec; private boolean codecIsAdaptive; private ByteBuffer[] inputBuffers; @@ -175,6 +201,8 @@ public abstract class MediaCodecTrackRenderer extends TrackRenderer { private boolean openedDrmSession; private boolean codecReconfigured; private int codecReconfigurationState; + private int codecReinitializationState; + private boolean codecHasQueuedBuffers; private int trackIndex; private int sourceState; @@ -210,6 +238,8 @@ public abstract class MediaCodecTrackRenderer extends TrackRenderer { formatHolder = new MediaFormatHolder(); decodeOnlyPresentationTimestamps = new ArrayList(); outputBufferInfo = new MediaCodec.BufferInfo(); + codecReconfigurationState = RECONFIGURATION_STATE_NONE; + codecReinitializationState = REINITIALIZATION_STATE_NONE; } @Override @@ -281,7 +311,7 @@ public abstract class MediaCodecTrackRenderer extends TrackRenderer { throw new ExoPlaybackException("Media requires a DrmSessionManager"); } if (!openedDrmSession) { - drmSessionManager.open(drmInitData, mimeType); + drmSessionManager.open(drmInitData); openedDrmSession = true; } int drmSessionState = drmSessionManager.getState(); @@ -313,9 +343,13 @@ public abstract class MediaCodecTrackRenderer extends TrackRenderer { String decoderName = decoderInfo.name; codecIsAdaptive = decoderInfo.adaptive; try { + long codecInitializingTimestamp = SystemClock.elapsedRealtime(); codec = MediaCodec.createByCodecName(decoderName); configureCodec(codec, format.getFrameworkMediaFormatV16(), mediaCrypto); codec.start(); + long codecInitializedTimestamp = SystemClock.elapsedRealtime(); + notifyDecoderInitialized(decoderName, codecInitializedTimestamp, + codecInitializedTimestamp - codecInitializingTimestamp); inputBuffers = codec.getInputBuffers(); outputBuffers = codec.getOutputBuffers(); } catch (Exception e) { @@ -370,12 +404,15 @@ public abstract class MediaCodecTrackRenderer extends TrackRenderer { codecHotswapTimeMs = -1; inputIndex = -1; outputIndex = -1; + waitingForKeys = false; decodeOnlyPresentationTimestamps.clear(); inputBuffers = null; outputBuffers = null; codecReconfigured = false; + codecHasQueuedBuffers = false; codecIsAdaptive = false; codecReconfigurationState = RECONFIGURATION_STATE_NONE; + codecReinitializationState = REINITIALIZATION_STATE_NONE; codecCounters.codecReleaseCount++; try { codec.stop(); @@ -418,7 +455,6 @@ public abstract class MediaCodecTrackRenderer extends TrackRenderer { sourceState = SOURCE_STATE_NOT_READY; inputStreamEnded = false; outputStreamEnded = false; - waitingForKeys = false; } @Override @@ -478,11 +514,13 @@ public abstract class MediaCodecTrackRenderer extends TrackRenderer { inputIndex = -1; outputIndex = -1; waitingForFirstSyncFrame = true; + waitingForKeys = false; decodeOnlyPresentationTimestamps.clear(); // Workaround for framework bugs. // See [Internal: b/8347958], [Internal: b/8578467], [Internal: b/8543366]. - if (Util.SDK_INT >= 18) { + if (Util.SDK_INT >= 18 && codecReinitializationState == REINITIALIZATION_STATE_NONE) { codec.flush(); + codecHasQueuedBuffers = false; } else { releaseCodec(); maybeInitCodec(); @@ -502,9 +540,13 @@ public abstract class MediaCodecTrackRenderer extends TrackRenderer { * @throws ExoPlaybackException If an error occurs feeding the input buffer. */ private boolean feedInputBuffer(boolean firstFeed) throws IOException, ExoPlaybackException { - if (inputStreamEnded) { + if (inputStreamEnded + || codecReinitializationState == REINITIALIZATION_STATE_WAIT_END_OF_STREAM) { + // The input stream has ended, or we need to re-initialize the codec but are still waiting + // for the existing codec to output any final output buffers. return false; } + if (inputIndex < 0) { inputIndex = codec.dequeueInputBuffer(0); if (inputIndex < 0) { @@ -514,6 +556,15 @@ public abstract class MediaCodecTrackRenderer extends TrackRenderer { sampleHolder.data.clear(); } + if (codecReinitializationState == REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM) { + // We need to re-initialize the codec. Send an end of stream signal to the existing codec so + // that it outputs any remaining buffers before we release it. + codec.queueInputBuffer(inputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM); + inputIndex = -1; + codecReinitializationState = REINITIALIZATION_STATE_WAIT_END_OF_STREAM; + return false; + } + int result; if (waitingForKeys) { // We've already read an encrypted sample into sampleHolder, and are waiting for keys. @@ -572,7 +623,7 @@ public abstract class MediaCodecTrackRenderer extends TrackRenderer { if (waitingForFirstSyncFrame) { // TODO: Find out if it's possible to supply samples prior to the first sync // frame for HE-AAC. - if ((sampleHolder.flags & C.SAMPLE_FLAG_SYNC) == 0) { + if (!sampleHolder.isSyncFrame()) { sampleHolder.data.clear(); if (codecReconfigurationState == RECONFIGURATION_STATE_QUEUE_PENDING) { // The buffer we just cleared contained reconfiguration data. We need to re-write this @@ -583,7 +634,7 @@ public abstract class MediaCodecTrackRenderer extends TrackRenderer { } waitingForFirstSyncFrame = false; } - boolean sampleEncrypted = (sampleHolder.flags & MediaExtractor.SAMPLE_FLAG_ENCRYPTED) != 0; + boolean sampleEncrypted = sampleHolder.isEncrypted(); waitingForKeys = shouldWaitForKeys(sampleEncrypted); if (waitingForKeys) { return false; @@ -592,7 +643,7 @@ public abstract class MediaCodecTrackRenderer extends TrackRenderer { int bufferSize = sampleHolder.data.position(); int adaptiveReconfigurationBytes = bufferSize - sampleHolder.size; long presentationTimeUs = sampleHolder.timeUs; - if (sampleHolder.decodeOnly) { + if (sampleHolder.isDecodeOnly()) { decodeOnlyPresentationTimestamps.add(presentationTimeUs); } if (sampleEncrypted) { @@ -603,6 +654,7 @@ public abstract class MediaCodecTrackRenderer extends TrackRenderer { codec.queueInputBuffer(inputIndex, 0 , bufferSize, presentationTimeUs, 0); } inputIndex = -1; + codecHasQueuedBuffers = true; codecReconfigurationState = RECONFIGURATION_STATE_NONE; } catch (CryptoException e) { notifyCryptoError(e); @@ -656,8 +708,14 @@ public abstract class MediaCodecTrackRenderer extends TrackRenderer { codecReconfigured = true; codecReconfigurationState = RECONFIGURATION_STATE_WRITE_PENDING; } else { - releaseCodec(); - maybeInitCodec(); + if (codecHasQueuedBuffers) { + // Signal end of stream and wait for any final output buffers before re-initialization. + codecReinitializationState = REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM; + } else { + // There aren't any final output buffers, so perform re-initialization immediately. + releaseCodec(); + maybeInitCodec(); + } } } @@ -745,7 +803,13 @@ public abstract class MediaCodecTrackRenderer extends TrackRenderer { } if ((outputBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { - outputStreamEnded = true; + if (codecReinitializationState == REINITIALIZATION_STATE_WAIT_END_OF_STREAM) { + // We're waiting to re-initialize the codec, and have now received all final output buffers. + releaseCodec(); + maybeInitCodec(); + } else { + outputStreamEnded = true; + } return false; } @@ -797,6 +861,19 @@ public abstract class MediaCodecTrackRenderer extends TrackRenderer { } } + private void notifyDecoderInitialized(final String decoderName, + final long initializedTimestamp, final long initializationDuration) { + if (eventHandler != null && eventListener != null) { + eventHandler.post(new Runnable() { + @Override + public void run() { + eventListener.onDecoderInitialized(decoderName, initializedTimestamp, + initializationDuration); + } + }); + } + } + private int getDecodeOnlyIndex(long presentationTimeUs) { final int size = decodeOnlyPresentationTimestamps.size(); for (int i = 0; i < size; i++) { diff --git a/library/src/main/java/com/google/android/exoplayer/MediaCodecUtil.java b/library/src/main/java/com/google/android/exoplayer/MediaCodecUtil.java index f3dbc7e7d1..5dbe3c5a11 100644 --- a/library/src/main/java/com/google/android/exoplayer/MediaCodecUtil.java +++ b/library/src/main/java/com/google/android/exoplayer/MediaCodecUtil.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer; +import com.google.android.exoplayer.util.Assertions; import com.google.android.exoplayer.util.MimeTypes; import com.google.android.exoplayer.util.Util; @@ -179,6 +180,33 @@ public class MediaCodecUtil { return capabilities.isFeatureSupported(CodecCapabilities.FEATURE_AdaptivePlayback); } + /** + * Tests whether the device advertises it can decode video of a given type at a specified + * width, height, and frame rate. + *

+ * Must not be called if the device SDK version is less than 21. + * + * @param mimeType The mime type. + * @param secure Whether the decoder is required to support secure decryption. Always pass false + * unless secure decryption really is required. + * @param width Width in pixels. + * @param height Height in pixels. + * @param frameRate Frame rate in frames per second. + * @return Whether the decoder advertises support of the given size and frame rate. + */ + @TargetApi(21) + public static boolean isSizeAndRateSupportedV21(String mimeType, boolean secure, + int width, int height, double frameRate) throws DecoderQueryException { + Assertions.checkState(Util.SDK_INT >= 21); + Pair info = getMediaCodecInfo(mimeType, secure); + if (info == null) { + return false; + } + MediaCodecInfo.VideoCapabilities videoCapabilities = info.second.getVideoCapabilities(); + return videoCapabilities != null + && videoCapabilities.areSizeAndRateSupported(width, height, frameRate); + } + /** * @param profile An AVC profile constant from {@link CodecProfileLevel}. * @param level An AVC profile level from {@link CodecProfileLevel}. diff --git a/library/src/main/java/com/google/android/exoplayer/MediaCodecVideoTrackRenderer.java b/library/src/main/java/com/google/android/exoplayer/MediaCodecVideoTrackRenderer.java index 685b32eff3..fbf3e1500c 100644 --- a/library/src/main/java/com/google/android/exoplayer/MediaCodecVideoTrackRenderer.java +++ b/library/src/main/java/com/google/android/exoplayer/MediaCodecVideoTrackRenderer.java @@ -373,6 +373,13 @@ public class MediaCodecVideoTrackRenderer extends MediaCodecTrackRenderer { : holder.format.pixelWidthHeightRatio; } + /** + * @return True if the first frame has been rendered (playback has not necessarily begun). + */ + protected final boolean haveRenderedFirstFrame() { + return renderedFirstFrame; + } + @Override protected void onOutputFormatChanged(android.media.MediaFormat format) { boolean hasCrop = format.containsKey(KEY_CROP_RIGHT) && format.containsKey(KEY_CROP_LEFT) @@ -427,7 +434,6 @@ public class MediaCodecVideoTrackRenderer extends MediaCodecTrackRenderer { if (!renderedFirstFrame) { renderOutputBufferImmediate(codec, bufferIndex); - renderedFirstFrame = true; return true; } @@ -463,14 +469,14 @@ public class MediaCodecVideoTrackRenderer extends MediaCodecTrackRenderer { return false; } - private void skipOutputBuffer(MediaCodec codec, int bufferIndex) { + protected void skipOutputBuffer(MediaCodec codec, int bufferIndex) { TraceUtil.beginSection("skipVideoBuffer"); codec.releaseOutputBuffer(bufferIndex, false); TraceUtil.endSection(); codecCounters.skippedOutputBufferCount++; } - private void dropOutputBuffer(MediaCodec codec, int bufferIndex) { + protected void dropOutputBuffer(MediaCodec codec, int bufferIndex) { TraceUtil.beginSection("dropVideoBuffer"); codec.releaseOutputBuffer(bufferIndex, false); TraceUtil.endSection(); @@ -481,22 +487,24 @@ public class MediaCodecVideoTrackRenderer extends MediaCodecTrackRenderer { } } - private void renderOutputBufferImmediate(MediaCodec codec, int bufferIndex) { + protected void renderOutputBufferImmediate(MediaCodec codec, int bufferIndex) { maybeNotifyVideoSizeChanged(); TraceUtil.beginSection("renderVideoBufferImmediate"); codec.releaseOutputBuffer(bufferIndex, true); TraceUtil.endSection(); codecCounters.renderedOutputBufferCount++; + renderedFirstFrame = true; maybeNotifyDrawnToSurface(); } @TargetApi(21) - private void renderOutputBufferTimedV21(MediaCodec codec, int bufferIndex, long releaseTimeNs) { + protected void renderOutputBufferTimedV21(MediaCodec codec, int bufferIndex, long releaseTimeNs) { maybeNotifyVideoSizeChanged(); TraceUtil.beginSection("releaseOutputBufferTimed"); codec.releaseOutputBuffer(bufferIndex, releaseTimeNs); TraceUtil.endSection(); codecCounters.renderedOutputBufferCount++; + renderedFirstFrame = true; maybeNotifyDrawnToSurface(); } diff --git a/library/src/main/java/com/google/android/exoplayer/MediaFormat.java b/library/src/main/java/com/google/android/exoplayer/MediaFormat.java index 5d1bf3945b..00db53fe2c 100644 --- a/library/src/main/java/com/google/android/exoplayer/MediaFormat.java +++ b/library/src/main/java/com/google/android/exoplayer/MediaFormat.java @@ -40,6 +40,8 @@ public class MediaFormat { public final String mimeType; public final int maxInputSize; + public final long durationUs; + public final int width; public final int height; public final float pixelWidthHeightRatio; @@ -49,11 +51,11 @@ public class MediaFormat { public final int bitrate; + public final List initializationData; + private int maxWidth; private int maxHeight; - public final List initializationData; - // Lazy-initialized hashcode. private int hashCode; // Possibly-lazy-initialized framework media format. @@ -66,25 +68,38 @@ public class MediaFormat { public static MediaFormat createVideoFormat(String mimeType, int maxInputSize, int width, int height, List initializationData) { - return createVideoFormat(mimeType, maxInputSize, width, height, 1, initializationData); + return createVideoFormat( + mimeType, maxInputSize, C.UNKNOWN_TIME_US, width, height, initializationData); } - public static MediaFormat createVideoFormat(String mimeType, int maxInputSize, int width, - int height, float pixelWidthHeightRatio, List initializationData) { - return new MediaFormat(mimeType, maxInputSize, width, height, pixelWidthHeightRatio, NO_VALUE, - NO_VALUE, NO_VALUE, initializationData); + public static MediaFormat createVideoFormat(String mimeType, int maxInputSize, long durationUs, + int width, int height, List initializationData) { + return createVideoFormat( + mimeType, maxInputSize, durationUs, width, height, 1, initializationData); + } + + public static MediaFormat createVideoFormat(String mimeType, int maxInputSize, long durationUs, + int width, int height, float pixelWidthHeightRatio, List initializationData) { + return new MediaFormat(mimeType, maxInputSize, durationUs, width, height, pixelWidthHeightRatio, + NO_VALUE, NO_VALUE, NO_VALUE, initializationData); } public static MediaFormat createAudioFormat(String mimeType, int maxInputSize, int channelCount, int sampleRate, List initializationData) { - return new MediaFormat(mimeType, maxInputSize, NO_VALUE, NO_VALUE, NO_VALUE, channelCount, - sampleRate, NO_VALUE, initializationData); + return createAudioFormat( + mimeType, maxInputSize, C.UNKNOWN_TIME_US, channelCount, sampleRate, initializationData); } - public static MediaFormat createAudioFormat(String mimeType, int maxInputSize, int channelCount, - int sampleRate, int bitrate, List initializationData) { - return new MediaFormat(mimeType, maxInputSize, NO_VALUE, NO_VALUE, NO_VALUE, channelCount, - sampleRate, bitrate, initializationData); + public static MediaFormat createAudioFormat(String mimeType, int maxInputSize, long durationUs, + int channelCount, int sampleRate, List initializationData) { + return createAudioFormat( + mimeType, maxInputSize, durationUs, channelCount, sampleRate, NO_VALUE, initializationData); + } + + public static MediaFormat createAudioFormat(String mimeType, int maxInputSize, long durationUs, + int channelCount, int sampleRate, int bitrate, List initializationData) { + return new MediaFormat(mimeType, maxInputSize, durationUs, NO_VALUE, NO_VALUE, NO_VALUE, + channelCount, sampleRate, bitrate, initializationData); } public static MediaFormat createId3Format() { @@ -100,8 +115,8 @@ public class MediaFormat { } public static MediaFormat createFormatForMimeType(String mimeType) { - return new MediaFormat(mimeType, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, - NO_VALUE, null); + return new MediaFormat(mimeType, NO_VALUE, C.UNKNOWN_TIME_US, NO_VALUE, NO_VALUE, NO_VALUE, + NO_VALUE, NO_VALUE, NO_VALUE, null); } @TargetApi(16) @@ -123,15 +138,18 @@ public class MediaFormat { initializationData.add(data); buffer.flip(); } + durationUs = format.containsKey(android.media.MediaFormat.KEY_DURATION) + ? format.getLong(android.media.MediaFormat.KEY_DURATION) : C.UNKNOWN_TIME_US; maxWidth = NO_VALUE; maxHeight = NO_VALUE; } - private MediaFormat(String mimeType, int maxInputSize, int width, int height, + private MediaFormat(String mimeType, int maxInputSize, long durationUs, int width, int height, float pixelWidthHeightRatio, int channelCount, int sampleRate, int bitrate, List initializationData) { this.mimeType = mimeType; this.maxInputSize = maxInputSize; + this.durationUs = durationUs; this.width = width; this.height = height; this.pixelWidthHeightRatio = pixelWidthHeightRatio; @@ -169,6 +187,7 @@ public class MediaFormat { result = 31 * result + width; result = 31 * result + height; result = 31 * result + Float.floatToRawIntBits(pixelWidthHeightRatio); + result = 31 * result + (int) durationUs; result = 31 * result + maxWidth; result = 31 * result + maxHeight; result = 31 * result + channelCount; @@ -225,7 +244,7 @@ public class MediaFormat { public String toString() { return "MediaFormat(" + mimeType + ", " + maxInputSize + ", " + width + ", " + height + ", " + pixelWidthHeightRatio + ", " + channelCount + ", " + sampleRate + ", " + bitrate + ", " - + maxWidth + ", " + maxHeight + ")"; + + durationUs + ", " + maxWidth + ", " + maxHeight + ")"; } /** @@ -246,6 +265,9 @@ public class MediaFormat { for (int i = 0; i < initializationData.size(); i++) { format.setByteBuffer("csd-" + i, ByteBuffer.wrap(initializationData.get(i))); } + if (durationUs != C.UNKNOWN_TIME_US) { + format.setLong(android.media.MediaFormat.KEY_DURATION, durationUs); + } maybeSetMaxDimensionsV16(format); frameworkMediaFormat = format; } diff --git a/library/src/main/java/com/google/android/exoplayer/MediaFormatHolder.java b/library/src/main/java/com/google/android/exoplayer/MediaFormatHolder.java index 621a0f7986..fef993b945 100644 --- a/library/src/main/java/com/google/android/exoplayer/MediaFormatHolder.java +++ b/library/src/main/java/com/google/android/exoplayer/MediaFormatHolder.java @@ -15,8 +15,7 @@ */ package com.google.android.exoplayer; -import java.util.Map; -import java.util.UUID; +import com.google.android.exoplayer.drm.DrmInitData; /** * Holds a {@link MediaFormat} and corresponding drm scheme initialization data. @@ -28,9 +27,8 @@ public final class MediaFormatHolder { */ public MediaFormat format; /** - * Initialization data for each of the drm schemes supported by the media, keyed by scheme UUID. - * Null if the media is not encrypted. + * Initialization data for drm schemes supported by the media. Null if the media is not encrypted. */ - public Map drmInitData; + public DrmInitData drmInitData; } diff --git a/library/src/main/java/com/google/android/exoplayer/SampleHolder.java b/library/src/main/java/com/google/android/exoplayer/SampleHolder.java index 9a258b752c..59873e5921 100644 --- a/library/src/main/java/com/google/android/exoplayer/SampleHolder.java +++ b/library/src/main/java/com/google/android/exoplayer/SampleHolder.java @@ -50,9 +50,8 @@ public final class SampleHolder { public int size; /** - * Flags that accompany the sample. A combination of - * {@link android.media.MediaExtractor#SAMPLE_FLAG_SYNC} and - * {@link android.media.MediaExtractor#SAMPLE_FLAG_ENCRYPTED} + * Flags that accompany the sample. A combination of {@link C#SAMPLE_FLAG_SYNC}, + * {@link C#SAMPLE_FLAG_ENCRYPTED} and {@link C#SAMPLE_FLAG_DECODE_ONLY}. */ public int flags; @@ -61,11 +60,6 @@ public final class SampleHolder { */ public long timeUs; - /** - * If true then the sample should be decoded, but should not be presented. - */ - public boolean decodeOnly; - private final int bufferReplacementMode; /** @@ -96,6 +90,27 @@ public final class SampleHolder { return false; } + /** + * Returns whether {@link #flags} has {@link C#SAMPLE_FLAG_ENCRYPTED} set. + */ + public boolean isEncrypted() { + return (flags & C.SAMPLE_FLAG_ENCRYPTED) != 0; + } + + /** + * Returns whether {@link #flags} has {@link C#SAMPLE_FLAG_DECODE_ONLY} set. + */ + public boolean isDecodeOnly() { + return (flags & C.SAMPLE_FLAG_DECODE_ONLY) != 0; + } + + /** + * Returns whether {@link #flags} has {@link C#SAMPLE_FLAG_SYNC} set. + */ + public boolean isSyncFrame() { + return (flags & C.SAMPLE_FLAG_SYNC) != 0; + } + /** * Clears {@link #data}. Does nothing if {@link #data} is null. */ diff --git a/library/src/main/java/com/google/android/exoplayer/audio/AudioTrack.java b/library/src/main/java/com/google/android/exoplayer/audio/AudioTrack.java index 9c33014da8..c589b813d3 100644 --- a/library/src/main/java/com/google/android/exoplayer/audio/AudioTrack.java +++ b/library/src/main/java/com/google/android/exoplayer/audio/AudioTrack.java @@ -44,6 +44,8 @@ import java.nio.ByteBuffer; *

Call {@link #reconfigure} when the output format changes. * *

Call {@link #reset} to free resources. It is safe to re-{@link #initialize} the instance. + * + *

Call {@link #release} when the instance will no longer be used. */ @TargetApi(16) public final class AudioTrack { @@ -91,6 +93,12 @@ public final class AudioTrack { /** Returned by {@link #getCurrentPositionUs} when the position is not set. */ public static final long CURRENT_POSITION_NOT_SET = Long.MIN_VALUE; + /** + * Set to {@code true} to enable a workaround for an issue where an audio effect does not keep its + * session active across releasing/initializing a new audio track, on platform API version < 21. + */ + private static final boolean ENABLE_PRE_V21_AUDIO_SESSION_WORKAROUND = false; + /** A minimum length for the {@link android.media.AudioTrack} buffer, in microseconds. */ private static final long MIN_BUFFER_DURATION_US = 250000; /** A maximum length for the {@link android.media.AudioTrack} buffer, in microseconds. */ @@ -132,6 +140,9 @@ public final class AudioTrack { private final ConditionVariable releasingConditionVariable; private final long[] playheadOffsets; + /** Used to keep the audio session active on pre-V21 builds (see {@link #initialize()}). */ + private android.media.AudioTrack keepSessionIdAudioTrack; + private android.media.AudioTrack audioTrack; private AudioTrackUtil audioTrackUtil; private int sampleRate; @@ -267,15 +278,37 @@ public final class AudioTrack { audioTrack = new android.media.AudioTrack(AudioManager.STREAM_MUSIC, sampleRate, channelConfig, encoding, bufferSize, android.media.AudioTrack.MODE_STREAM, sessionId); } - checkAudioTrackInitialized(); + + sessionId = audioTrack.getAudioSessionId(); + if (ENABLE_PRE_V21_AUDIO_SESSION_WORKAROUND) { + if (Util.SDK_INT < 21) { + // The workaround creates an audio track with a one byte buffer on the same session, and + // does not release it until this object is released, which keeps the session active. + if (keepSessionIdAudioTrack != null + && sessionId != keepSessionIdAudioTrack.getAudioSessionId()) { + releaseKeepSessionIdAudioTrack(); + } + if (keepSessionIdAudioTrack == null) { + int sampleRate = 4000; // Equal to private android.media.AudioTrack.MIN_SAMPLE_RATE. + int channelConfig = AudioFormat.CHANNEL_OUT_MONO; + int encoding = AudioFormat.ENCODING_PCM_8BIT; + int bufferSize = 1; // Use a one byte buffer, as it is not actually used for playback. + keepSessionIdAudioTrack = new android.media.AudioTrack(AudioManager.STREAM_MUSIC, + sampleRate, channelConfig, encoding, bufferSize, android.media.AudioTrack.MODE_STATIC, + sessionId); + } + } + } + if (Util.SDK_INT >= 19) { audioTrackUtil = new AudioTrackUtilV19(audioTrack); } else { audioTrackUtil = new AudioTrackUtil(audioTrack); } setVolume(volume); - return audioTrack.getAudioSessionId(); + + return sessionId; } /** @@ -515,9 +548,9 @@ public final class AudioTrack { } /** - * Releases resources associated with this instance asynchronously. Calling {@link #initialize} - * will block until the audio track has been released, so it is safe to initialize immediately - * after resetting. + * Releases the underlying audio track asynchronously. Calling {@link #initialize} will block + * until the audio track has been released, so it is safe to initialize immediately after + * resetting. The audio session may remain active until the instance is {@link #release}d. */ public void reset() { if (isInitialized()) { @@ -547,6 +580,29 @@ public final class AudioTrack { } } + /** Releases all resources associated with this instance. */ + public void release() { + reset(); + releaseKeepSessionIdAudioTrack(); + } + + /** Releases {@link #keepSessionIdAudioTrack} asynchronously, if it is non-{@code null}. */ + private void releaseKeepSessionIdAudioTrack() { + if (keepSessionIdAudioTrack == null) { + return; + } + + // AudioTrack.release can take some time, so we call it on a background thread. + final android.media.AudioTrack toRelease = keepSessionIdAudioTrack; + keepSessionIdAudioTrack = null; + new Thread() { + @Override + public void run() { + toRelease.release(); + } + }.start(); + } + /** Returns whether {@link #getCurrentPositionUs} can return the current playback position. */ private boolean hasCurrentPositionUs() { return isInitialized() && startMediaTimeUs != START_NOT_SET; diff --git a/library/src/main/java/com/google/android/exoplayer/chunk/ChunkSampleSource.java b/library/src/main/java/com/google/android/exoplayer/chunk/ChunkSampleSource.java index 78bb18f2b6..581513b6ae 100644 --- a/library/src/main/java/com/google/android/exoplayer/chunk/ChunkSampleSource.java +++ b/library/src/main/java/com/google/android/exoplayer/chunk/ChunkSampleSource.java @@ -352,13 +352,14 @@ public class ChunkSampleSource implements SampleSource, Loader.Callback { if (mediaFormat != null && !mediaFormat.equals(downstreamMediaFormat, true)) { chunkSource.getMaxVideoDimensions(mediaFormat); formatHolder.format = mediaFormat; - formatHolder.drmInitData = mediaChunk.getPsshInfo(); + formatHolder.drmInitData = mediaChunk.getDrmInitData(); downstreamMediaFormat = mediaFormat; return FORMAT_READ; } if (mediaChunk.read(sampleHolder)) { - sampleHolder.decodeOnly = frameAccurateSeeking && sampleHolder.timeUs < lastSeekPositionUs; + boolean decodeOnly = frameAccurateSeeking && sampleHolder.timeUs < lastSeekPositionUs; + sampleHolder.flags |= decodeOnly ? C.SAMPLE_FLAG_DECODE_ONLY : 0; onSampleRead(mediaChunk, sampleHolder); return SAMPLE_READ; } else { diff --git a/library/src/main/java/com/google/android/exoplayer/chunk/ContainerMediaChunk.java b/library/src/main/java/com/google/android/exoplayer/chunk/ContainerMediaChunk.java index a3eefa9b5c..b19fd190af 100644 --- a/library/src/main/java/com/google/android/exoplayer/chunk/ContainerMediaChunk.java +++ b/library/src/main/java/com/google/android/exoplayer/chunk/ContainerMediaChunk.java @@ -19,14 +19,12 @@ import com.google.android.exoplayer.MediaFormat; import com.google.android.exoplayer.ParserException; import com.google.android.exoplayer.SampleHolder; import com.google.android.exoplayer.chunk.parser.Extractor; +import com.google.android.exoplayer.drm.DrmInitData; import com.google.android.exoplayer.upstream.DataSource; import com.google.android.exoplayer.upstream.DataSpec; import com.google.android.exoplayer.upstream.NonBlockingInputStream; import com.google.android.exoplayer.util.Assertions; -import java.util.Map; -import java.util.UUID; - /** * A {@link MediaChunk} extracted from a container. */ @@ -38,7 +36,7 @@ public final class ContainerMediaChunk extends MediaChunk { private boolean prepared; private MediaFormat mediaFormat; - private Map psshInfo; + private DrmInitData drmInitData; /** * @deprecated Use the other constructor, passing null as {@code psshInfo}. @@ -60,8 +58,9 @@ public final class ContainerMediaChunk extends MediaChunk { * @param endTimeUs The end time of the media contained by the chunk, in microseconds. * @param nextChunkIndex The index of the next chunk, or -1 if this is the last chunk. * @param extractor The extractor that will be used to extract the samples. - * @param psshInfo Pssh data. May be null if pssh data is present within the stream, meaning it - * can be obtained directly from {@code extractor}, or if no pssh data is required. + * @param drmInitData DRM initialization data. May be null if DRM initialization data is present + * within the stream, meaning it can be obtained directly from {@code extractor}, or if no + * DRM initialization data is required. * @param maybeSelfContained Set to true if this chunk might be self contained, meaning it might * contain a moov atom defining the media format of the chunk. This parameter can always be * safely set to true. Setting to false where the chunk is known to not be self contained may @@ -70,12 +69,12 @@ public final class ContainerMediaChunk extends MediaChunk { */ public ContainerMediaChunk(DataSource dataSource, DataSpec dataSpec, Format format, int trigger, long startTimeUs, long endTimeUs, int nextChunkIndex, Extractor extractor, - Map psshInfo, boolean maybeSelfContained, long sampleOffsetUs) { + DrmInitData drmInitData, boolean maybeSelfContained, long sampleOffsetUs) { super(dataSource, dataSpec, format, trigger, startTimeUs, endTimeUs, nextChunkIndex); this.extractor = extractor; this.maybeSelfContained = maybeSelfContained; this.sampleOffsetUs = sampleOffsetUs; - this.psshInfo = psshInfo; + this.drmInitData = drmInitData; } @Override @@ -111,9 +110,9 @@ public final class ContainerMediaChunk extends MediaChunk { } if (prepared) { mediaFormat = extractor.getFormat(); - Map extractorPsshInfo = extractor.getPsshInfo(); - if (extractorPsshInfo != null) { - psshInfo = extractorPsshInfo; + DrmInitData extractorDrmInitData = extractor.getDrmInitData(); + if (extractorDrmInitData != null) { + drmInitData = extractorDrmInitData; } } } @@ -145,8 +144,8 @@ public final class ContainerMediaChunk extends MediaChunk { } @Override - public Map getPsshInfo() { - return psshInfo; + public DrmInitData getDrmInitData() { + return drmInitData; } } diff --git a/library/src/main/java/com/google/android/exoplayer/chunk/Format.java b/library/src/main/java/com/google/android/exoplayer/chunk/Format.java index 2810dc4ff5..b5f36fa96c 100644 --- a/library/src/main/java/com/google/android/exoplayer/chunk/Format.java +++ b/library/src/main/java/com/google/android/exoplayer/chunk/Format.java @@ -84,14 +84,6 @@ public class Format { */ public final String language; - /** - * The average bandwidth in bytes per second. - * - * @deprecated Use {@link #bitrate}. However note that the units of measurement are different. - */ - @Deprecated - public final int bandwidth; - /** * @param id The format identifier. * @param mimeType The format mime type. @@ -144,7 +136,6 @@ public class Format { this.bitrate = bitrate; this.language = language; this.codecs = codecs; - this.bandwidth = bitrate / 8; } @Override diff --git a/library/src/main/java/com/google/android/exoplayer/chunk/MediaChunk.java b/library/src/main/java/com/google/android/exoplayer/chunk/MediaChunk.java index e03a529d8c..2119272f13 100644 --- a/library/src/main/java/com/google/android/exoplayer/chunk/MediaChunk.java +++ b/library/src/main/java/com/google/android/exoplayer/chunk/MediaChunk.java @@ -18,12 +18,10 @@ package com.google.android.exoplayer.chunk; import com.google.android.exoplayer.MediaFormat; import com.google.android.exoplayer.ParserException; import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.drm.DrmInitData; import com.google.android.exoplayer.upstream.DataSource; import com.google.android.exoplayer.upstream.DataSpec; -import java.util.Map; -import java.util.UUID; - /** * An abstract base class for {@link Chunk}s that contain media samples. */ @@ -129,12 +127,12 @@ public abstract class MediaChunk extends Chunk { public abstract MediaFormat getMediaFormat(); /** - * Returns the pssh information associated with the chunk. + * Returns the DRM initialization data associated with the chunk. *

* Should only be called after the chunk has been successfully prepared. * - * @return The pssh information. + * @return The DRM initialization data. */ - public abstract Map getPsshInfo(); + public abstract DrmInitData getDrmInitData(); } diff --git a/library/src/main/java/com/google/android/exoplayer/chunk/SingleSampleMediaChunk.java b/library/src/main/java/com/google/android/exoplayer/chunk/SingleSampleMediaChunk.java index f097d9ee32..dde49ca6ff 100644 --- a/library/src/main/java/com/google/android/exoplayer/chunk/SingleSampleMediaChunk.java +++ b/library/src/main/java/com/google/android/exoplayer/chunk/SingleSampleMediaChunk.java @@ -17,14 +17,12 @@ package com.google.android.exoplayer.chunk; import com.google.android.exoplayer.MediaFormat; import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.drm.DrmInitData; import com.google.android.exoplayer.upstream.DataSource; import com.google.android.exoplayer.upstream.DataSpec; import com.google.android.exoplayer.upstream.NonBlockingInputStream; import com.google.android.exoplayer.util.Assertions; -import java.util.Map; -import java.util.UUID; - /** * A {@link MediaChunk} containing a single sample. */ @@ -132,7 +130,7 @@ public class SingleSampleMediaChunk extends MediaChunk { } @Override - public Map getPsshInfo() { + public DrmInitData getDrmInitData() { return null; } diff --git a/library/src/main/java/com/google/android/exoplayer/chunk/parser/Extractor.java b/library/src/main/java/com/google/android/exoplayer/chunk/parser/Extractor.java index d501e26bcb..7f4264ea15 100644 --- a/library/src/main/java/com/google/android/exoplayer/chunk/parser/Extractor.java +++ b/library/src/main/java/com/google/android/exoplayer/chunk/parser/Extractor.java @@ -15,15 +15,12 @@ */ package com.google.android.exoplayer.chunk.parser; -import com.google.android.exoplayer.C; import com.google.android.exoplayer.MediaFormat; import com.google.android.exoplayer.ParserException; import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.drm.DrmInitData; import com.google.android.exoplayer.upstream.NonBlockingInputStream; -import java.util.Map; -import java.util.UUID; - /** * Facilitates extraction of media samples from a container format. */ @@ -43,7 +40,7 @@ public interface Extractor { public static final int RESULT_READ_SAMPLE = 4; /** * Initialization data was read. The parsed data can be read using {@link #getFormat()} and - * {@link #getPsshInfo}. + * {@link #getDrmInitData()}. */ public static final int RESULT_READ_INIT = 8; /** @@ -80,17 +77,12 @@ public interface Extractor { public MediaFormat getFormat(); /** - * Returns the duration of the stream in microseconds, or {@link C#UNKNOWN_TIME_US} if unknown. - */ - public long getDurationUs(); - - /** - * Returns the pssh information parsed from the stream. + * Returns DRM initialization data parsed from the stream. * - * @return The pssh information. May be null if pssh data has yet to be parsed, or if the stream - * does not contain any pssh data. + * @return The DRM initialization data. May be null if the initialization data has yet to be + * parsed, or if the stream does not contain any DRM initialization data. */ - public Map getPsshInfo(); + public DrmInitData getDrmInitData(); /** * Consumes data from a {@link NonBlockingInputStream}. diff --git a/library/src/main/java/com/google/android/exoplayer/chunk/parser/mp4/FragmentedMp4Extractor.java b/library/src/main/java/com/google/android/exoplayer/chunk/parser/mp4/FragmentedMp4Extractor.java index d6229cf805..42de648051 100644 --- a/library/src/main/java/com/google/android/exoplayer/chunk/parser/mp4/FragmentedMp4Extractor.java +++ b/library/src/main/java/com/google/android/exoplayer/chunk/parser/mp4/FragmentedMp4Extractor.java @@ -21,27 +21,23 @@ import com.google.android.exoplayer.ParserException; import com.google.android.exoplayer.SampleHolder; import com.google.android.exoplayer.chunk.parser.Extractor; import com.google.android.exoplayer.chunk.parser.SegmentIndex; +import com.google.android.exoplayer.drm.DrmInitData; import com.google.android.exoplayer.mp4.Atom; import com.google.android.exoplayer.mp4.Atom.ContainerAtom; import com.google.android.exoplayer.mp4.Atom.LeafAtom; import com.google.android.exoplayer.mp4.CommonMp4AtomParsers; -import com.google.android.exoplayer.mp4.Mp4Util; import com.google.android.exoplayer.mp4.Track; import com.google.android.exoplayer.upstream.NonBlockingInputStream; +import com.google.android.exoplayer.util.H264Util; +import com.google.android.exoplayer.util.MimeTypes; import com.google.android.exoplayer.util.ParsableByteArray; import com.google.android.exoplayer.util.Util; -import android.annotation.SuppressLint; -import android.media.MediaCodec; -import android.media.MediaExtractor; - import java.nio.ByteBuffer; import java.util.Arrays; import java.util.Collections; -import java.util.HashMap; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Set; import java.util.Stack; import java.util.UUID; @@ -145,7 +141,7 @@ public final class FragmentedMp4Extractor implements Extractor { private int lastSyncSampleIndex; // Data parsed from moov and sidx atoms - private final HashMap psshData; + private DrmInitData.Mapped drmInitData; private SegmentIndex segmentIndex; private Track track; private DefaultSampleValues extendsDefaults; @@ -161,11 +157,10 @@ public final class FragmentedMp4Extractor implements Extractor { public FragmentedMp4Extractor(int workaroundFlags) { this.workaroundFlags = workaroundFlags; parserState = STATE_READING_ATOM_HEADER; - atomHeader = new ParsableByteArray(Mp4Util.ATOM_HEADER_SIZE); + atomHeader = new ParsableByteArray(Atom.ATOM_HEADER_SIZE); extendedTypeScratch = new byte[16]; containerAtoms = new Stack(); fragmentRun = new TrackFragment(); - psshData = new HashMap(); } /** @@ -179,8 +174,8 @@ public final class FragmentedMp4Extractor implements Extractor { } @Override - public Map getPsshInfo() { - return psshData.isEmpty() ? null : psshData; + public DrmInitData getDrmInitData() { + return drmInitData; } @Override @@ -198,11 +193,6 @@ public final class FragmentedMp4Extractor implements Extractor { return track == null ? null : track.mediaFormat; } - @Override - public long getDurationUs() { - return track == null ? C.UNKNOWN_TIME_US : track.durationUs; - } - @Override public int read(NonBlockingInputStream inputStream, SampleHolder out) throws ParserException { @@ -269,14 +259,14 @@ public final class FragmentedMp4Extractor implements Extractor { } private int readAtomHeader(NonBlockingInputStream inputStream) { - int remainingBytes = Mp4Util.ATOM_HEADER_SIZE - atomBytesRead; + int remainingBytes = Atom.ATOM_HEADER_SIZE - atomBytesRead; int bytesRead = inputStream.read(atomHeader.data, atomBytesRead, remainingBytes); if (bytesRead == -1) { return RESULT_END_OF_STREAM; } rootAtomBytesRead += bytesRead; atomBytesRead += bytesRead; - if (atomBytesRead != Mp4Util.ATOM_HEADER_SIZE) { + if (atomBytesRead != Atom.ATOM_HEADER_SIZE) { return RESULT_NEED_MORE_DATA; } @@ -298,10 +288,10 @@ public final class FragmentedMp4Extractor implements Extractor { if (CONTAINER_TYPES.contains(atomTypeInteger)) { enterState(STATE_READING_ATOM_HEADER); containerAtoms.add(new ContainerAtom(atomType, - rootAtomBytesRead + atomSize - Mp4Util.ATOM_HEADER_SIZE)); + rootAtomBytesRead + atomSize - Atom.ATOM_HEADER_SIZE)); } else { atomData = new ParsableByteArray(atomSize); - System.arraycopy(atomHeader.data, 0, atomData.data, 0, Mp4Util.ATOM_HEADER_SIZE); + System.arraycopy(atomHeader.data, 0, atomData.data, 0, Atom.ATOM_HEADER_SIZE); enterState(STATE_READING_ATOM_PAYLOAD); } } else { @@ -370,12 +360,15 @@ public final class FragmentedMp4Extractor implements Extractor { LeafAtom child = moovChildren.get(i); if (child.type == Atom.TYPE_pssh) { ParsableByteArray psshAtom = child.data; - psshAtom.setPosition(Mp4Util.FULL_ATOM_HEADER_SIZE); + psshAtom.setPosition(Atom.FULL_ATOM_HEADER_SIZE); UUID uuid = new UUID(psshAtom.readLong(), psshAtom.readLong()); int dataSize = psshAtom.readInt(); byte[] data = new byte[dataSize]; psshAtom.readBytes(data, 0, dataSize); - psshData.put(uuid, data); + if (drmInitData == null) { + drmInitData = new DrmInitData.Mapped(MimeTypes.VIDEO_MP4); + } + drmInitData.put(uuid, data); } } ContainerAtom mvex = moov.getContainerAtomOfType(Atom.TYPE_mvex); @@ -406,7 +399,7 @@ public final class FragmentedMp4Extractor implements Extractor { * Parses a trex atom (defined in 14496-12). */ private static DefaultSampleValues parseTrex(ParsableByteArray trex) { - trex.setPosition(Mp4Util.FULL_ATOM_HEADER_SIZE + 4); + trex.setPosition(Atom.FULL_ATOM_HEADER_SIZE + 4); int defaultSampleDescriptionIndex = trex.readUnsignedIntToInt() - 1; int defaultSampleDuration = trex.readUnsignedIntToInt(); int defaultSampleSize = trex.readUnsignedIntToInt(); @@ -460,9 +453,9 @@ public final class FragmentedMp4Extractor implements Extractor { private static void parseSaiz(TrackEncryptionBox encryptionBox, ParsableByteArray saiz, TrackFragment out) { int vectorSize = encryptionBox.initializationVectorSize; - saiz.setPosition(Mp4Util.ATOM_HEADER_SIZE); + saiz.setPosition(Atom.ATOM_HEADER_SIZE); int fullAtom = saiz.readInt(); - int flags = Mp4Util.parseFullAtomFlags(fullAtom); + int flags = Atom.parseFullAtomFlags(fullAtom); if ((flags & 0x01) == 1) { saiz.skip(8); } @@ -497,9 +490,9 @@ public final class FragmentedMp4Extractor implements Extractor { */ private static DefaultSampleValues parseTfhd(DefaultSampleValues extendsDefaults, ParsableByteArray tfhd) { - tfhd.setPosition(Mp4Util.ATOM_HEADER_SIZE); + tfhd.setPosition(Atom.ATOM_HEADER_SIZE); int fullAtom = tfhd.readInt(); - int flags = Mp4Util.parseFullAtomFlags(fullAtom); + int flags = Atom.parseFullAtomFlags(fullAtom); tfhd.skip(4); // trackId if ((flags & 0x01 /* base_data_offset_present */) != 0) { @@ -526,9 +519,9 @@ public final class FragmentedMp4Extractor implements Extractor { * media, expressed in the media's timescale. */ private static long parseTfdt(ParsableByteArray tfdt) { - tfdt.setPosition(Mp4Util.ATOM_HEADER_SIZE); + tfdt.setPosition(Atom.ATOM_HEADER_SIZE); int fullAtom = tfdt.readInt(); - int version = Mp4Util.parseFullAtomVersion(fullAtom); + int version = Atom.parseFullAtomVersion(fullAtom); return version == 1 ? tfdt.readUnsignedLongToLong() : tfdt.readUnsignedInt(); } @@ -543,9 +536,9 @@ public final class FragmentedMp4Extractor implements Extractor { */ private static void parseTrun(Track track, DefaultSampleValues defaultSampleValues, long decodeTime, int workaroundFlags, ParsableByteArray trun, TrackFragment out) { - trun.setPosition(Mp4Util.ATOM_HEADER_SIZE); + trun.setPosition(Atom.ATOM_HEADER_SIZE); int fullAtom = trun.readInt(); - int flags = Mp4Util.parseFullAtomFlags(fullAtom); + int flags = Atom.parseFullAtomFlags(fullAtom); int sampleCount = trun.readUnsignedIntToInt(); if ((flags & 0x01 /* data_offset_present */) != 0) { @@ -603,7 +596,7 @@ public final class FragmentedMp4Extractor implements Extractor { private static void parseUuid(ParsableByteArray uuid, TrackFragment out, byte[] extendedTypeScratch) { - uuid.setPosition(Mp4Util.ATOM_HEADER_SIZE); + uuid.setPosition(Atom.ATOM_HEADER_SIZE); uuid.readBytes(extendedTypeScratch, 0, 16); // Currently this parser only supports Microsoft's PIFF SampleEncryptionBox. @@ -622,9 +615,9 @@ public final class FragmentedMp4Extractor implements Extractor { } private static void parseSenc(ParsableByteArray senc, int offset, TrackFragment out) { - senc.setPosition(Mp4Util.ATOM_HEADER_SIZE + offset); + senc.setPosition(Atom.ATOM_HEADER_SIZE + offset); int fullAtom = senc.readInt(); - int flags = Mp4Util.parseFullAtomFlags(fullAtom); + int flags = Atom.parseFullAtomFlags(fullAtom); if ((flags & 0x01 /* override_track_encryption_box_parameters */) != 0) { // TODO: Implement this. @@ -646,9 +639,9 @@ public final class FragmentedMp4Extractor implements Extractor { * Parses a sidx atom (defined in 14496-12). */ private static SegmentIndex parseSidx(ParsableByteArray atom) { - atom.setPosition(Mp4Util.ATOM_HEADER_SIZE); + atom.setPosition(Atom.ATOM_HEADER_SIZE); int fullAtom = atom.readInt(); - int version = Mp4Util.parseFullAtomVersion(fullAtom); + int version = Atom.parseFullAtomVersion(fullAtom); atom.skip(4); long timescale = atom.readUnsignedInt(); @@ -788,7 +781,7 @@ public final class FragmentedMp4Extractor implements Extractor { if (track.type == Track.TYPE_VIDEO) { // The mp4 file contains length-prefixed NAL units, but the decoder wants start code // delimited content. - Mp4Util.replaceLengthPrefixesWithAvcStartCodes(outputData, sampleSize); + H264Util.replaceLengthPrefixesWithAvcStartCodes(outputData, sampleSize); } out.size = sampleSize; } @@ -798,12 +791,14 @@ public final class FragmentedMp4Extractor implements Extractor { return RESULT_READ_SAMPLE; } - @SuppressLint("InlinedApi") private void readSampleEncryptionData(ParsableByteArray sampleEncryptionData, SampleHolder out) { TrackEncryptionBox encryptionBox = track.sampleDescriptionEncryptionBoxes[fragmentRun.sampleDescriptionIndex]; + if (!encryptionBox.isEncrypted) { + return; + } + byte[] keyId = encryptionBox.keyId; - boolean isEncrypted = encryptionBox.isEncrypted; int vectorSize = encryptionBox.initializationVectorSize; boolean subsampleEncryption = fragmentRun.sampleHasSubsampleEncryptionTable[sampleIndex]; @@ -831,11 +826,10 @@ public final class FragmentedMp4Extractor implements Extractor { clearDataSizes[0] = 0; encryptedDataSizes[0] = fragmentRun.sampleSizeTable[sampleIndex]; } + out.cryptoInfo.set(subsampleCount, clearDataSizes, encryptedDataSizes, keyId, vector, - isEncrypted ? MediaCodec.CRYPTO_MODE_AES_CTR : MediaCodec.CRYPTO_MODE_UNENCRYPTED); - if (isEncrypted) { - out.flags |= MediaExtractor.SAMPLE_FLAG_ENCRYPTED; - } + C.CRYPTO_MODE_AES_CTR); + out.flags |= C.SAMPLE_FLAG_ENCRYPTED; } } diff --git a/library/src/main/java/com/google/android/exoplayer/chunk/parser/webm/WebmExtractor.java b/library/src/main/java/com/google/android/exoplayer/chunk/parser/webm/WebmExtractor.java index ddf15e5610..f9aca0375d 100644 --- a/library/src/main/java/com/google/android/exoplayer/chunk/parser/webm/WebmExtractor.java +++ b/library/src/main/java/com/google/android/exoplayer/chunk/parser/webm/WebmExtractor.java @@ -21,6 +21,7 @@ import com.google.android.exoplayer.ParserException; import com.google.android.exoplayer.SampleHolder; import com.google.android.exoplayer.chunk.parser.Extractor; import com.google.android.exoplayer.chunk.parser.SegmentIndex; +import com.google.android.exoplayer.drm.DrmInitData; import com.google.android.exoplayer.upstream.NonBlockingInputStream; import com.google.android.exoplayer.util.LongArray; import com.google.android.exoplayer.util.MimeTypes; @@ -28,8 +29,6 @@ import com.google.android.exoplayer.util.MimeTypes; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Arrays; -import java.util.Map; -import java.util.UUID; import java.util.concurrent.TimeUnit; /** @@ -38,6 +37,8 @@ import java.util.concurrent.TimeUnit; *

WebM is a subset of the EBML elements defined for Matroska. More information about EBML and * Matroska is available here. * More info about WebM is here. + * RFC on encrypted WebM can be found + * here. */ public final class WebmExtractor implements Extractor { @@ -47,6 +48,7 @@ public final class WebmExtractor implements Extractor { private static final String CODEC_ID_OPUS = "A_OPUS"; private static final int VORBIS_MAX_INPUT_SIZE = 8192; private static final int OPUS_MAX_INPUT_SIZE = 5760; + private static final int BLOCK_COUNTER_SIZE = 16; private static final int UNKNOWN = -1; // Element IDs @@ -80,23 +82,31 @@ public final class WebmExtractor implements Extractor { private static final int ID_CHANNELS = 0x9F; private static final int ID_SAMPLING_FREQUENCY = 0xB5; + private static final int ID_CONTENT_ENCODINGS = 0x6D80; + private static final int ID_CONTENT_ENCODING = 0x6240; + private static final int ID_CONTENT_ENCODING_ORDER = 0x5031; + private static final int ID_CONTENT_ENCODING_SCOPE = 0x5032; + private static final int ID_CONTENT_ENCODING_TYPE = 0x5033; + private static final int ID_CONTENT_ENCRYPTION = 0x5035; + private static final int ID_CONTENT_ENCRYPTION_ALGORITHM = 0x47E1; + private static final int ID_CONTENT_ENCRYPTION_KEY_ID = 0x47E2; + private static final int ID_CONTENT_ENCRYPTION_AES_SETTINGS = 0x47E7; + private static final int ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE = 0x47E8; + private static final int ID_CUES = 0x1C53BB6B; private static final int ID_CUE_POINT = 0xBB; private static final int ID_CUE_TIME = 0xB3; private static final int ID_CUE_TRACK_POSITIONS = 0xB7; private static final int ID_CUE_CLUSTER_POSITION = 0xF1; - // SimpleBlock Lacing Values private static final int LACING_NONE = 0; - private static final int LACING_XIPH = 1; - private static final int LACING_FIXED = 2; - private static final int LACING_EBML = 3; private static final int READ_TERMINATING_RESULTS = RESULT_NEED_MORE_DATA | RESULT_END_OF_STREAM | RESULT_READ_SAMPLE | RESULT_NEED_SAMPLE_HOLDER; private final EbmlReader reader; private final byte[] simpleBlockTimecodeAndFlags = new byte[3]; + private DrmInitData.Universal drmInitData; private SampleHolder sampleHolder; private int readResults; @@ -104,7 +114,7 @@ public final class WebmExtractor implements Extractor { private long segmentStartOffsetBytes = UNKNOWN; private long segmentEndOffsetBytes = UNKNOWN; private long timecodeScale = 1000000L; - private long durationUs = UNKNOWN; + private long durationUs = C.UNKNOWN_TIME_US; private int pixelWidth = UNKNOWN; private int pixelHeight = UNKNOWN; private int channelCount = UNKNOWN; @@ -113,7 +123,9 @@ public final class WebmExtractor implements Extractor { private String codecId; private long codecDelayNs; private long seekPreRollNs; - private boolean seenAudioTrack; + private boolean isAudioTrack; + private boolean hasContentEncryption; + private byte[] encryptionKeyId; private long cuesSizeBytes = UNKNOWN; private long clusterTimecodeUs = UNKNOWN; private long simpleBlockTimecodeUs = UNKNOWN; @@ -182,14 +194,8 @@ public final class WebmExtractor implements Extractor { } @Override - public long getDurationUs() { - return durationUs == UNKNOWN ? C.UNKNOWN_TIME_US : durationUs; - } - - @Override - public Map getPsshInfo() { - // TODO: Parse pssh data from Webm streams. - return null; + public DrmInitData getDrmInitData() { + return drmInitData; } /* package */ int getElementType(int id) { @@ -202,6 +208,10 @@ public final class WebmExtractor implements Extractor { case ID_TRACK_ENTRY: case ID_AUDIO: case ID_VIDEO: + case ID_CONTENT_ENCODINGS: + case ID_CONTENT_ENCODING: + case ID_CONTENT_ENCRYPTION: + case ID_CONTENT_ENCRYPTION_AES_SETTINGS: case ID_CUES: case ID_CUE_POINT: case ID_CUE_TRACK_POSITIONS: @@ -216,12 +226,18 @@ public final class WebmExtractor implements Extractor { case ID_CODEC_DELAY: case ID_SEEK_PRE_ROLL: case ID_CHANNELS: + case ID_CONTENT_ENCODING_ORDER: + case ID_CONTENT_ENCODING_SCOPE: + case ID_CONTENT_ENCODING_TYPE: + case ID_CONTENT_ENCRYPTION_ALGORITHM: + case ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE: case ID_CUE_TIME: case ID_CUE_CLUSTER_POSITION: return EbmlReader.TYPE_UNSIGNED_INT; case ID_DOC_TYPE: case ID_CODEC_ID: return EbmlReader.TYPE_STRING; + case ID_CONTENT_ENCRYPTION_KEY_ID: case ID_SIMPLE_BLOCK: case ID_BLOCK: case ID_CODEC_PRIVATE: @@ -250,6 +266,12 @@ public final class WebmExtractor implements Extractor { cueTimesUs = new LongArray(); cueClusterPositions = new LongArray(); break; + case ID_CONTENT_ENCODING: + // TODO: check and fail if more than one content encoding is present. + break; + case ID_CONTENT_ENCRYPTION: + hasContentEncryption = true; + break; default: // pass } @@ -261,17 +283,24 @@ public final class WebmExtractor implements Extractor { case ID_CUES: buildCues(); return false; - case ID_VIDEO: - buildVideoFormat(); + case ID_CONTENT_ENCODING: + if (!hasContentEncryption) { + // We found a ContentEncoding other than Encryption. + throw new ParserException("Found an unsupported ContentEncoding"); + } + if (encryptionKeyId == null) { + throw new ParserException("Encrypted Track found but ContentEncKeyID was not found"); + } + drmInitData = new DrmInitData.Universal(MimeTypes.VIDEO_WEBM, encryptionKeyId); return true; case ID_AUDIO: - seenAudioTrack = true; + isAudioTrack = true; return true; case ID_TRACK_ENTRY: - if (seenAudioTrack) { - // Audio format has to be built here since codec private may not be available at the end - // of ID_AUDIO. + if (isAudioTrack) { buildAudioFormat(); + } else { + buildVideoFormat(); } return true; default: @@ -311,6 +340,37 @@ public final class WebmExtractor implements Extractor { case ID_CHANNELS: channelCount = (int) value; break; + case ID_CONTENT_ENCODING_ORDER: + // This extractor only supports one ContentEncoding element and hence the order has to be 0. + if (value != 0) { + throw new ParserException("ContentEncodingOrder " + value + " not supported"); + } + break; + case ID_CONTENT_ENCODING_SCOPE: + // This extractor only supports the scope of all frames (since that's the only scope used + // for Encryption). + if (value != 1) { + throw new ParserException("ContentEncodingScope " + value + " not supported"); + } + break; + case ID_CONTENT_ENCODING_TYPE: + // This extractor only supports Encrypted ContentEncodingType. + if (value != 1) { + throw new ParserException("ContentEncodingType " + value + " not supported"); + } + break; + case ID_CONTENT_ENCRYPTION_ALGORITHM: + // Only the value 5 (AES) is allowed according to the WebM specification. + if (value != 5) { + throw new ParserException("ContentEncAlgo " + value + " not supported"); + } + break; + case ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE: + // Only the value 1 is allowed according to the WebM specification. + if (value != 1) { + throw new ParserException("AESSettingsCipherMode " + value + " not supported"); + } + break; case ID_CUE_TIME: cueTimesUs.add(scaleTimecodeToUs(value)); break; @@ -402,22 +462,49 @@ public final class WebmExtractor implements Extractor { } boolean invisible = (simpleBlockTimecodeAndFlags[2] & 0x08) == 0x08; int lacing = (simpleBlockTimecodeAndFlags[2] & 0x06) >> 1; + if (lacing != LACING_NONE) { + throw new ParserException("Lacing mode " + lacing + " not supported"); + } + long elementEndOffsetBytes = elementOffsetBytes + headerSizeBytes + contentsSizeBytes; + simpleBlockTimecodeUs = clusterTimecodeUs + timecodeUs; + sampleHolder.flags = (keyframe ? C.SAMPLE_FLAG_SYNC : 0) + | (invisible ? C.SAMPLE_FLAG_DECODE_ONLY : 0); + sampleHolder.timeUs = clusterTimecodeUs + timecodeUs; + sampleHolder.size = (int) (elementEndOffsetBytes - reader.getBytesRead()); - // Validate lacing and set info into sample holder. - switch (lacing) { - case LACING_NONE: - long elementEndOffsetBytes = elementOffsetBytes + headerSizeBytes + contentsSizeBytes; - simpleBlockTimecodeUs = clusterTimecodeUs + timecodeUs; - sampleHolder.flags = keyframe ? C.SAMPLE_FLAG_SYNC : 0; - sampleHolder.decodeOnly = invisible; - sampleHolder.timeUs = clusterTimecodeUs + timecodeUs; - sampleHolder.size = (int) (elementEndOffsetBytes - reader.getBytesRead()); - break; - case LACING_EBML: - case LACING_FIXED: - case LACING_XIPH: - default: - throw new ParserException("Lacing mode " + lacing + " not supported"); + if (hasContentEncryption) { + byte[] signalByte = new byte[1]; + reader.readBytes(inputStream, signalByte, 1); + sampleHolder.size -= 1; + // First bit of the signalByte (extension bit) must be 0. + if ((signalByte[0] & 0x80) != 0) { + throw new ParserException("Extension bit is set in signal byte"); + } + boolean isEncrypted = (signalByte[0] & 0x01) == 0x01; + if (isEncrypted) { + byte[] iv = null; + iv = sampleHolder.cryptoInfo.iv; + if (iv == null || iv.length != BLOCK_COUNTER_SIZE) { + iv = new byte[BLOCK_COUNTER_SIZE]; + } + reader.readBytes(inputStream, iv, 8); // The container has only 8 bytes of IV. + sampleHolder.size -= 8; + + int[] clearDataSizes = sampleHolder.cryptoInfo.numBytesOfClearData; + if (clearDataSizes == null || clearDataSizes.length < 1) { + clearDataSizes = new int[1]; + } + int[] encryptedDataSizes = sampleHolder.cryptoInfo.numBytesOfEncryptedData; + if (encryptedDataSizes == null || encryptedDataSizes.length < 1) { + encryptedDataSizes = new int[1]; + } + clearDataSizes[0] = 0; + encryptedDataSizes[0] = sampleHolder.size; + + sampleHolder.cryptoInfo.set(1, clearDataSizes, encryptedDataSizes, + encryptionKeyId, iv, C.CRYPTO_MODE_AES_CTR); + sampleHolder.flags |= C.SAMPLE_FLAG_ENCRYPTED; + } } if (sampleHolder.data == null || sampleHolder.data.capacity() < sampleHolder.size) { @@ -437,6 +524,10 @@ public final class WebmExtractor implements Extractor { codecPrivate = new byte[contentsSizeBytes]; reader.readBytes(inputStream, codecPrivate, contentsSizeBytes); break; + case ID_CONTENT_ENCRYPTION_KEY_ID: + encryptionKeyId = new byte[contentsSizeBytes]; + reader.readBytes(inputStream, encryptionKeyId, contentsSizeBytes); + break; default: // pass } @@ -463,8 +554,8 @@ public final class WebmExtractor implements Extractor { private void buildVideoFormat() throws ParserException { if (pixelWidth != UNKNOWN && pixelHeight != UNKNOWN && (format == null || format.width != pixelWidth || format.height != pixelHeight)) { - format = MediaFormat.createVideoFormat( - MimeTypes.VIDEO_VP9, MediaFormat.NO_VALUE, pixelWidth, pixelHeight, null); + format = MediaFormat.createVideoFormat(MimeTypes.VIDEO_VP9, MediaFormat.NO_VALUE, durationUs, + pixelWidth, pixelHeight, null); readResults |= RESULT_READ_INIT; } else if (format == null) { throw new ParserException("Unable to build format"); @@ -485,17 +576,15 @@ public final class WebmExtractor implements Extractor { && (format == null || format.channelCount != channelCount || format.sampleRate != sampleRate)) { if (CODEC_ID_VORBIS.equals(codecId)) { - format = MediaFormat.createAudioFormat( - MimeTypes.AUDIO_VORBIS, VORBIS_MAX_INPUT_SIZE, - channelCount, sampleRate, parseVorbisCodecPrivate()); + format = MediaFormat.createAudioFormat(MimeTypes.AUDIO_VORBIS, VORBIS_MAX_INPUT_SIZE, + durationUs, channelCount, sampleRate, parseVorbisCodecPrivate()); } else if (CODEC_ID_OPUS.equals(codecId)) { ArrayList opusInitializationData = new ArrayList(3); opusInitializationData.add(codecPrivate); opusInitializationData.add(ByteBuffer.allocate(Long.SIZE).putLong(codecDelayNs).array()); opusInitializationData.add(ByteBuffer.allocate(Long.SIZE).putLong(seekPreRollNs).array()); - format = MediaFormat.createAudioFormat( - MimeTypes.AUDIO_OPUS, OPUS_MAX_INPUT_SIZE, channelCount, sampleRate, - opusInitializationData); + format = MediaFormat.createAudioFormat(MimeTypes.AUDIO_OPUS, OPUS_MAX_INPUT_SIZE, + durationUs, channelCount, sampleRate, opusInitializationData); } readResults |= RESULT_READ_INIT; } else if (format == null) { @@ -512,7 +601,7 @@ public final class WebmExtractor implements Extractor { private void buildCues() throws ParserException { if (segmentStartOffsetBytes == UNKNOWN) { throw new ParserException("Segment start/end offsets unknown"); - } else if (durationUs == UNKNOWN) { + } else if (durationUs == C.UNKNOWN_TIME_US) { throw new ParserException("Duration unknown"); } else if (cuesSizeBytes == UNKNOWN) { throw new ParserException("Cues size unknown"); diff --git a/library/src/main/java/com/google/android/exoplayer/dash/DashChunkSource.java b/library/src/main/java/com/google/android/exoplayer/dash/DashChunkSource.java index 7186412722..dc1d7fb26f 100644 --- a/library/src/main/java/com/google/android/exoplayer/dash/DashChunkSource.java +++ b/library/src/main/java/com/google/android/exoplayer/dash/DashChunkSource.java @@ -39,6 +39,7 @@ import com.google.android.exoplayer.dash.mpd.MediaPresentationDescription; import com.google.android.exoplayer.dash.mpd.Period; import com.google.android.exoplayer.dash.mpd.RangedUri; import com.google.android.exoplayer.dash.mpd.Representation; +import com.google.android.exoplayer.drm.DrmInitData; import com.google.android.exoplayer.text.webvtt.WebvttParser; import com.google.android.exoplayer.upstream.DataSource; import com.google.android.exoplayer.upstream.DataSpec; @@ -54,8 +55,6 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; -import java.util.Map; -import java.util.UUID; /** * An {@link ChunkSource} for DASH streams. @@ -96,7 +95,7 @@ public class DashChunkSource implements ChunkSource { private final ManifestFetcher manifestFetcher; private final int adaptationSetIndex; private final int[] representationIndices; - private final Map psshInfo; + private final DrmInitData drmInitData; private MediaPresentationDescription currentManifest; private boolean finishedCurrentManifest; @@ -190,7 +189,7 @@ public class DashChunkSource implements ChunkSource { this.evaluation = new Evaluation(); this.headerBuilder = new StringBuilder(); - psshInfo = getPsshInfo(currentManifest, adaptationSetIndex); + drmInitData = getDrmInitData(currentManifest, adaptationSetIndex); Representation[] representations = getFilteredRepresentations(currentManifest, adaptationSetIndex, representationIndices); long periodDurationUs = (representations[0].periodDurationMs == TrackRenderer.UNKNOWN_TIME_US) @@ -407,7 +406,7 @@ public class DashChunkSource implements ChunkSource { // Do nothing. } - private boolean mimeTypeIsWebm(String mimeType) { + private static boolean mimeTypeIsWebm(String mimeType) { return mimeType.startsWith(MimeTypes.VIDEO_WEBM) || mimeType.startsWith(MimeTypes.AUDIO_WEBM); } @@ -475,8 +474,8 @@ public class DashChunkSource implements ChunkSource { startTimeUs, endTimeUs, nextAbsoluteSegmentNum, null, representationHolder.vttHeader); } else { return new ContainerMediaChunk(dataSource, dataSpec, representation.format, trigger, - startTimeUs, endTimeUs, nextAbsoluteSegmentNum, representationHolder.extractor, psshInfo, - false, presentationTimeOffsetUs); + startTimeUs, endTimeUs, nextAbsoluteSegmentNum, representationHolder.extractor, + drmInitData, false, presentationTimeOffsetUs); } } @@ -529,19 +528,24 @@ public class DashChunkSource implements ChunkSource { } } - private static Map getPsshInfo(MediaPresentationDescription manifest, + private static DrmInitData getDrmInitData(MediaPresentationDescription manifest, int adaptationSetIndex) { AdaptationSet adaptationSet = manifest.periods.get(0).adaptationSets.get(adaptationSetIndex); + String drmInitMimeType = mimeTypeIsWebm(adaptationSet.representations.get(0).format.mimeType) + ? MimeTypes.VIDEO_WEBM : MimeTypes.VIDEO_MP4; if (adaptationSet.contentProtections.isEmpty()) { return null; } else { - Map psshInfo = new HashMap(); + DrmInitData.Mapped drmInitData = null; for (ContentProtection contentProtection : adaptationSet.contentProtections) { if (contentProtection.uuid != null && contentProtection.data != null) { - psshInfo.put(contentProtection.uuid, contentProtection.data); + if (drmInitData == null) { + drmInitData = new DrmInitData.Mapped(drmInitMimeType); + } + drmInitData.put(contentProtection.uuid, contentProtection.data); } } - return psshInfo.isEmpty() ? null : psshInfo; + return drmInitData; } } @@ -581,7 +585,7 @@ public class DashChunkSource implements ChunkSource { } if ((result & Extractor.RESULT_READ_INDEX) != 0) { representationHolders.get(format.id).segmentIndex = - new DashWrappingSegmentIndex(extractor.getIndex(), uri, indexAnchor); + new DashWrappingSegmentIndex(extractor.getIndex(), uri.toString(), indexAnchor); } } diff --git a/library/src/main/java/com/google/android/exoplayer/dash/DashWrappingSegmentIndex.java b/library/src/main/java/com/google/android/exoplayer/dash/DashWrappingSegmentIndex.java index 44648469af..d62d6ee5c2 100644 --- a/library/src/main/java/com/google/android/exoplayer/dash/DashWrappingSegmentIndex.java +++ b/library/src/main/java/com/google/android/exoplayer/dash/DashWrappingSegmentIndex.java @@ -19,8 +19,6 @@ import com.google.android.exoplayer.chunk.parser.SegmentIndex; import com.google.android.exoplayer.dash.mpd.RangedUri; import com.google.android.exoplayer.util.Util; -import android.net.Uri; - /** * An implementation of {@link DashSegmentIndex} that wraps a {@link SegmentIndex} parsed from a * media stream. @@ -28,16 +26,16 @@ import android.net.Uri; public class DashWrappingSegmentIndex implements DashSegmentIndex { private final SegmentIndex segmentIndex; - private final Uri uri; + private final String uri; private final long indexAnchor; /** * @param segmentIndex The {@link SegmentIndex} to wrap. - * @param uri The {@link Uri} where the data is located. + * @param uri The URI where the data is located. * @param indexAnchor The index anchor point. This value is added to the byte offsets specified * in the wrapped {@link SegmentIndex}. */ - public DashWrappingSegmentIndex(SegmentIndex segmentIndex, Uri uri, long indexAnchor) { + public DashWrappingSegmentIndex(SegmentIndex segmentIndex, String uri, long indexAnchor) { this.segmentIndex = segmentIndex; this.uri = uri; this.indexAnchor = indexAnchor; diff --git a/library/src/main/java/com/google/android/exoplayer/dash/mpd/ContentProtection.java b/library/src/main/java/com/google/android/exoplayer/dash/mpd/ContentProtection.java index c8f7cfb501..8f02fdc6f4 100644 --- a/library/src/main/java/com/google/android/exoplayer/dash/mpd/ContentProtection.java +++ b/library/src/main/java/com/google/android/exoplayer/dash/mpd/ContentProtection.java @@ -15,6 +15,10 @@ */ package com.google.android.exoplayer.dash.mpd; +import com.google.android.exoplayer.util.Assertions; +import com.google.android.exoplayer.util.Util; + +import java.util.Arrays; import java.util.UUID; /** @@ -43,9 +47,38 @@ public class ContentProtection { * @param data Protection scheme specific initialization data. May be null. */ public ContentProtection(String schemeUriId, UUID uuid, byte[] data) { - this.schemeUriId = schemeUriId; + this.schemeUriId = Assertions.checkNotNull(schemeUriId); this.uuid = uuid; this.data = data; } + @Override + public boolean equals(Object obj) { + if (!(obj instanceof ContentProtection)) { + return false; + } + if (obj == this) { + return true; + } + + ContentProtection other = (ContentProtection) obj; + return schemeUriId.equals(other.schemeUriId) + && Util.areEqual(uuid, other.uuid) + && Arrays.equals(data, other.data); + } + + @Override + public int hashCode() { + int hashCode = 1; + + hashCode = hashCode * 37 + schemeUriId.hashCode(); + if (uuid != null) { + hashCode = hashCode * 37 + uuid.hashCode(); + } + if (data != null) { + hashCode = hashCode * 37 + Arrays.hashCode(data); + } + return hashCode; + } + } diff --git a/library/src/main/java/com/google/android/exoplayer/dash/mpd/MediaPresentationDescriptionParser.java b/library/src/main/java/com/google/android/exoplayer/dash/mpd/MediaPresentationDescriptionParser.java index 813ce0c4e0..7767fa8810 100644 --- a/library/src/main/java/com/google/android/exoplayer/dash/mpd/MediaPresentationDescriptionParser.java +++ b/library/src/main/java/com/google/android/exoplayer/dash/mpd/MediaPresentationDescriptionParser.java @@ -24,9 +24,9 @@ import com.google.android.exoplayer.dash.mpd.SegmentBase.SingleSegmentBase; import com.google.android.exoplayer.upstream.NetworkLoadable; import com.google.android.exoplayer.util.Assertions; import com.google.android.exoplayer.util.MimeTypes; +import com.google.android.exoplayer.util.UriUtil; import com.google.android.exoplayer.util.Util; -import android.net.Uri; import android.text.TextUtils; import org.xml.sax.helpers.DefaultHandler; @@ -38,6 +38,8 @@ import java.io.IOException; import java.io.InputStream; import java.text.ParseException; import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; import java.util.List; /** @@ -83,7 +85,7 @@ public class MediaPresentationDescriptionParser extends DefaultHandler throw new ParserException( "inputStream does not contain a valid media presentation description"); } - return parseMediaPresentationDescription(xpp, Util.parseBaseUri(connectionUrl)); + return parseMediaPresentationDescription(xpp, connectionUrl); } catch (XmlPullParserException e) { throw new ParserException(e); } catch (ParseException e) { @@ -92,7 +94,7 @@ public class MediaPresentationDescriptionParser extends DefaultHandler } protected MediaPresentationDescription parseMediaPresentationDescription(XmlPullParser xpp, - Uri baseUrl) throws XmlPullParserException, IOException, ParseException { + String baseUrl) throws XmlPullParserException, IOException, ParseException { long availabilityStartTime = parseDateTime(xpp, "availabilityStartTime", -1); long durationMs = parseDuration(xpp, "mediaPresentationDuration", -1); long minBufferTimeMs = parseDuration(xpp, "minBufferTime", -1); @@ -137,7 +139,7 @@ public class MediaPresentationDescriptionParser extends DefaultHandler return new UtcTimingElement(schemeIdUri, value); } - protected Period parsePeriod(XmlPullParser xpp, Uri baseUrl, long mpdDurationMs) + protected Period parsePeriod(XmlPullParser xpp, String baseUrl, long mpdDurationMs) throws XmlPullParserException, IOException { String id = xpp.getAttributeValue(null, "id"); long startMs = parseDuration(xpp, "start", 0); @@ -170,7 +172,7 @@ public class MediaPresentationDescriptionParser extends DefaultHandler // AdaptationSet parsing. - protected AdaptationSet parseAdaptationSet(XmlPullParser xpp, Uri baseUrl, long periodStartMs, + protected AdaptationSet parseAdaptationSet(XmlPullParser xpp, String baseUrl, long periodStartMs, long periodDurationMs, SegmentBase segmentBase) throws XmlPullParserException, IOException { String mimeType = xpp.getAttributeValue(null, "mimeType"); @@ -178,24 +180,22 @@ public class MediaPresentationDescriptionParser extends DefaultHandler int contentType = parseAdaptationSetTypeFromMimeType(mimeType); int id = -1; - List contentProtections = null; + ContentProtectionsBuilder contentProtectionsBuilder = new ContentProtectionsBuilder(); List representations = new ArrayList(); do { xpp.next(); if (isStartTag(xpp, "BaseURL")) { baseUrl = parseBaseUrl(xpp, baseUrl); } else if (isStartTag(xpp, "ContentProtection")) { - if (contentProtections == null) { - contentProtections = new ArrayList(); - } - contentProtections.add(parseContentProtection(xpp)); + contentProtectionsBuilder.addAdaptationSetProtection(parseContentProtection(xpp)); } else if (isStartTag(xpp, "ContentComponent")) { id = Integer.parseInt(xpp.getAttributeValue(null, "id")); contentType = checkAdaptationSetTypeConsistency(contentType, parseAdaptationSetType(xpp.getAttributeValue(null, "contentType"))); } else if (isStartTag(xpp, "Representation")) { Representation representation = parseRepresentation(xpp, baseUrl, periodStartMs, - periodDurationMs, mimeType, language, segmentBase); + periodDurationMs, mimeType, language, segmentBase, contentProtectionsBuilder); + contentProtectionsBuilder.endRepresentation(); contentType = checkAdaptationSetTypeConsistency(contentType, parseAdaptationSetTypeFromMimeType(representation.format.mimeType)); representations.add(representation); @@ -211,7 +211,7 @@ public class MediaPresentationDescriptionParser extends DefaultHandler } } while (!isEndTag(xpp, "AdaptationSet")); - return buildAdaptationSet(id, contentType, representations, contentProtections); + return buildAdaptationSet(id, contentType, representations, contentProtectionsBuilder.build()); } protected AdaptationSet buildAdaptationSet(int id, int contentType, @@ -287,8 +287,9 @@ public class MediaPresentationDescriptionParser extends DefaultHandler // Representation parsing. - protected Representation parseRepresentation(XmlPullParser xpp, Uri baseUrl, long periodStartMs, - long periodDurationMs, String mimeType, String language, SegmentBase segmentBase) + protected Representation parseRepresentation(XmlPullParser xpp, String baseUrl, + long periodStartMs, long periodDurationMs, String mimeType, String language, + SegmentBase segmentBase, ContentProtectionsBuilder contentProtectionsBuilder) throws XmlPullParserException, IOException { String id = xpp.getAttributeValue(null, "id"); int bandwidth = parseInt(xpp, "bandwidth"); @@ -312,6 +313,8 @@ public class MediaPresentationDescriptionParser extends DefaultHandler } else if (isStartTag(xpp, "SegmentTemplate")) { segmentBase = parseSegmentTemplate(xpp, baseUrl, (SegmentTemplate) segmentBase, periodDurationMs); + } else if (isStartTag(xpp, "ContentProtection")) { + contentProtectionsBuilder.addRepresentationProtection(parseContentProtection(xpp)); } } while (!isEndTag(xpp, "Representation")); @@ -335,7 +338,7 @@ public class MediaPresentationDescriptionParser extends DefaultHandler // SegmentBase, SegmentList and SegmentTemplate parsing. - protected SingleSegmentBase parseSegmentBase(XmlPullParser xpp, Uri baseUrl, + protected SingleSegmentBase parseSegmentBase(XmlPullParser xpp, String baseUrl, SingleSegmentBase parent) throws XmlPullParserException, IOException { long timescale = parseLong(xpp, "timescale", parent != null ? parent.timescale : 1); @@ -364,12 +367,12 @@ public class MediaPresentationDescriptionParser extends DefaultHandler } protected SingleSegmentBase buildSingleSegmentBase(RangedUri initialization, long timescale, - long presentationTimeOffset, Uri baseUrl, long indexStart, long indexLength) { + long presentationTimeOffset, String baseUrl, long indexStart, long indexLength) { return new SingleSegmentBase(initialization, timescale, presentationTimeOffset, baseUrl, indexStart, indexLength); } - protected SegmentList parseSegmentList(XmlPullParser xpp, Uri baseUrl, SegmentList parent, + protected SegmentList parseSegmentList(XmlPullParser xpp, String baseUrl, SegmentList parent, long periodDurationMs) throws XmlPullParserException, IOException { long timescale = parseLong(xpp, "timescale", parent != null ? parent.timescale : 1); @@ -413,7 +416,7 @@ public class MediaPresentationDescriptionParser extends DefaultHandler startNumber, duration, timeline, segments); } - protected SegmentTemplate parseSegmentTemplate(XmlPullParser xpp, Uri baseUrl, + protected SegmentTemplate parseSegmentTemplate(XmlPullParser xpp, String baseUrl, SegmentTemplate parent, long periodDurationMs) throws XmlPullParserException, IOException { long timescale = parseLong(xpp, "timescale", parent != null ? parent.timescale : 1); @@ -450,7 +453,7 @@ public class MediaPresentationDescriptionParser extends DefaultHandler protected SegmentTemplate buildSegmentTemplate(RangedUri initialization, long timescale, long presentationTimeOffset, long periodDurationMs, int startNumber, long duration, List timeline, UrlTemplate initializationTemplate, - UrlTemplate mediaTemplate, Uri baseUrl) { + UrlTemplate mediaTemplate, String baseUrl) { return new SegmentTemplate(initialization, timescale, presentationTimeOffset, periodDurationMs, startNumber, duration, timeline, initializationTemplate, mediaTemplate, baseUrl); } @@ -487,15 +490,15 @@ public class MediaPresentationDescriptionParser extends DefaultHandler return defaultValue; } - protected RangedUri parseInitialization(XmlPullParser xpp, Uri baseUrl) { + protected RangedUri parseInitialization(XmlPullParser xpp, String baseUrl) { return parseRangedUrl(xpp, baseUrl, "sourceURL", "range"); } - protected RangedUri parseSegmentUrl(XmlPullParser xpp, Uri baseUrl) { + protected RangedUri parseSegmentUrl(XmlPullParser xpp, String baseUrl) { return parseRangedUrl(xpp, baseUrl, "media", "mediaRange"); } - protected RangedUri parseRangedUrl(XmlPullParser xpp, Uri baseUrl, String urlAttribute, + protected RangedUri parseRangedUrl(XmlPullParser xpp, String baseUrl, String urlAttribute, String rangeAttribute) { String urlText = xpp.getAttributeValue(null, urlAttribute); long rangeStart = 0; @@ -509,7 +512,7 @@ public class MediaPresentationDescriptionParser extends DefaultHandler return buildRangedUri(baseUrl, urlText, rangeStart, rangeLength); } - protected RangedUri buildRangedUri(Uri baseUrl, String urlText, long rangeStart, + protected RangedUri buildRangedUri(String baseUrl, String urlText, long rangeStart, long rangeLength) { return new RangedUri(baseUrl, urlText, rangeStart, rangeLength); } @@ -548,15 +551,10 @@ public class MediaPresentationDescriptionParser extends DefaultHandler } } - protected static Uri parseBaseUrl(XmlPullParser xpp, Uri parentBaseUrl) + protected static String parseBaseUrl(XmlPullParser xpp, String parentBaseUrl) throws XmlPullParserException, IOException { xpp.next(); - String newBaseUrlText = xpp.getText(); - Uri newBaseUri = Uri.parse(newBaseUrlText); - if (!newBaseUri.isAbsolute()) { - newBaseUri = Uri.withAppendedPath(parentBaseUrl, newBaseUrlText); - } - return newBaseUri; + return UriUtil.resolve(parentBaseUrl, xpp.getText()); } protected static int parseInt(XmlPullParser xpp, String name) { @@ -582,4 +580,120 @@ public class MediaPresentationDescriptionParser extends DefaultHandler return value == null ? defaultValue : value; } + /** + * Builds a list of {@link ContentProtection} elements for an {@link AdaptationSet}. + *

+ * If child Representation elements contain ContentProtection elements, then it is required that + * they all define the same ones. If they do, the ContentProtection elements are bubbled up to the + * AdaptationSet. Child Representation elements defining different ContentProtection elements is + * considered an error. + */ + protected static final class ContentProtectionsBuilder implements Comparator { + + private ArrayList adaptationSetProtections; + private ArrayList representationProtections; + private ArrayList currentRepresentationProtections; + + private boolean representationProtectionsSet; + + /** + * Adds a {@link ContentProtection} found in the AdaptationSet element. + * + * @param contentProtection The {@link ContentProtection} to add. + */ + public void addAdaptationSetProtection(ContentProtection contentProtection) { + if (adaptationSetProtections == null) { + adaptationSetProtections = new ArrayList(); + } + maybeAddContentProtection(adaptationSetProtections, contentProtection); + } + + /** + * Adds a {@link ContentProtection} found in a child Representation element. + * + * @param contentProtection The {@link ContentProtection} to add. + */ + public void addRepresentationProtection(ContentProtection contentProtection) { + if (currentRepresentationProtections == null) { + currentRepresentationProtections = new ArrayList(); + } + maybeAddContentProtection(currentRepresentationProtections, contentProtection); + } + + /** + * Should be invoked after processing each child Representation element, in order to apply + * consistency checks. + */ + public void endRepresentation() { + if (!representationProtectionsSet) { + if (currentRepresentationProtections != null) { + Collections.sort(currentRepresentationProtections, this); + } + representationProtections = currentRepresentationProtections; + representationProtectionsSet = true; + } else { + // Assert that each Representation element defines the same ContentProtection elements. + if (currentRepresentationProtections == null) { + Assertions.checkState(representationProtections == null); + } else { + Collections.sort(currentRepresentationProtections, this); + Assertions.checkState(currentRepresentationProtections.equals(representationProtections)); + } + } + currentRepresentationProtections = null; + } + + /** + * Returns the final list of consistent {@link ContentProtection} elements. + */ + public ArrayList build() { + if (adaptationSetProtections == null) { + return representationProtections; + } else if (representationProtections == null) { + return adaptationSetProtections; + } else { + // Bubble up ContentProtection elements found in the child Representation elements. + for (int i = 0; i < representationProtections.size(); i++) { + maybeAddContentProtection(adaptationSetProtections, representationProtections.get(i)); + } + return adaptationSetProtections; + } + } + + /** + * Checks a ContentProtection for consistency with the given list, adding it if necessary. + *

    + *
  • If the new ContentProtection matches another in the list, it's consistent and is not + * added to the list. + *
  • If the new ContentProtection has the same schemeUriId as another ContentProtection in the + * list, but its other attributes do not match, then it's inconsistent and an + * {@link IllegalStateException} is thrown. + *
  • Else the new ContentProtection has a unique schemeUriId, it's consistent and is added. + *
+ * + * @param contentProtections The list of ContentProtection elements currently known. + * @param contentProtection The ContentProtection to add. + */ + private void maybeAddContentProtection(List contentProtections, + ContentProtection contentProtection) { + if (!contentProtections.contains(contentProtection)) { + for (int i = 0; i < contentProtections.size(); i++) { + // If contains returned false (no complete match), but find a matching schemeUriId, then + // the MPD contains inconsistent ContentProtection data. + Assertions.checkState( + !contentProtections.get(i).schemeUriId.equals(contentProtection.schemeUriId)); + } + contentProtections.add(contentProtection); + } + } + + // Comparator implementation. + + @Override + public int compare(ContentProtection first, ContentProtection second) { + return first.schemeUriId.compareTo(second.schemeUriId); + } + + } + } diff --git a/library/src/main/java/com/google/android/exoplayer/dash/mpd/RangedUri.java b/library/src/main/java/com/google/android/exoplayer/dash/mpd/RangedUri.java index 2ce5ad3092..22a8bfdee5 100644 --- a/library/src/main/java/com/google/android/exoplayer/dash/mpd/RangedUri.java +++ b/library/src/main/java/com/google/android/exoplayer/dash/mpd/RangedUri.java @@ -16,7 +16,7 @@ package com.google.android.exoplayer.dash.mpd; import com.google.android.exoplayer.util.Assertions; -import com.google.android.exoplayer.util.Util; +import com.google.android.exoplayer.util.UriUtil; import android.net.Uri; @@ -35,31 +35,28 @@ public final class RangedUri { */ public final long length; - // The {@link Uri} is stored internally in two parts, {@link #baseUri} and {@link uriString}. - // This helps optimize memory usage in the same way that DASH manifests allow many URLs to be - // expressed concisely in the form of a single BaseURL and many relative paths. Note that this - // optimization relies on the same {@code Uri} being passed as the {@link #baseUri} to many + // The URI is stored internally in two parts: reference URI and a base URI to use when + // resolving it. This helps optimize memory usage in the same way that DASH manifests allow many + // URLs to be expressed concisely in the form of a single BaseURL and many relative paths. Note + // that this optimization relies on the same object being passed as the base URI to many // instances of this class. - private final Uri baseUri; - private final String stringUri; + private final String baseUri; + private final String referenceUri; private int hashCode; /** * Constructs an ranged uri. - *

- * See {@link Util#getMergedUri(Uri, String)} for a description of how {@code baseUri} and - * {@code stringUri} are merged. * * @param baseUri A uri that can form the base of the uri defined by the instance. - * @param stringUri A relative or absolute uri in string form. + * @param referenceUri A reference uri that should be resolved with respect to {@code baseUri}. * @param start The (zero based) index of the first byte of the range. * @param length The length of the range, or -1 to indicate that the range is unbounded. */ - public RangedUri(Uri baseUri, String stringUri, long start, long length) { - Assertions.checkArgument(baseUri != null || stringUri != null); + public RangedUri(String baseUri, String referenceUri, long start, long length) { + Assertions.checkArgument(baseUri != null || referenceUri != null); this.baseUri = baseUri; - this.stringUri = stringUri; + this.referenceUri = referenceUri; this.start = start; this.length = length; } @@ -70,7 +67,16 @@ public final class RangedUri { * @return The {@link Uri} represented by the instance. */ public Uri getUri() { - return Util.getMergedUri(baseUri, stringUri); + return UriUtil.resolveToUri(baseUri, referenceUri); + } + + /** + * Returns the uri represented by the instance as a string. + * + * @return The uri represented by the instance. + */ + public String getUriString() { + return UriUtil.resolve(baseUri, referenceUri); } /** @@ -85,13 +91,13 @@ public final class RangedUri { * @return The merged {@link RangedUri} if the merge was successful. Null otherwise. */ public RangedUri attemptMerge(RangedUri other) { - if (other == null || !getUri().equals(other.getUri())) { + if (other == null || !getUriString().equals(other.getUriString())) { return null; } else if (length != -1 && start + length == other.start) { - return new RangedUri(baseUri, stringUri, start, + return new RangedUri(baseUri, referenceUri, start, other.length == -1 ? -1 : length + other.length); } else if (other.length != -1 && other.start + other.length == start) { - return new RangedUri(baseUri, stringUri, other.start, + return new RangedUri(baseUri, referenceUri, other.start, length == -1 ? -1 : other.length + length); } else { return null; @@ -104,7 +110,7 @@ public final class RangedUri { int result = 17; result = 31 * result + (int) start; result = 31 * result + (int) length; - result = 31 * result + getUri().hashCode(); + result = 31 * result + getUriString().hashCode(); hashCode = result; } return hashCode; @@ -121,7 +127,7 @@ public final class RangedUri { RangedUri other = (RangedUri) obj; return this.start == other.start && this.length == other.length - && getUri().equals(other.getUri()); + && getUriString().equals(other.getUriString()); } } diff --git a/library/src/main/java/com/google/android/exoplayer/dash/mpd/Representation.java b/library/src/main/java/com/google/android/exoplayer/dash/mpd/Representation.java index afae71de23..c5b0fcdad4 100644 --- a/library/src/main/java/com/google/android/exoplayer/dash/mpd/Representation.java +++ b/library/src/main/java/com/google/android/exoplayer/dash/mpd/Representation.java @@ -147,7 +147,7 @@ public abstract class Representation { public static class SingleSegmentRepresentation extends Representation { /** - * The {@link Uri} of the single segment. + * The uri of the single segment. */ public final Uri uri; @@ -174,7 +174,7 @@ public abstract class Representation { * @param contentLength The content length, or -1 if unknown. */ public static SingleSegmentRepresentation newInstance(long periodStartMs, long periodDurationMs, - String contentId, long revisionId, Format format, Uri uri, long initializationStart, + String contentId, long revisionId, Format format, String uri, long initializationStart, long initializationEnd, long indexStart, long indexEnd, long contentLength) { RangedUri rangedUri = new RangedUri(uri, null, initializationStart, initializationEnd - initializationStart + 1); @@ -197,13 +197,13 @@ public abstract class Representation { public SingleSegmentRepresentation(long periodStartMs, long periodDurationMs, String contentId, long revisionId, Format format, SingleSegmentBase segmentBase, long contentLength) { super(periodStartMs, periodDurationMs, contentId, revisionId, format, segmentBase); - this.uri = segmentBase.uri; + this.uri = Uri.parse(segmentBase.uri); this.indexUri = segmentBase.getIndex(); this.contentLength = contentLength; // If we have an index uri then the index is defined externally, and we shouldn't return one // directly. If we don't, then we can't do better than an index defining a single segment. segmentIndex = indexUri != null ? null : new DashSingleSegmentIndex(periodStartMs * 1000, - periodDurationMs * 1000, new RangedUri(uri, null, 0, -1)); + periodDurationMs * 1000, new RangedUri(segmentBase.uri, null, 0, -1)); } @Override diff --git a/library/src/main/java/com/google/android/exoplayer/dash/mpd/SegmentBase.java b/library/src/main/java/com/google/android/exoplayer/dash/mpd/SegmentBase.java index c6eec00602..dffe7200fa 100644 --- a/library/src/main/java/com/google/android/exoplayer/dash/mpd/SegmentBase.java +++ b/library/src/main/java/com/google/android/exoplayer/dash/mpd/SegmentBase.java @@ -19,8 +19,6 @@ import com.google.android.exoplayer.C; import com.google.android.exoplayer.dash.DashSegmentIndex; import com.google.android.exoplayer.util.Util; -import android.net.Uri; - import java.util.List; /** @@ -73,7 +71,7 @@ public abstract class SegmentBase { /** * The uri of the segment. */ - public final Uri uri; + public final String uri; /* package */ final long indexStart; /* package */ final long indexLength; @@ -89,7 +87,7 @@ public abstract class SegmentBase { * @param indexLength The length of the index data in bytes. */ public SingleSegmentBase(RangedUri initialization, long timescale, long presentationTimeOffset, - Uri uri, long indexStart, long indexLength) { + String uri, long indexStart, long indexLength) { super(initialization, timescale, presentationTimeOffset); this.uri = uri; this.indexStart = indexStart; @@ -99,7 +97,7 @@ public abstract class SegmentBase { /** * @param uri The uri of the segment. */ - public SingleSegmentBase(Uri uri) { + public SingleSegmentBase(String uri) { this(null, 1, 0, uri, 0, -1); } @@ -289,7 +287,7 @@ public abstract class SegmentBase { /* package */ final UrlTemplate initializationTemplate; /* package */ final UrlTemplate mediaTemplate; - private final Uri baseUrl; + private final String baseUrl; /** * @param initialization A {@link RangedUri} corresponding to initialization data, if such data @@ -315,7 +313,7 @@ public abstract class SegmentBase { public SegmentTemplate(RangedUri initialization, long timescale, long presentationTimeOffset, long periodDurationMs, int startNumber, long duration, List segmentTimeline, UrlTemplate initializationTemplate, - UrlTemplate mediaTemplate, Uri baseUrl) { + UrlTemplate mediaTemplate, String baseUrl) { super(initialization, timescale, presentationTimeOffset, periodDurationMs, startNumber, duration, segmentTimeline); this.initializationTemplate = initializationTemplate; diff --git a/library/src/main/java/com/google/android/exoplayer/dash/mpd/UtcTimingElementResolver.java b/library/src/main/java/com/google/android/exoplayer/dash/mpd/UtcTimingElementResolver.java index bfb8d042b2..eb87b23cee 100644 --- a/library/src/main/java/com/google/android/exoplayer/dash/mpd/UtcTimingElementResolver.java +++ b/library/src/main/java/com/google/android/exoplayer/dash/mpd/UtcTimingElementResolver.java @@ -32,6 +32,7 @@ import java.io.InputStreamReader; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Locale; +import java.util.TimeZone; import java.util.concurrent.CancellationException; /** @@ -173,6 +174,7 @@ public class UtcTimingElementResolver implements Loader.Callback { try { // TODO: It may be necessary to handle timestamp offsets from UTC. SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US); + format.setTimeZone(TimeZone.getTimeZone("UTC")); return format.parse(firstLine).getTime(); } catch (ParseException e) { throw new ParserException(e); diff --git a/library/src/main/java/com/google/android/exoplayer/drm/DrmInitData.java b/library/src/main/java/com/google/android/exoplayer/drm/DrmInitData.java new file mode 100644 index 0000000000..ae862d0bc9 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/drm/DrmInitData.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2014 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.exoplayer.drm; + +import android.media.MediaDrm; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Encapsulates initialization data required by a {@link MediaDrm} instance. + */ +public abstract class DrmInitData { + + /** + * The container mime type. + */ + public final String mimeType; + + public DrmInitData(String mimeType) { + this.mimeType = mimeType; + } + + /** + * Retrieves initialization data for a given DRM scheme, specified by its UUID. + * + * @param schemeUuid The DRM scheme's UUID. + * @return The initialization data for the scheme, or null if the scheme is not supported. + */ + public abstract byte[] get(UUID schemeUuid); + + /** + * A {@link DrmInitData} implementation that maps UUID onto scheme specific data. + */ + public static final class Mapped extends DrmInitData { + + private final Map schemeData; + + public Mapped(String mimeType) { + super(mimeType); + schemeData = new HashMap(); + } + + @Override + public byte[] get(UUID schemeUuid) { + return schemeData.get(schemeUuid); + } + + /** + * Inserts scheme specific initialization data. + * + * @param schemeUuid The scheme UUID. + * @param data The corresponding initialization data. + */ + public void put(UUID schemeUuid, byte[] data) { + schemeData.put(schemeUuid, data); + } + + /** + * Inserts scheme specific initialization data. + * + * @param data A mapping from scheme UUID to initialization data. + */ + public void putAll(Map data) { + schemeData.putAll(data); + } + + } + + /** + * A {@link DrmInitData} implementation that returns the same initialization data for all schemes. + */ + public static final class Universal extends DrmInitData { + + private byte[] data; + + public Universal(String mimeType, byte[] data) { + super(mimeType); + this.data = data; + } + + @Override + public byte[] get(UUID schemeUuid) { + return data; + } + + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/drm/DrmSessionManager.java b/library/src/main/java/com/google/android/exoplayer/drm/DrmSessionManager.java index 3bdfae9d12..a5e78ab008 100644 --- a/library/src/main/java/com/google/android/exoplayer/drm/DrmSessionManager.java +++ b/library/src/main/java/com/google/android/exoplayer/drm/DrmSessionManager.java @@ -18,9 +18,6 @@ package com.google.android.exoplayer.drm; import android.annotation.TargetApi; import android.media.MediaCrypto; -import java.util.Map; -import java.util.UUID; - /** * Manages a DRM session. */ @@ -36,7 +33,7 @@ public interface DrmSessionManager { */ public static final int STATE_CLOSED = 1; /** - * The session is being opened (i.e. {@link #open(Map, String)} has been called, but the session + * The session is being opened (i.e. {@link #open(DrmInitData)} has been called, but the session * is not yet open). */ public static final int STATE_OPENING = 2; @@ -52,11 +49,9 @@ public interface DrmSessionManager { /** * Opens the session, possibly asynchronously. * - * @param drmInitData Initialization data for the drm schemes supported by the media, keyed by - * scheme UUID. - * @param mimeType The mimeType of the media. + * @param drmInitData DRM initialization data. */ - void open(Map drmInitData, String mimeType); + void open(DrmInitData drmInitData); /** * Closes the session. diff --git a/library/src/main/java/com/google/android/exoplayer/drm/KeysExpiredException.java b/library/src/main/java/com/google/android/exoplayer/drm/KeysExpiredException.java new file mode 100644 index 0000000000..75945a7ec1 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/drm/KeysExpiredException.java @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2014 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.exoplayer.drm; + +/** + * Thrown when the drm keys loaded into an open session expire. + */ +public final class KeysExpiredException extends Exception { +} diff --git a/library/src/main/java/com/google/android/exoplayer/drm/StreamingDrmSessionManager.java b/library/src/main/java/com/google/android/exoplayer/drm/StreamingDrmSessionManager.java index 866c5f96ef..95a91523ec 100644 --- a/library/src/main/java/com/google/android/exoplayer/drm/StreamingDrmSessionManager.java +++ b/library/src/main/java/com/google/android/exoplayer/drm/StreamingDrmSessionManager.java @@ -31,7 +31,6 @@ import android.os.Looper; import android.os.Message; import java.util.HashMap; -import java.util.Map; import java.util.UUID; /** @@ -81,15 +80,6 @@ public class StreamingDrmSessionManager implements DrmSessionManager { private byte[] schemePsshData; private byte[] sessionId; - /** - * @deprecated Use the other constructor, passing null as {@code optionalKeyRequestParameters}. - */ - @Deprecated - public StreamingDrmSessionManager(UUID uuid, Looper playbackLooper, MediaDrmCallback callback, - Handler eventHandler, EventListener eventListener) throws UnsupportedSchemeException { - this(uuid, playbackLooper, callback, null, eventHandler, eventListener); - } - /** * @param uuid The UUID of the drm scheme. * @param playbackLooper The looper associated with the media playback thread. Should usually be @@ -168,7 +158,7 @@ public class StreamingDrmSessionManager implements DrmSessionManager { } @Override - public void open(Map psshData, String mimeType) { + public void open(DrmInitData drmInitData) { if (++openCount != 1) { return; } @@ -178,8 +168,8 @@ public class StreamingDrmSessionManager implements DrmSessionManager { postRequestHandler = new PostRequestHandler(requestHandlerThread.getLooper()); } if (this.schemePsshData == null) { - this.mimeType = mimeType; - schemePsshData = psshData.get(uuid); + mimeType = drmInitData.mimeType; + schemePsshData = drmInitData.get(uuid); if (schemePsshData == null) { onError(new IllegalStateException("Media does not support uuid: " + uuid)); return; @@ -332,7 +322,7 @@ public class StreamingDrmSessionManager implements DrmSessionManager { return; case MediaDrm.EVENT_KEY_EXPIRED: state = STATE_OPENED; - postKeyRequest(); + onError(new KeysExpiredException()); return; case MediaDrm.EVENT_PROVISION_REQUIRED: state = STATE_OPENED; diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/DefaultExtractorInput.java b/library/src/main/java/com/google/android/exoplayer/extractor/DefaultExtractorInput.java new file mode 100644 index 0000000000..0fb9008afc --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/extractor/DefaultExtractorInput.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2014 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.exoplayer.extractor; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.upstream.DataSource; + +import java.io.EOFException; +import java.io.IOException; + +/** + * An {@link ExtractorInput} that wraps a {@link DataSource}. + */ +public final class DefaultExtractorInput implements ExtractorInput { + + private static final byte[] SCRATCH_SPACE = new byte[4096]; + + private final DataSource dataSource; + + private long position; + private long length; + + /** + * @param dataSource The wrapped {@link DataSource}. + * @param position The initial position in the stream. + * @param length The length of the stream, or {@link C#LENGTH_UNBOUNDED} if it is unknown. + */ + public DefaultExtractorInput(DataSource dataSource, long position, long length) { + this.dataSource = dataSource; + this.position = position; + this.length = length; + } + + @Override + public int read(byte[] target, int offset, int length) throws IOException, InterruptedException { + if (Thread.interrupted()) { + throw new InterruptedException(); + } + int bytesRead = dataSource.read(target, offset, length); + if (bytesRead == C.RESULT_END_OF_INPUT) { + return C.RESULT_END_OF_INPUT; + } + position += bytesRead; + return bytesRead; + } + + @Override + public boolean readFully(byte[] target, int offset, int length, boolean allowEndOfInput) + throws IOException, InterruptedException { + int remaining = length; + while (remaining > 0) { + if (Thread.interrupted()) { + throw new InterruptedException(); + } + int bytesRead = dataSource.read(target, offset, remaining); + if (bytesRead == C.RESULT_END_OF_INPUT) { + if (allowEndOfInput && remaining == length) { + return false; + } + throw new EOFException(); + } + offset += bytesRead; + remaining -= bytesRead; + } + position += length; + return true; + } + + @Override + public void readFully(byte[] target, int offset, int length) + throws IOException, InterruptedException { + readFully(target, offset, length, false); + } + + @Override + public void skipFully(int length) throws IOException, InterruptedException { + int remaining = length; + while (remaining > 0) { + if (Thread.interrupted()) { + throw new InterruptedException(); + } + int bytesRead = dataSource.read(SCRATCH_SPACE, 0, Math.min(SCRATCH_SPACE.length, remaining)); + if (bytesRead == C.RESULT_END_OF_INPUT) { + throw new EOFException(); + } + remaining -= bytesRead; + } + position += length; + } + + @Override + public long getPosition() { + return position; + } + + @Override + public long getLength() { + return length; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/hls/parser/SampleQueue.java b/library/src/main/java/com/google/android/exoplayer/extractor/DefaultTrackOutput.java similarity index 67% rename from library/src/main/java/com/google/android/exoplayer/hls/parser/SampleQueue.java rename to library/src/main/java/com/google/android/exoplayer/extractor/DefaultTrackOutput.java index 348a2cd843..ba69ae0397 100644 --- a/library/src/main/java/com/google/android/exoplayer/hls/parser/SampleQueue.java +++ b/library/src/main/java/com/google/android/exoplayer/extractor/DefaultTrackOutput.java @@ -13,20 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer.hls.parser; +package com.google.android.exoplayer.extractor; -import com.google.android.exoplayer.C; import com.google.android.exoplayer.MediaFormat; import com.google.android.exoplayer.SampleHolder; import com.google.android.exoplayer.upstream.BufferPool; +import com.google.android.exoplayer.upstream.DataSource; import com.google.android.exoplayer.util.ParsableByteArray; +import java.io.IOException; + /** - * Wraps a {@link RollingSampleBuffer}, adding higher level functionality such as enforcing that - * the first sample returned from the queue is a keyframe, allowing splicing to another queue, and - * so on. + * A {@link TrackOutput} that buffers extracted samples in a queue, and allows for consumption from + * that queue. */ -/* package */ abstract class SampleQueue { +public final class DefaultTrackOutput implements TrackOutput { private final RollingSampleBuffer rollingBuffer; private final SampleHolder sampleInfoHolder; @@ -36,14 +37,11 @@ import com.google.android.exoplayer.util.ParsableByteArray; private long lastReadTimeUs; private long spliceOutTimeUs; - // Accessed only by the loading thread. - private boolean writingSample; - // Accessed by both the loading and consuming threads. - private volatile MediaFormat mediaFormat; private volatile long largestParsedTimestampUs; + private volatile MediaFormat format; - protected SampleQueue(BufferPool bufferPool) { + public DefaultTrackOutput(BufferPool bufferPool) { rollingBuffer = new RollingSampleBuffer(bufferPool); sampleInfoHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_DISABLED); needKeyframe = true; @@ -52,24 +50,60 @@ import com.google.android.exoplayer.util.ParsableByteArray; largestParsedTimestampUs = Long.MIN_VALUE; } - public void release() { - rollingBuffer.release(); + // Called by the consuming thread, but only when there is no loading thread. + + /** + * Clears the queue, returning all allocations to the allocator. + */ + public void clear() { + rollingBuffer.clear(); + needKeyframe = true; + lastReadTimeUs = Long.MIN_VALUE; + spliceOutTimeUs = Long.MIN_VALUE; + largestParsedTimestampUs = Long.MIN_VALUE; + } + + /** + * Returns the current absolute write index. + */ + public int getWriteIndex() { + return rollingBuffer.getWriteIndex(); } // Called by the consuming thread. + /** + * Returns the current absolute read index. + */ + public int getReadIndex() { + return rollingBuffer.getReadIndex(); + } + + /** + * True if the output has received a format. False otherwise. + */ + public boolean hasFormat() { + return format != null; + } + + /** + * The format most recently received by the output, or null if a format has yet to be received. + */ + public MediaFormat getFormat() { + return format; + } + + /** + * The largest timestamp of any sample received by the output, or {@link Long#MIN_VALUE} if a + * sample has yet to be received. + */ public long getLargestParsedTimestampUs() { return largestParsedTimestampUs; } - public boolean hasMediaFormat() { - return mediaFormat != null; - } - - public MediaFormat getMediaFormat() { - return mediaFormat; - } - + /** + * True if at least one sample can be read from the queue. False otherwise. + */ public boolean isEmpty() { return !advanceToEligibleSample(); } @@ -115,7 +149,7 @@ import com.google.android.exoplayer.util.ParsableByteArray; * @param nextQueue The queue being spliced to. * @return Whether the splice was configured successfully. */ - public boolean configureSpliceTo(SampleQueue nextQueue) { + public boolean configureSpliceTo(DefaultTrackOutput nextQueue) { if (spliceOutTimeUs != Long.MIN_VALUE) { // We've already configured the splice. return true; @@ -128,8 +162,7 @@ import com.google.android.exoplayer.util.ParsableByteArray; } RollingSampleBuffer nextRollingBuffer = nextQueue.rollingBuffer; while (nextRollingBuffer.peekSample(sampleInfoHolder) - && (sampleInfoHolder.timeUs < firstPossibleSpliceTime - || (sampleInfoHolder.flags & C.SAMPLE_FLAG_SYNC) == 0)) { + && (sampleInfoHolder.timeUs < firstPossibleSpliceTime || !sampleInfoHolder.isSyncFrame())) { // Discard samples from the next queue for as long as they are before the earliest possible // splice time, or not keyframes. nextRollingBuffer.skipSample(); @@ -152,7 +185,7 @@ import com.google.android.exoplayer.util.ParsableByteArray; private boolean advanceToEligibleSample() { boolean haveNext = rollingBuffer.peekSample(sampleInfoHolder); if (needKeyframe) { - while (haveNext && (sampleInfoHolder.flags & C.SAMPLE_FLAG_SYNC) == 0) { + while (haveNext && !sampleInfoHolder.isSyncFrame()) { rollingBuffer.skipSample(); haveNext = rollingBuffer.peekSample(sampleInfoHolder); } @@ -168,35 +201,27 @@ import com.google.android.exoplayer.util.ParsableByteArray; // Called by the loading thread. - protected boolean writingSample() { - return writingSample; + public int sampleData(DataSource dataSource, int length) throws IOException { + return rollingBuffer.appendData(dataSource, length); } - protected void setMediaFormat(MediaFormat mediaFormat) { - this.mediaFormat = mediaFormat; + // TrackOutput implementation. Called by the loading thread. + + @Override + public void format(MediaFormat format) { + this.format = format; } - protected void startSample(long sampleTimeUs) { - startSample(sampleTimeUs, 0); - } - - protected void startSample(long sampleTimeUs, int offset) { - writingSample = true; - largestParsedTimestampUs = Math.max(largestParsedTimestampUs, sampleTimeUs); - rollingBuffer.startSample(sampleTimeUs, offset); - } - - protected void appendData(ParsableByteArray buffer, int length) { + @Override + public void sampleData(ParsableByteArray buffer, int length) { rollingBuffer.appendData(buffer, length); } - protected void commitSample(boolean isKeyframe) { - commitSample(isKeyframe, 0); - } - - protected void commitSample(boolean isKeyframe, int offset) { - rollingBuffer.commitSample(isKeyframe, offset); - writingSample = false; + @Override + public void sampleMetadata(long timeUs, int flags, int size, int offset, byte[] encryptionKey) { + largestParsedTimestampUs = Math.max(largestParsedTimestampUs, timeUs); + rollingBuffer.commitSample(timeUs, flags, rollingBuffer.getWritePosition() - size - offset, + size, encryptionKey); } } diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/Extractor.java b/library/src/main/java/com/google/android/exoplayer/extractor/Extractor.java new file mode 100644 index 0000000000..a2a11593a1 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/extractor/Extractor.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2014 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.exoplayer.extractor; + +import com.google.android.exoplayer.C; + +import java.io.IOException; + +/** + * Facilitates extraction of data from a container format. + */ +public interface Extractor { + + /** + * Returned by {@link #read(ExtractorInput)} if the {@link ExtractorInput} passed to the next + * {@link #read(ExtractorInput)} is required to provide data continuing from the position in the + * stream reached by the returning call. + */ + public static final int RESULT_CONTINUE = 0; + /** + * Returned by {@link #read(ExtractorInput)} if the end of the {@link ExtractorInput} was reached. + * Equal to {@link C#RESULT_END_OF_INPUT}. + */ + public static final int RESULT_END_OF_INPUT = C.RESULT_END_OF_INPUT; + + /** + * Initializes the extractor with an {@link ExtractorOutput}. + * + * @param output An {@link ExtractorOutput} to receive extracted data. + */ + void init(ExtractorOutput output); + + /** + * Extracts data read from a provided {@link ExtractorInput}. + *

+ * Each read will extract at most one sample from the stream before returning. + * + * @param input The {@link ExtractorInput} from which data should be read. + * @return One of the {@code RESULT_} values defined in this interface. + * @throws IOException If an error occurred reading from the input. + * @throws InterruptedException If the thread was interrupted. + */ + int read(ExtractorInput input) throws IOException, InterruptedException; + +} diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/ExtractorInput.java b/library/src/main/java/com/google/android/exoplayer/extractor/ExtractorInput.java new file mode 100644 index 0000000000..63c09aab3d --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ExtractorInput.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2014 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.exoplayer.extractor; + +import com.google.android.exoplayer.C; + +import java.io.EOFException; +import java.io.IOException; + +/** + * Provides data to be consumed by an {@link Extractor}. + */ +public interface ExtractorInput { + + /** + * Reads up to {@code length} bytes from the input. + *

+ * This method blocks until at least one byte of data can be read, the end of the input is + * detected, or an exception is thrown. + * + * @param target A target array into which data should be written. + * @param offset The offset into the target array at which to write. + * @param length The maximum number of bytes to read from the input. + * @return The number of bytes read, or {@link C#RESULT_END_OF_INPUT} if the input has ended. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread has been interrupted. + */ + int read(byte[] target, int offset, int length) throws IOException, InterruptedException; + + /** + * Like {@link #read(byte[], int, int)}, but reads the requested {@code length} in full. + *

+ * If the end of the input is found having read no data, then behavior is dependent on + * {@code allowEndOfInput}. If {@code allowEndOfInput == true} then {@code false} is returned. + * Otherwise an {@link EOFException} is thrown. + *

+ * Encountering the end of input having partially satisfied the read is always considered an + * error, and will result in an {@link EOFException} being thrown. + * + * @param target A target array into which data should be written. + * @param offset The offset into the target array at which to write. + * @param length The number of bytes to read from the input. + * @param allowEndOfInput True if encountering the end of the input having read no data is + * allowed, and should result in {@code false} being returned. False if it should be + * considered an error, causing an {@link EOFException} to be thrown. + * @return True if the read was successful. False if the end of the input was encountered having + * read no data. + * @throws EOFException If the end of input was encountered having partially satisfied the read + * (i.e. having read at least one byte, but fewer than {@code length}), or if no bytes were + * read and {@code allowEndOfInput} is false. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread has been interrupted. + */ + boolean readFully(byte[] target, int offset, int length, boolean allowEndOfInput) + throws IOException, InterruptedException; + + /** + * Equivalent to {@code readFully(target, offset, length, false)}. + * + * @param target A target array into which data should be written. + * @param offset The offset into the target array at which to write. + * @param length The number of bytes to read from the input. + * @throws EOFException If the end of input was encountered. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread is interrupted. + */ + void readFully(byte[] target, int offset, int length) throws IOException, InterruptedException; + + /** + * Like {@link #readFully(byte[], int, int)}, except the data is skipped instead of read. + *

+ * Encountering the end of input is always considered an error, and will result in an + * {@link EOFException} being thrown. + * + * @param length The number of bytes to skip from the input. + * @throws EOFException If the end of input was encountered. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread is interrupted. + */ + void skipFully(int length) throws IOException, InterruptedException; + + /** + * The current position (byte offset) in the stream. + * + * @return The position (byte offset) in the stream. + */ + long getPosition(); + + /** + * Returns the length of the source stream, or {@link C#LENGTH_UNBOUNDED} if it is unknown. + * + * @return The length of the source stream, or {@link C#LENGTH_UNBOUNDED}. + */ + long getLength(); + +} diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/ExtractorOutput.java b/library/src/main/java/com/google/android/exoplayer/extractor/ExtractorOutput.java new file mode 100644 index 0000000000..09bc4704c0 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ExtractorOutput.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2014 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.exoplayer.extractor; + +/** + * Receives stream level data extracted by an {@link Extractor}. + */ +public interface ExtractorOutput { + + /** + * Invoked when the {@link Extractor} identifies the existence of a track in the stream. + *

+ * Returns a {@link TrackOutput} that will receive track level data belonging to the track. + * + * @param trackId A track identifier. + * @return The {@link TrackOutput} that should receive track level data belonging to the track. + */ + TrackOutput track(int trackId); + + /** + * Invoked when all tracks have been identified, meaning that {@link #track(int)} will not be + * invoked again. + */ + void endTracks(); + +} diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/RollingSampleBuffer.java b/library/src/main/java/com/google/android/exoplayer/extractor/RollingSampleBuffer.java new file mode 100644 index 0000000000..446f64df4b --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/extractor/RollingSampleBuffer.java @@ -0,0 +1,518 @@ +/* + * Copyright (C) 2014 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.exoplayer.extractor; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.upstream.BufferPool; +import com.google.android.exoplayer.upstream.DataSource; +import com.google.android.exoplayer.util.ParsableByteArray; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.concurrent.ConcurrentLinkedQueue; + +/** + * A rolling buffer of sample data and corresponding sample information. + */ +/* package */ final class RollingSampleBuffer { + + private static final int INITIAL_SCRATCH_SIZE = 32; + + private final BufferPool fragmentPool; + private final int fragmentLength; + + private final InfoQueue infoQueue; + private final ConcurrentLinkedQueue dataQueue; + private final SampleExtrasHolder extrasHolder; + private final ParsableByteArray scratch; + + // Accessed only by the consuming thread. + private long totalBytesDropped; + + // Accessed only by the loading thread. + private long totalBytesWritten; + private byte[] lastFragment; + private int lastFragmentOffset; + + public RollingSampleBuffer(BufferPool bufferPool) { + this.fragmentPool = bufferPool; + fragmentLength = bufferPool.bufferLength; + infoQueue = new InfoQueue(); + dataQueue = new ConcurrentLinkedQueue(); + extrasHolder = new SampleExtrasHolder(); + scratch = new ParsableByteArray(INITIAL_SCRATCH_SIZE); + lastFragmentOffset = fragmentLength; + } + + // Called by the consuming thread, but only when there is no loading thread. + + /** + * Clears the buffer, returning all allocations to the allocator. + */ + public void clear() { + infoQueue.clear(); + while (!dataQueue.isEmpty()) { + fragmentPool.releaseDirect(dataQueue.remove()); + } + totalBytesDropped = 0; + totalBytesWritten = 0; + lastFragment = null; + lastFragmentOffset = fragmentLength; + } + + /** + * Returns the current absolute write index. + */ + public int getWriteIndex() { + return infoQueue.getWriteIndex(); + } + + // Called by the consuming thread. + + /** + * Returns the current absolute read index. + */ + public int getReadIndex() { + return infoQueue.getReadIndex(); + } + + /** + * Fills {@code holder} with information about the current sample, but does not write its data. + *

+ * The fields set are {@link SampleHolder#size}, {@link SampleHolder#timeUs} and + * {@link SampleHolder#flags}. + * + * @param holder The holder into which the current sample information should be written. + * @return True if the holder was filled. False if there is no current sample. + */ + public boolean peekSample(SampleHolder holder) { + return infoQueue.peekSample(holder, extrasHolder); + } + + /** + * Skips the current sample. + */ + public void skipSample() { + long nextOffset = infoQueue.moveToNextSample(); + dropDownstreamTo(nextOffset); + } + + /** + * Reads the current sample, advancing the read index to the next sample. + * + * @param sampleHolder The holder into which the current sample should be written. + * @return True if a sample was read. False if there is no current sample. + */ + public boolean readSample(SampleHolder sampleHolder) { + // Write the sample information into the holder and extrasHolder. + boolean haveSample = infoQueue.peekSample(sampleHolder, extrasHolder); + if (!haveSample) { + return false; + } + + // Read encryption data if the sample is encrypted. + if (sampleHolder.isEncrypted()) { + readEncryptionData(sampleHolder, extrasHolder); + } + // Write the sample data into the holder. + if (sampleHolder.data == null || sampleHolder.data.capacity() < sampleHolder.size) { + sampleHolder.replaceBuffer(sampleHolder.size); + } + if (sampleHolder.data != null) { + readData(extrasHolder.offset, sampleHolder.data, sampleHolder.size); + } + // Advance the read head. + long nextOffset = infoQueue.moveToNextSample(); + dropDownstreamTo(nextOffset); + return true; + } + + /** + * Reads encryption data for the current sample. + *

+ * The encryption data is written into {@code sampleHolder.cryptoInfo}, and + * {@code sampleHolder.size} is adjusted to subtract the number of bytes that were read. The + * same value is added to {@code extrasHolder.offset}. + * + * @param sampleHolder The holder into which the encryption data should be written. + * @param extrasHolder The extras holder whose offset should be read and subsequently adjusted. + */ + private void readEncryptionData(SampleHolder sampleHolder, SampleExtrasHolder extrasHolder) { + long offset = extrasHolder.offset; + + // Read the signal byte. + readData(offset, scratch.data, 1); + offset++; + byte signalByte = scratch.data[0]; + boolean subsampleEncryption = (signalByte & 0x80) != 0; + int ivSize = signalByte & 0x7F; + + // Read the initialization vector. + if (sampleHolder.cryptoInfo.iv == null) { + sampleHolder.cryptoInfo.iv = new byte[16]; + } + readData(offset, sampleHolder.cryptoInfo.iv, ivSize); + offset += ivSize; + + // Read the subsample count, if present. + int subsampleCount; + if (subsampleEncryption) { + readData(offset, scratch.data, 2); + offset += 2; + scratch.setPosition(0); + subsampleCount = scratch.readUnsignedShort(); + } else { + subsampleCount = 1; + } + + // Write the clear and encrypted subsample sizes. + int[] clearDataSizes = sampleHolder.cryptoInfo.numBytesOfClearData; + if (clearDataSizes == null || clearDataSizes.length < subsampleCount) { + clearDataSizes = new int[subsampleCount]; + } + int[] encryptedDataSizes = sampleHolder.cryptoInfo.numBytesOfEncryptedData; + if (encryptedDataSizes == null || encryptedDataSizes.length < subsampleCount) { + encryptedDataSizes = new int[subsampleCount]; + } + if (subsampleEncryption) { + int subsampleDataLength = 6 * subsampleCount; + ensureCapacity(scratch, subsampleDataLength); + readData(offset, scratch.data, subsampleDataLength); + offset += subsampleDataLength; + scratch.setPosition(0); + for (int i = 0; i < subsampleCount; i++) { + clearDataSizes[i] = scratch.readUnsignedShort(); + encryptedDataSizes[i] = scratch.readUnsignedIntToInt(); + } + } else { + clearDataSizes[0] = 0; + encryptedDataSizes[0] = sampleHolder.size - (int) (offset - extrasHolder.offset); + } + + // Populate the cryptoInfo. + sampleHolder.cryptoInfo.set(subsampleCount, clearDataSizes, encryptedDataSizes, + extrasHolder.encryptionKeyId, sampleHolder.cryptoInfo.iv, C.CRYPTO_MODE_AES_CTR); + + // Adjust the offset and size to take into account the bytes read. + int bytesRead = (int) (offset - extrasHolder.offset); + extrasHolder.offset += bytesRead; + sampleHolder.size -= bytesRead; + } + + /** + * Reads data from the front of the rolling buffer. + * + * @param absolutePosition The absolute position from which data should be read. + * @param target The buffer into which data should be written. + * @param length The number of bytes to read. + */ + private void readData(long absolutePosition, ByteBuffer target, int length) { + int remaining = length; + while (remaining > 0) { + dropDownstreamTo(absolutePosition); + int positionInFragment = (int) (absolutePosition - totalBytesDropped); + int toCopy = Math.min(remaining, fragmentLength - positionInFragment); + target.put(dataQueue.peek(), positionInFragment, toCopy); + absolutePosition += toCopy; + remaining -= toCopy; + } + } + + /** + * Reads data from the front of the rolling buffer. + * + * @param absolutePosition The absolute position from which data should be read. + * @param target The array into which data should be written. + * @param length The number of bytes to read. + */ + // TODO: Consider reducing duplication of this method and the one above. + private void readData(long absolutePosition, byte[] target, int length) { + int bytesRead = 0; + while (bytesRead < length) { + dropDownstreamTo(absolutePosition); + int positionInFragment = (int) (absolutePosition - totalBytesDropped); + int toCopy = Math.min(length - bytesRead, fragmentLength - positionInFragment); + System.arraycopy(dataQueue.peek(), positionInFragment, target, bytesRead, toCopy); + absolutePosition += toCopy; + bytesRead += toCopy; + } + } + + /** + * Discard any fragments that hold data prior to the specified absolute position, returning + * them to the pool. + * + * @param absolutePosition The absolute position up to which fragments can be discarded. + */ + private void dropDownstreamTo(long absolutePosition) { + int relativePosition = (int) (absolutePosition - totalBytesDropped); + int fragmentIndex = relativePosition / fragmentLength; + for (int i = 0; i < fragmentIndex; i++) { + fragmentPool.releaseDirect(dataQueue.remove()); + totalBytesDropped += fragmentLength; + } + } + + /** + * Ensure that the passed {@link ParsableByteArray} is of at least the specified limit. + */ + private static void ensureCapacity(ParsableByteArray byteArray, int limit) { + if (byteArray.limit() < limit) { + byteArray.reset(new byte[limit], limit); + } + } + + // Called by the loading thread. + + /** + * Returns the current write position in the rolling buffer. + * + * @return The current write position. + */ + public long getWritePosition() { + return totalBytesWritten; + } + + /** + * Appends data to the rolling buffer. + * + * @param dataSource The source from which to read. + * @param length The maximum length of the read, or {@link C#LENGTH_UNBOUNDED} if the caller does + * not wish to impose a limit. + * @return The number of bytes appended. + * @throws IOException If an error occurs reading from the source. + */ + public int appendData(DataSource dataSource, int length) throws IOException { + ensureSpaceForWrite(); + int remainingFragmentCapacity = fragmentLength - lastFragmentOffset; + length = length != C.LENGTH_UNBOUNDED ? Math.min(length, remainingFragmentCapacity) + : remainingFragmentCapacity; + + int bytesRead = dataSource.read(lastFragment, lastFragmentOffset, length); + if (bytesRead == C.RESULT_END_OF_INPUT) { + return C.RESULT_END_OF_INPUT; + } + + lastFragmentOffset += bytesRead; + totalBytesWritten += bytesRead; + return bytesRead; + } + + /** + * Appends data to the rolling buffer. + * + * @param buffer A buffer containing the data to append. + * @param length The length of the data to append. + */ + public void appendData(ParsableByteArray buffer, int length) { + int remainingWriteLength = length; + while (remainingWriteLength > 0) { + ensureSpaceForWrite(); + int thisWriteLength = Math.min(remainingWriteLength, fragmentLength - lastFragmentOffset); + buffer.readBytes(lastFragment, lastFragmentOffset, thisWriteLength); + lastFragmentOffset += thisWriteLength; + remainingWriteLength -= thisWriteLength; + } + totalBytesWritten += length; + } + + /** + * Indicates the end point for the current sample, making it available for consumption. + * + * @param sampleTimeUs The sample timestamp. + * @param flags Flags that accompany the sample. See {@link SampleHolder#flags}. + * @param position The position of the sample data in the rolling buffer. + * @param size The size of the sample, in bytes. + * @param encryptionKey The encryption key associated with the sample, or null. + */ + public void commitSample(long sampleTimeUs, int flags, long position, int size, + byte[] encryptionKey) { + infoQueue.commitSample(sampleTimeUs, flags, position, size, encryptionKey); + } + + /** + * Ensures at least one byte can be written, allocating a new fragment if necessary. + */ + private void ensureSpaceForWrite() { + if (lastFragmentOffset == fragmentLength) { + lastFragmentOffset = 0; + lastFragment = fragmentPool.allocateDirect(); + dataQueue.add(lastFragment); + } + } + + /** + * Holds information about the samples in the rolling buffer. + */ + private static final class InfoQueue { + + private static final int SAMPLE_CAPACITY_INCREMENT = 1000; + + private int capacity; + + private long[] offsets; + private int[] sizes; + private int[] flags; + private long[] timesUs; + private byte[][] encryptionKeys; + + private int queueSize; + private int absoluteReadIndex; + private int relativeReadIndex; + private int relativeWriteIndex; + + public InfoQueue() { + capacity = SAMPLE_CAPACITY_INCREMENT; + offsets = new long[capacity]; + timesUs = new long[capacity]; + flags = new int[capacity]; + sizes = new int[capacity]; + encryptionKeys = new byte[capacity][]; + } + + // Called by the consuming thread, but only when there is no loading thread. + + /** + * Clears the queue. + */ + public void clear() { + absoluteReadIndex = 0; + relativeReadIndex = 0; + relativeWriteIndex = 0; + queueSize = 0; + } + + /** + * Returns the current absolute write index. + */ + public int getWriteIndex() { + return absoluteReadIndex + queueSize; + } + + // Called by the consuming thread. + + /** + * Returns the current absolute read index. + */ + public int getReadIndex() { + return absoluteReadIndex; + } + + /** + * Fills {@code holder} with information about the current sample, but does not write its data. + * The first entry in {@code offsetHolder} is filled with the absolute position of the sample's + * data in the rolling buffer. + *

+ * The fields set are {SampleHolder#size}, {SampleHolder#timeUs}, {SampleHolder#flags} and + * {@code offsetHolder[0]}. + * + * @param holder The holder into which the current sample information should be written. + * @param extrasHolder The holder into which extra sample information should be written. + * @return True if the holders were filled. False if there is no current sample. + */ + public synchronized boolean peekSample(SampleHolder holder, SampleExtrasHolder extrasHolder) { + if (queueSize == 0) { + return false; + } + holder.timeUs = timesUs[relativeReadIndex]; + holder.size = sizes[relativeReadIndex]; + holder.flags = flags[relativeReadIndex]; + extrasHolder.offset = offsets[relativeReadIndex]; + extrasHolder.encryptionKeyId = encryptionKeys[relativeReadIndex]; + return true; + } + + /** + * Advances the read index to the next sample. + * + * @return The absolute position of the first byte in the rolling buffer that may still be + * required after advancing the index. Data prior to this position can be dropped. + */ + public synchronized long moveToNextSample() { + queueSize--; + int lastReadIndex = relativeReadIndex++; + absoluteReadIndex++; + if (relativeReadIndex == capacity) { + // Wrap around. + relativeReadIndex = 0; + } + return queueSize > 0 ? offsets[relativeReadIndex] + : (sizes[lastReadIndex] + offsets[lastReadIndex]); + } + + // Called by the loading thread. + + public synchronized void commitSample(long timeUs, int sampleFlags, long offset, int size, + byte[] encryptionKey) { + timesUs[relativeWriteIndex] = timeUs; + offsets[relativeWriteIndex] = offset; + sizes[relativeWriteIndex] = size; + flags[relativeWriteIndex] = sampleFlags; + encryptionKeys[relativeWriteIndex] = encryptionKey; + // Increment the write index. + queueSize++; + if (queueSize == capacity) { + // Increase the capacity. + int newCapacity = capacity + SAMPLE_CAPACITY_INCREMENT; + long[] newOffsets = new long[newCapacity]; + long[] newTimesUs = new long[newCapacity]; + int[] newFlags = new int[newCapacity]; + int[] newSizes = new int[newCapacity]; + byte[][] newEncryptionKeys = new byte[newCapacity][]; + int beforeWrap = capacity - relativeReadIndex; + System.arraycopy(offsets, relativeReadIndex, newOffsets, 0, beforeWrap); + System.arraycopy(timesUs, relativeReadIndex, newTimesUs, 0, beforeWrap); + System.arraycopy(flags, relativeReadIndex, newFlags, 0, beforeWrap); + System.arraycopy(sizes, relativeReadIndex, newSizes, 0, beforeWrap); + System.arraycopy(encryptionKeys, relativeReadIndex, newEncryptionKeys, 0, beforeWrap); + int afterWrap = relativeReadIndex; + System.arraycopy(offsets, 0, newOffsets, beforeWrap, afterWrap); + System.arraycopy(timesUs, 0, newTimesUs, beforeWrap, afterWrap); + System.arraycopy(flags, 0, newFlags, beforeWrap, afterWrap); + System.arraycopy(sizes, 0, newSizes, beforeWrap, afterWrap); + System.arraycopy(encryptionKeys, 0, newEncryptionKeys, beforeWrap, afterWrap); + offsets = newOffsets; + timesUs = newTimesUs; + flags = newFlags; + sizes = newSizes; + encryptionKeys = newEncryptionKeys; + relativeReadIndex = 0; + relativeWriteIndex = capacity; + queueSize = capacity; + capacity = newCapacity; + } else { + relativeWriteIndex++; + if (relativeWriteIndex == capacity) { + // Wrap around. + relativeWriteIndex = 0; + } + } + } + + } + + /** + * Holds additional sample information not held by {@link SampleHolder}. + */ + private static final class SampleExtrasHolder { + + public long offset; + public byte[] encryptionKeyId; + + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/TrackOutput.java b/library/src/main/java/com/google/android/exoplayer/extractor/TrackOutput.java new file mode 100644 index 0000000000..1ad55e50db --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/extractor/TrackOutput.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2014 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.exoplayer.extractor; + +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.util.ParsableByteArray; + +/** + * Receives track level data extracted by an {@link Extractor}. + */ +public interface TrackOutput { + + /** + * Invoked when the {@link MediaFormat} of the track has been extracted from the stream. + * + * @param format The extracted {@link MediaFormat}. + */ + void format(MediaFormat format); + + /** + * Invoked to write sample data to the output. + * + * @param data A {@link ParsableByteArray} from which to read the sample data. + * @param length The number of bytes to read. + */ + void sampleData(ParsableByteArray data, int length); + + /** + * Invoked when metadata associated with a sample has been extracted from the stream. + *

+ * The corresponding sample data will have already been passed to the output via calls to + * {@link #sampleData(ParsableByteArray, int)}. + * + * @param timeUs The media timestamp associated with the sample, in microseconds. + * @param flags Flags associated with the sample. See {@link SampleHolder#flags}. + * @param size The size of the sample data, in bytes. + * @param offset The number of bytes that have been passed to + * {@link #sampleData(ParsableByteArray, int)} since the last byte belonging to the sample + * whose metadata is being passed. + * @param encryptionKey The encryption key associated with the sample. May be null. + */ + void sampleMetadata(long timeUs, int flags, int size, int offset, byte[] encryptionKey); + +} diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/ts/AdtsExtractor.java b/library/src/main/java/com/google/android/exoplayer/extractor/ts/AdtsExtractor.java new file mode 100644 index 0000000000..d6c3ab73ab --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ts/AdtsExtractor.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2014 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.exoplayer.extractor.ts; + +import com.google.android.exoplayer.extractor.Extractor; +import com.google.android.exoplayer.extractor.ExtractorInput; +import com.google.android.exoplayer.extractor.ExtractorOutput; +import com.google.android.exoplayer.util.ParsableByteArray; + +import java.io.IOException; + +/** + * Facilitates the extraction of AAC samples from elementary audio files formatted as AAC with ADTS + * headers. + */ +public class AdtsExtractor implements Extractor { + + private static final int MAX_PACKET_SIZE = 200; + + private final long firstSampleTimestamp; + private final ParsableByteArray packetBuffer; + + // Accessed only by the loading thread. + private AdtsReader adtsReader; + private boolean firstPacket; + + public AdtsExtractor(long firstSampleTimestamp) { + this.firstSampleTimestamp = firstSampleTimestamp; + packetBuffer = new ParsableByteArray(MAX_PACKET_SIZE); + firstPacket = true; + } + + @Override + public void init(ExtractorOutput output) { + adtsReader = new AdtsReader(output.track(0)); + output.endTracks(); + } + + @Override + public int read(ExtractorInput input) + throws IOException, InterruptedException { + int bytesRead = input.read(packetBuffer.data, 0, MAX_PACKET_SIZE); + if (bytesRead == -1) { + return RESULT_END_OF_INPUT; + } + + // Feed whatever data we have to the reader, regardless of whether the read finished or not. + packetBuffer.setPosition(0); + packetBuffer.setLimit(bytesRead); + + // TODO: Make it possible for adtsReader to consume the dataSource directly, so that it becomes + // unnecessary to copy the data through packetBuffer. + adtsReader.consume(packetBuffer, firstSampleTimestamp, firstPacket); + firstPacket = false; + return RESULT_CONTINUE; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/hls/parser/AdtsReader.java b/library/src/main/java/com/google/android/exoplayer/extractor/ts/AdtsReader.java similarity index 92% rename from library/src/main/java/com/google/android/exoplayer/hls/parser/AdtsReader.java rename to library/src/main/java/com/google/android/exoplayer/extractor/ts/AdtsReader.java index 9dec6cc84a..5165f089f8 100644 --- a/library/src/main/java/com/google/android/exoplayer/hls/parser/AdtsReader.java +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ts/AdtsReader.java @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer.hls.parser; +package com.google.android.exoplayer.extractor.ts; import com.google.android.exoplayer.C; import com.google.android.exoplayer.MediaFormat; -import com.google.android.exoplayer.upstream.BufferPool; +import com.google.android.exoplayer.extractor.TrackOutput; import com.google.android.exoplayer.util.CodecSpecificDataUtil; import com.google.android.exoplayer.util.MimeTypes; import com.google.android.exoplayer.util.ParsableBitArray; @@ -48,15 +48,16 @@ import java.util.Collections; private boolean lastByteWasFF; private boolean hasCrc; - // Parsed from the header. + // Used when parsing the header. + private boolean hasOutputFormat; private long frameDurationUs; private int sampleSize; // Used when reading the samples. private long timeUs; - public AdtsReader(BufferPool bufferPool) { - super(bufferPool); + public AdtsReader(TrackOutput output) { + super(output); adtsScratch = new ParsableBitArray(new byte[HEADER_SIZE + CRC_SIZE]); state = STATE_FINDING_SYNC; } @@ -78,17 +79,16 @@ import java.util.Collections; int targetLength = hasCrc ? HEADER_SIZE + CRC_SIZE : HEADER_SIZE; if (continueRead(data, adtsScratch.getData(), targetLength)) { parseHeader(); - startSample(timeUs); bytesRead = 0; state = STATE_READING_SAMPLE; } break; case STATE_READING_SAMPLE: int bytesToRead = Math.min(data.bytesLeft(), sampleSize - bytesRead); - appendData(data, bytesToRead); + output.sampleData(data, bytesToRead); bytesRead += bytesToRead; if (bytesRead == sampleSize) { - commitSample(true); + output.sampleMetadata(timeUs, C.SAMPLE_FLAG_SYNC, sampleSize, 0, null); timeUs += frameDurationUs; bytesRead = 0; state = STATE_FINDING_SYNC; @@ -152,7 +152,7 @@ import java.util.Collections; private void parseHeader() { adtsScratch.setPosition(0); - if (!hasMediaFormat()) { + if (!hasOutputFormat) { int audioObjectType = adtsScratch.readBits(2) + 1; int sampleRateIndex = adtsScratch.readBits(4); adtsScratch.skipBits(1); @@ -167,7 +167,8 @@ import java.util.Collections; MediaFormat.NO_VALUE, audioParams.second, audioParams.first, Collections.singletonList(audioSpecificConfig)); frameDurationUs = (C.MICROS_PER_SECOND * 1024L) / mediaFormat.sampleRate; - setMediaFormat(mediaFormat); + output.format(mediaFormat); + hasOutputFormat = true; } else { adtsScratch.skipBits(10); } diff --git a/library/src/main/java/com/google/android/exoplayer/hls/parser/ElementaryStreamReader.java b/library/src/main/java/com/google/android/exoplayer/extractor/ts/ElementaryStreamReader.java similarity index 79% rename from library/src/main/java/com/google/android/exoplayer/hls/parser/ElementaryStreamReader.java rename to library/src/main/java/com/google/android/exoplayer/extractor/ts/ElementaryStreamReader.java index a8c5c7b562..d2df2aef14 100644 --- a/library/src/main/java/com/google/android/exoplayer/hls/parser/ElementaryStreamReader.java +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ts/ElementaryStreamReader.java @@ -13,18 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer.hls.parser; +package com.google.android.exoplayer.extractor.ts; -import com.google.android.exoplayer.upstream.BufferPool; +import com.google.android.exoplayer.extractor.TrackOutput; import com.google.android.exoplayer.util.ParsableByteArray; /** * Extracts individual samples from an elementary media stream, preserving original order. */ -/* package */ abstract class ElementaryStreamReader extends SampleQueue { +/* package */ abstract class ElementaryStreamReader { - protected ElementaryStreamReader(BufferPool bufferPool) { - super(bufferPool); + protected final TrackOutput output; + + /** + * @param output A {@link TrackOutput} to which samples should be written. + */ + protected ElementaryStreamReader(TrackOutput output) { + this.output = output; } /** diff --git a/library/src/main/java/com/google/android/exoplayer/hls/parser/H264Reader.java b/library/src/main/java/com/google/android/exoplayer/extractor/ts/H264Reader.java similarity index 81% rename from library/src/main/java/com/google/android/exoplayer/hls/parser/H264Reader.java rename to library/src/main/java/com/google/android/exoplayer/extractor/ts/H264Reader.java index 7e94376c32..4627cb6526 100644 --- a/library/src/main/java/com/google/android/exoplayer/hls/parser/H264Reader.java +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ts/H264Reader.java @@ -13,16 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer.hls.parser; +package com.google.android.exoplayer.extractor.ts; +import com.google.android.exoplayer.C; import com.google.android.exoplayer.MediaFormat; -import com.google.android.exoplayer.mp4.Mp4Util; -import com.google.android.exoplayer.upstream.BufferPool; +import com.google.android.exoplayer.extractor.TrackOutput; import com.google.android.exoplayer.util.Assertions; +import com.google.android.exoplayer.util.H264Util; import com.google.android.exoplayer.util.MimeTypes; import com.google.android.exoplayer.util.ParsableBitArray; import com.google.android.exoplayer.util.ParsableByteArray; +import android.util.Log; + import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -32,29 +35,59 @@ import java.util.List; */ /* package */ class H264Reader extends ElementaryStreamReader { + private static final String TAG = "H264Reader"; + private static final int NAL_UNIT_TYPE_IDR = 5; private static final int NAL_UNIT_TYPE_SEI = 6; private static final int NAL_UNIT_TYPE_SPS = 7; private static final int NAL_UNIT_TYPE_PPS = 8; private static final int NAL_UNIT_TYPE_AUD = 9; + private static final int EXTENDED_SAR = 0xFF; + private static final float[] ASPECT_RATIO_IDC_VALUES = new float[] { + 1f /* Unspecified. Assume square */, + 1f, + 12f / 11f, + 10f / 11f, + 16f / 11f, + 40f / 33f, + 24f / 11f, + 20f / 11f, + 32f / 11f, + 80f / 33f, + 18f / 11f, + 15f / 11f, + 64f / 33f, + 160f / 99f, + 4f / 3f, + 3f / 2f, + 2f + }; private final SeiReader seiReader; private final boolean[] prefixFlags; private final NalUnitTargetBuffer sps; private final NalUnitTargetBuffer pps; private final NalUnitTargetBuffer sei; + private final ParsableByteArray seiWrapper; + private boolean hasOutputFormat; private int scratchEscapeCount; private int[] scratchEscapePositions; - private boolean isKeyframe; - public H264Reader(BufferPool bufferPool, SeiReader seiReader) { - super(bufferPool); + private boolean writingSample; + private boolean isKeyframe; + private long samplePosition; + private long sampleTimeUs; + private long totalBytesWritten; + + public H264Reader(TrackOutput output, SeiReader seiReader) { + super(output); this.seiReader = seiReader; prefixFlags = new boolean[3]; sps = new NalUnitTargetBuffer(NAL_UNIT_TYPE_SPS, 128); pps = new NalUnitTargetBuffer(NAL_UNIT_TYPE_PPS, 128); sei = new NalUnitTargetBuffer(NAL_UNIT_TYPE_SEI, 128); + seiWrapper = new ParsableByteArray(); scratchEscapePositions = new int[10]; } @@ -66,11 +99,12 @@ import java.util.List; byte[] dataArray = data.data; // Append the data to the buffer. - appendData(data, data.bytesLeft()); + totalBytesWritten += data.bytesLeft(); + output.sampleData(data, data.bytesLeft()); // Scan the appended data, processing NAL units as they are encountered while (offset < limit) { - int nextNalUnitOffset = Mp4Util.findNalUnit(dataArray, offset, limit, prefixFlags); + int nextNalUnitOffset = H264Util.findNalUnit(dataArray, offset, limit, prefixFlags); if (nextNalUnitOffset < limit) { // We've seen the start of a NAL unit. @@ -81,16 +115,21 @@ import java.util.List; feedNalUnitTargetBuffersData(dataArray, offset, nextNalUnitOffset); } - int nalUnitType = Mp4Util.getNalUnitType(dataArray, nextNalUnitOffset); - int nalUnitOffsetInData = nextNalUnitOffset - limit; + int nalUnitType = H264Util.getNalUnitType(dataArray, nextNalUnitOffset); + int bytesWrittenPastNalUnit = limit - nextNalUnitOffset; if (nalUnitType == NAL_UNIT_TYPE_AUD) { - if (writingSample()) { - if (isKeyframe && !hasMediaFormat() && sps.isCompleted() && pps.isCompleted()) { + if (writingSample) { + if (isKeyframe && !hasOutputFormat && sps.isCompleted() && pps.isCompleted()) { parseMediaFormat(sps, pps); } - commitSample(isKeyframe, nalUnitOffsetInData); + int flags = isKeyframe ? C.SAMPLE_FLAG_SYNC : 0; + int size = (int) (totalBytesWritten - samplePosition) - bytesWrittenPastNalUnit; + output.sampleMetadata(sampleTimeUs, flags, size, bytesWrittenPastNalUnit, null); + writingSample = false; } - startSample(pesTimeUs, nalUnitOffsetInData); + writingSample = true; + samplePosition = totalBytesWritten - bytesWrittenPastNalUnit; + sampleTimeUs = pesTimeUs; isKeyframe = false; } else if (nalUnitType == NAL_UNIT_TYPE_IDR) { isKeyframe = true; @@ -117,7 +156,7 @@ import java.util.List; } private void feedNalUnitTargetBuffersStart(int nalUnitType) { - if (!hasMediaFormat()) { + if (!hasOutputFormat) { sps.startNalUnit(nalUnitType); pps.startNalUnit(nalUnitType); } @@ -125,7 +164,7 @@ import java.util.List; } private void feedNalUnitTargetBuffersData(byte[] dataArray, int offset, int limit) { - if (!hasMediaFormat()) { + if (!hasOutputFormat) { sps.appendToNalUnit(dataArray, offset, limit); pps.appendToNalUnit(dataArray, offset, limit); } @@ -137,7 +176,8 @@ import java.util.List; pps.endNalUnit(discardPadding); if (sei.endNalUnit(discardPadding)) { int unescapedLength = unescapeStream(sei.nalData, sei.nalLength); - seiReader.read(sei.nalData, 0, unescapedLength, pesTimeUs); + seiWrapper.reset(sei.nalData, unescapedLength); + seiReader.consume(seiWrapper, pesTimeUs, true); } } @@ -228,9 +268,29 @@ import java.util.List; frameHeight -= (frameCropTopOffset + frameCropBottomOffset) * cropUnitY; } - // Set the format. - setMediaFormat(MediaFormat.createVideoFormat(MimeTypes.VIDEO_H264, MediaFormat.NO_VALUE, - frameWidth, frameHeight, initializationData)); + float pixelWidthHeightRatio = 1; + boolean vuiParametersPresentFlag = bitArray.readBit(); + if (vuiParametersPresentFlag) { + boolean aspectRatioInfoPresentFlag = bitArray.readBit(); + if (aspectRatioInfoPresentFlag) { + int aspectRatioIdc = bitArray.readBits(8); + if (aspectRatioIdc == EXTENDED_SAR) { + int sarWidth = bitArray.readBits(16); + int sarHeight = bitArray.readBits(16); + if (sarWidth != 0 && sarHeight != 0) { + pixelWidthHeightRatio = (float) sarWidth / sarHeight; + } + } else if (aspectRatioIdc < ASPECT_RATIO_IDC_VALUES.length) { + pixelWidthHeightRatio = ASPECT_RATIO_IDC_VALUES[aspectRatioIdc]; + } else { + Log.w(TAG, "Unexpected aspect_ratio_idc value: " + aspectRatioIdc); + } + } + } + + output.format(MediaFormat.createVideoFormat(MimeTypes.VIDEO_H264, MediaFormat.NO_VALUE, + C.UNKNOWN_TIME_US, frameWidth, frameHeight, pixelWidthHeightRatio, initializationData)); + hasOutputFormat = true; } private void skipScalingList(ParsableBitArray bitArray, int size) { diff --git a/library/src/main/java/com/google/android/exoplayer/hls/parser/Id3Reader.java b/library/src/main/java/com/google/android/exoplayer/extractor/ts/Id3Reader.java similarity index 62% rename from library/src/main/java/com/google/android/exoplayer/hls/parser/Id3Reader.java rename to library/src/main/java/com/google/android/exoplayer/extractor/ts/Id3Reader.java index 7de263d6da..e5a9d355ae 100644 --- a/library/src/main/java/com/google/android/exoplayer/hls/parser/Id3Reader.java +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ts/Id3Reader.java @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer.hls.parser; +package com.google.android.exoplayer.extractor.ts; +import com.google.android.exoplayer.C; import com.google.android.exoplayer.MediaFormat; -import com.google.android.exoplayer.upstream.BufferPool; +import com.google.android.exoplayer.extractor.TrackOutput; import com.google.android.exoplayer.util.ParsableByteArray; /** @@ -24,24 +25,32 @@ import com.google.android.exoplayer.util.ParsableByteArray; */ /* package */ class Id3Reader extends ElementaryStreamReader { - public Id3Reader(BufferPool bufferPool) { - super(bufferPool); - setMediaFormat(MediaFormat.createId3Format()); + private boolean writingSample; + private long sampleTimeUs; + private int sampleSize; + + public Id3Reader(TrackOutput output) { + super(output); + output.format(MediaFormat.createId3Format()); } @Override public void consume(ParsableByteArray data, long pesTimeUs, boolean startOfPacket) { if (startOfPacket) { - startSample(pesTimeUs); + writingSample = true; + sampleTimeUs = pesTimeUs; + sampleSize = 0; } - if (writingSample()) { - appendData(data, data.bytesLeft()); + if (writingSample) { + sampleSize += data.bytesLeft(); + output.sampleData(data, data.bytesLeft()); } } @Override public void packetFinished() { - commitSample(true); + output.sampleMetadata(sampleTimeUs, C.SAMPLE_FLAG_SYNC, sampleSize, 0, null); + writingSample = false; } } diff --git a/library/src/main/java/com/google/android/exoplayer/hls/parser/SeiReader.java b/library/src/main/java/com/google/android/exoplayer/extractor/ts/SeiReader.java similarity index 72% rename from library/src/main/java/com/google/android/exoplayer/hls/parser/SeiReader.java rename to library/src/main/java/com/google/android/exoplayer/extractor/ts/SeiReader.java index 1065690529..66d6819832 100644 --- a/library/src/main/java/com/google/android/exoplayer/hls/parser/SeiReader.java +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ts/SeiReader.java @@ -13,11 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer.hls.parser; +package com.google.android.exoplayer.extractor.ts; +import com.google.android.exoplayer.C; import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.extractor.TrackOutput; import com.google.android.exoplayer.text.eia608.Eia608Parser; -import com.google.android.exoplayer.upstream.BufferPool; import com.google.android.exoplayer.util.ParsableByteArray; /** @@ -26,20 +27,17 @@ import com.google.android.exoplayer.util.ParsableByteArray; * TODO: Technically, we shouldn't allow a sample to be read from the queue until we're sure that * a sample with an earlier timestamp won't be added to it. */ -/* package */ class SeiReader extends SampleQueue { +/* package */ class SeiReader extends ElementaryStreamReader { - private final ParsableByteArray seiBuffer; - - public SeiReader(BufferPool bufferPool) { - super(bufferPool); - setMediaFormat(MediaFormat.createEia608Format()); - seiBuffer = new ParsableByteArray(); + public SeiReader(TrackOutput output) { + super(output); + output.format(MediaFormat.createEia608Format()); } - public void read(byte[] data, int position, int limit, long pesTimeUs) { - seiBuffer.reset(data, limit); + @Override + public void consume(ParsableByteArray seiBuffer, long pesTimeUs, boolean startOfPacket) { // Skip the NAL prefix and type. - seiBuffer.setPosition(position + 4); + seiBuffer.skip(4); int b; while (seiBuffer.bytesLeft() > 1 /* last byte will be rbsp_trailing_bits */) { @@ -57,13 +55,17 @@ import com.google.android.exoplayer.util.ParsableByteArray; } while (b == 0xFF); // Process the payload. We only support EIA-608 payloads currently. if (Eia608Parser.isSeiMessageEia608(payloadType, payloadSize, seiBuffer)) { - startSample(pesTimeUs); - appendData(seiBuffer, payloadSize); - commitSample(true); + output.sampleData(seiBuffer, payloadSize); + output.sampleMetadata(pesTimeUs, C.SAMPLE_FLAG_SYNC, payloadSize, 0, null); } else { seiBuffer.skip(payloadSize); } } } + @Override + public void packetFinished() { + // Do nothing. + } + } diff --git a/library/src/main/java/com/google/android/exoplayer/hls/parser/TsExtractor.java b/library/src/main/java/com/google/android/exoplayer/extractor/ts/TsExtractor.java similarity index 79% rename from library/src/main/java/com/google/android/exoplayer/hls/parser/TsExtractor.java rename to library/src/main/java/com/google/android/exoplayer/extractor/ts/TsExtractor.java index f1d117c839..5e2fb1041a 100644 --- a/library/src/main/java/com/google/android/exoplayer/hls/parser/TsExtractor.java +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ts/TsExtractor.java @@ -13,14 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer.hls.parser; +package com.google.android.exoplayer.extractor.ts; import com.google.android.exoplayer.C; -import com.google.android.exoplayer.MediaFormat; -import com.google.android.exoplayer.SampleHolder; -import com.google.android.exoplayer.upstream.BufferPool; -import com.google.android.exoplayer.upstream.DataSource; -import com.google.android.exoplayer.util.Assertions; +import com.google.android.exoplayer.extractor.Extractor; +import com.google.android.exoplayer.extractor.ExtractorInput; +import com.google.android.exoplayer.extractor.ExtractorOutput; import com.google.android.exoplayer.util.ParsableBitArray; import com.google.android.exoplayer.util.ParsableByteArray; @@ -32,7 +30,7 @@ import java.io.IOException; /** * Facilitates the extraction of data from the MPEG-2 TS container format. */ -public final class TsExtractor extends HlsExtractor { +public final class TsExtractor implements Extractor { private static final String TAG = "TsExtractor"; @@ -50,119 +48,43 @@ public final class TsExtractor extends HlsExtractor { private static final long MAX_PTS = 0x1FFFFFFFFL; private final ParsableByteArray tsPacketBuffer; - private final SparseArray sampleQueues; // Indexed by streamType + private final SparseArray streamReaders; // Indexed by streamType private final SparseArray tsPayloadReaders; // Indexed by pid - private final BufferPool bufferPool; private final long firstSampleTimestamp; private final ParsableBitArray tsScratch; // Accessed only by the loading thread. - private int tsPacketBytesRead; + private ExtractorOutput output; private long timestampOffsetUs; private long lastPts; - // Accessed by both the loading and consuming threads. - private volatile boolean prepared; - - public TsExtractor(boolean shouldSpliceIn, long firstSampleTimestamp, BufferPool bufferPool) { - super(shouldSpliceIn); + public TsExtractor(long firstSampleTimestamp) { this.firstSampleTimestamp = firstSampleTimestamp; - this.bufferPool = bufferPool; tsScratch = new ParsableBitArray(new byte[3]); tsPacketBuffer = new ParsableByteArray(TS_PACKET_SIZE); - sampleQueues = new SparseArray(); + streamReaders = new SparseArray(); tsPayloadReaders = new SparseArray(); tsPayloadReaders.put(TS_PAT_PID, new PatReader()); lastPts = Long.MIN_VALUE; } @Override - public int getTrackCount() { - Assertions.checkState(prepared); - return sampleQueues.size(); + public void init(ExtractorOutput output) { + this.output = output; } @Override - public MediaFormat getFormat(int track) { - Assertions.checkState(prepared); - return sampleQueues.valueAt(track).getMediaFormat(); - } - - @Override - public boolean isPrepared() { - return prepared; - } - - @Override - public void release() { - for (int i = 0; i < sampleQueues.size(); i++) { - sampleQueues.valueAt(i).release(); - } - } - - @Override - public long getLargestSampleTimestamp() { - long largestParsedTimestampUs = Long.MIN_VALUE; - for (int i = 0; i < sampleQueues.size(); i++) { - largestParsedTimestampUs = Math.max(largestParsedTimestampUs, - sampleQueues.valueAt(i).getLargestParsedTimestampUs()); - } - return largestParsedTimestampUs; - } - - @Override - public boolean getSample(int track, SampleHolder holder) { - Assertions.checkState(prepared); - return sampleQueues.valueAt(track).getSample(holder); - } - - @Override - public void discardUntil(int track, long timeUs) { - Assertions.checkState(prepared); - sampleQueues.valueAt(track).discardUntil(timeUs); - } - - @Override - public boolean hasSamples(int track) { - Assertions.checkState(prepared); - return !sampleQueues.valueAt(track).isEmpty(); - } - - private boolean checkPrepared() { - int pesPayloadReaderCount = sampleQueues.size(); - if (pesPayloadReaderCount == 0) { - return false; - } - for (int i = 0; i < pesPayloadReaderCount; i++) { - if (!sampleQueues.valueAt(i).hasMediaFormat()) { - return false; - } - } - return true; - } - - @Override - public int read(DataSource dataSource) throws IOException { - int bytesRead = dataSource.read(tsPacketBuffer.data, tsPacketBytesRead, - TS_PACKET_SIZE - tsPacketBytesRead); - if (bytesRead == -1) { - return -1; + public int read(ExtractorInput input) + throws IOException, InterruptedException { + if (!input.readFully(tsPacketBuffer.data, 0, TS_PACKET_SIZE, true)) { + return RESULT_END_OF_INPUT; } - tsPacketBytesRead += bytesRead; - if (tsPacketBytesRead < TS_PACKET_SIZE) { - // We haven't read the whole packet yet. - return bytesRead; - } - - // Reset before reading the packet. - tsPacketBytesRead = 0; tsPacketBuffer.setPosition(0); tsPacketBuffer.setLimit(TS_PACKET_SIZE); - int syncByte = tsPacketBuffer.readUnsignedByte(); if (syncByte != TS_SYNC_BYTE) { - return bytesRead; + return RESULT_CONTINUE; } tsPacketBuffer.readBytes(tsScratch, 3); @@ -185,20 +107,11 @@ public final class TsExtractor extends HlsExtractor { if (payloadExists) { TsPayloadReader payloadReader = tsPayloadReaders.get(pid); if (payloadReader != null) { - payloadReader.consume(tsPacketBuffer, payloadUnitStartIndicator); + payloadReader.consume(tsPacketBuffer, payloadUnitStartIndicator, output); } } - if (!prepared) { - prepared = checkPrepared(); - } - - return bytesRead; - } - - @Override - protected SampleQueue getSampleQueue(int track) { - return sampleQueues.valueAt(track); + return RESULT_CONTINUE; } /** @@ -233,7 +146,8 @@ public final class TsExtractor extends HlsExtractor { */ private abstract static class TsPayloadReader { - public abstract void consume(ParsableByteArray data, boolean payloadUnitStartIndicator); + public abstract void consume(ParsableByteArray data, boolean payloadUnitStartIndicator, + ExtractorOutput output); } @@ -249,7 +163,8 @@ public final class TsExtractor extends HlsExtractor { } @Override - public void consume(ParsableByteArray data, boolean payloadUnitStartIndicator) { + public void consume(ParsableByteArray data, boolean payloadUnitStartIndicator, + ExtractorOutput output) { // Skip pointer. if (payloadUnitStartIndicator) { int pointerField = data.readUnsignedByte(); @@ -288,7 +203,8 @@ public final class TsExtractor extends HlsExtractor { } @Override - public void consume(ParsableByteArray data, boolean payloadUnitStartIndicator) { + public void consume(ParsableByteArray data, boolean payloadUnitStartIndicator, + ExtractorOutput output) { // Skip pointer. if (payloadUnitStartIndicator) { int pointerField = data.readUnsignedByte(); @@ -325,7 +241,7 @@ public final class TsExtractor extends HlsExtractor { data.skip(esInfoLength); entriesSize -= esInfoLength + 5; - if (sampleQueues.get(streamType) != null) { + if (streamReaders.get(streamType) != null) { continue; } @@ -336,25 +252,26 @@ public final class TsExtractor extends HlsExtractor { pesPayloadReader = new MpaReader(bufferPool); break; case TS_STREAM_TYPE_AAC: - pesPayloadReader = new AdtsReader(bufferPool); + pesPayloadReader = new AdtsReader(output.track(TS_STREAM_TYPE_AAC)); break; case TS_STREAM_TYPE_H264: - SeiReader seiReader = new SeiReader(bufferPool); - sampleQueues.put(TS_STREAM_TYPE_EIA608, seiReader); - pesPayloadReader = new H264Reader(bufferPool, seiReader); + SeiReader seiReader = new SeiReader(output.track(TS_STREAM_TYPE_EIA608)); + streamReaders.put(TS_STREAM_TYPE_EIA608, seiReader); + pesPayloadReader = new H264Reader(output.track(TS_STREAM_TYPE_H264), + seiReader); break; case TS_STREAM_TYPE_ID3: - pesPayloadReader = new Id3Reader(bufferPool); + pesPayloadReader = new Id3Reader(output.track(TS_STREAM_TYPE_ID3)); break; } if (pesPayloadReader != null) { - sampleQueues.put(streamType, pesPayloadReader); + streamReaders.put(streamType, pesPayloadReader); tsPayloadReaders.put(elementaryPid, new PesReader(pesPayloadReader)); } } - // Skip CRC_32. + output.endTracks(); } } @@ -393,7 +310,8 @@ public final class TsExtractor extends HlsExtractor { } @Override - public void consume(ParsableByteArray data, boolean payloadUnitStartIndicator) { + public void consume(ParsableByteArray data, boolean payloadUnitStartIndicator, + ExtractorOutput output) { if (payloadUnitStartIndicator) { switch (state) { case STATE_FINDING_HEADER: diff --git a/library/src/main/java/com/google/android/exoplayer/hls/HlsChunkSource.java b/library/src/main/java/com/google/android/exoplayer/hls/HlsChunkSource.java index 87458ee370..a3faa7a41a 100644 --- a/library/src/main/java/com/google/android/exoplayer/hls/HlsChunkSource.java +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsChunkSource.java @@ -17,9 +17,9 @@ package com.google.android.exoplayer.hls; import com.google.android.exoplayer.C; import com.google.android.exoplayer.MediaFormat; -import com.google.android.exoplayer.hls.parser.AdtsExtractor; -import com.google.android.exoplayer.hls.parser.HlsExtractor; -import com.google.android.exoplayer.hls.parser.TsExtractor; +import com.google.android.exoplayer.extractor.Extractor; +import com.google.android.exoplayer.extractor.ts.AdtsExtractor; +import com.google.android.exoplayer.extractor.ts.TsExtractor; import com.google.android.exoplayer.upstream.Aes128DataSource; import com.google.android.exoplayer.upstream.BandwidthMeter; import com.google.android.exoplayer.upstream.BufferPool; @@ -27,6 +27,7 @@ import com.google.android.exoplayer.upstream.DataSource; import com.google.android.exoplayer.upstream.DataSpec; import com.google.android.exoplayer.upstream.HttpDataSource.InvalidResponseCodeException; import com.google.android.exoplayer.util.Assertions; +import com.google.android.exoplayer.util.UriUtil; import com.google.android.exoplayer.util.Util; import android.net.Uri; @@ -107,10 +108,9 @@ public class HlsChunkSource { public static final long DEFAULT_MAX_BUFFER_TO_SWITCH_DOWN_MS = 20000; /** - * The default maximum time a media playlist is blacklisted without - * rechecking if it is alive again (because an encoder reset, for example) + * The default time for which a media playlist should be blacklisted. */ - public static final long DEFAULT_MAX_TIME_MEDIA_PLAYLIST_BLACKLISTED_MS = 60000; + public static final long DEFAULT_PLAYLIST_BLACKLIST_MS = 60000; private static final String TAG = "HlsChunkSource"; private static final String AAC_FILE_EXTENSION = ".aac"; @@ -122,7 +122,7 @@ public class HlsChunkSource { private final Variant[] enabledVariants; private final BandwidthMeter bandwidthMeter; private final int adaptiveMode; - private final Uri baseUri; + private final String baseUri; private final int maxWidth; private final int maxHeight; private final int targetBufferSize; @@ -132,8 +132,7 @@ public class HlsChunkSource { /* package */ byte[] scratchSpace; /* package */ final HlsMediaPlaylist[] mediaPlaylists; - /* package */ final boolean[] mediaPlaylistBlacklistFlags; - /* package */ final long[] mediaPlaylistBlacklistedTimeMs; + /* package */ final long[] mediaPlaylistBlacklistTimesMs; /* package */ final long[] lastMediaPlaylistLoadTimesMs; /* package */ boolean live; /* package */ long durationUs; @@ -188,16 +187,14 @@ public class HlsChunkSource { if (playlist.type == HlsPlaylist.TYPE_MEDIA) { enabledVariants = new Variant[] {new Variant(0, playlistUrl, 0, null, -1, -1)}; mediaPlaylists = new HlsMediaPlaylist[1]; - mediaPlaylistBlacklistFlags = new boolean[1]; - mediaPlaylistBlacklistedTimeMs = new long[1]; + mediaPlaylistBlacklistTimesMs = new long[1]; lastMediaPlaylistLoadTimesMs = new long[1]; setMediaPlaylist(0, (HlsMediaPlaylist) playlist); } else { Assertions.checkState(playlist.type == HlsPlaylist.TYPE_MASTER); enabledVariants = filterVariants((HlsMasterPlaylist) playlist, variantIndices); mediaPlaylists = new HlsMediaPlaylist[enabledVariants.length]; - mediaPlaylistBlacklistFlags = new boolean[enabledVariants.length]; - mediaPlaylistBlacklistedTimeMs = new long[enabledVariants.length]; + mediaPlaylistBlacklistTimesMs = new long[enabledVariants.length]; lastMediaPlaylistLoadTimesMs = new long[enabledVariants.length]; } @@ -305,11 +302,11 @@ public class HlsChunkSource { } HlsMediaPlaylist.Segment segment = mediaPlaylist.segments.get(chunkIndex); - Uri chunkUri = Util.getMergedUri(mediaPlaylist.baseUri, segment.url); + Uri chunkUri = UriUtil.resolveToUri(mediaPlaylist.baseUri, segment.url); // Check if encryption is specified. - if (HlsMediaPlaylist.ENCRYPTION_METHOD_AES_128.equals(segment.encryptionMethod)) { - Uri keyUri = Util.getMergedUri(mediaPlaylist.baseUri, segment.encryptionKeyUri); + if (segment.isEncrypted) { + Uri keyUri = UriUtil.resolveToUri(mediaPlaylist.baseUri, segment.encryptionKeyUri); if (!keyUri.equals(encryptionKeyUri)) { // Encryption is specified and the key has changed. HlsChunk toReturn = newEncryptionKeyChunk(keyUri, segment.encryptionIV); @@ -344,16 +341,17 @@ public class HlsChunkSource { boolean isLastChunk = !mediaPlaylist.live && chunkIndex == mediaPlaylist.segments.size() - 1; // Configure the extractor that will read the chunk. - HlsExtractor extractor; + HlsExtractorWrapper extractorWrapper; if (previousTsChunk == null || segment.discontinuity || switchingVariant || liveDiscontinuity) { - extractor = chunkUri.getLastPathSegment().endsWith(AAC_FILE_EXTENSION) - ? new AdtsExtractor(switchingVariantSpliced, startTimeUs, bufferPool) - : new TsExtractor(switchingVariantSpliced, startTimeUs, bufferPool); + Extractor extractor = chunkUri.getLastPathSegment().endsWith(AAC_FILE_EXTENSION) + ? new AdtsExtractor(startTimeUs) + : new TsExtractor(startTimeUs); + extractorWrapper = new HlsExtractorWrapper(bufferPool, extractor, switchingVariantSpliced); } else { - extractor = previousTsChunk.extractor; + extractorWrapper = previousTsChunk.extractor; } - return new TsChunk(dataSource, dataSpec, extractor, enabledVariants[variantIndex].index, + return new TsChunk(dataSource, dataSpec, extractorWrapper, enabledVariants[variantIndex].index, startTimeUs, endTimeUs, chunkMediaSequence, isLastChunk); } @@ -370,8 +368,7 @@ public class HlsChunkSource { int responseCode = responseCodeException.responseCode; if (responseCode == 404 || responseCode == 410) { MediaPlaylistChunk playlistChunk = (MediaPlaylistChunk) chunk; - mediaPlaylistBlacklistFlags[playlistChunk.variantIndex] = true; - mediaPlaylistBlacklistedTimeMs[playlistChunk.variantIndex] = SystemClock.elapsedRealtime(); + mediaPlaylistBlacklistTimesMs[playlistChunk.variantIndex] = SystemClock.elapsedRealtime(); if (!allPlaylistsBlacklisted()) { // We've handled the 404/410 by blacklisting the playlist. Log.w(TAG, "Blacklisted playlist (" + responseCode + "): " @@ -381,8 +378,7 @@ public class HlsChunkSource { // This was the last non-blacklisted playlist. Don't blacklist it. Log.w(TAG, "Final playlist not blacklisted (" + responseCode + "): " + playlistChunk.dataSpec.uri); - mediaPlaylistBlacklistFlags[playlistChunk.variantIndex] = false; - mediaPlaylistBlacklistedTimeMs[playlistChunk.variantIndex] = 0; + mediaPlaylistBlacklistTimesMs[playlistChunk.variantIndex] = 0; return false; } } @@ -392,19 +388,27 @@ public class HlsChunkSource { private int getNextVariantIndex(TsChunk previousTsChunk, long playbackPositionUs) { clearStaleBlacklistedPlaylists(); + if (previousTsChunk == null) { + // Don't consider switching if we don't have a previous chunk. + return variantIndex; + } + long bitrateEstimate = bandwidthMeter.getBitrateEstimate(); + if (bitrateEstimate == BandwidthMeter.NO_ESTIMATE) { + // Don't consider switching if we don't have a bandwidth estimate. + return variantIndex; + } int idealVariantIndex = getVariantIndexForBandwdith( - (int) (bandwidthMeter.getBitrateEstimate() * BANDWIDTH_FRACTION)); + (int) (bitrateEstimate * BANDWIDTH_FRACTION)); if (idealVariantIndex == variantIndex) { // We're already using the ideal variant. return variantIndex; } // We're not using the ideal variant for the available bandwidth, but only switch if the // conditions are appropriate. - long bufferedPositionUs = previousTsChunk == null ? playbackPositionUs - : adaptiveMode == ADAPTIVE_MODE_SPLICE ? previousTsChunk.startTimeUs + long bufferedPositionUs = adaptiveMode == ADAPTIVE_MODE_SPLICE ? previousTsChunk.startTimeUs : previousTsChunk.endTimeUs; long bufferedUs = bufferedPositionUs - playbackPositionUs; - if (mediaPlaylistBlacklistFlags[variantIndex] + if (mediaPlaylistBlacklistTimesMs[variantIndex] != 0 || (idealVariantIndex > variantIndex && bufferedUs < maxBufferDurationToSwitchDownUs) || (idealVariantIndex < variantIndex && bufferedUs > minBufferDurationToSwitchUpUs)) { // Switch variant. @@ -417,7 +421,7 @@ public class HlsChunkSource { private int getVariantIndexForBandwdith(int bandwidth) { int lowestQualityEnabledVariant = 0; for (int i = 0; i < enabledVariants.length; i++) { - if (!mediaPlaylistBlacklistFlags[i]) { + if (mediaPlaylistBlacklistTimesMs[i] == 0) { if (enabledVariants[i].bandwidth <= bandwidth) { return i; } @@ -443,14 +447,15 @@ public class HlsChunkSource { } private MediaPlaylistChunk newMediaPlaylistChunk(int variantIndex) { - Uri mediaPlaylistUri = Util.getMergedUri(baseUri, enabledVariants[variantIndex].url); - DataSpec dataSpec = new DataSpec(mediaPlaylistUri, 0, C.LENGTH_UNBOUNDED, null); + Uri mediaPlaylistUri = UriUtil.resolveToUri(baseUri, enabledVariants[variantIndex].url); + DataSpec dataSpec = new DataSpec(mediaPlaylistUri, 0, C.LENGTH_UNBOUNDED, null, + DataSpec.FLAG_ALLOW_GZIP); return new MediaPlaylistChunk(variantIndex, upstreamDataSource, dataSpec, mediaPlaylistUri.toString()); } private EncryptionKeyChunk newEncryptionKeyChunk(Uri keyUri, String iv) { - DataSpec dataSpec = new DataSpec(keyUri, 0, C.LENGTH_UNBOUNDED, null); + DataSpec dataSpec = new DataSpec(keyUri, 0, C.LENGTH_UNBOUNDED, null, DataSpec.FLAG_ALLOW_GZIP); return new EncryptionKeyChunk(upstreamDataSource, dataSpec, iv); } @@ -468,7 +473,7 @@ public class HlsChunkSource { System.arraycopy(ivData, offset, ivDataWithPadding, ivDataWithPadding.length - ivData.length + offset, ivData.length - offset); - encryptedDataSource = new Aes128DataSource(secretKey, ivDataWithPadding, upstreamDataSource); + encryptedDataSource = new Aes128DataSource(upstreamDataSource, secretKey, ivDataWithPadding); encryptionKeyUri = keyUri; encryptedDataSourceIv = iv; encryptedDataSourceSecretKey = secretKey; @@ -545,8 +550,8 @@ public class HlsChunkSource { } private boolean allPlaylistsBlacklisted() { - for (int i = 0; i < mediaPlaylistBlacklistFlags.length; i++) { - if (!mediaPlaylistBlacklistFlags[i]) { + for (int i = 0; i < mediaPlaylistBlacklistTimesMs.length; i++) { + if (mediaPlaylistBlacklistTimesMs[i] == 0) { return false; } } @@ -555,11 +560,10 @@ public class HlsChunkSource { private void clearStaleBlacklistedPlaylists() { long currentTime = SystemClock.elapsedRealtime(); - for (int i = 0; i < mediaPlaylistBlacklistFlags.length; i++) { - if (mediaPlaylistBlacklistFlags[i] && - currentTime - mediaPlaylistBlacklistedTimeMs[i] > DEFAULT_MAX_TIME_MEDIA_PLAYLIST_BLACKLISTED_MS) { - mediaPlaylistBlacklistFlags[i] = false; - mediaPlaylistBlacklistedTimeMs[i] = 0; + for (int i = 0; i < mediaPlaylistBlacklistTimesMs.length; i++) { + if (mediaPlaylistBlacklistTimesMs[i] != 0 + && currentTime - mediaPlaylistBlacklistTimesMs[i] > DEFAULT_PLAYLIST_BLACKLIST_MS) { + mediaPlaylistBlacklistTimesMs[i] = 0; } } } diff --git a/library/src/main/java/com/google/android/exoplayer/hls/parser/HlsExtractor.java b/library/src/main/java/com/google/android/exoplayer/hls/HlsExtractorWrapper.java similarity index 51% rename from library/src/main/java/com/google/android/exoplayer/hls/parser/HlsExtractor.java rename to library/src/main/java/com/google/android/exoplayer/hls/HlsExtractorWrapper.java index 88aef4a0d6..dcdf7afe4a 100644 --- a/library/src/main/java/com/google/android/exoplayer/hls/parser/HlsExtractor.java +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsExtractorWrapper.java @@ -13,27 +13,44 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer.hls.parser; +package com.google.android.exoplayer.hls; import com.google.android.exoplayer.MediaFormat; import com.google.android.exoplayer.SampleHolder; -import com.google.android.exoplayer.upstream.DataSource; +import com.google.android.exoplayer.extractor.DefaultTrackOutput; +import com.google.android.exoplayer.extractor.Extractor; +import com.google.android.exoplayer.extractor.ExtractorInput; +import com.google.android.exoplayer.extractor.ExtractorOutput; +import com.google.android.exoplayer.extractor.TrackOutput; +import com.google.android.exoplayer.upstream.BufferPool; +import com.google.android.exoplayer.util.Assertions; + +import android.util.SparseArray; import java.io.IOException; /** - * Facilitates extraction of media samples for HLS playbacks. + * Wraps a {@link Extractor}, adding functionality to enable reading of the extracted samples. */ -// TODO: Consider consolidating more common logic in this base class. -public abstract class HlsExtractor { +public final class HlsExtractorWrapper implements ExtractorOutput { + private final BufferPool bufferPool; + private final Extractor extractor; + private final SparseArray sampleQueues; private final boolean shouldSpliceIn; + private volatile boolean tracksBuilt; + // Accessed only by the consuming thread. + private boolean prepared; private boolean spliceConfigured; - public HlsExtractor(boolean shouldSpliceIn) { + public HlsExtractorWrapper(BufferPool bufferPool, Extractor extractor, boolean shouldSpliceIn) { + this.bufferPool = bufferPool; + this.extractor = extractor; this.shouldSpliceIn = shouldSpliceIn; + sampleQueues = new SparseArray(); + extractor.init(this); } /** @@ -50,7 +67,7 @@ public abstract class HlsExtractor { * * @param nextExtractor The extractor being spliced to. */ - public final void configureSpliceTo(HlsExtractor nextExtractor) { + public final void configureSpliceTo(HlsExtractorWrapper nextExtractor) { if (spliceConfigured || !nextExtractor.shouldSpliceIn || !nextExtractor.isPrepared()) { // The splice is already configured, or the next extractor doesn't want to be spliced in, or // the next extractor isn't ready to be spliced in. @@ -59,7 +76,9 @@ public abstract class HlsExtractor { boolean spliceConfigured = true; int trackCount = getTrackCount(); for (int i = 0; i < trackCount; i++) { - spliceConfigured &= getSampleQueue(i).configureSpliceTo(nextExtractor.getSampleQueue(i)); + DefaultTrackOutput currentSampleQueue = sampleQueues.valueAt(i); + DefaultTrackOutput nextSampleQueue = nextExtractor.sampleQueues.valueAt(i); + spliceConfigured &= currentSampleQueue.configureSpliceTo(nextSampleQueue); } this.spliceConfigured = spliceConfigured; return; @@ -72,7 +91,9 @@ public abstract class HlsExtractor { * * @return The number of available tracks. */ - public abstract int getTrackCount(); + public int getTrackCount() { + return sampleQueues.size(); + } /** * Gets the format of the specified track. @@ -82,28 +103,49 @@ public abstract class HlsExtractor { * @param track The track index. * @return The corresponding format. */ - public abstract MediaFormat getFormat(int track); + public MediaFormat getFormat(int track) { + return sampleQueues.valueAt(track).getFormat(); + } /** * Whether the extractor is prepared. * * @return True if the extractor is prepared. False otherwise. */ - public abstract boolean isPrepared(); + public boolean isPrepared() { + if (!prepared && tracksBuilt) { + for (int i = 0; i < sampleQueues.size(); i++) { + if (!sampleQueues.valueAt(i).hasFormat()) { + return false; + } + } + prepared = true; + } + return prepared; + } /** - * Releases the extractor, recycling any pending or incomplete samples to the sample pool. - *

- * This method should not be called whilst {@link #read(DataSource)} is also being invoked. + * Clears queues for all tracks, returning all allocations to the buffer pool. */ - public abstract void release(); + public void clear() { + for (int i = 0; i < sampleQueues.size(); i++) { + sampleQueues.valueAt(i).clear(); + } + } /** * Gets the largest timestamp of any sample parsed by the extractor. * * @return The largest timestamp, or {@link Long#MIN_VALUE} if no samples have been parsed. */ - public abstract long getLargestSampleTimestamp(); + public long getLargestParsedTimestampUs() { + long largestParsedTimestampUs = Long.MIN_VALUE; + for (int i = 0; i < sampleQueues.size(); i++) { + largestParsedTimestampUs = Math.max(largestParsedTimestampUs, + sampleQueues.valueAt(i).getLargestParsedTimestampUs()); + } + return largestParsedTimestampUs; + } /** * Gets the next sample for the specified track. @@ -112,7 +154,10 @@ public abstract class HlsExtractor { * @param holder A {@link SampleHolder} into which the sample should be read. * @return True if a sample was read. False otherwise. */ - public abstract boolean getSample(int track, SampleHolder holder); + public boolean getSample(int track, SampleHolder holder) { + Assertions.checkState(isPrepared()); + return sampleQueues.valueAt(track).getSample(holder); + } /** * Discards samples for the specified track up to the specified time. @@ -120,7 +165,10 @@ public abstract class HlsExtractor { * @param track The track from which samples should be discarded. * @param timeUs The time up to which samples should be discarded, in microseconds. */ - public abstract void discardUntil(int track, long timeUs); + public void discardUntil(int track, long timeUs) { + Assertions.checkState(isPrepared()); + sampleQueues.valueAt(track).discardUntil(timeUs); + } /** * Whether samples are available for reading from {@link #getSample(int, SampleHolder)} for the @@ -129,23 +177,36 @@ public abstract class HlsExtractor { * @return True if samples are available for reading from {@link #getSample(int, SampleHolder)} * for the specified track. False otherwise. */ - public abstract boolean hasSamples(int track); + public boolean hasSamples(int track) { + Assertions.checkState(isPrepared()); + return !sampleQueues.valueAt(track).isEmpty(); + } /** - * Reads up to a single TS packet. + * Reads from the provided {@link ExtractorInput}. * - * @param dataSource The {@link DataSource} from which to read. + * @param input The {@link ExtractorInput} from which to read. + * @return One of {@link Extractor#RESULT_CONTINUE} and {@link Extractor#RESULT_END_OF_INPUT}. * @throws IOException If an error occurred reading from the source. - * @return The number of bytes read from the source. + * @throws InterruptedException If the thread was interrupted. */ - public abstract int read(DataSource dataSource) throws IOException; + public int read(ExtractorInput input) throws IOException, InterruptedException { + int result = extractor.read(input); + return result; + } - /** - * Gets the {@link SampleQueue} for the specified track. - * - * @param track The track index. - * @return The corresponding sample queue. - */ - protected abstract SampleQueue getSampleQueue(int track); + // ExtractorOutput implementation. + + @Override + public TrackOutput track(int id) { + DefaultTrackOutput sampleQueue = new DefaultTrackOutput(bufferPool); + sampleQueues.put(id, sampleQueue); + return sampleQueue; + } + + @Override + public void endTracks() { + this.tracksBuilt = true; + } } diff --git a/library/src/main/java/com/google/android/exoplayer/hls/HlsMasterPlaylist.java b/library/src/main/java/com/google/android/exoplayer/hls/HlsMasterPlaylist.java index 7ce299df0d..15c4784d10 100644 --- a/library/src/main/java/com/google/android/exoplayer/hls/HlsMasterPlaylist.java +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsMasterPlaylist.java @@ -15,8 +15,6 @@ */ package com.google.android.exoplayer.hls; -import android.net.Uri; - import java.util.List; /** @@ -25,10 +23,12 @@ import java.util.List; public final class HlsMasterPlaylist extends HlsPlaylist { public final List variants; + public final List subtitles; - public HlsMasterPlaylist(Uri baseUri, List variants) { + public HlsMasterPlaylist(String baseUri, List variants, List subtitles) { super(baseUri, HlsPlaylist.TYPE_MASTER); this.variants = variants; + this.subtitles = subtitles; } } diff --git a/library/src/main/java/com/google/android/exoplayer/hls/HlsMediaPlaylist.java b/library/src/main/java/com/google/android/exoplayer/hls/HlsMediaPlaylist.java index 3e9f151c08..ec4c538155 100644 --- a/library/src/main/java/com/google/android/exoplayer/hls/HlsMediaPlaylist.java +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsMediaPlaylist.java @@ -17,8 +17,6 @@ package com.google.android.exoplayer.hls; import com.google.android.exoplayer.C; -import android.net.Uri; - import java.util.List; /** @@ -30,24 +28,25 @@ public final class HlsMediaPlaylist extends HlsPlaylist { * Media segment reference. */ public static final class Segment implements Comparable { + public final boolean discontinuity; public final double durationSecs; public final String url; public final long startTimeUs; - public final String encryptionMethod; + public final boolean isEncrypted; public final String encryptionKeyUri; public final String encryptionIV; public final int byterangeOffset; public final int byterangeLength; public Segment(String uri, double durationSecs, boolean discontinuity, long startTimeUs, - String encryptionMethod, String encryptionKeyUri, String encryptionIV, - int byterangeOffset, int byterangeLength) { + boolean isEncrypted, String encryptionKeyUri, String encryptionIV, int byterangeOffset, + int byterangeLength) { this.url = uri; this.durationSecs = durationSecs; this.discontinuity = discontinuity; this.startTimeUs = startTimeUs; - this.encryptionMethod = encryptionMethod; + this.isEncrypted = isEncrypted; this.encryptionKeyUri = encryptionKeyUri; this.encryptionIV = encryptionIV; this.byterangeOffset = byterangeOffset; @@ -70,7 +69,7 @@ public final class HlsMediaPlaylist extends HlsPlaylist { public final boolean live; public final long durationUs; - public HlsMediaPlaylist(Uri baseUri, int mediaSequence, int targetDurationSecs, int version, + public HlsMediaPlaylist(String baseUri, int mediaSequence, int targetDurationSecs, int version, boolean live, List segments) { super(baseUri, HlsPlaylist.TYPE_MEDIA); this.mediaSequence = mediaSequence; diff --git a/library/src/main/java/com/google/android/exoplayer/hls/HlsParserUtil.java b/library/src/main/java/com/google/android/exoplayer/hls/HlsParserUtil.java index 366bab1178..7e5dd64367 100644 --- a/library/src/main/java/com/google/android/exoplayer/hls/HlsParserUtil.java +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsParserUtil.java @@ -23,7 +23,10 @@ import java.util.regex.Pattern; /** * Utility methods for HLS manifest parsing. */ -/* package */ class HlsParserUtil { +/* package */ final class HlsParserUtil { + + private static final String BOOLEAN_YES = "YES"; + private static final String BOOLEAN_NO = "NO"; private HlsParserUtil() {} @@ -36,14 +39,6 @@ import java.util.regex.Pattern; throw new ParserException(String.format("Couldn't match %s tag in %s", tag, line)); } - public static String parseOptionalStringAttr(String line, Pattern pattern) { - Matcher matcher = pattern.matcher(line); - if (matcher.find() && matcher.groupCount() == 1) { - return matcher.group(1); - } - return null; - } - public static int parseIntAttr(String line, Pattern pattern, String tag) throws ParserException { return Integer.parseInt(parseStringAttr(line, pattern, tag)); @@ -54,4 +49,24 @@ import java.util.regex.Pattern; return Double.parseDouble(parseStringAttr(line, pattern, tag)); } + public static String parseOptionalStringAttr(String line, Pattern pattern) { + Matcher matcher = pattern.matcher(line); + if (matcher.find() && matcher.groupCount() == 1) { + return matcher.group(1); + } + return null; + } + + public static boolean parseOptionalBooleanAttr(String line, Pattern pattern) { + Matcher matcher = pattern.matcher(line); + if (matcher.find() && matcher.groupCount() == 1) { + return BOOLEAN_YES.equals(matcher.group(1)); + } + return false; + } + + public static Pattern compileBooleanAttrPattern(String attrName) { + return Pattern.compile(attrName + "=(" + BOOLEAN_YES + "|" + BOOLEAN_NO + ")"); + } + } diff --git a/library/src/main/java/com/google/android/exoplayer/hls/HlsPlaylist.java b/library/src/main/java/com/google/android/exoplayer/hls/HlsPlaylist.java index 3c86328ba6..b6cd9dac9a 100644 --- a/library/src/main/java/com/google/android/exoplayer/hls/HlsPlaylist.java +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsPlaylist.java @@ -15,7 +15,6 @@ */ package com.google.android.exoplayer.hls; -import android.net.Uri; /** * Represents an HLS playlist. @@ -25,10 +24,10 @@ public abstract class HlsPlaylist { public final static int TYPE_MASTER = 0; public final static int TYPE_MEDIA = 1; - public final Uri baseUri; + public final String baseUri; public final int type; - protected HlsPlaylist(Uri baseUri, int type) { + protected HlsPlaylist(String baseUri, int type) { this.baseUri = baseUri; this.type = type; } diff --git a/library/src/main/java/com/google/android/exoplayer/hls/HlsPlaylistParser.java b/library/src/main/java/com/google/android/exoplayer/hls/HlsPlaylistParser.java index 8db2094c9f..47526ed838 100644 --- a/library/src/main/java/com/google/android/exoplayer/hls/HlsPlaylistParser.java +++ b/library/src/main/java/com/google/android/exoplayer/hls/HlsPlaylistParser.java @@ -19,9 +19,6 @@ import com.google.android.exoplayer.C; import com.google.android.exoplayer.ParserException; import com.google.android.exoplayer.hls.HlsMediaPlaylist.Segment; import com.google.android.exoplayer.upstream.NetworkLoadable; -import com.google.android.exoplayer.util.Util; - -import android.net.Uri; import java.io.BufferedReader; import java.io.IOException; @@ -40,12 +37,8 @@ import java.util.regex.Pattern; public final class HlsPlaylistParser implements NetworkLoadable.Parser { private static final String VERSION_TAG = "#EXT-X-VERSION"; - private static final String STREAM_INF_TAG = "#EXT-X-STREAM-INF"; - private static final String BANDWIDTH_ATTR = "BANDWIDTH"; - private static final String CODECS_ATTR = "CODECS"; - private static final String RESOLUTION_ATTR = "RESOLUTION"; - + private static final String MEDIA_TAG = "#EXT-X-MEDIA"; private static final String DISCONTINUITY_TAG = "#EXT-X-DISCONTINUITY"; private static final String MEDIA_DURATION_TAG = "#EXTINF"; private static final String MEDIA_SEQUENCE_TAG = "#EXT-X-MEDIA-SEQUENCE"; @@ -54,17 +47,32 @@ public final class HlsPlaylistParser implements NetworkLoadable.Parser extraLines = new LinkedList(); String line; @@ -97,7 +115,7 @@ public final class HlsPlaylistParser implements NetworkLoadable.Parser variants = new ArrayList(); + ArrayList variants = new ArrayList(); + ArrayList subtitles = new ArrayList(); int bandwidth = 0; String[] codecs = null; int width = -1; int height = -1; int variantIndex = 0; + boolean expectingStreamInfUrl = false; String line; while (iterator.hasNext()) { line = iterator.next(); - if (line.startsWith(STREAM_INF_TAG)) { + if (line.startsWith(MEDIA_TAG)) { + String type = HlsParserUtil.parseStringAttr(line, TYPE_ATTR_REGEX, TYPE_ATTR); + if (SUBTITLES_TYPE.equals(type)) { + // We assume all subtitles belong to the same group. + String name = HlsParserUtil.parseStringAttr(line, NAME_ATTR_REGEX, NAME_ATTR); + String uri = HlsParserUtil.parseStringAttr(line, URI_ATTR_REGEX, URI_ATTR); + String language = HlsParserUtil.parseOptionalStringAttr(line, LANGUAGE_ATTR_REGEX); + boolean isDefault = HlsParserUtil.parseOptionalBooleanAttr(line, DEFAULT_ATTR_REGEX); + boolean autoSelect = HlsParserUtil.parseOptionalBooleanAttr(line, AUTOSELECT_ATTR_REGEX); + subtitles.add(new Subtitle(name, uri, language, isDefault, autoSelect)); + } else { + // TODO: Support other types of media tag. + } + } else if (line.startsWith(STREAM_INF_TAG)) { bandwidth = HlsParserUtil.parseIntAttr(line, BANDWIDTH_ATTR_REGEX, BANDWIDTH_ATTR); String codecsString = HlsParserUtil.parseOptionalStringAttr(line, CODECS_ATTR_REGEX); if (codecsString != null) { @@ -149,18 +180,21 @@ public final class HlsPlaylistParser implements NetworkLoadable.Parser extractors; + private final LinkedList extractors; private final boolean frameAccurateSeeking; private final int minLoadableRetryCount; @@ -83,7 +83,8 @@ public class HlsSampleSource implements SampleSource, Loader.Callback { this.frameAccurateSeeking = frameAccurateSeeking; this.remainingReleaseCount = downstreamRendererCount; this.minLoadableRetryCount = minLoadableRetryCount; - extractors = new LinkedList(); + this.pendingResetPositionUs = NO_RESET_PENDING; + extractors = new LinkedList(); } @Override @@ -96,7 +97,7 @@ public class HlsSampleSource implements SampleSource, Loader.Callback { } continueBufferingInternal(); if (!extractors.isEmpty()) { - HlsExtractor extractor = extractors.getFirst(); + HlsExtractorWrapper extractor = extractors.getFirst(); if (extractor.isPrepared()) { trackCount = extractor.getTrackCount(); trackEnabledStates = new boolean[trackCount]; @@ -190,12 +191,16 @@ public class HlsSampleSource implements SampleSource, Loader.Callback { return DISCONTINUITY_READ; } - if (onlyReadDiscontinuity || isPendingReset() || extractors.isEmpty()) { + if (onlyReadDiscontinuity) { + return NOTHING_READ; + } + + if (isPendingReset()) { maybeThrowLoadableException(); return NOTHING_READ; } - HlsExtractor extractor = getCurrentExtractor(); + HlsExtractorWrapper extractor = getCurrentExtractor(); if (extractors.size() > 1) { // If there's more than one extractor, attempt to configure a seamless splice from the // current one to the next one. @@ -223,7 +228,8 @@ public class HlsSampleSource implements SampleSource, Loader.Callback { } if (extractor.getSample(track, sampleHolder)) { - sampleHolder.decodeOnly = frameAccurateSeeking && sampleHolder.timeUs < lastSeekPositionUs; + boolean decodeOnly = frameAccurateSeeking && sampleHolder.timeUs < lastSeekPositionUs; + sampleHolder.flags |= decodeOnly ? C.SAMPLE_FLAG_DECODE_ONLY : 0; return SAMPLE_READ; } @@ -240,10 +246,11 @@ public class HlsSampleSource implements SampleSource, Loader.Callback { Assertions.checkState(prepared); Assertions.checkState(enabledTrackCount > 0); lastSeekPositionUs = positionUs; - if (pendingResetPositionUs == positionUs || downstreamPositionUs == positionUs) { - downstreamPositionUs = positionUs; + if ((isPendingReset() ? pendingResetPositionUs : downstreamPositionUs) == positionUs) { return; } + + // TODO: Optimize the seek for the case where the position is already buffered. downstreamPositionUs = positionUs; for (int i = 0; i < pendingDiscontinuities.length; i++) { pendingDiscontinuities[i] = true; @@ -260,9 +267,9 @@ public class HlsSampleSource implements SampleSource, Loader.Callback { } else if (loadingFinished) { return TrackRenderer.END_OF_TRACK_US; } else { - long largestSampleTimestamp = extractors.getLast().getLargestSampleTimestamp(); - return largestSampleTimestamp == Long.MIN_VALUE ? downstreamPositionUs - : largestSampleTimestamp; + long largestParsedTimestampUs = extractors.getLast().getLargestParsedTimestampUs(); + return largestParsedTimestampUs == Long.MIN_VALUE ? downstreamPositionUs + : largestParsedTimestampUs; } } @@ -328,17 +335,17 @@ public class HlsSampleSource implements SampleSource, Loader.Callback { * * @return The current extractor from which samples should be read. Guaranteed to be non-null. */ - private HlsExtractor getCurrentExtractor() { - HlsExtractor extractor = extractors.getFirst(); + private HlsExtractorWrapper getCurrentExtractor() { + HlsExtractorWrapper extractor = extractors.getFirst(); while (extractors.size() > 1 && !haveSamplesForEnabledTracks(extractor)) { // We're finished reading from the extractor for all tracks, and so can discard it. - extractors.removeFirst().release(); + extractors.removeFirst().clear(); extractor = extractors.getFirst(); } return extractor; } - private void discardSamplesForDisabledTracks(HlsExtractor extractor, long timeUs) { + private void discardSamplesForDisabledTracks(HlsExtractorWrapper extractor, long timeUs) { if (!extractor.isPrepared()) { return; } @@ -349,7 +356,7 @@ public class HlsSampleSource implements SampleSource, Loader.Callback { } } - private boolean haveSamplesForEnabledTracks(HlsExtractor extractor) { + private boolean haveSamplesForEnabledTracks(HlsExtractorWrapper extractor) { if (!extractor.isPrepared()) { return false; } @@ -381,7 +388,7 @@ public class HlsSampleSource implements SampleSource, Loader.Callback { private void clearState() { for (int i = 0; i < extractors.size(); i++) { - extractors.get(i).release(); + extractors.get(i).clear(); } extractors.clear(); clearCurrentLoadable(); diff --git a/library/src/main/java/com/google/android/exoplayer/hls/Subtitle.java b/library/src/main/java/com/google/android/exoplayer/hls/Subtitle.java new file mode 100644 index 0000000000..082e86c21b --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/Subtitle.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2014 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.exoplayer.hls; + +/** + * Subtitle media tag. + */ +public final class Subtitle { + + public final String name; + public final String uri; + public final String language; + public final boolean isDefault; + public final boolean autoSelect; + + public Subtitle(String name, String uri, String language, boolean isDefault, boolean autoSelect) { + this.name = name; + this.uri = uri; + this.language = language; + this.autoSelect = autoSelect; + this.isDefault = isDefault; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/hls/TsChunk.java b/library/src/main/java/com/google/android/exoplayer/hls/TsChunk.java index a66330bb5c..5a639f9fad 100644 --- a/library/src/main/java/com/google/android/exoplayer/hls/TsChunk.java +++ b/library/src/main/java/com/google/android/exoplayer/hls/TsChunk.java @@ -15,7 +15,9 @@ */ package com.google.android.exoplayer.hls; -import com.google.android.exoplayer.hls.parser.HlsExtractor; +import com.google.android.exoplayer.extractor.DefaultExtractorInput; +import com.google.android.exoplayer.extractor.Extractor; +import com.google.android.exoplayer.extractor.ExtractorInput; import com.google.android.exoplayer.upstream.DataSource; import com.google.android.exoplayer.upstream.DataSpec; @@ -26,8 +28,6 @@ import java.io.IOException; */ public final class TsChunk extends HlsChunk { - private static final byte[] SCRATCH_SPACE = new byte[4096]; - /** * The index of the variant in the master playlist. */ @@ -51,7 +51,7 @@ public final class TsChunk extends HlsChunk { /** * The extractor into which this chunk is being consumed. */ - public final HlsExtractor extractor; + public final HlsExtractorWrapper extractor; private int loadPosition; private volatile boolean loadFinished; @@ -67,7 +67,7 @@ public final class TsChunk extends HlsChunk { * @param chunkIndex The index of the chunk. * @param isLastChunk True if this is the last chunk in the media. False otherwise. */ - public TsChunk(DataSource dataSource, DataSpec dataSpec, HlsExtractor extractor, + public TsChunk(DataSource dataSource, DataSpec dataSpec, HlsExtractorWrapper extractor, int variantIndex, long startTimeUs, long endTimeUs, int chunkIndex, boolean isLastChunk) { super(dataSource, dataSpec); this.extractor = extractor; @@ -102,30 +102,23 @@ public final class TsChunk extends HlsChunk { @Override public void load() throws IOException, InterruptedException { + ExtractorInput input; try { - dataSource.open(dataSpec); - int bytesRead = 0; - int bytesSkipped = 0; + input = new DefaultExtractorInput(dataSource, 0, dataSource.open(dataSpec)); // If we previously fed part of this chunk to the extractor, skip it this time. // TODO: Ideally we'd construct a dataSpec that only loads the remainder of the data here, // rather than loading the whole chunk again and then skipping data we previously loaded. To // do this is straightforward for non-encrypted content, but more complicated for content // encrypted with AES, for which we'll need to modify the way that decryption is performed. - while (bytesRead != -1 && !loadCanceled && bytesSkipped < loadPosition) { - int skipLength = Math.min(loadPosition - bytesSkipped, SCRATCH_SPACE.length); - bytesRead = dataSource.read(SCRATCH_SPACE, 0, skipLength); - if (bytesRead != -1) { - bytesSkipped += bytesRead; + input.skipFully(loadPosition); + try { + int result = Extractor.RESULT_CONTINUE; + while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { + result = extractor.read(input); } + } finally { + loadPosition = (int) input.getPosition(); } - // Feed the remaining data into the extractor. - while (bytesRead != -1 && !loadCanceled) { - bytesRead = extractor.read(dataSource); - if (bytesRead != -1) { - loadPosition += bytesRead; - } - } - loadFinished = !loadCanceled; } finally { dataSource.close(); } diff --git a/library/src/main/java/com/google/android/exoplayer/hls/parser/AdtsExtractor.java b/library/src/main/java/com/google/android/exoplayer/hls/parser/AdtsExtractor.java deleted file mode 100644 index af164a5f36..0000000000 --- a/library/src/main/java/com/google/android/exoplayer/hls/parser/AdtsExtractor.java +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright (C) 2014 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.exoplayer.hls.parser; - -import com.google.android.exoplayer.MediaFormat; -import com.google.android.exoplayer.SampleHolder; -import com.google.android.exoplayer.upstream.BufferPool; -import com.google.android.exoplayer.upstream.DataSource; -import com.google.android.exoplayer.util.Assertions; -import com.google.android.exoplayer.util.ParsableByteArray; - -import java.io.IOException; - -/** - * Facilitates the extraction of AAC samples from elementary audio files formatted as AAC with ADTS - * headers. - */ -public class AdtsExtractor extends HlsExtractor { - - private static final int MAX_PACKET_SIZE = 200; - - private final long firstSampleTimestamp; - private final ParsableByteArray packetBuffer; - private final AdtsReader adtsReader; - - // Accessed only by the loading thread. - private boolean firstPacket; - // Accessed by both the loading and consuming threads. - private volatile boolean prepared; - - public AdtsExtractor(boolean shouldSpliceIn, long firstSampleTimestamp, BufferPool bufferPool) { - super(shouldSpliceIn); - this.firstSampleTimestamp = firstSampleTimestamp; - packetBuffer = new ParsableByteArray(MAX_PACKET_SIZE); - adtsReader = new AdtsReader(bufferPool); - firstPacket = true; - } - - @Override - public int getTrackCount() { - Assertions.checkState(prepared); - return 1; - } - - @Override - public MediaFormat getFormat(int track) { - Assertions.checkState(prepared); - return adtsReader.getMediaFormat(); - } - - @Override - public boolean isPrepared() { - return prepared; - } - - @Override - public void release() { - adtsReader.release(); - } - - @Override - public long getLargestSampleTimestamp() { - return adtsReader.getLargestParsedTimestampUs(); - } - - @Override - public boolean getSample(int track, SampleHolder holder) { - Assertions.checkState(prepared); - Assertions.checkState(track == 0); - return adtsReader.getSample(holder); - } - - @Override - public void discardUntil(int track, long timeUs) { - Assertions.checkState(prepared); - Assertions.checkState(track == 0); - adtsReader.discardUntil(timeUs); - } - - @Override - public boolean hasSamples(int track) { - Assertions.checkState(prepared); - Assertions.checkState(track == 0); - return !adtsReader.isEmpty(); - } - - @Override - public int read(DataSource dataSource) throws IOException { - int bytesRead = dataSource.read(packetBuffer.data, 0, MAX_PACKET_SIZE); - if (bytesRead == -1) { - return -1; - } - - packetBuffer.setPosition(0); - packetBuffer.setLimit(bytesRead); - - // TODO: Make it possible for adtsReader to consume the dataSource directly, so that it becomes - // unnecessary to copy the data through packetBuffer. - adtsReader.consume(packetBuffer, firstSampleTimestamp, firstPacket); - firstPacket = false; - if (!prepared) { - prepared = adtsReader.hasMediaFormat(); - } - return bytesRead; - } - - @Override - protected SampleQueue getSampleQueue(int track) { - Assertions.checkState(track == 0); - return adtsReader; - } - -} diff --git a/library/src/main/java/com/google/android/exoplayer/hls/parser/RollingSampleBuffer.java b/library/src/main/java/com/google/android/exoplayer/hls/parser/RollingSampleBuffer.java deleted file mode 100644 index 48a618edde..0000000000 --- a/library/src/main/java/com/google/android/exoplayer/hls/parser/RollingSampleBuffer.java +++ /dev/null @@ -1,303 +0,0 @@ -/* - * Copyright (C) 2014 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.exoplayer.hls.parser; - -import com.google.android.exoplayer.C; -import com.google.android.exoplayer.SampleHolder; -import com.google.android.exoplayer.upstream.BufferPool; -import com.google.android.exoplayer.util.Assertions; -import com.google.android.exoplayer.util.ParsableByteArray; - -import java.nio.ByteBuffer; -import java.util.concurrent.ConcurrentLinkedQueue; - -/** - * A rolling buffer of sample data and corresponding sample information. - */ -/* package */ final class RollingSampleBuffer { - - private final BufferPool fragmentPool; - private final int fragmentLength; - - private final InfoQueue infoQueue; - private final ConcurrentLinkedQueue dataQueue; - private final long[] dataOffsetHolder; - - // Accessed only by the consuming thread. - private long totalBytesDropped; - - // Accessed only by the loading thread. - private long totalBytesWritten; - private byte[] lastFragment; - private int lastFragmentOffset; - private long pendingSampleTimeUs; - private long pendingSampleOffset; - - public RollingSampleBuffer(BufferPool bufferPool) { - this.fragmentPool = bufferPool; - fragmentLength = bufferPool.bufferLength; - infoQueue = new InfoQueue(); - dataQueue = new ConcurrentLinkedQueue(); - dataOffsetHolder = new long[1]; - } - - public void release() { - while (!dataQueue.isEmpty()) { - fragmentPool.releaseDirect(dataQueue.remove()); - } - } - - // Called by the consuming thread. - - /** - * Fills {@code holder} with information about the current sample, but does not write its data. - *

- * The fields set are {SampleHolder#size}, {SampleHolder#timeUs} and {SampleHolder#flags}. - * - * @param holder The holder into which the current sample information should be written. - * @return True if the holder was filled. False if there is no current sample. - */ - public boolean peekSample(SampleHolder holder) { - return infoQueue.peekSample(holder, dataOffsetHolder); - } - - /** - * Skips the current sample. - */ - public void skipSample() { - long nextOffset = infoQueue.moveToNextSample(); - dropFragmentsTo(nextOffset); - } - - /** - * Reads the current sample, advancing the read index to the next sample. - * - * @param holder The holder into which the current sample should be written. - */ - public void readSample(SampleHolder holder) { - // Write the sample information into the holder. - infoQueue.peekSample(holder, dataOffsetHolder); - // Write the sample data into the holder. - if (holder.data == null || holder.data.capacity() < holder.size) { - holder.replaceBuffer(holder.size); - } - if (holder.data != null) { - readData(dataOffsetHolder[0], holder.data, holder.size); - } - // Advance the read head. - long nextOffset = infoQueue.moveToNextSample(); - dropFragmentsTo(nextOffset); - } - - /** - * Reads data from the front of the rolling buffer. - * - * @param absolutePosition The absolute position from which data should be read. - * @param target The buffer into which data should be written. - * @param length The number of bytes to read. - */ - private void readData(long absolutePosition, ByteBuffer target, int length) { - int remaining = length; - while (remaining > 0) { - dropFragmentsTo(absolutePosition); - int positionInFragment = (int) (absolutePosition - totalBytesDropped); - int toCopy = Math.min(remaining, fragmentLength - positionInFragment); - target.put(dataQueue.peek(), positionInFragment, toCopy); - absolutePosition += toCopy; - remaining -= toCopy; - } - } - - /** - * Discard any fragments that hold data prior to the specified absolute position, returning - * them to the pool. - * - * @param absolutePosition The absolute position up to which fragments can be discarded. - */ - private void dropFragmentsTo(long absolutePosition) { - int relativePosition = (int) (absolutePosition - totalBytesDropped); - int fragmentIndex = relativePosition / fragmentLength; - for (int i = 0; i < fragmentIndex; i++) { - fragmentPool.releaseDirect(dataQueue.remove()); - totalBytesDropped += fragmentLength; - } - } - - // Called by the loading thread. - - /** - * Indicates the start point for the next sample. - * - * @param sampleTimeUs The sample timestamp. - * @param offset The offset of the sample's data, relative to the total number of bytes written - * to the buffer. Must be negative or zero. - */ - public void startSample(long sampleTimeUs, int offset) { - Assertions.checkState(offset <= 0); - pendingSampleTimeUs = sampleTimeUs; - pendingSampleOffset = totalBytesWritten + offset; - } - - /** - * Appends data to the rolling buffer. - * - * @param buffer A buffer containing the data to append. - * @param length The length of the data to append. - */ - public void appendData(ParsableByteArray buffer, int length) { - int remainingWriteLength = length; - while (remainingWriteLength > 0) { - if (dataQueue.isEmpty() || lastFragmentOffset == fragmentLength) { - lastFragmentOffset = 0; - lastFragment = fragmentPool.allocateDirect(); - dataQueue.add(lastFragment); - } - int thisWriteLength = Math.min(remainingWriteLength, fragmentLength - lastFragmentOffset); - buffer.readBytes(lastFragment, lastFragmentOffset, thisWriteLength); - lastFragmentOffset += thisWriteLength; - remainingWriteLength -= thisWriteLength; - } - totalBytesWritten += length; - } - - /** - * Indicates the end point for the current sample, making it available for consumption. - * - * @param isKeyframe True if the sample being committed is a keyframe. False otherwise. - * @param offset The offset of the first byte after the end of the sample's data, relative to - * the total number of bytes written to the buffer. Must be negative or zero. - */ - public void commitSample(boolean isKeyframe, int offset) { - Assertions.checkState(offset <= 0); - int sampleSize = (int) (totalBytesWritten + offset - pendingSampleOffset); - infoQueue.commitSample(pendingSampleTimeUs, pendingSampleOffset, sampleSize, - isKeyframe ? C.SAMPLE_FLAG_SYNC : 0); - } - - /** - * Holds information about the samples in the rolling buffer. - */ - private static class InfoQueue { - - private static final int SAMPLE_CAPACITY_INCREMENT = 1000; - - private int capacity; - - private long[] offsets; - private int[] sizes; - private int[] flags; - private long[] timesUs; - - private int queueSize; - private int readIndex; - private int writeIndex; - - public InfoQueue() { - capacity = SAMPLE_CAPACITY_INCREMENT; - offsets = new long[capacity]; - timesUs = new long[capacity]; - flags = new int[capacity]; - sizes = new int[capacity]; - } - - // Called by the consuming thread. - - /** - * Fills {@code holder} with information about the current sample, but does not write its data. - * The first entry in {@code offsetHolder} is filled with the absolute position of the sample's - * data in the rolling buffer. - *

- * The fields set are {SampleHolder#size}, {SampleHolder#timeUs}, {SampleHolder#flags} and - * {@code offsetHolder[0]}. - * - * @param holder The holder into which the current sample information should be written. - * @param offsetHolder The holder into which the absolute position of the sample's data should - * be written. - * @return True if the holders were filled. False if there is no current sample. - */ - public synchronized boolean peekSample(SampleHolder holder, long[] offsetHolder) { - if (queueSize == 0) { - return false; - } - holder.timeUs = timesUs[readIndex]; - holder.size = sizes[readIndex]; - holder.flags = flags[readIndex]; - offsetHolder[0] = offsets[readIndex]; - return true; - } - - /** - * Advances the read index to the next sample. - * - * @return The absolute position of the first byte in the rolling buffer that may still be - * required after advancing the index. Data prior to this position can be dropped. - */ - public synchronized long moveToNextSample() { - queueSize--; - int lastReadIndex = readIndex++; - if (readIndex == capacity) { - // Wrap around. - readIndex = 0; - } - return queueSize > 0 ? offsets[readIndex] : (sizes[lastReadIndex] + offsets[lastReadIndex]); - } - - // Called by the loading thread. - - public synchronized void commitSample(long timeUs, long offset, int size, int sampleFlags) { - timesUs[writeIndex] = timeUs; - offsets[writeIndex] = offset; - sizes[writeIndex] = size; - flags[writeIndex] = sampleFlags; - // Increment the write index. - queueSize++; - if (queueSize == capacity) { - // Increase the capacity. - int newCapacity = capacity + SAMPLE_CAPACITY_INCREMENT; - long[] newOffsets = new long[newCapacity]; - long[] newTimesUs = new long[newCapacity]; - int[] newFlags = new int[newCapacity]; - int[] newSizes = new int[newCapacity]; - int beforeWrap = capacity - readIndex; - System.arraycopy(offsets, readIndex, newOffsets, 0, beforeWrap); - System.arraycopy(timesUs, readIndex, newTimesUs, 0, beforeWrap); - System.arraycopy(flags, readIndex, newFlags, 0, beforeWrap); - System.arraycopy(sizes, readIndex, newSizes, 0, beforeWrap); - int afterWrap = readIndex; - System.arraycopy(offsets, 0, newOffsets, beforeWrap, afterWrap); - System.arraycopy(timesUs, 0, newTimesUs, beforeWrap, afterWrap); - System.arraycopy(flags, 0, newFlags, beforeWrap, afterWrap); - System.arraycopy(sizes, 0, newSizes, beforeWrap, afterWrap); - offsets = newOffsets; - timesUs = newTimesUs; - flags = newFlags; - sizes = newSizes; - readIndex = 0; - writeIndex = capacity; - queueSize = capacity; - capacity = newCapacity; - } else { - writeIndex++; - if (writeIndex == capacity) { - // Wrap around. - writeIndex = 0; - } - } - } - - } - -} diff --git a/library/src/main/java/com/google/android/exoplayer/metadata/GeobMetadata.java b/library/src/main/java/com/google/android/exoplayer/metadata/GeobMetadata.java new file mode 100644 index 0000000000..1d4fcb4dee --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/metadata/GeobMetadata.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2014 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.exoplayer.metadata; + +/** + * A metadata that contains parsed ID3 GEOB (General Encapsulated Object) frame data associated + * with time indices. + */ +public class GeobMetadata { + + public static final String TYPE = "GEOB"; + + public final String mimeType; + public final String filename; + public final String description; + public final byte[] data; + + public GeobMetadata(String mimeType, String filename, String description, byte[] data) { + this.mimeType = mimeType; + this.filename = filename; + this.description = description; + this.data = data; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/metadata/Id3Parser.java b/library/src/main/java/com/google/android/exoplayer/metadata/Id3Parser.java index 1ec0e363dd..0972a77c37 100644 --- a/library/src/main/java/com/google/android/exoplayer/metadata/Id3Parser.java +++ b/library/src/main/java/com/google/android/exoplayer/metadata/Id3Parser.java @@ -29,6 +29,11 @@ import java.util.Map; */ public class Id3Parser implements MetadataParser> { + private static final int ID3_TEXT_ENCODING_ISO_8859_1 = 0; + private static final int ID3_TEXT_ENCODING_UTF_16 = 1; + private static final int ID3_TEXT_ENCODING_UTF_16BE = 2; + private static final int ID3_TEXT_ENCODING_UTF_8 = 3; + @Override public boolean canParse(String mimeType) { return mimeType.equals(MimeTypes.APPLICATION_ID3); @@ -60,13 +65,48 @@ public class Id3Parser implements MetadataParser> { byte[] frame = new byte[frameSize - 1]; id3Data.readBytes(frame, 0, frameSize - 1); - int firstZeroIndex = indexOf(frame, 0, (byte) 0); + int firstZeroIndex = indexOfEOS(frame, 0, encoding); String description = new String(frame, 0, firstZeroIndex, charset); - int valueStartIndex = indexOfNot(frame, firstZeroIndex, (byte) 0); - int valueEndIndex = indexOf(frame, valueStartIndex, (byte) 0); + int valueStartIndex = firstZeroIndex + delimiterLength(encoding); + int valueEndIndex = indexOfEOS(frame, valueStartIndex, encoding); String value = new String(frame, valueStartIndex, valueEndIndex - valueStartIndex, charset); metadata.put(TxxxMetadata.TYPE, new TxxxMetadata(description, value)); + } else if (frameId0 == 'P' && frameId1 == 'R' && frameId2 == 'I' && frameId3 == 'V') { + // Check frame ID == PRIV + byte[] frame = new byte[frameSize]; + id3Data.readBytes(frame, 0, frameSize); + + int firstZeroIndex = indexOf(frame, 0, (byte) 0); + String owner = new String(frame, 0, firstZeroIndex, "ISO-8859-1"); + byte[] privateData = new byte[frameSize - firstZeroIndex - 1]; + System.arraycopy(frame, firstZeroIndex + 1, privateData, 0, frameSize - firstZeroIndex - 1); + metadata.put(PrivMetadata.TYPE, new PrivMetadata(owner, privateData)); + } else if (frameId0 == 'G' && frameId1 == 'E' && frameId2 == 'O' && frameId3 == 'B') { + // Check frame ID == GEOB + int encoding = id3Data.readUnsignedByte(); + String charset = getCharsetName(encoding); + byte[] frame = new byte[frameSize - 1]; + id3Data.readBytes(frame, 0, frameSize - 1); + + int firstZeroIndex = indexOf(frame, 0, (byte) 0); + String mimeType = new String(frame, 0, firstZeroIndex, "ISO-8859-1"); + int filenameStartIndex = firstZeroIndex + 1; + int filenameEndIndex = indexOfEOS(frame, filenameStartIndex, encoding); + String filename = new String(frame, filenameStartIndex, + filenameEndIndex - filenameStartIndex, charset); + int descriptionStartIndex = filenameEndIndex + delimiterLength(encoding); + int descriptionEndIndex = indexOfEOS(frame, descriptionStartIndex, encoding); + String description = new String(frame, descriptionStartIndex, + descriptionEndIndex - descriptionStartIndex, charset); + + int objectDataSize = frameSize - 1 /* encoding byte */ - descriptionEndIndex + - delimiterLength(encoding); + byte[] objectData = new byte[objectDataSize]; + System.arraycopy(frame, descriptionEndIndex + delimiterLength(encoding), objectData, 0, + objectDataSize); + metadata.put(GeobMetadata.TYPE, new GeobMetadata(mimeType, filename, + description, objectData)); } else { String type = String.format("%c%c%c%c", frameId0, frameId1, frameId2, frameId3); byte[] frame = new byte[frameSize]; @@ -89,15 +129,30 @@ public class Id3Parser implements MetadataParser> { return data.length; } - private static int indexOfNot(byte[] data, int fromIndex, byte key) { - for (int i = fromIndex; i < data.length; i++) { - if (data[i] != key) { - return i; - } + private static int indexOfEOS(byte[] data, int fromIndex, int encodingByte) { + int terminationPos = indexOf(data, fromIndex, (byte) 0); + + // For single byte encoding charsets, we are done + if (encodingByte == ID3_TEXT_ENCODING_ISO_8859_1 || encodingByte == ID3_TEXT_ENCODING_UTF_8) { + return terminationPos; } + + // Otherwise, look for a two zero bytes + while (terminationPos < data.length - 1) { + if (data[terminationPos + 1] == (byte) 0) { + return terminationPos; + } + terminationPos = indexOf(data, terminationPos + 1, (byte) 0); + } + return data.length; } + private static int delimiterLength(int encodingByte) { + return (encodingByte == ID3_TEXT_ENCODING_ISO_8859_1 + || encodingByte == ID3_TEXT_ENCODING_UTF_8) ? 1 : 2; + } + /** * Parses an ID3 header. * @@ -142,13 +197,13 @@ public class Id3Parser implements MetadataParser> { */ private static String getCharsetName(int encodingByte) { switch (encodingByte) { - case 0: + case ID3_TEXT_ENCODING_ISO_8859_1: return "ISO-8859-1"; - case 1: + case ID3_TEXT_ENCODING_UTF_16: return "UTF-16"; - case 2: + case ID3_TEXT_ENCODING_UTF_16BE: return "UTF-16BE"; - case 3: + case ID3_TEXT_ENCODING_UTF_8: return "UTF-8"; default: return "ISO-8859-1"; diff --git a/library/src/main/java/com/google/android/exoplayer/metadata/PrivMetadata.java b/library/src/main/java/com/google/android/exoplayer/metadata/PrivMetadata.java new file mode 100644 index 0000000000..8573b25906 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/metadata/PrivMetadata.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2014 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.exoplayer.metadata; + +/** + * A metadata that contains parsed ID3 PRIV (Private) frame data associated + * with time indices. + */ +public class PrivMetadata { + + public static final String TYPE = "PRIV"; + + public final String owner; + public final byte[] privateData; + + public PrivMetadata(String owner, byte[] privateData) { + this.owner = owner; + this.privateData = privateData; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/mp4/Atom.java b/library/src/main/java/com/google/android/exoplayer/mp4/Atom.java index 292c231087..ffe0f9cc77 100644 --- a/library/src/main/java/com/google/android/exoplayer/mp4/Atom.java +++ b/library/src/main/java/com/google/android/exoplayer/mp4/Atom.java @@ -24,6 +24,19 @@ import java.util.List; public abstract class Atom { + /** Size of an atom header, in bytes. */ + public static final int ATOM_HEADER_SIZE = 8; + + /** Size of a long atom header, in bytes. */ + public static final int LONG_ATOM_HEADER_SIZE = 16; + + /** Size of a full atom header, in bytes. */ + public static final int FULL_ATOM_HEADER_SIZE = 12; + + /** Value for the first 32 bits of atomSize when the atom size is actually a long value. */ + public static final int LONG_SIZE_PREFIX = 1; + + public static final int TYPE_ftyp = getAtomTypeInteger("ftyp"); public static final int TYPE_avc1 = getAtomTypeInteger("avc1"); public static final int TYPE_avc3 = getAtomTypeInteger("avc3"); public static final int TYPE_esds = getAtomTypeInteger("esds"); @@ -153,6 +166,20 @@ public abstract class Atom { } + /** + * Parses the version number out of the additional integer component of a full atom. + */ + public static int parseFullAtomVersion(int fullAtomInt) { + return 0x000000FF & (fullAtomInt >> 24); + } + + /** + * Parses the atom flags out of the additional integer component of a full atom. + */ + public static int parseFullAtomFlags(int fullAtomInt) { + return 0x00FFFFFF & fullAtomInt; + } + private static String getAtomTypeString(int type) { return "" + (char) (type >> 24) + (char) ((type >> 16) & 0xFF) diff --git a/library/src/main/java/com/google/android/exoplayer/mp4/CommonMp4AtomParsers.java b/library/src/main/java/com/google/android/exoplayer/mp4/CommonMp4AtomParsers.java index 4443e573c9..2170425058 100644 --- a/library/src/main/java/com/google/android/exoplayer/mp4/CommonMp4AtomParsers.java +++ b/library/src/main/java/com/google/android/exoplayer/mp4/CommonMp4AtomParsers.java @@ -20,6 +20,7 @@ import com.google.android.exoplayer.MediaFormat; import com.google.android.exoplayer.chunk.parser.mp4.TrackEncryptionBox; import com.google.android.exoplayer.util.Assertions; import com.google.android.exoplayer.util.CodecSpecificDataUtil; +import com.google.android.exoplayer.util.H264Util; import com.google.android.exoplayer.util.MimeTypes; import com.google.android.exoplayer.util.ParsableByteArray; import com.google.android.exoplayer.util.Util; @@ -67,7 +68,7 @@ public final class CommonMp4AtomParsers { long mediaTimescale = parseMdhd(mdia.getLeafAtomOfType(Atom.TYPE_mdhd).data); Pair sampleDescriptions = - parseStsd(stbl.getLeafAtomOfType(Atom.TYPE_stsd).data); + parseStsd(stbl.getLeafAtomOfType(Atom.TYPE_stsd).data, durationUs); return new Track(id, trackType, mediaTimescale, durationUs, sampleDescriptions.first, sampleDescriptions.second); } @@ -102,7 +103,7 @@ public final class CommonMp4AtomParsers { ParsableByteArray ctts = cttsAtom != null ? cttsAtom.data : null; // Skip full atom. - stsz.setPosition(Mp4Util.FULL_ATOM_HEADER_SIZE); + stsz.setPosition(Atom.FULL_ATOM_HEADER_SIZE); int fixedSampleSize = stsz.readUnsignedIntToInt(); int sampleCount = stsz.readUnsignedIntToInt(); @@ -112,10 +113,10 @@ public final class CommonMp4AtomParsers { int[] flags = new int[sampleCount]; // Prepare to read chunk offsets. - chunkOffsets.setPosition(Mp4Util.FULL_ATOM_HEADER_SIZE); + chunkOffsets.setPosition(Atom.FULL_ATOM_HEADER_SIZE); int chunkCount = chunkOffsets.readUnsignedIntToInt(); - stsc.setPosition(Mp4Util.FULL_ATOM_HEADER_SIZE); + stsc.setPosition(Atom.FULL_ATOM_HEADER_SIZE); int remainingSamplesPerChunkChanges = stsc.readUnsignedIntToInt() - 1; Assertions.checkState(stsc.readInt() == 1, "stsc first chunk must be 1"); int samplesPerChunk = stsc.readUnsignedIntToInt(); @@ -130,7 +131,7 @@ public final class CommonMp4AtomParsers { int remainingSamplesInChunk = samplesPerChunk; // Prepare to read sample timestamps. - stts.setPosition(Mp4Util.FULL_ATOM_HEADER_SIZE); + stts.setPosition(Atom.FULL_ATOM_HEADER_SIZE); int remainingTimestampDeltaChanges = stts.readUnsignedIntToInt() - 1; int remainingSamplesAtTimestampDelta = stts.readUnsignedIntToInt(); int timestampDeltaInTimeUnits = stts.readUnsignedIntToInt(); @@ -141,8 +142,8 @@ public final class CommonMp4AtomParsers { int remainingTimestampOffsetChanges = 0; int timestampOffset = 0; if (ctts != null) { - ctts.setPosition(Mp4Util.ATOM_HEADER_SIZE); - cttsHasSignedOffsets = Mp4Util.parseFullAtomVersion(ctts.readInt()) == 1; + ctts.setPosition(Atom.ATOM_HEADER_SIZE); + cttsHasSignedOffsets = Atom.parseFullAtomVersion(ctts.readInt()) == 1; remainingTimestampOffsetChanges = ctts.readUnsignedIntToInt() - 1; remainingSamplesAtTimestampOffset = ctts.readUnsignedIntToInt(); timestampOffset = cttsHasSignedOffsets ? ctts.readInt() : ctts.readUnsignedIntToInt(); @@ -151,7 +152,7 @@ public final class CommonMp4AtomParsers { int nextSynchronizationSampleIndex = -1; int remainingSynchronizationSamples = 0; if (stss != null) { - stss.setPosition(Mp4Util.FULL_ATOM_HEADER_SIZE); + stss.setPosition(Atom.FULL_ATOM_HEADER_SIZE); remainingSynchronizationSamples = stss.readUnsignedIntToInt(); nextSynchronizationSampleIndex = stss.readUnsignedIntToInt() - 1; } @@ -249,10 +250,10 @@ public final class CommonMp4AtomParsers { * @return Timescale for the movie. */ private static long parseMvhd(ParsableByteArray mvhd) { - mvhd.setPosition(Mp4Util.ATOM_HEADER_SIZE); + mvhd.setPosition(Atom.ATOM_HEADER_SIZE); int fullAtom = mvhd.readInt(); - int version = Mp4Util.parseFullAtomVersion(fullAtom); + int version = Atom.parseFullAtomVersion(fullAtom); mvhd.skip(version == 0 ? 8 : 16); @@ -266,9 +267,9 @@ public final class CommonMp4AtomParsers { * the movie header box). The duration is set to -1 if the duration is unspecified. */ private static Pair parseTkhd(ParsableByteArray tkhd) { - tkhd.setPosition(Mp4Util.ATOM_HEADER_SIZE); + tkhd.setPosition(Atom.ATOM_HEADER_SIZE); int fullAtom = tkhd.readInt(); - int version = Mp4Util.parseFullAtomVersion(fullAtom); + int version = Atom.parseFullAtomVersion(fullAtom); tkhd.skip(version == 0 ? 8 : 16); @@ -302,7 +303,7 @@ public final class CommonMp4AtomParsers { * @return The track type. */ private static int parseHdlr(ParsableByteArray hdlr) { - hdlr.setPosition(Mp4Util.FULL_ATOM_HEADER_SIZE + 4); + hdlr.setPosition(Atom.FULL_ATOM_HEADER_SIZE + 4); return hdlr.readInt(); } @@ -313,16 +314,17 @@ public final class CommonMp4AtomParsers { * @return The media timescale, defined as the number of time units that pass in one second. */ private static long parseMdhd(ParsableByteArray mdhd) { - mdhd.setPosition(Mp4Util.ATOM_HEADER_SIZE); + mdhd.setPosition(Atom.ATOM_HEADER_SIZE); int fullAtom = mdhd.readInt(); - int version = Mp4Util.parseFullAtomVersion(fullAtom); + int version = Atom.parseFullAtomVersion(fullAtom); mdhd.skip(version == 0 ? 8 : 16); return mdhd.readUnsignedInt(); } - private static Pair parseStsd(ParsableByteArray stsd) { - stsd.setPosition(Mp4Util.FULL_ATOM_HEADER_SIZE); + private static Pair parseStsd( + ParsableByteArray stsd, long durationUs) { + stsd.setPosition(Atom.FULL_ATOM_HEADER_SIZE); int numberOfEntries = stsd.readInt(); MediaFormat mediaFormat = null; TrackEncryptionBox[] trackEncryptionBoxes = new TrackEncryptionBox[numberOfEntries]; @@ -334,19 +336,19 @@ public final class CommonMp4AtomParsers { if (childAtomType == Atom.TYPE_avc1 || childAtomType == Atom.TYPE_avc3 || childAtomType == Atom.TYPE_encv) { Pair avc = - parseAvcFromParent(stsd, childStartPosition, childAtomSize); + parseAvcFromParent(stsd, childStartPosition, childAtomSize, durationUs); mediaFormat = avc.first; trackEncryptionBoxes[i] = avc.second; } else if (childAtomType == Atom.TYPE_mp4a || childAtomType == Atom.TYPE_enca || childAtomType == Atom.TYPE_ac_3) { - Pair audioSampleEntry = - parseAudioSampleEntry(stsd, childAtomType, childStartPosition, childAtomSize); + Pair audioSampleEntry = parseAudioSampleEntry(stsd, + childAtomType, childStartPosition, childAtomSize, durationUs); mediaFormat = audioSampleEntry.first; trackEncryptionBoxes[i] = audioSampleEntry.second; } else if (childAtomType == Atom.TYPE_TTML) { mediaFormat = MediaFormat.createTtmlFormat(); } else if (childAtomType == Atom.TYPE_mp4v) { - mediaFormat = parseMp4vFromParent(stsd, childStartPosition, childAtomSize); + mediaFormat = parseMp4vFromParent(stsd, childStartPosition, childAtomSize, durationUs); } stsd.setPosition(childStartPosition + childAtomSize); } @@ -355,8 +357,8 @@ public final class CommonMp4AtomParsers { /** Returns the media format for an avc1 box. */ private static Pair parseAvcFromParent(ParsableByteArray parent, - int position, int size) { - parent.setPosition(position + Mp4Util.ATOM_HEADER_SIZE); + int position, int size, long durationUs) { + parent.setPosition(position + Atom.ATOM_HEADER_SIZE); parent.skip(24); int width = parent.readUnsignedShort(); @@ -388,12 +390,12 @@ public final class CommonMp4AtomParsers { } MediaFormat format = MediaFormat.createVideoFormat(MimeTypes.VIDEO_H264, MediaFormat.NO_VALUE, - width, height, pixelWidthHeightRatio, initializationData); + durationUs, width, height, pixelWidthHeightRatio, initializationData); return Pair.create(format, trackEncryptionBox); } private static List parseAvcCFromParent(ParsableByteArray parent, int position) { - parent.setPosition(position + Mp4Util.ATOM_HEADER_SIZE + 4); + parent.setPosition(position + Atom.ATOM_HEADER_SIZE + 4); // Start of the AVCDecoderConfigurationRecord (defined in 14496-15) int nalUnitLength = (parent.readUnsignedByte() & 0x3) + 1; if (nalUnitLength != 4) { @@ -406,18 +408,18 @@ public final class CommonMp4AtomParsers { // expose the AVC profile and level somewhere useful; Most likely in MediaFormat. int numSequenceParameterSets = parent.readUnsignedByte() & 0x1F; for (int j = 0; j < numSequenceParameterSets; j++) { - initializationData.add(Mp4Util.parseChildNalUnit(parent)); + initializationData.add(H264Util.parseChildNalUnit(parent)); } int numPictureParameterSets = parent.readUnsignedByte(); for (int j = 0; j < numPictureParameterSets; j++) { - initializationData.add(Mp4Util.parseChildNalUnit(parent)); + initializationData.add(H264Util.parseChildNalUnit(parent)); } return initializationData; } private static TrackEncryptionBox parseSinfFromParent(ParsableByteArray parent, int position, int size) { - int childPosition = position + Mp4Util.ATOM_HEADER_SIZE; + int childPosition = position + Atom.ATOM_HEADER_SIZE; TrackEncryptionBox trackEncryptionBox = null; while (childPosition - position < size) { @@ -440,7 +442,7 @@ public final class CommonMp4AtomParsers { } private static float parsePaspFromParent(ParsableByteArray parent, int position) { - parent.setPosition(position + Mp4Util.ATOM_HEADER_SIZE); + parent.setPosition(position + Atom.ATOM_HEADER_SIZE); int hSpacing = parent.readUnsignedIntToInt(); int vSpacing = parent.readUnsignedIntToInt(); return (float) hSpacing / vSpacing; @@ -448,7 +450,7 @@ public final class CommonMp4AtomParsers { private static TrackEncryptionBox parseSchiFromParent(ParsableByteArray parent, int position, int size) { - int childPosition = position + Mp4Util.ATOM_HEADER_SIZE; + int childPosition = position + Atom.ATOM_HEADER_SIZE; while (childPosition - position < size) { parent.setPosition(childPosition); int childAtomSize = parent.readInt(); @@ -468,9 +470,9 @@ public final class CommonMp4AtomParsers { } /** Returns the media format for an mp4v box. */ - private static MediaFormat parseMp4vFromParent(ParsableByteArray parent, - int position, int size) { - parent.setPosition(position + Mp4Util.ATOM_HEADER_SIZE); + private static MediaFormat parseMp4vFromParent(ParsableByteArray parent, int position, int size, + long durationUs) { + parent.setPosition(position + Atom.ATOM_HEADER_SIZE); parent.skip(24); int width = parent.readUnsignedShort(); @@ -492,12 +494,12 @@ public final class CommonMp4AtomParsers { } return MediaFormat.createVideoFormat( - MimeTypes.VIDEO_MP4V, MediaFormat.NO_VALUE, width, height, initializationData); + MimeTypes.VIDEO_MP4V, MediaFormat.NO_VALUE, durationUs, width, height, initializationData); } private static Pair parseAudioSampleEntry( - ParsableByteArray parent, int atomType, int position, int size) { - parent.setPosition(position + Mp4Util.ATOM_HEADER_SIZE); + ParsableByteArray parent, int atomType, int position, int size, long durationUs) { + parent.setPosition(position + Atom.ATOM_HEADER_SIZE); parent.skip(16); int channelCount = parent.readUnsignedShort(); int sampleSize = parent.readUnsignedShort(); @@ -555,14 +557,14 @@ public final class CommonMp4AtomParsers { } MediaFormat format = MediaFormat.createAudioFormat( - mimeType, sampleSize, channelCount, sampleRate, bitrate, + mimeType, sampleSize, durationUs, channelCount, sampleRate, bitrate, initializationData == null ? null : Collections.singletonList(initializationData)); return Pair.create(format, trackEncryptionBox); } /** Returns codec-specific initialization data contained in an esds box. */ private static byte[] parseEsdsFromParent(ParsableByteArray parent, int position) { - parent.setPosition(position + Mp4Util.ATOM_HEADER_SIZE + 4); + parent.setPosition(position + Atom.ATOM_HEADER_SIZE + 4); // Start of the ES_Descriptor (defined in 14496-1) parent.skip(1); // ES_Descriptor tag int varIntByte = parent.readUnsignedByte(); @@ -606,7 +608,7 @@ public final class CommonMp4AtomParsers { private static Ac3Format parseAc3SpecificBoxFromParent(ParsableByteArray parent, int position) { // Start of the dac3 atom (defined in ETSI TS 102 366) - parent.setPosition(position + Mp4Util.ATOM_HEADER_SIZE); + parent.setPosition(position + Atom.ATOM_HEADER_SIZE); // fscod (sample rate code) int fscod = (parent.readUnsignedByte() & 0xC0) >> 6; @@ -644,7 +646,7 @@ public final class CommonMp4AtomParsers { private static int parseEc3SpecificBoxFromParent(ParsableByteArray parent, int position) { // Start of the dec3 atom (defined in ETSI TS 102 366) - parent.setPosition(position + Mp4Util.ATOM_HEADER_SIZE); + parent.setPosition(position + Atom.ATOM_HEADER_SIZE); // TODO: Implement parsing for enhanced AC-3 with multiple sub-streams. return 0; } diff --git a/library/src/main/java/com/google/android/exoplayer/mp4/Mp4TrackSampleTable.java b/library/src/main/java/com/google/android/exoplayer/mp4/Mp4TrackSampleTable.java index 9e60146140..d3ce98225f 100644 --- a/library/src/main/java/com/google/android/exoplayer/mp4/Mp4TrackSampleTable.java +++ b/library/src/main/java/com/google/android/exoplayer/mp4/Mp4TrackSampleTable.java @@ -22,6 +22,9 @@ import com.google.android.exoplayer.util.Util; /** Sample table for a track in an MP4 file. */ public final class Mp4TrackSampleTable { + /** Sample index when no sample is available. */ + public static final int NO_SAMPLE = -1; + /** Sample offsets in bytes. */ public final long[] offsets; /** Sample sizes in bytes. */ @@ -53,7 +56,7 @@ public final class Mp4TrackSampleTable { * timestamp, if one is available. * * @param timeUs Timestamp adjacent to which to find a synchronization sample. - * @return Index of the synchronization sample, or {@link Mp4Util#NO_SAMPLE} if none. + * @return Index of the synchronization sample, or {@link #NO_SAMPLE} if none. */ public int getIndexOfEarlierOrEqualSynchronizationSample(long timeUs) { int startIndex = Util.binarySearchFloor(timestampsUs, timeUs, true, false); @@ -63,7 +66,7 @@ public final class Mp4TrackSampleTable { } } - return Mp4Util.NO_SAMPLE; + return NO_SAMPLE; } /** @@ -71,7 +74,7 @@ public final class Mp4TrackSampleTable { * if one is available. * * @param timeUs Timestamp adjacent to which to find a synchronization sample. - * @return index Index of the synchronization sample, or {@link Mp4Util#NO_SAMPLE} if none. + * @return index Index of the synchronization sample, or {@link #NO_SAMPLE} if none. */ public int getIndexOfLaterOrEqualSynchronizationSample(long timeUs) { int startIndex = Util.binarySearchCeil(timestampsUs, timeUs, true, false); @@ -81,7 +84,7 @@ public final class Mp4TrackSampleTable { } } - return Mp4Util.NO_SAMPLE; + return NO_SAMPLE; } } diff --git a/library/src/main/java/com/google/android/exoplayer/smoothstreaming/SmoothStreamingChunkSource.java b/library/src/main/java/com/google/android/exoplayer/smoothstreaming/SmoothStreamingChunkSource.java index 6e04658ef9..9e9cb92bae 100644 --- a/library/src/main/java/com/google/android/exoplayer/smoothstreaming/SmoothStreamingChunkSource.java +++ b/library/src/main/java/com/google/android/exoplayer/smoothstreaming/SmoothStreamingChunkSource.java @@ -30,6 +30,7 @@ import com.google.android.exoplayer.chunk.MediaChunk; import com.google.android.exoplayer.chunk.parser.Extractor; import com.google.android.exoplayer.chunk.parser.mp4.FragmentedMp4Extractor; import com.google.android.exoplayer.chunk.parser.mp4.TrackEncryptionBox; +import com.google.android.exoplayer.drm.DrmInitData; import com.google.android.exoplayer.mp4.Track; import com.google.android.exoplayer.smoothstreaming.SmoothStreamingManifest.ProtectionElement; import com.google.android.exoplayer.smoothstreaming.SmoothStreamingManifest.StreamElement; @@ -38,6 +39,7 @@ import com.google.android.exoplayer.upstream.DataSource; import com.google.android.exoplayer.upstream.DataSpec; import com.google.android.exoplayer.util.CodecSpecificDataUtil; import com.google.android.exoplayer.util.ManifestFetcher; +import com.google.android.exoplayer.util.MimeTypes; import android.net.Uri; import android.os.SystemClock; @@ -48,8 +50,6 @@ import java.io.IOException; import java.util.Arrays; import java.util.Collections; import java.util.List; -import java.util.Map; -import java.util.UUID; /** * An {@link ChunkSource} for SmoothStreaming. @@ -71,7 +71,7 @@ public class SmoothStreamingChunkSource implements ChunkSource { private final int maxHeight; private final SparseArray extractors; - private final Map psshInfo; + private final DrmInitData drmInitData; private final SmoothStreamingFormat[] formats; private SmoothStreamingManifest currentManifest; @@ -143,9 +143,11 @@ public class SmoothStreamingChunkSource implements ChunkSource { byte[] keyId = getKeyId(protectionElement.data); trackEncryptionBoxes = new TrackEncryptionBox[1]; trackEncryptionBoxes[0] = new TrackEncryptionBox(true, INITIALIZATION_VECTOR_SIZE, keyId); - psshInfo = Collections.singletonMap(protectionElement.uuid, protectionElement.data); + DrmInitData.Mapped drmInitData = new DrmInitData.Mapped(MimeTypes.VIDEO_MP4); + drmInitData.put(protectionElement.uuid, protectionElement.data); + this.drmInitData = drmInitData; } else { - psshInfo = null; + drmInitData = null; } int trackCount = trackIndices != null ? trackIndices.length : streamElement.tracks.length; @@ -299,7 +301,7 @@ public class SmoothStreamingChunkSource implements ChunkSource { Uri uri = streamElement.buildRequestUri(selectedFormat.trackIndex, chunkIndex); Chunk mediaChunk = newMediaChunk(selectedFormat, uri, null, - extractors.get(Integer.parseInt(selectedFormat.id)), psshInfo, dataSource, + extractors.get(Integer.parseInt(selectedFormat.id)), drmInitData, dataSource, currentAbsoluteChunkIndex, isLastChunk, chunkStartTimeUs, nextChunkStartTimeUs, 0); out.chunk = mediaChunk; } @@ -365,7 +367,7 @@ public class SmoothStreamingChunkSource implements ChunkSource { } private static MediaChunk newMediaChunk(Format formatInfo, Uri uri, String cacheKey, - Extractor extractor, Map psshInfo, DataSource dataSource, int chunkIndex, + Extractor extractor, DrmInitData drmInitData, DataSource dataSource, int chunkIndex, boolean isLast, long chunkStartTimeUs, long nextChunkStartTimeUs, int trigger) { int nextChunkIndex = isLast ? -1 : chunkIndex + 1; long nextStartTimeUs = isLast ? -1 : nextChunkStartTimeUs; @@ -374,7 +376,7 @@ public class SmoothStreamingChunkSource implements ChunkSource { // In SmoothStreaming each chunk contains sample timestamps relative to the start of the chunk. // To convert them the absolute timestamps, we need to set sampleOffsetUs to -chunkStartTimeUs. return new ContainerMediaChunk(dataSource, dataSpec, formatInfo, trigger, chunkStartTimeUs, - nextStartTimeUs, nextChunkIndex, extractor, psshInfo, false, -chunkStartTimeUs); + nextStartTimeUs, nextChunkIndex, extractor, drmInitData, false, -chunkStartTimeUs); } private static byte[] getKeyId(byte[] initData) { diff --git a/library/src/main/java/com/google/android/exoplayer/smoothstreaming/SmoothStreamingManifest.java b/library/src/main/java/com/google/android/exoplayer/smoothstreaming/SmoothStreamingManifest.java index d7e6ee1358..b04e272575 100644 --- a/library/src/main/java/com/google/android/exoplayer/smoothstreaming/SmoothStreamingManifest.java +++ b/library/src/main/java/com/google/android/exoplayer/smoothstreaming/SmoothStreamingManifest.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer.smoothstreaming; import com.google.android.exoplayer.C; import com.google.android.exoplayer.util.Assertions; +import com.google.android.exoplayer.util.UriUtil; import com.google.android.exoplayer.util.Util; import android.net.Uri; @@ -197,14 +198,14 @@ public class SmoothStreamingManifest { public final TrackElement[] tracks; public final int chunkCount; - private final Uri baseUri; + private final String baseUri; private final String chunkTemplate; private final List chunkStartTimes; private final long[] chunkStartTimesUs; private final long lastChunkDurationUs; - public StreamElement(Uri baseUri, String chunkTemplate, int type, String subType, + public StreamElement(String baseUri, String chunkTemplate, int type, String subType, long timescale, String name, int qualityLevels, int maxWidth, int maxHeight, int displayWidth, int displayHeight, String language, TrackElement[] tracks, List chunkStartTimes, long lastChunkDuration) { @@ -274,7 +275,7 @@ public class SmoothStreamingManifest { String chunkUrl = chunkTemplate .replace(URL_PLACEHOLDER_BITRATE, Integer.toString(tracks[track].bitrate)) .replace(URL_PLACEHOLDER_START_TIME, chunkStartTimes.get(chunkIndex).toString()); - return Util.getMergedUri(baseUri, chunkUrl); + return UriUtil.resolveToUri(baseUri, chunkUrl); } } diff --git a/library/src/main/java/com/google/android/exoplayer/smoothstreaming/SmoothStreamingManifestParser.java b/library/src/main/java/com/google/android/exoplayer/smoothstreaming/SmoothStreamingManifestParser.java index 5cfbc829e3..27dd7752da 100644 --- a/library/src/main/java/com/google/android/exoplayer/smoothstreaming/SmoothStreamingManifestParser.java +++ b/library/src/main/java/com/google/android/exoplayer/smoothstreaming/SmoothStreamingManifestParser.java @@ -23,9 +23,7 @@ import com.google.android.exoplayer.upstream.NetworkLoadable; import com.google.android.exoplayer.util.Assertions; import com.google.android.exoplayer.util.CodecSpecificDataUtil; import com.google.android.exoplayer.util.MimeTypes; -import com.google.android.exoplayer.util.Util; -import android.net.Uri; import android.util.Base64; import android.util.Pair; @@ -65,8 +63,8 @@ public class SmoothStreamingManifestParser implements try { XmlPullParser xmlParser = xmlParserFactory.newPullParser(); xmlParser.setInput(inputStream, null); - SmoothStreamMediaParser smoothStreamMediaParser = new SmoothStreamMediaParser(null, - Util.parseBaseUri(connectionUrl)); + SmoothStreamMediaParser smoothStreamMediaParser = + new SmoothStreamMediaParser(null, connectionUrl); return (SmoothStreamingManifest) smoothStreamMediaParser.parse(xmlParser); } catch (XmlPullParserException e) { throw new ParserException(e); @@ -89,13 +87,13 @@ public class SmoothStreamingManifestParser implements */ private static abstract class ElementParser { - private final Uri baseUri; + private final String baseUri; private final String tag; private final ElementParser parent; private final List> normalizedAttributes; - public ElementParser(ElementParser parent, Uri baseUri, String tag) { + public ElementParser(ElementParser parent, String baseUri, String tag) { this.parent = parent; this.baseUri = baseUri; this.tag = tag; @@ -158,7 +156,7 @@ public class SmoothStreamingManifestParser implements } } - private ElementParser newChildParser(ElementParser parent, String name, Uri baseUri) { + private ElementParser newChildParser(ElementParser parent, String name, String baseUri) { if (TrackElementParser.TAG.equals(name)) { return new TrackElementParser(parent, baseUri); } else if (ProtectionElementParser.TAG.equals(name)) { @@ -342,7 +340,7 @@ public class SmoothStreamingManifestParser implements private ProtectionElement protectionElement; private List streamElements; - public SmoothStreamMediaParser(ElementParser parent, Uri baseUri) { + public SmoothStreamMediaParser(ElementParser parent, String baseUri) { super(parent, baseUri, TAG); lookAheadCount = -1; protectionElement = null; @@ -392,7 +390,7 @@ public class SmoothStreamingManifestParser implements private UUID uuid; private byte[] initData; - public ProtectionElementParser(ElementParser parent, Uri baseUri) { + public ProtectionElementParser(ElementParser parent, String baseUri) { super(parent, baseUri, TAG); } @@ -455,7 +453,7 @@ public class SmoothStreamingManifestParser implements private static final String KEY_FRAGMENT_START_TIME = "t"; private static final String KEY_FRAGMENT_REPEAT_COUNT = "r"; - private final Uri baseUri; + private final String baseUri; private final List tracks; private int type; @@ -473,7 +471,7 @@ public class SmoothStreamingManifestParser implements private long lastChunkDuration; - public StreamElementParser(ElementParser parent, Uri baseUri) { + public StreamElementParser(ElementParser parent, String baseUri) { super(parent, baseUri, TAG); this.baseUri = baseUri; tracks = new LinkedList(); @@ -615,7 +613,7 @@ public class SmoothStreamingManifestParser implements private int nalUnitLengthField; private String content; - public TrackElementParser(ElementParser parent, Uri baseUri) { + public TrackElementParser(ElementParser parent, String baseUri) { super(parent, baseUri, TAG); this.csd = new LinkedList(); } diff --git a/library/src/main/java/com/google/android/exoplayer/source/DefaultSampleSource.java b/library/src/main/java/com/google/android/exoplayer/source/DefaultSampleSource.java index ccad721abb..2359a974f5 100644 --- a/library/src/main/java/com/google/android/exoplayer/source/DefaultSampleSource.java +++ b/library/src/main/java/com/google/android/exoplayer/source/DefaultSampleSource.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer.source; import com.google.android.exoplayer.C; +import com.google.android.exoplayer.MediaFormat; import com.google.android.exoplayer.MediaFormatHolder; import com.google.android.exoplayer.SampleHolder; import com.google.android.exoplayer.SampleSource; @@ -62,9 +63,14 @@ public final class DefaultSampleSource implements SampleSource { if (sampleExtractor.prepare()) { prepared = true; - trackInfos = sampleExtractor.getTrackInfos(); - trackStates = new int[trackInfos.length]; - pendingDiscontinuities = new boolean[trackInfos.length]; + int trackCount = sampleExtractor.getTrackCount(); + trackStates = new int[trackCount]; + pendingDiscontinuities = new boolean[trackCount]; + trackInfos = new TrackInfo[trackCount]; + for (int track = 0; track < trackCount; track++) { + MediaFormat mediaFormat = sampleExtractor.getMediaFormat(track); + trackInfos[track] = new TrackInfo(mediaFormat.mimeType, mediaFormat.durationUs); + } } return prepared; @@ -119,7 +125,8 @@ public final class DefaultSampleSource implements SampleSource { return NOTHING_READ; } if (trackStates[track] != TRACK_STATE_FORMAT_SENT) { - sampleExtractor.getTrackMediaFormat(track, formatHolder); + formatHolder.format = sampleExtractor.getMediaFormat(track); + formatHolder.drmInitData = sampleExtractor.getDrmInitData(track); trackStates[track] = TRACK_STATE_FORMAT_SENT; return FORMAT_READ; } diff --git a/library/src/main/java/com/google/android/exoplayer/source/FrameworkSampleExtractor.java b/library/src/main/java/com/google/android/exoplayer/source/FrameworkSampleExtractor.java index e4ab4805c6..c89f37de5f 100644 --- a/library/src/main/java/com/google/android/exoplayer/source/FrameworkSampleExtractor.java +++ b/library/src/main/java/com/google/android/exoplayer/source/FrameworkSampleExtractor.java @@ -15,14 +15,13 @@ */ package com.google.android.exoplayer.source; -import com.google.android.exoplayer.C; import com.google.android.exoplayer.MediaFormat; -import com.google.android.exoplayer.MediaFormatHolder; import com.google.android.exoplayer.SampleHolder; import com.google.android.exoplayer.SampleSource; -import com.google.android.exoplayer.TrackInfo; import com.google.android.exoplayer.TrackRenderer; +import com.google.android.exoplayer.drm.DrmInitData; import com.google.android.exoplayer.util.Assertions; +import com.google.android.exoplayer.util.MimeTypes; import com.google.android.exoplayer.util.Util; import android.annotation.TargetApi; @@ -53,8 +52,6 @@ public final class FrameworkSampleExtractor implements SampleExtractor { private final MediaExtractor mediaExtractor; - private TrackInfo[] trackInfos; - /** * Instantiates a new sample extractor reading from the specified {@code uri}. * @@ -106,24 +103,9 @@ public final class FrameworkSampleExtractor implements SampleExtractor { mediaExtractor.setDataSource(fileDescriptor, fileDescriptorOffset, fileDescriptorLength); } - int trackCount = mediaExtractor.getTrackCount(); - trackInfos = new TrackInfo[trackCount]; - for (int i = 0; i < trackCount; i++) { - android.media.MediaFormat format = mediaExtractor.getTrackFormat(i); - long durationUs = format.containsKey(android.media.MediaFormat.KEY_DURATION) - ? format.getLong(android.media.MediaFormat.KEY_DURATION) : C.UNKNOWN_TIME_US; - String mime = format.getString(android.media.MediaFormat.KEY_MIME); - trackInfos[i] = new TrackInfo(mime, durationUs); - } - return true; } - @Override - public TrackInfo[] getTrackInfos() { - return trackInfos; - } - @Override public void selectTrack(int index) { mediaExtractor.selectTrack(index); @@ -151,10 +133,18 @@ public final class FrameworkSampleExtractor implements SampleExtractor { } @Override - public void getTrackMediaFormat(int track, MediaFormatHolder mediaFormatHolder) { - mediaFormatHolder.format = - MediaFormat.createFromFrameworkMediaFormatV16(mediaExtractor.getTrackFormat(track)); - mediaFormatHolder.drmInitData = Util.SDK_INT >= 18 ? getPsshInfoV18() : null; + public int getTrackCount() { + return mediaExtractor.getTrackCount(); + } + + @Override + public MediaFormat getMediaFormat(int track) { + return MediaFormat.createFromFrameworkMediaFormatV16(mediaExtractor.getTrackFormat(track)); + } + + @Override + public DrmInitData getDrmInitData(int track) { + return Util.SDK_INT >= 18 ? getDrmInitDataV18() : null; } @Override @@ -173,7 +163,7 @@ public final class FrameworkSampleExtractor implements SampleExtractor { } sampleHolder.timeUs = mediaExtractor.getSampleTime(); sampleHolder.flags = mediaExtractor.getSampleFlags(); - if ((sampleHolder.flags & MediaExtractor.SAMPLE_FLAG_ENCRYPTED) != 0) { + if (sampleHolder.isEncrypted()) { sampleHolder.cryptoInfo.setFromExtractorV16(mediaExtractor); } @@ -188,9 +178,15 @@ public final class FrameworkSampleExtractor implements SampleExtractor { } @TargetApi(18) - private Map getPsshInfoV18() { + private DrmInitData getDrmInitDataV18() { + // MediaExtractor only supports psshInfo for MP4, so it's ok to hard code the mimeType here. Map psshInfo = mediaExtractor.getPsshInfo(); - return (psshInfo == null || psshInfo.isEmpty()) ? null : psshInfo; + if (psshInfo == null || psshInfo.isEmpty()) { + return null; + } + DrmInitData.Mapped drmInitData = new DrmInitData.Mapped(MimeTypes.VIDEO_MP4); + drmInitData.putAll(psshInfo); + return drmInitData; } } diff --git a/library/src/main/java/com/google/android/exoplayer/source/Mp4SampleExtractor.java b/library/src/main/java/com/google/android/exoplayer/source/Mp4SampleExtractor.java new file mode 100644 index 0000000000..f7154c2acf --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/source/Mp4SampleExtractor.java @@ -0,0 +1,743 @@ +/* + * Copyright (C) 2014 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.exoplayer.source; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.SampleSource; +import com.google.android.exoplayer.TrackRenderer; +import com.google.android.exoplayer.drm.DrmInitData; +import com.google.android.exoplayer.mp4.Atom; +import com.google.android.exoplayer.mp4.Atom.ContainerAtom; +import com.google.android.exoplayer.mp4.CommonMp4AtomParsers; +import com.google.android.exoplayer.mp4.Mp4TrackSampleTable; +import com.google.android.exoplayer.mp4.Track; +import com.google.android.exoplayer.upstream.BufferPool; +import com.google.android.exoplayer.upstream.BufferedNonBlockingInputStream; +import com.google.android.exoplayer.upstream.DataSource; +import com.google.android.exoplayer.upstream.DataSourceStream; +import com.google.android.exoplayer.upstream.DataSpec; +import com.google.android.exoplayer.upstream.Loader; +import com.google.android.exoplayer.upstream.Loader.Loadable; +import com.google.android.exoplayer.util.Assertions; +import com.google.android.exoplayer.util.H264Util; +import com.google.android.exoplayer.util.MimeTypes; +import com.google.android.exoplayer.util.ParsableByteArray; +import com.google.android.exoplayer.util.Util; + +import android.util.Log; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.Stack; + +/** + * Extracts data from a {@link DataSpec} in unfragmented MP4 format (ISO 14496-12). + */ +public final class Mp4SampleExtractor implements SampleExtractor, Loader.Callback { + + private static final String TAG = "Mp4SampleExtractor"; + private static final String LOADER_THREAD_NAME = "Mp4SampleExtractor"; + + private static final int NO_TRACK = -1; + + // Reading results + private static final int RESULT_NEED_MORE_DATA = 1; + private static final int RESULT_END_OF_STREAM = 2; + + // Parser states + private static final int STATE_READING_ATOM_HEADER = 0; + private static final int STATE_READING_ATOM_PAYLOAD = 1; + + /** Set of atom types that contain data to be parsed. */ + private static final Set LEAF_ATOM_TYPES = getAtomTypeSet( + Atom.TYPE_mdhd, Atom.TYPE_mvhd, Atom.TYPE_hdlr, Atom.TYPE_vmhd, Atom.TYPE_smhd, + Atom.TYPE_stsd, Atom.TYPE_avc1, Atom.TYPE_avcC, Atom.TYPE_mp4a, Atom.TYPE_esds, + Atom.TYPE_stts, Atom.TYPE_stss, Atom.TYPE_ctts, Atom.TYPE_stsc, Atom.TYPE_stsz, + Atom.TYPE_stco, Atom.TYPE_co64, Atom.TYPE_tkhd); + + /** Set of atom types that contain other atoms that need to be parsed. */ + private static final Set CONTAINER_TYPES = getAtomTypeSet( + Atom.TYPE_moov, Atom.TYPE_trak, Atom.TYPE_mdia, Atom.TYPE_minf, Atom.TYPE_stbl); + + /** Default number of times to retry loading data prior to failing. */ + private static final int DEFAULT_LOADABLE_RETRY_COUNT = 3; + + private final DataSource dataSource; + private final DataSpec dataSpec; + + private final int readAheadAllocationSize; + private final int reloadMinimumSeekDistance; + private final int maximumTrackSampleInterval; + private final int loadRetryCount; + + private final BufferPool bufferPool; + private final Loader loader; + private final ParsableByteArray atomHeader; + private final Stack containerAtoms; + + private DataSourceStream dataSourceStream; + private BufferedNonBlockingInputStream inputStream; + private long inputStreamOffset; + private long rootAtomBytesRead; + private boolean loadCompleted; + + private int parserState; + private int atomBytesRead; + private int atomType; + private long atomSize; + private ParsableByteArray atomData; + + private boolean prepared; + + private int loadErrorCount; + + private Mp4Track[] tracks; + + /** An exception from {@link #inputStream}'s callbacks, or {@code null} if there was no error. */ + private IOException lastLoadError; + private long loadErrorPosition; + + /** If handling a call to {@link #seekTo}, the new required stream offset, or -1 otherwise. */ + private long pendingSeekPosition; + /** If the input stream is being reopened at a new position, the new offset, or -1 otherwise. */ + private long pendingLoadPosition; + + /** + * Creates a new sample extractor for reading {@code dataSource} and {@code dataSpec} as an + * unfragmented MP4 file with default settings. + * + *

The default settings read ahead by 5 MiB, handle maximum offsets between samples at the same + * timestamp in different tracks of 3 MiB and restart loading when seeking forward by >= 256 KiB. + * + * @param dataSource Data source used to read from {@code dataSpec}. + * @param dataSpec Data specification specifying what to read. + */ + public Mp4SampleExtractor(DataSource dataSource, DataSpec dataSpec) { + this(dataSource, dataSpec, 5 * 1024 * 1024, 3 * 1024 * 1024, 256 * 1024, + DEFAULT_LOADABLE_RETRY_COUNT); + } + + /** + * Creates a new sample extractor for reading {@code dataSource} and {@code dataSpec} as an + * unfragmented MP4 file. + * + * @param dataSource Data source used to read from {@code dataSpec}. + * @param dataSpec Data specification specifying what to read. + * @param readAheadAllocationSize Size of the allocation that buffers the stream, in bytes. The + * value must exceed the maximum sample size, so that a sample can be read in its entirety. + * @param maximumTrackSampleInterval Size of the buffer that handles reading from any selected + * track. The value should be chosen so that the buffer is as big as the interval in bytes + * between the start of the earliest and the end of the latest sample required to render media + * from all selected tracks, at any timestamp in the data source. + * @param reloadMinimumSeekDistance Determines when {@code dataSource} is reopened while seeking: + * if the number of bytes between the current position and the new position is greater than or + * equal to this value, or the new position is before the current position, loading will + * restart. The value should be set to the number of bytes that can be loaded/consumed from an + * existing connection in the time it takes to start a new connection. + * @param loadableRetryCount The number of times to retry loading if an error occurs. + */ + public Mp4SampleExtractor(DataSource dataSource, DataSpec dataSpec, int readAheadAllocationSize, + int maximumTrackSampleInterval, int reloadMinimumSeekDistance, int loadableRetryCount) { + // TODO: Handle minimumTrackSampleInterval specified in time not bytes. + this.dataSource = Assertions.checkNotNull(dataSource); + this.dataSpec = Assertions.checkNotNull(dataSpec); + this.readAheadAllocationSize = readAheadAllocationSize; + this.maximumTrackSampleInterval = maximumTrackSampleInterval; + this.reloadMinimumSeekDistance = reloadMinimumSeekDistance; + this.loadRetryCount = loadableRetryCount; + + // TODO: Implement Allocator here so it is possible to check there is only one buffer at a time. + bufferPool = new BufferPool(readAheadAllocationSize); + loader = new Loader(LOADER_THREAD_NAME); + atomHeader = new ParsableByteArray(Atom.LONG_ATOM_HEADER_SIZE); + containerAtoms = new Stack(); + + parserState = STATE_READING_ATOM_HEADER; + pendingLoadPosition = -1; + pendingSeekPosition = -1; + loadErrorPosition = -1; + } + + @Override + public boolean prepare() throws IOException { + if (inputStream == null) { + loadFromOffset(0L); + } + + if (!prepared) { + if (readHeaders() && !prepared) { + throw new IOException("moov atom not found."); + } + + if (!prepared) { + maybeThrowLoadError(); + } + } + + return prepared; + } + + @Override + public void selectTrack(int trackIndex) { + Assertions.checkState(prepared); + + if (tracks[trackIndex].selected) { + return; + } + tracks[trackIndex].selected = true; + + // Get the timestamp of the earliest currently-selected sample. + int earliestSampleTrackIndex = getTrackIndexOfEarliestCurrentSample(); + if (earliestSampleTrackIndex == NO_TRACK) { + tracks[trackIndex].sampleIndex = 0; + return; + } + if (earliestSampleTrackIndex == Mp4TrackSampleTable.NO_SAMPLE) { + tracks[trackIndex].sampleIndex = Mp4TrackSampleTable.NO_SAMPLE; + return; + } + long timestampUs = + tracks[earliestSampleTrackIndex].sampleTable.timestampsUs[earliestSampleTrackIndex]; + + // Find the latest sync sample in the new track that has an earlier or equal timestamp. + tracks[trackIndex].sampleIndex = + tracks[trackIndex].sampleTable.getIndexOfEarlierOrEqualSynchronizationSample(timestampUs); + } + + @Override + public void deselectTrack(int trackIndex) { + Assertions.checkState(prepared); + + tracks[trackIndex].selected = false; + } + + @Override + public long getBufferedPositionUs() { + Assertions.checkState(prepared); + + if (pendingLoadPosition != -1) { + return TrackRenderer.UNKNOWN_TIME_US; + } + + if (loadCompleted) { + return TrackRenderer.END_OF_TRACK_US; + } + + // Get the absolute position to which there is data buffered. + long bufferedPosition = + inputStreamOffset + inputStream.getReadPosition() + inputStream.getAvailableByteCount(); + + // Find the timestamp of the latest sample that does not exceed the buffered position. + long latestTimestampBeforeEnd = Long.MIN_VALUE; + for (int trackIndex = 0; trackIndex < tracks.length; trackIndex++) { + if (!tracks[trackIndex].selected) { + continue; + } + + Mp4TrackSampleTable sampleTable = tracks[trackIndex].sampleTable; + int sampleIndex = Util.binarySearchFloor(sampleTable.offsets, bufferedPosition, false, true); + if (sampleIndex > 0 + && sampleTable.offsets[sampleIndex] + sampleTable.sizes[sampleIndex] > bufferedPosition) { + sampleIndex--; + } + + // Update the latest timestamp if this is greater. + long timestamp = sampleTable.timestampsUs[sampleIndex]; + if (timestamp > latestTimestampBeforeEnd) { + latestTimestampBeforeEnd = timestamp; + } + } + + return latestTimestampBeforeEnd < 0L ? C.UNKNOWN_TIME_US : latestTimestampBeforeEnd; + } + + @Override + public void seekTo(long positionUs) { + Assertions.checkState(prepared); + + long earliestSamplePosition = Long.MAX_VALUE; + for (int trackIndex = 0; trackIndex < tracks.length; trackIndex++) { + if (!tracks[trackIndex].selected) { + continue; + } + + Mp4TrackSampleTable sampleTable = tracks[trackIndex].sampleTable; + int sampleIndex = sampleTable.getIndexOfEarlierOrEqualSynchronizationSample(positionUs); + if (sampleIndex == Mp4TrackSampleTable.NO_SAMPLE) { + sampleIndex = sampleTable.getIndexOfLaterOrEqualSynchronizationSample(positionUs); + } + tracks[trackIndex].sampleIndex = sampleIndex; + + long offset = sampleTable.offsets[tracks[trackIndex].sampleIndex]; + if (offset < earliestSamplePosition) { + earliestSamplePosition = offset; + } + } + + pendingSeekPosition = earliestSamplePosition; + if (pendingLoadPosition != -1) { + loadFromOffset(earliestSamplePosition); + return; + } + + inputStream.returnToMark(); + long earliestOffset = inputStreamOffset + inputStream.getReadPosition(); + long latestOffset = earliestOffset + inputStream.getAvailableByteCount(); + if (earliestSamplePosition < earliestOffset + || earliestSamplePosition >= latestOffset + reloadMinimumSeekDistance) { + loadFromOffset(earliestSamplePosition); + } + } + + @Override + public int getTrackCount() { + Assertions.checkState(prepared); + return tracks.length; + } + + @Override + public MediaFormat getMediaFormat(int track) { + Assertions.checkState(prepared); + return tracks[track].track.mediaFormat; + } + + @Override + public DrmInitData getDrmInitData(int track) { + return null; + } + + @Override + public int readSample(int trackIndex, SampleHolder sampleHolder) throws IOException { + Assertions.checkState(prepared); + + Mp4Track track = tracks[trackIndex]; + Assertions.checkState(track.selected); + int sampleIndex = track.sampleIndex; + + // Check for the end of the stream. + if (sampleIndex == Mp4TrackSampleTable.NO_SAMPLE) { + // TODO: Should END_OF_STREAM be returned as soon as this track has no more samples, or as + // soon as no tracks have a sample (as implemented here)? + return hasSampleInAnySelectedTrack() ? SampleSource.NOTHING_READ : SampleSource.END_OF_STREAM; + } + + // Return if the input stream will be reopened at the requested position. + if (pendingLoadPosition != -1) { + return SampleSource.NOTHING_READ; + } + + // If there was a seek request, try to skip forwards to the requested position. + if (pendingSeekPosition != -1) { + int bytesToSeekPosition = + (int) (pendingSeekPosition - (inputStreamOffset + inputStream.getReadPosition())); + int skippedByteCount = inputStream.skip(bytesToSeekPosition); + if (skippedByteCount == -1) { + throw new IOException("Unexpected end-of-stream while seeking to sample."); + } + bytesToSeekPosition -= skippedByteCount; + inputStream.mark(); + if (bytesToSeekPosition == 0) { + pendingSeekPosition = -1; + } else { + maybeThrowLoadError(); + return SampleSource.NOTHING_READ; + } + } + + // Return if the sample offset hasn't been loaded yet. + inputStream.returnToMark(); + long sampleOffset = track.sampleTable.offsets[sampleIndex]; + long seekOffsetLong = (sampleOffset - inputStreamOffset) - inputStream.getReadPosition(); + Assertions.checkState(seekOffsetLong <= Integer.MAX_VALUE); + int seekOffset = (int) seekOffsetLong; + if (inputStream.skip(seekOffset) != seekOffset) { + maybeThrowLoadError(); + return SampleSource.NOTHING_READ; + } + + // Return if the sample has been loaded. + int sampleSize = track.sampleTable.sizes[sampleIndex]; + if (inputStream.getAvailableByteCount() < sampleSize) { + maybeThrowLoadError(); + return SampleSource.NOTHING_READ; + } + + if (sampleHolder.data == null || sampleHolder.data.capacity() < sampleSize) { + sampleHolder.replaceBuffer(sampleSize); + } + + ByteBuffer data = sampleHolder.data; + if (data == null) { + inputStream.skip(sampleSize); + sampleHolder.size = 0; + } else { + int bytesRead = inputStream.read(data, sampleSize); + Assertions.checkState(bytesRead == sampleSize); + + if (MimeTypes.VIDEO_H264.equals(tracks[trackIndex].track.mediaFormat.mimeType)) { + // The mp4 file contains length-prefixed access units, but the decoder wants start code + // delimited content. + H264Util.replaceLengthPrefixesWithAvcStartCodes(sampleHolder.data, sampleSize); + } + sampleHolder.size = sampleSize; + } + + // Move the input stream mark forwards if the earliest current sample was just read. + if (getTrackIndexOfEarliestCurrentSample() == trackIndex) { + inputStream.mark(); + } + + // TODO: Read encryption data. + sampleHolder.timeUs = track.sampleTable.timestampsUs[sampleIndex]; + sampleHolder.flags = track.sampleTable.flags[sampleIndex]; + + // Advance to the next sample, checking if this was the last sample. + track.sampleIndex = + sampleIndex + 1 == track.sampleTable.getSampleCount() ? Mp4TrackSampleTable.NO_SAMPLE : sampleIndex + 1; + + // Reset the loading error counter if we read past the offset at which the error was thrown. + if (dataSourceStream.getReadPosition() > loadErrorPosition) { + loadErrorCount = 0; + loadErrorPosition = -1; + } + + return SampleSource.SAMPLE_READ; + } + + @Override + public void release() { + pendingLoadPosition = -1; + loader.release(); + + if (inputStream != null) { + inputStream.close(); + } + } + + @Override + public void onLoadError(Loadable loadable, IOException exception) { + lastLoadError = exception; + + loadErrorCount++; + if (loadErrorPosition == -1) { + loadErrorPosition = dataSourceStream.getLoadPosition(); + } + int delayMs = getRetryDelayMs(loadErrorCount); + Log.w(TAG, "Retry loading (delay " + delayMs + " ms)."); + loader.startLoading(dataSourceStream, this, delayMs); + } + + @Override + public void onLoadCompleted(Loadable loadable) { + loadCompleted = true; + } + + @Override + public void onLoadCanceled(Loadable loadable) { + if (pendingLoadPosition != -1) { + loadFromOffset(pendingLoadPosition); + pendingLoadPosition = -1; + } + } + + private void loadFromOffset(long offsetBytes) { + inputStreamOffset = offsetBytes; + rootAtomBytesRead = offsetBytes; + + if (loader.isLoading()) { + // Wait for loading to be canceled before proceeding. + pendingLoadPosition = offsetBytes; + loader.cancelLoading(); + return; + } + + if (inputStream != null) { + inputStream.close(); + } + + DataSpec dataSpec = new DataSpec( + this.dataSpec.uri, offsetBytes, C.LENGTH_UNBOUNDED, this.dataSpec.key); + dataSourceStream = + new DataSourceStream(dataSource, dataSpec, bufferPool, readAheadAllocationSize); + loader.startLoading(dataSourceStream, this); + + // Wrap the input stream with a buffering stream so that it is possible to read from any track. + inputStream = + new BufferedNonBlockingInputStream(dataSourceStream, maximumTrackSampleInterval); + loadCompleted = false; + + loadErrorCount = 0; + loadErrorPosition = -1; + } + + /** + * Returns the index of the track that contains the earliest current sample, or {@link #NO_TRACK} + * if no track is selected, or {@link Mp4TrackSampleTable#NO_SAMPLE} if no samples remain in + * selected tracks. + */ + private int getTrackIndexOfEarliestCurrentSample() { + int earliestSampleTrackIndex = NO_TRACK; + long earliestSampleOffset = Long.MAX_VALUE; + for (int trackIndex = 0; trackIndex < tracks.length; trackIndex++) { + Mp4Track track = tracks[trackIndex]; + if (!track.selected) { + continue; + } + + int sampleIndex = track.sampleIndex; + if (sampleIndex == Mp4TrackSampleTable.NO_SAMPLE) { + if (earliestSampleTrackIndex == NO_TRACK) { + // A track is selected, but it has no more samples. + earliestSampleTrackIndex = Mp4TrackSampleTable.NO_SAMPLE; + } + continue; + } + + long trackSampleOffset = track.sampleTable.offsets[sampleIndex]; + if (trackSampleOffset < earliestSampleOffset) { + earliestSampleOffset = trackSampleOffset; + earliestSampleTrackIndex = trackIndex; + } + } + + return earliestSampleTrackIndex; + } + + private boolean hasSampleInAnySelectedTrack() { + boolean hasSample = false; + for (int trackIndex = 0; trackIndex < tracks.length; trackIndex++) { + if (tracks[trackIndex].selected && tracks[trackIndex].sampleIndex + != Mp4TrackSampleTable.NO_SAMPLE) { + hasSample = true; + break; + } + } + return hasSample; + } + + /** Reads headers, returning whether the end of the stream was reached. */ + private boolean readHeaders() { + int results = 0; + while (!prepared && (results & (RESULT_NEED_MORE_DATA | RESULT_END_OF_STREAM)) == 0) { + switch (parserState) { + case STATE_READING_ATOM_HEADER: + results |= readAtomHeader(); + break; + case STATE_READING_ATOM_PAYLOAD: + results |= readAtomPayload(); + break; + } + } + + return (results & RESULT_END_OF_STREAM) != 0; + } + + private int readAtomHeader() { + if (pendingLoadPosition != -1) { + return RESULT_NEED_MORE_DATA; + } + + // The size value is either 4 or 8 bytes long (in which case atomSize = Mp4Util.LONG_ATOM_SIZE). + int remainingBytes; + if (atomSize != Atom.LONG_SIZE_PREFIX) { + remainingBytes = Atom.ATOM_HEADER_SIZE - atomBytesRead; + } else { + remainingBytes = Atom.LONG_ATOM_HEADER_SIZE - atomBytesRead; + } + + int bytesRead = inputStream.read(atomHeader.data, atomBytesRead, remainingBytes); + if (bytesRead == -1) { + return RESULT_END_OF_STREAM; + } + rootAtomBytesRead += bytesRead; + atomBytesRead += bytesRead; + if (atomBytesRead < Atom.ATOM_HEADER_SIZE + || (atomSize == Atom.LONG_SIZE_PREFIX && atomBytesRead < Atom.LONG_ATOM_HEADER_SIZE)) { + return RESULT_NEED_MORE_DATA; + } + + atomHeader.setPosition(0); + atomSize = atomHeader.readUnsignedInt(); + atomType = atomHeader.readInt(); + if (atomSize == Atom.LONG_SIZE_PREFIX) { + // The extended atom size is contained in the next 8 bytes, so try to read it now. + if (atomBytesRead < Atom.LONG_ATOM_HEADER_SIZE) { + return readAtomHeader(); + } + + atomSize = atomHeader.readLong(); + } + + Integer atomTypeInteger = atomType; // Avoids boxing atomType twice. + if (CONTAINER_TYPES.contains(atomTypeInteger)) { + if (atomSize == Atom.LONG_SIZE_PREFIX) { + containerAtoms.add(new ContainerAtom( + atomType, rootAtomBytesRead + atomSize - Atom.LONG_ATOM_HEADER_SIZE)); + } else { + containerAtoms.add(new ContainerAtom( + atomType, rootAtomBytesRead + atomSize - Atom.ATOM_HEADER_SIZE)); + } + enterState(STATE_READING_ATOM_HEADER); + } else if (LEAF_ATOM_TYPES.contains(atomTypeInteger)) { + Assertions.checkState(atomSize <= Integer.MAX_VALUE); + atomData = new ParsableByteArray((int) atomSize); + System.arraycopy(atomHeader.data, 0, atomData.data, 0, Atom.ATOM_HEADER_SIZE); + enterState(STATE_READING_ATOM_PAYLOAD); + } else { + atomData = null; + enterState(STATE_READING_ATOM_PAYLOAD); + } + + return 0; + } + + private int readAtomPayload() { + int bytesRead; + if (atomData != null) { + bytesRead = inputStream.read(atomData.data, atomBytesRead, (int) atomSize - atomBytesRead); + } else { + if (atomSize >= reloadMinimumSeekDistance || atomSize > Integer.MAX_VALUE) { + loadFromOffset(rootAtomBytesRead + atomSize - atomBytesRead); + onContainerAtomRead(); + enterState(STATE_READING_ATOM_HEADER); + return 0; + } else { + bytesRead = inputStream.skip((int) atomSize - atomBytesRead); + } + } + if (bytesRead == -1) { + return RESULT_END_OF_STREAM; + } + rootAtomBytesRead += bytesRead; + atomBytesRead += bytesRead; + if (atomBytesRead != atomSize) { + return RESULT_NEED_MORE_DATA; + } + + if (atomData != null && !containerAtoms.isEmpty()) { + containerAtoms.peek().add(new Atom.LeafAtom(atomType, atomData)); + } + + onContainerAtomRead(); + + enterState(STATE_READING_ATOM_HEADER); + return 0; + } + + private void onContainerAtomRead() { + while (!containerAtoms.isEmpty() && containerAtoms.peek().endByteOffset == rootAtomBytesRead) { + Atom.ContainerAtom containerAtom = containerAtoms.pop(); + if (containerAtom.type == Atom.TYPE_moov) { + processMoovAtom(containerAtom); + } else if (!containerAtoms.isEmpty()) { + containerAtoms.peek().add(containerAtom); + } + } + } + + private void enterState(int state) { + switch (state) { + case STATE_READING_ATOM_HEADER: + atomBytesRead = 0; + atomSize = 0; + break; + } + parserState = state; + inputStream.mark(); + } + + /** Updates the stored track metadata to reflect the contents on the specified moov atom. */ + private void processMoovAtom(Atom.ContainerAtom moov) { + List tracks = new ArrayList(); + long earliestSampleOffset = Long.MAX_VALUE; + for (int i = 0; i < moov.containerChildren.size(); i++) { + Atom.ContainerAtom atom = moov.containerChildren.get(i); + if (atom.type != Atom.TYPE_trak) { + continue; + } + + Track track = CommonMp4AtomParsers.parseTrak(atom, moov.getLeafAtomOfType(Atom.TYPE_mvhd)); + if (track.type != Track.TYPE_AUDIO && track.type != Track.TYPE_VIDEO) { + continue; + } + + Atom.ContainerAtom stblAtom = atom.getContainerAtomOfType(Atom.TYPE_mdia) + .getContainerAtomOfType(Atom.TYPE_minf).getContainerAtomOfType(Atom.TYPE_stbl); + Mp4TrackSampleTable trackSampleTable = CommonMp4AtomParsers.parseStbl(track, stblAtom); + + if (trackSampleTable.getSampleCount() == 0) { + continue; + } + + tracks.add(new Mp4Track(track, trackSampleTable)); + + // Keep track of the byte offset of the earliest sample. + long firstSampleOffset = trackSampleTable.offsets[0]; + if (firstSampleOffset < earliestSampleOffset) { + earliestSampleOffset = firstSampleOffset; + } + } + this.tracks = tracks.toArray(new Mp4Track[0]); + + if (earliestSampleOffset < inputStream.getReadPosition()) { + loadFromOffset(earliestSampleOffset); + } + + prepared = true; + } + + /** Returns an unmodifiable set of atom types. */ + private static Set getAtomTypeSet(int... atomTypes) { + Set atomTypeSet = new HashSet(); + for (int atomType : atomTypes) { + atomTypeSet.add(atomType); + } + return Collections.unmodifiableSet(atomTypeSet); + } + + private int getRetryDelayMs(int errorCount) { + return Math.min((errorCount - 1) * 1000, 5000); + } + + private void maybeThrowLoadError() throws IOException { + if (loadErrorCount > loadRetryCount) { + throw lastLoadError; + } + } + + private static final class Mp4Track { + + public final Track track; + public final Mp4TrackSampleTable sampleTable; + + public boolean selected; + public int sampleIndex; + + public Mp4Track(Track track, Mp4TrackSampleTable sampleTable) { + this.track = track; + this.sampleTable = sampleTable; + } + + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/source/SampleExtractor.java b/library/src/main/java/com/google/android/exoplayer/source/SampleExtractor.java index 383aa80ce2..88c516f04a 100644 --- a/library/src/main/java/com/google/android/exoplayer/source/SampleExtractor.java +++ b/library/src/main/java/com/google/android/exoplayer/source/SampleExtractor.java @@ -16,11 +16,10 @@ package com.google.android.exoplayer.source; import com.google.android.exoplayer.MediaFormat; -import com.google.android.exoplayer.MediaFormatHolder; import com.google.android.exoplayer.SampleHolder; import com.google.android.exoplayer.SampleSource; -import com.google.android.exoplayer.TrackInfo; import com.google.android.exoplayer.TrackRenderer; +import com.google.android.exoplayer.drm.DrmInitData; import java.io.IOException; @@ -28,7 +27,7 @@ import java.io.IOException; * Extractor for reading track metadata and samples stored in tracks. * *

Call {@link #prepare} until it returns {@code true}, then access track metadata via - * {@link #getTrackInfos} and {@link #getTrackMediaFormat}. + * {@link #getMediaFormat}. * *

Pass indices of tracks to read from to {@link #selectTrack}. A track can later be deselected * by calling {@link #deselectTrack}. It is safe to select/deselect tracks after reading sample @@ -46,9 +45,6 @@ public interface SampleExtractor { */ boolean prepare() throws IOException; - /** Returns track information about all tracks that can be selected. */ - TrackInfo[] getTrackInfos(); - /** Selects the track at {@code index} for reading sample data. */ void selectTrack(int index); @@ -75,8 +71,14 @@ public interface SampleExtractor { */ void seekTo(long positionUs); - /** Stores the {@link MediaFormat} of {@code track}. */ - void getTrackMediaFormat(int track, MediaFormatHolder mediaFormatHolder); + /** Returns the number of tracks, if {@link #prepare} has returned {@code true}. */ + int getTrackCount(); + + /** Returns the {@link MediaFormat} of {@code track}. */ + MediaFormat getMediaFormat(int track); + + /** Returns the DRM initialization data for {@code track}. */ + DrmInitData getDrmInitData(int track); /** * Reads the next sample in the track at index {@code track} into {@code sampleHolder}, returning diff --git a/library/src/main/java/com/google/android/exoplayer/text/TextTrackRenderer.java b/library/src/main/java/com/google/android/exoplayer/text/TextTrackRenderer.java index e2452102d1..52af1c9c45 100644 --- a/library/src/main/java/com/google/android/exoplayer/text/TextTrackRenderer.java +++ b/library/src/main/java/com/google/android/exoplayer/text/TextTrackRenderer.java @@ -179,7 +179,7 @@ public class TextTrackRenderer extends TrackRenderer implements Callback { SampleHolder sampleHolder = parserHelper.getSampleHolder(); sampleHolder.clearData(); int result = source.readData(trackIndex, positionUs, formatHolder, sampleHolder, false); - if (result == SampleSource.SAMPLE_READ && !sampleHolder.decodeOnly) { + if (result == SampleSource.SAMPLE_READ && !sampleHolder.isDecodeOnly()) { parserHelper.startParseOperation(); textRendererNeedsUpdate = false; } else if (result == SampleSource.END_OF_STREAM) { diff --git a/library/src/main/java/com/google/android/exoplayer/text/eia608/ClosedCaptionCtrl.java b/library/src/main/java/com/google/android/exoplayer/text/eia608/ClosedCaptionCtrl.java index c784f50cd9..0fd1b1fcb6 100644 --- a/library/src/main/java/com/google/android/exoplayer/text/eia608/ClosedCaptionCtrl.java +++ b/library/src/main/java/com/google/android/exoplayer/text/eia608/ClosedCaptionCtrl.java @@ -57,6 +57,8 @@ package com.google.android.exoplayer.text.eia608; public static final byte CARRIAGE_RETURN = 0x2D; public static final byte ERASE_NON_DISPLAYED_MEMORY = 0x2E; + public static final byte BACKSPACE = 0x21; + public static final byte MID_ROW_CHAN_1 = 0x11; public static final byte MID_ROW_CHAN_2 = 0x19; diff --git a/library/src/main/java/com/google/android/exoplayer/text/eia608/Eia608Parser.java b/library/src/main/java/com/google/android/exoplayer/text/eia608/Eia608Parser.java index b5b8c39eff..4beede6eed 100644 --- a/library/src/main/java/com/google/android/exoplayer/text/eia608/Eia608Parser.java +++ b/library/src/main/java/com/google/android/exoplayer/text/eia608/Eia608Parser.java @@ -82,6 +82,26 @@ public class Eia608Parser { 0xFB // 3F: 251 'û' "Latin small letter U with circumflex" }; + // Extended Spanish/Miscellaneous and French char set. + private static final int[] SPECIAL_ES_FR_CHARACTER_SET = new int[] { + // Spanish and misc. + 0xC1, 0xC9, 0xD3, 0xDA, 0xDC, 0xFC, 0x2018, 0xA1, + 0x2A, 0x27, 0x2014, 0xA9, 0x2120, 0x2022, 0x201C, 0x201D, + // French. + 0xC0, 0xC2, 0xC7, 0xC8, 0xCA, 0xCB, 0xEB, 0xCE, + 0xCF, 0xEF, 0xD4, 0xD9, 0xF9, 0xDB, 0xAB, 0xBB + }; + + //Extended Portuguese and German/Danish char set. + private static final int[] SPECIAL_PT_DE_CHARACTER_SET = new int[] { + // Portuguese. + 0xC3, 0xE3, 0xCD, 0xCC, 0xEC, 0xD2, 0xF2, 0xD5, + 0xF5, 0x7B, 0x7D, 0x5C, 0x5E, 0x5F, 0x7C, 0x7E, + // German/Danish. + 0xC4, 0xE4, 0xD6, 0xF6, 0xDF, 0xA5, 0xA4, 0x2502, + 0xC5, 0xE5, 0xD8, 0xF8, 0x250C, 0x2510, 0x2514, 0x2518 + }; + private final ParsableBitArray seiBuffer; private final StringBuilder stringBuilder; private final ArrayList captions; @@ -134,31 +154,45 @@ public class Eia608Parser { } // Special North American character set. - if ((ccData1 == 0x11) && ((ccData2 & 0x70) == 0x30)) { + // ccData2 - P|0|1|1|X|X|X|X + if ((ccData1 == 0x11 || ccData1 == 0x19) + && ((ccData2 & 0x70) == 0x30)) { stringBuilder.append(getSpecialChar(ccData2)); continue; } + // Extended Spanish/Miscellaneous and French character set. + // ccData2 - P|0|1|X|X|X|X|X + if ((ccData1 == 0x12 || ccData1 == 0x1A) + && ((ccData2 & 0x60) == 0x20)) { + backspace(); // Remove standard equivalent of the special extended char. + stringBuilder.append(getExtendedEsFrChar(ccData2)); + continue; + } + + // Extended Portuguese and German/Danish character set. + // ccData2 - P|0|1|X|X|X|X|X + if ((ccData1 == 0x13 || ccData1 == 0x1B) + && ((ccData2 & 0x60) == 0x20)) { + backspace(); // Remove standard equivalent of the special extended char. + stringBuilder.append(getExtendedPtDeChar(ccData2)); + continue; + } + // Control character. if (ccData1 < 0x20) { - if (stringBuilder.length() > 0) { - captions.add(new ClosedCaptionText(stringBuilder.toString())); - stringBuilder.setLength(0); - } - captions.add(new ClosedCaptionCtrl(ccData1, ccData2)); + addCtrl(ccData1, ccData2); continue; } // Basic North American character set. stringBuilder.append(getChar(ccData1)); - if (ccData2 != 0) { + if (ccData2 >= 0x20) { stringBuilder.append(getChar(ccData2)); } } - if (stringBuilder.length() > 0) { - captions.add(new ClosedCaptionText(stringBuilder.toString())); - } + addBufferedText(); if (captions.isEmpty()) { return null; @@ -166,7 +200,7 @@ public class Eia608Parser { ClosedCaption[] captionArray = new ClosedCaption[captions.size()]; captions.toArray(captionArray); - return new ClosedCaptionList(sampleHolder.timeUs, sampleHolder.decodeOnly, captionArray); + return new ClosedCaptionList(sampleHolder.timeUs, sampleHolder.isDecodeOnly(), captionArray); } private static char getChar(byte ccData) { @@ -179,6 +213,32 @@ public class Eia608Parser { return (char) SPECIAL_CHARACTER_SET[index]; } + private static char getExtendedEsFrChar(byte ccData) { + int index = ccData & 0x1F; + return (char) SPECIAL_ES_FR_CHARACTER_SET[index]; + } + + private static char getExtendedPtDeChar(byte ccData) { + int index = ccData & 0x1F; + return (char) SPECIAL_PT_DE_CHARACTER_SET[index]; + } + + private void addBufferedText() { + if (stringBuilder.length() > 0) { + captions.add(new ClosedCaptionText(stringBuilder.toString())); + stringBuilder.setLength(0); + } + } + + private void addCtrl(byte ccData1, byte ccData2) { + addBufferedText(); + captions.add(new ClosedCaptionCtrl(ccData1, ccData2)); + } + + private void backspace() { + addCtrl((byte) 0x14, ClosedCaptionCtrl.BACKSPACE); + } + /** * Inspects an sei message to determine whether it contains EIA-608. *

diff --git a/library/src/main/java/com/google/android/exoplayer/text/eia608/Eia608TrackRenderer.java b/library/src/main/java/com/google/android/exoplayer/text/eia608/Eia608TrackRenderer.java index 8e855bf730..664b549cf9 100644 --- a/library/src/main/java/com/google/android/exoplayer/text/eia608/Eia608TrackRenderer.java +++ b/library/src/main/java/com/google/android/exoplayer/text/eia608/Eia608TrackRenderer.java @@ -317,6 +317,11 @@ public class Eia608TrackRenderer extends TrackRenderer implements Callback { case ClosedCaptionCtrl.CARRIAGE_RETURN: maybeAppendNewline(); return; + case ClosedCaptionCtrl.BACKSPACE: + if (captionStringBuilder.length() > 0) { + captionStringBuilder.setLength(captionStringBuilder.length() - 1); + } + return; } } diff --git a/library/src/main/java/com/google/android/exoplayer/upstream/Aes128DataSource.java b/library/src/main/java/com/google/android/exoplayer/upstream/Aes128DataSource.java index 938dd70ef1..2115e5b0bc 100644 --- a/library/src/main/java/com/google/android/exoplayer/upstream/Aes128DataSource.java +++ b/library/src/main/java/com/google/android/exoplayer/upstream/Aes128DataSource.java @@ -34,20 +34,24 @@ import javax.crypto.spec.SecretKeySpec; /** * A {@link DataSource} that decrypts the data read from an upstream source, encrypted with AES-128 * with a 128-bit key and PKCS7 padding. - * */ public class Aes128DataSource implements DataSource { private final DataSource upstream; - private final byte[] secretKey; - private final byte[] iv; + private final byte[] encryptionKey; + private final byte[] encryptionIv; private CipherInputStream cipherInputStream; - public Aes128DataSource(byte[] secretKey, byte[] iv, DataSource upstream) { + /** + * @param upstream The upstream {@link DataSource}. + * @param encryptionKey The encryption key. + * @param encryptionIv The encryption initialization vector. + */ + public Aes128DataSource(DataSource upstream, byte[] encryptionKey, byte[] encryptionIv) { this.upstream = upstream; - this.secretKey = secretKey; - this.iv = iv; + this.encryptionKey = encryptionKey; + this.encryptionIv = encryptionIv; } @Override @@ -61,8 +65,8 @@ public class Aes128DataSource implements DataSource { throw new RuntimeException(e); } - Key cipherKey = new SecretKeySpec(secretKey, "AES"); - AlgorithmParameterSpec cipherIV = new IvParameterSpec(iv); + Key cipherKey = new SecretKeySpec(encryptionKey, "AES"); + AlgorithmParameterSpec cipherIV = new IvParameterSpec(encryptionIv); try { cipher.init(Cipher.DECRYPT_MODE, cipherKey, cipherIV); diff --git a/library/src/main/java/com/google/android/exoplayer/upstream/BufferedNonBlockingInputStream.java b/library/src/main/java/com/google/android/exoplayer/upstream/BufferedNonBlockingInputStream.java new file mode 100644 index 0000000000..f511e3e6f4 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/upstream/BufferedNonBlockingInputStream.java @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2014 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.exoplayer.upstream; + +import com.google.android.exoplayer.util.Assertions; + +import java.nio.ByteBuffer; + +/** + * Input stream with non-blocking reading/skipping that also stores read/skipped data in a buffer. + * Call {@link #mark} to discard any buffered data before the current reading position. Call + * {@link #returnToMark} to move the current reading position back to the marked position, which is + * initially the start of the input stream. + */ +public final class BufferedNonBlockingInputStream implements NonBlockingInputStream { + + private final NonBlockingInputStream inputStream; + private final byte[] bufferedBytes; + + private long inputStreamPosition; + + private int readPosition; + private int writePosition; + + /** + * Wraps the specified {@code nonBlockingInputStream} for buffered reading using a buffer of size + * {@code bufferSize} bytes. + */ + public BufferedNonBlockingInputStream( + NonBlockingInputStream nonBlockingInputStream, int bufferSize) { + inputStream = Assertions.checkNotNull(nonBlockingInputStream); + bufferedBytes = new byte[bufferSize]; + } + + @Override + public int skip(int length) { + return consumeStream(null, null, 0, length); + } + + @Override + public int read(byte[] buffer, int offset, int length) { + return consumeStream(null, buffer, offset, length); + } + + @Override + public int read(ByteBuffer buffer, int length) { + return consumeStream(buffer, null, 0, length); + } + + @Override + public long getAvailableByteCount() { + // The amount that can be read from the input stream is limited by how much can be buffered. + return (writePosition - readPosition) + + Math.min(inputStream.getAvailableByteCount(), bufferedBytes.length - writePosition); + } + + @Override + public boolean isEndOfStream() { + return writePosition == readPosition && inputStream.isEndOfStream(); + } + + @Override + public void close() { + inputStream.close(); + inputStreamPosition = -1; + } + + /** Returns the current position in the stream. */ + public long getReadPosition() { + return inputStreamPosition - (writePosition - readPosition); + } + + /** + * Moves the mark to be at the current position. Any data before the current position is + * discarded. After calling this method, calling {@link #returnToMark} will move the reading + * position back to the mark position. + */ + public void mark() { + System.arraycopy(bufferedBytes, readPosition, bufferedBytes, 0, writePosition - readPosition); + writePosition -= readPosition; + readPosition = 0; + } + + /** Moves the current position back to the mark position. */ + public void returnToMark() { + readPosition = 0; + } + + /** + * Reads or skips data from the input stream. If {@code byteBuffer} is non-{@code null}, reads + * {@code length} bytes into {@code byteBuffer} (other arguments are ignored). If + * {@code byteArray} is non-{@code null}, reads {@code length} bytes into {@code byteArray} at + * {@code offset} (other arguments are ignored). Otherwise, skips {@code length} bytes. + * + * @param byteBuffer {@link ByteBuffer} to read into, or {@code null} to read into + * {@code byteArray} or skip. + * @param byteArray Byte array to read into, or {@code null} to read into {@code byteBuffer} or + * skip. + * @param offset Offset in {@code byteArray} to write to, if it is non-{@code null}. + * @param length Number of bytes to read or skip. + * @return The number of bytes consumed, or -1 if nothing was consumed and the end of stream was + * reached. + */ + private int consumeStream(ByteBuffer byteBuffer, byte[] byteArray, int offset, int length) { + // If necessary, reduce length so that we do not need to write past the end of the array. + int pendingBytes = writePosition - readPosition; + length = Math.min(length, bufferedBytes.length - writePosition + pendingBytes); + + // If reading past the end of buffered data, request more and populate the buffer. + int streamBytesRead = 0; + if (length - pendingBytes > 0) { + streamBytesRead = inputStream.read(bufferedBytes, writePosition, length - pendingBytes); + if (streamBytesRead > 0) { + inputStreamPosition += streamBytesRead; + + writePosition += streamBytesRead; + pendingBytes += streamBytesRead; + } + } + + // Signal the end of the stream if nothing more will be read. + if (streamBytesRead == -1 && pendingBytes == 0) { + return -1; + } + + // Fill the buffer using buffered data if reading, or just skip otherwise. + length = Math.min(pendingBytes, length); + if (byteBuffer != null) { + byteBuffer.put(bufferedBytes, readPosition, length); + } else if (byteArray != null) { + System.arraycopy(bufferedBytes, readPosition, byteArray, offset, length); + } + readPosition += length; + return length; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/upstream/DataSource.java b/library/src/main/java/com/google/android/exoplayer/upstream/DataSource.java index 624e42a111..3d711708ee 100644 --- a/library/src/main/java/com/google/android/exoplayer/upstream/DataSource.java +++ b/library/src/main/java/com/google/android/exoplayer/upstream/DataSource.java @@ -55,13 +55,16 @@ public interface DataSource { /** * Reads up to {@code length} bytes of data and stores them into {@code buffer}, starting at - * index {@code offset}. This method blocks until at least one byte of data can be read, the end - * of the opened range is detected, or an exception is thrown. + * index {@code offset}. + *

+ * This method blocks until at least one byte of data can be read, the end of the opened range is + * detected, or an exception is thrown. * * @param buffer The buffer into which the read data should be stored. * @param offset The start offset into {@code buffer} at which data should be written. * @param readLength The maximum number of bytes to read. - * @return The actual number of bytes read, or -1 if the end of the opened range is reached. + * @return The number of bytes read, or {@link C#RESULT_END_OF_INPUT} if the end of the opened + * range is reached. * @throws IOException If an error occurs reading from the source. */ public int read(byte[] buffer, int offset, int readLength) throws IOException; diff --git a/library/src/main/java/com/google/android/exoplayer/upstream/DataSourceStream.java b/library/src/main/java/com/google/android/exoplayer/upstream/DataSourceStream.java index 5c4dcd65b2..82dd5a3309 100644 --- a/library/src/main/java/com/google/android/exoplayer/upstream/DataSourceStream.java +++ b/library/src/main/java/com/google/android/exoplayer/upstream/DataSourceStream.java @@ -47,6 +47,10 @@ public final class DataSourceStream implements Loadable, NonBlockingInputStream private final Allocator allocator; private final ReadHead readHead; + /** Whether {@link #allocation}'s capacity is fixed. If true, the allocation is not resized. */ + private final boolean isAllocationFixedSize; + private final int allocationSize; + private Allocation allocation; private volatile boolean loadCanceled; @@ -58,6 +62,9 @@ public final class DataSourceStream implements Loadable, NonBlockingInputStream private int writeFragmentRemainingLength; /** + * Constructs an instance whose allocation grows to contain all of the data specified by the + * {@code dataSpec}. + * * @param dataSource The source from which the data should be loaded. * @param dataSpec Defines the data to be loaded. {@code dataSpec.length} must not exceed * {@link Integer#MAX_VALUE}. If {@code dataSpec.length == C.LENGTH_UNBOUNDED} then @@ -72,12 +79,48 @@ public final class DataSourceStream implements Loadable, NonBlockingInputStream this.allocator = allocator; resolvedLength = C.LENGTH_UNBOUNDED; readHead = new ReadHead(); + + isAllocationFixedSize = false; + allocationSize = 0; + } + + /** + * Constructs an instance whose allocation is of a fixed size, which may be smaller than the data + * specified by the {@code dataSpec}. + *

+ * The allocation size determines how far ahead loading can proceed relative to the current + * reading position. + * + * @param dataSource The source form which the data should be loaded. + * @param dataSpec Defines the data to be loaded. + * @param allocator Used to obtain an {@link Allocation} for holding the data. + * @param allocationSize The minimum size for a fixed-size allocation that will hold the data + * loaded from {@code dataSource}. + */ + public DataSourceStream( + DataSource dataSource, DataSpec dataSpec, Allocator allocator, int allocationSize) { + Assertions.checkState(dataSpec.length <= Integer.MAX_VALUE); + this.dataSource = dataSource; + this.dataSpec = dataSpec; + this.allocator = allocator; + this.allocationSize = allocationSize; + resolvedLength = C.LENGTH_UNBOUNDED; + readHead = new ReadHead(); + + isAllocationFixedSize = true; } /** * Resets the read position to the start of the data. + * + * @throws UnsupportedOperationException Thrown if the allocation size is fixed. */ public void resetReadPosition() { + if (isAllocationFixedSize) { + throw new UnsupportedOperationException( + "The read position cannot be reset when using a fixed allocation"); + } + readHead.reset(); } @@ -176,7 +219,12 @@ public final class DataSourceStream implements Loadable, NonBlockingInputStream byte[][] buffers = allocation.getBuffers(); while (bytesRead < bytesToRead) { if (readHead.fragmentRemaining == 0) { - readHead.fragmentIndex++; + if (readHead.fragmentIndex == buffers.length - 1) { + Assertions.checkState(isAllocationFixedSize); + readHead.fragmentIndex = 0; + } else { + readHead.fragmentIndex++; + } readHead.fragmentOffset = allocation.getFragmentOffset(readHead.fragmentIndex); readHead.fragmentRemaining = allocation.getFragmentLength(readHead.fragmentIndex); } @@ -194,6 +242,13 @@ public final class DataSourceStream implements Loadable, NonBlockingInputStream readHead.fragmentRemaining -= bufferReadLength; } + if (isAllocationFixedSize) { + synchronized (readHead) { + // Notify load() of the updated position so it can resume. + readHead.notify(); + } + } + return bytesRead; } @@ -210,6 +265,7 @@ public final class DataSourceStream implements Loadable, NonBlockingInputStream } @Override + @SuppressWarnings("NonAtomicVolatileUpdate") public void load() throws IOException, InterruptedException { if (loadCanceled || isLoadFinished()) { // The load was canceled, or is already complete. @@ -221,7 +277,7 @@ public final class DataSourceStream implements Loadable, NonBlockingInputStream if (loadPosition == 0 && resolvedLength == C.LENGTH_UNBOUNDED) { loadDataSpec = dataSpec; long resolvedLength = dataSource.open(loadDataSpec); - if (resolvedLength > Integer.MAX_VALUE) { + if (!isAllocationFixedSize && resolvedLength > Integer.MAX_VALUE) { throw new DataSourceStreamLoadException( new UnexpectedLengthException(dataSpec.length, resolvedLength)); } @@ -230,14 +286,18 @@ public final class DataSourceStream implements Loadable, NonBlockingInputStream long remainingLength = resolvedLength != C.LENGTH_UNBOUNDED ? resolvedLength - loadPosition : C.LENGTH_UNBOUNDED; loadDataSpec = new DataSpec(dataSpec.uri, dataSpec.position + loadPosition, - remainingLength, dataSpec.key); + remainingLength, dataSpec.key, dataSpec.flags); dataSource.open(loadDataSpec); } if (allocation == null) { - int initialAllocationSize = resolvedLength != C.LENGTH_UNBOUNDED - ? (int) resolvedLength : CHUNKED_ALLOCATION_INCREMENT; - allocation = allocator.allocate(initialAllocationSize); + if (isAllocationFixedSize) { + allocation = allocator.allocate(allocationSize); + } else { + int initialAllocationSize = resolvedLength != C.LENGTH_UNBOUNDED + ? (int) resolvedLength : CHUNKED_ALLOCATION_INCREMENT; + allocation = allocator.allocate(initialAllocationSize); + } } int allocationCapacity = allocation.capacity(); @@ -253,18 +313,25 @@ public final class DataSourceStream implements Loadable, NonBlockingInputStream if (Thread.interrupted()) { throw new InterruptedException(); } - read = dataSource.read(buffers[writeFragmentIndex], writeFragmentOffset, - writeFragmentRemainingLength); + + int bytesToWrite = getBytesToWrite(); + read = dataSource.read(buffers[writeFragmentIndex], writeFragmentOffset, bytesToWrite); if (read > 0) { loadPosition += read; writeFragmentOffset += read; writeFragmentRemainingLength -= read; if (writeFragmentRemainingLength == 0 && maybeMoreToLoad()) { writeFragmentIndex++; - if (loadPosition == allocationCapacity) { - allocation.ensureCapacity(allocationCapacity + CHUNKED_ALLOCATION_INCREMENT); - allocationCapacity = allocation.capacity(); - buffers = allocation.getBuffers(); + if (writeFragmentIndex == buffers.length) { + if (isAllocationFixedSize) { + // Wrap back to the first fragment. + writeFragmentIndex = 0; + } else { + // Grow the allocation. + allocation.ensureCapacity(allocationCapacity + CHUNKED_ALLOCATION_INCREMENT); + allocationCapacity = allocation.capacity(); + buffers = allocation.getBuffers(); + } } writeFragmentOffset = allocation.getFragmentOffset(writeFragmentIndex); writeFragmentRemainingLength = allocation.getFragmentLength(writeFragmentIndex); @@ -281,6 +348,25 @@ public final class DataSourceStream implements Loadable, NonBlockingInputStream } } + /** + * Returns the number of bytes that can be written to the current fragment, blocking until the + * reader has consumed data if the allocation has a fixed size and is full. + */ + private int getBytesToWrite() throws InterruptedException { + if (!isAllocationFixedSize) { + return writeFragmentRemainingLength; + } + + synchronized (readHead) { + while (loadPosition == readHead.position + allocation.capacity()) { + readHead.wait(); + } + } + + return Math.min(writeFragmentRemainingLength, + allocation.capacity() - (int) (loadPosition - readHead.position)); + } + private boolean maybeMoreToLoad() { return resolvedLength == C.LENGTH_UNBOUNDED || loadPosition < resolvedLength; } diff --git a/library/src/main/java/com/google/android/exoplayer/upstream/DataSpec.java b/library/src/main/java/com/google/android/exoplayer/upstream/DataSpec.java index ff3b7dda0d..a153d955cb 100644 --- a/library/src/main/java/com/google/android/exoplayer/upstream/DataSpec.java +++ b/library/src/main/java/com/google/android/exoplayer/upstream/DataSpec.java @@ -25,22 +25,32 @@ import android.net.Uri; */ public final class DataSpec { + /** + * Permits an underlying network stack to request that the server use gzip compression. + *

+ * Should not typically be set if the data being requested is already compressed (e.g. most audio + * and video requests). May be set when requesting other data. + *

+ * When a {@link DataSource} is used to request data with this flag set, and if the + * {@link DataSource} does make a network request, then the value returned from + * {@link DataSource#open(DataSpec)} will typically be {@link C#LENGTH_UNBOUNDED}. The data read + * from {@link DataSource#read(byte[], int, int)} will be the decompressed data. + */ + public static final int FLAG_ALLOW_GZIP = 1; + /** * Identifies the source from which data should be read. */ public final Uri uri; - /** - * True if the data at {@link #uri} is the full stream. False otherwise. An example where this - * may be false is if {@link #uri} defines the location of a cached part of the stream. - */ - public final boolean uriIsFullStream; /** * The absolute position of the data in the full stream. */ public final long absoluteStreamPosition; /** - * The position of the data when read from {@link #uri}. Always equal to - * {@link #absoluteStreamPosition} if {@link #uriIsFullStream}. + * The position of the data when read from {@link #uri}. + *

+ * Always equal to {@link #absoluteStreamPosition} unless the {@link #uri} defines the location + * of a subset of the underyling data. */ public final long position; /** @@ -52,6 +62,10 @@ public final class DataSpec { * {@link DataSpec} is not intended to be used in conjunction with a cache. */ public final String key; + /** + * Request flags. Currently {@link #FLAG_ALLOW_GZIP} is the only supported flag. + */ + public final int flags; /** * Construct a {@link DataSpec} for the given uri and with {@link #key} set to null. @@ -59,11 +73,21 @@ public final class DataSpec { * @param uri {@link #uri}. */ public DataSpec(Uri uri) { - this(uri, 0, C.LENGTH_UNBOUNDED, null); + this(uri, 0); } /** - * Construct a {@link DataSpec} for which {@link #uriIsFullStream} is true. + * Construct a {@link DataSpec} for the given uri and with {@link #key} set to null. + * + * @param uri {@link #uri}. + * @param flags {@link #flags}. + */ + public DataSpec(Uri uri, int flags) { + this(uri, 0, C.LENGTH_UNBOUNDED, null, flags); + } + + /** + * Construct a {@link DataSpec} where {@link #position} equals {@link #absoluteStreamPosition}. * * @param uri {@link #uri}. * @param absoluteStreamPosition {@link #absoluteStreamPosition}, equal to {@link #position}. @@ -71,50 +95,50 @@ public final class DataSpec { * @param key {@link #key}. */ public DataSpec(Uri uri, long absoluteStreamPosition, long length, String key) { - this(uri, absoluteStreamPosition, length, key, absoluteStreamPosition, true); + this(uri, absoluteStreamPosition, absoluteStreamPosition, length, key, 0); } /** - * Construct a {@link DataSpec} for which {@link #uriIsFullStream} is false. + * Construct a {@link DataSpec} where {@link #position} equals {@link #absoluteStreamPosition}. * * @param uri {@link #uri}. - * @param absoluteStreamPosition {@link #absoluteStreamPosition}. + * @param absoluteStreamPosition {@link #absoluteStreamPosition}, equal to {@link #position}. * @param length {@link #length}. * @param key {@link #key}. - * @param position {@link #position}. + * @param flags {@link #flags}. */ - public DataSpec(Uri uri, long absoluteStreamPosition, long length, String key, long position) { - this(uri, absoluteStreamPosition, length, key, position, false); + public DataSpec(Uri uri, long absoluteStreamPosition, long length, String key, int flags) { + this(uri, absoluteStreamPosition, absoluteStreamPosition, length, key, flags); } /** - * Construct a {@link DataSpec}. + * Construct a {@link DataSpec} where {@link #position} may differ from + * {@link #absoluteStreamPosition}. * * @param uri {@link #uri}. * @param absoluteStreamPosition {@link #absoluteStreamPosition}. + * @param position {@link #position}. * @param length {@link #length}. * @param key {@link #key}. - * @param position {@link #position}. - * @param uriIsFullStream {@link #uriIsFullStream}. + * @param flags {@link #flags}. */ - public DataSpec(Uri uri, long absoluteStreamPosition, long length, String key, long position, - boolean uriIsFullStream) { + public DataSpec(Uri uri, long absoluteStreamPosition, long position, long length, String key, + int flags) { Assertions.checkArgument(absoluteStreamPosition >= 0); Assertions.checkArgument(position >= 0); Assertions.checkArgument(length > 0 || length == C.LENGTH_UNBOUNDED); - Assertions.checkArgument(absoluteStreamPosition == position || !uriIsFullStream); this.uri = uri; - this.uriIsFullStream = uriIsFullStream; this.absoluteStreamPosition = absoluteStreamPosition; this.position = position; this.length = length; this.key = key; + this.flags = flags; } @Override public String toString() { - return "DataSpec[" + uri + ", " + uriIsFullStream + ", " + absoluteStreamPosition + ", " + - position + ", " + length + ", " + key + "]"; + return "DataSpec[" + uri + ", " + ", " + absoluteStreamPosition + ", " + + position + ", " + length + ", " + key + ", " + flags + "]"; } } diff --git a/library/src/main/java/com/google/android/exoplayer/upstream/DefaultHttpDataSource.java b/library/src/main/java/com/google/android/exoplayer/upstream/DefaultHttpDataSource.java index 6fbab8982e..d809b28110 100644 --- a/library/src/main/java/com/google/android/exoplayer/upstream/DefaultHttpDataSource.java +++ b/library/src/main/java/com/google/android/exoplayer/upstream/DefaultHttpDataSource.java @@ -18,17 +18,21 @@ package com.google.android.exoplayer.upstream; import com.google.android.exoplayer.C; import com.google.android.exoplayer.util.Assertions; import com.google.android.exoplayer.util.Predicate; +import com.google.android.exoplayer.util.Util; import android.text.TextUtils; import android.util.Log; +import java.io.EOFException; import java.io.IOException; import java.io.InputStream; +import java.io.InterruptedIOException; import java.net.HttpURLConnection; import java.net.URL; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -43,6 +47,7 @@ public class DefaultHttpDataSource implements HttpDataSource { private static final String TAG = "HttpDataSource"; private static final Pattern CONTENT_RANGE_HEADER = Pattern.compile("^bytes (\\d+)-(\\d+)/(\\d+)$"); + private static final AtomicReference skipBufferReference = new AtomicReference(); private final int connectTimeoutMillis; private final int readTimeoutMillis; @@ -56,7 +61,10 @@ public class DefaultHttpDataSource implements HttpDataSource { private InputStream inputStream; private boolean opened; - private long dataLength; + private long bytesToSkip; + private long bytesToRead; + + private long bytesSkipped; private long bytesRead; /** @@ -132,23 +140,11 @@ public class DefaultHttpDataSource implements HttpDataSource { } } - /* - * TODO: If the server uses gzip compression when serving the response, this may end up returning - * the size of the compressed response, where-as it should be returning the decompressed size or - * -1. See: developer.android.com/reference/java/net/HttpURLConnection.html - * - * To fix this we should: - * - * 1. Explicitly require no compression for media requests (since media should be compressed - * already) by setting the Accept-Encoding header to "identity" - * 2. In other cases, for example when requesting manifests, we don't want to disable compression. - * For these cases we should ensure that we return -1 here (and avoid performing any sanity - * checks on the content length). - */ @Override public long open(DataSpec dataSpec) throws HttpDataSourceException { this.dataSpec = dataSpec; this.bytesRead = 0; + this.bytesSkipped = 0; try { connection = makeConnection(dataSpec); } catch (IOException e) { @@ -156,14 +152,16 @@ public class DefaultHttpDataSource implements HttpDataSource { dataSpec); } - // Check for a valid response code. int responseCode; try { responseCode = connection.getResponseCode(); } catch (IOException e) { + closeConnection(); throw new HttpDataSourceException("Unable to connect to " + dataSpec.uri.toString(), e, dataSpec); } + + // Check for a valid response code. if (responseCode < 200 || responseCode > 299) { Map> headers = connection.getHeaderFields(); closeConnection(); @@ -177,16 +175,23 @@ public class DefaultHttpDataSource implements HttpDataSource { throw new InvalidContentTypeException(contentType, dataSpec); } - long contentLength = getContentLength(connection); - dataLength = dataSpec.length == C.LENGTH_UNBOUNDED ? contentLength : dataSpec.length; + // If we requested a range starting from a non-zero position and received a 200 rather than a + // 206, then the server does not support partial requests. We'll need to manually skip to the + // requested position. + bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0; - if (dataSpec.length != C.LENGTH_UNBOUNDED && contentLength != C.LENGTH_UNBOUNDED - && contentLength != dataSpec.length) { - // The DataSpec specified a length and we resolved a length from the response headers, but - // the two lengths do not match. - closeConnection(); - throw new HttpDataSourceException( - new UnexpectedLengthException(dataSpec.length, contentLength), dataSpec); + // Determine the length of the data to be read, after skipping. + if ((dataSpec.flags & DataSpec.FLAG_ALLOW_GZIP) == 0) { + long contentLength = getContentLength(connection); + bytesToRead = dataSpec.length != C.LENGTH_UNBOUNDED ? dataSpec.length + : contentLength != C.LENGTH_UNBOUNDED ? contentLength - bytesToSkip + : C.LENGTH_UNBOUNDED; + } else { + // Gzip is enabled. If the server opts to use gzip then the content length in the response + // will be that of the compressed data, which isn't what we want. Furthermore, there isn't a + // reliable way to determine whether the gzip was used or not. Always use the dataSpec length + // in this case. + bytesToRead = dataSpec.length; } try { @@ -201,37 +206,24 @@ public class DefaultHttpDataSource implements HttpDataSource { listener.onTransferStart(); } - return dataLength; + return bytesToRead; } @Override public int read(byte[] buffer, int offset, int readLength) throws HttpDataSourceException { - int read = 0; try { - read = inputStream.read(buffer, offset, readLength); + skipInternal(); + return readInternal(buffer, offset, readLength); } catch (IOException e) { throw new HttpDataSourceException(e, dataSpec); } - - if (read > 0) { - bytesRead += read; - if (listener != null) { - listener.onBytesTransferred(read); - } - } else if (dataLength != C.LENGTH_UNBOUNDED && dataLength != bytesRead) { - // Check for cases where the server closed the connection having not sent the correct amount - // of data. We can only do this if we know the length of the data we were expecting. - throw new HttpDataSourceException(new UnexpectedLengthException(dataLength, bytesRead), - dataSpec); - } - - return read; } @Override public void close() throws HttpDataSourceException { try { if (inputStream != null) { + Util.maybeTerminateInputStream(connection, bytesRemaining()); try { inputStream.close(); } catch (IOException e) { @@ -250,13 +242,6 @@ public class DefaultHttpDataSource implements HttpDataSource { } } - private void closeConnection() { - if (connection != null) { - connection.disconnect(); - connection = null; - } - } - /** * Returns the current connection, or null if the source is not currently opened. * @@ -266,6 +251,16 @@ public class DefaultHttpDataSource implements HttpDataSource { return connection; } + /** + * Returns the number of bytes that have been skipped since the most recent call to + * {@link #open(DataSpec)}. + * + * @return The number of bytes skipped. + */ + protected final long bytesSkipped() { + return bytesSkipped; + } + /** * Returns the number of bytes that have been read since the most recent call to * {@link #open(DataSpec)}. @@ -285,7 +280,7 @@ public class DefaultHttpDataSource implements HttpDataSource { * @return The remaining length, or {@link C#LENGTH_UNBOUNDED}. */ protected final long bytesRemaining() { - return dataLength == C.LENGTH_UNBOUNDED ? dataLength : dataLength - bytesRead; + return bytesToRead == C.LENGTH_UNBOUNDED ? bytesToRead : bytesToRead - bytesRead; } private HttpURLConnection makeConnection(DataSpec dataSpec) throws IOException { @@ -301,6 +296,9 @@ public class DefaultHttpDataSource implements HttpDataSource { } setRangeHeader(connection, dataSpec); connection.setRequestProperty("User-Agent", userAgent); + if ((dataSpec.flags & DataSpec.FLAG_ALLOW_GZIP) == 0) { + connection.setRequestProperty("Accept-Encoding", "identity"); + } connection.connect(); return connection; } @@ -355,4 +353,87 @@ public class DefaultHttpDataSource implements HttpDataSource { return contentLength; } + /** + * Skips any bytes that need skipping. Else does nothing. + *

+ * This implementation is based roughly on {@code libcore.io.Streams.skipByReading()}. + * + * @throws InterruptedIOException If the thread is interrupted during the operation. + * @throws EOFException If the end of the input stream is reached before the bytes are skipped. + */ + private void skipInternal() throws IOException { + if (bytesSkipped == bytesToSkip) { + return; + } + + // Acquire the shared skip buffer. + byte[] skipBuffer = skipBufferReference.getAndSet(null); + if (skipBuffer == null) { + skipBuffer = new byte[4096]; + } + + while (bytesSkipped != bytesToSkip) { + int readLength = (int) Math.min(bytesToSkip - bytesSkipped, skipBuffer.length); + int read = inputStream.read(skipBuffer, 0, readLength); + if (Thread.interrupted()) { + throw new InterruptedIOException(); + } + if (read == -1) { + throw new EOFException(); + } + bytesSkipped += read; + if (listener != null) { + listener.onBytesTransferred(read); + } + } + + // Release the shared skip buffer. + skipBufferReference.set(skipBuffer); + } + + /** + * Reads up to {@code length} bytes of data and stores them into {@code buffer}, starting at + * index {@code offset}. + *

+ * This method blocks until at least one byte of data can be read, the end of the opened range is + * detected, or an exception is thrown. + * + * @param buffer The buffer into which the read data should be stored. + * @param offset The start offset into {@code buffer} at which data should be written. + * @param readLength The maximum number of bytes to read. + * @return The number of bytes read, or {@link C#RESULT_END_OF_INPUT} if the end of the opened + * range is reached. + * @throws IOException If an error occurs reading from the source. + */ + private int readInternal(byte[] buffer, int offset, int readLength) throws IOException { + readLength = bytesToRead == C.LENGTH_UNBOUNDED ? readLength + : (int) Math.min(readLength, bytesToRead - bytesRead); + if (readLength == 0) { + // We've read all of the requested data. + return C.RESULT_END_OF_INPUT; + } + + int read = inputStream.read(buffer, offset, readLength); + if (read == -1) { + if (bytesToRead != C.LENGTH_UNBOUNDED && bytesToRead != bytesRead) { + // The server closed the connection having not sent sufficient data. + throw new EOFException(); + } + return C.RESULT_END_OF_INPUT; + } + + bytesRead += read; + if (listener != null) { + listener.onBytesTransferred(read); + } + return read; + } + + private void closeConnection() { + if (connection != null) { + connection.disconnect(); + connection = null; + } + } + } diff --git a/library/src/main/java/com/google/android/exoplayer/upstream/FileDataSource.java b/library/src/main/java/com/google/android/exoplayer/upstream/FileDataSource.java index ec9a3b9ade..42431d4739 100644 --- a/library/src/main/java/com/google/android/exoplayer/upstream/FileDataSource.java +++ b/library/src/main/java/com/google/android/exoplayer/upstream/FileDataSource.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer.upstream; import com.google.android.exoplayer.C; +import java.io.EOFException; import java.io.IOException; import java.io.RandomAccessFile; @@ -65,6 +66,9 @@ public final class FileDataSource implements DataSource { file.seek(dataSpec.position); bytesRemaining = dataSpec.length == C.LENGTH_UNBOUNDED ? file.length() - dataSpec.position : dataSpec.length; + if (bytesRemaining < 0) { + throw new EOFException(); + } } catch (IOException e) { throw new FileDataSourceException(e); } diff --git a/library/src/main/java/com/google/android/exoplayer/upstream/Loader.java b/library/src/main/java/com/google/android/exoplayer/upstream/Loader.java index eb420c8f12..d11f2166c8 100644 --- a/library/src/main/java/com/google/android/exoplayer/upstream/Loader.java +++ b/library/src/main/java/com/google/android/exoplayer/upstream/Loader.java @@ -127,6 +127,21 @@ public final class Loader { startLoading(myLooper, loadable, callback); } + /** + * Invokes {@link #startLoading(Looper, Loadable, Callback)}, using the {@link Looper} + * associated with the calling thread. Loading is delayed by {@code delayMs}. + * + * @param loadable The {@link Loadable} to load. + * @param callback A callback to invoke when the load ends. + * @param delayMs Number of milliseconds to wait before calling {@link Loadable#load()}. + * @throws IllegalStateException If the calling thread does not have an associated {@link Looper}. + */ + public void startLoading(Loadable loadable, Callback callback, int delayMs) { + Looper myLooper = Looper.myLooper(); + Assertions.checkState(myLooper != null); + startLoading(myLooper, loadable, callback, delayMs); + } + /** * Start loading a {@link Loadable}. *

@@ -138,9 +153,24 @@ public final class Loader { * @param callback A callback to invoke when the load ends. */ public void startLoading(Looper looper, Loadable loadable, Callback callback) { + startLoading(looper, loadable, callback, 0); + } + + /** + * Start loading a {@link Loadable} after {@code delayMs} has elapsed. + *

+ * A {@link Loader} instance can only load one {@link Loadable} at a time, and so this method + * must not be called when another load is in progress. + * + * @param looper The looper of the thread on which the callback should be invoked. + * @param loadable The {@link Loadable} to load. + * @param callback A callback to invoke when the load ends. + * @param delayMs Number of milliseconds to wait before calling {@link Loadable#load()}. + */ + public void startLoading(Looper looper, Loadable loadable, Callback callback, int delayMs) { Assertions.checkState(!loading); loading = true; - currentTask = new LoadTask(looper, loadable, callback); + currentTask = new LoadTask(looper, loadable, callback, delayMs); downloadExecutorService.submit(currentTask); } @@ -182,13 +212,15 @@ public final class Loader { private final Loadable loadable; private final Loader.Callback callback; + private final int delayMs; private volatile Thread executorThread; - public LoadTask(Looper looper, Loadable loadable, Loader.Callback callback) { + public LoadTask(Looper looper, Loadable loadable, Loader.Callback callback, int delayMs) { super(looper); this.loadable = loadable; this.callback = callback; + this.delayMs = delayMs; } public void quit() { @@ -202,6 +234,9 @@ public final class Loader { public void run() { try { executorThread = Thread.currentThread(); + if (delayMs > 0) { + Thread.sleep(delayMs); + } if (!loadable.isLoadCanceled()) { loadable.load(); } diff --git a/library/src/main/java/com/google/android/exoplayer/upstream/NetworkLoadable.java b/library/src/main/java/com/google/android/exoplayer/upstream/NetworkLoadable.java index 5e8058f6dd..d4f4e6abf7 100644 --- a/library/src/main/java/com/google/android/exoplayer/upstream/NetworkLoadable.java +++ b/library/src/main/java/com/google/android/exoplayer/upstream/NetworkLoadable.java @@ -63,7 +63,7 @@ public final class NetworkLoadable implements Loadable { public NetworkLoadable(String url, HttpDataSource httpDataSource, Parser parser) { this.httpDataSource = httpDataSource; this.parser = parser; - dataSpec = new DataSpec(Uri.parse(url)); + dataSpec = new DataSpec(Uri.parse(url), DataSpec.FLAG_ALLOW_GZIP); } /** diff --git a/library/src/main/java/com/google/android/exoplayer/upstream/TeeDataSource.java b/library/src/main/java/com/google/android/exoplayer/upstream/TeeDataSource.java index cbb571f308..2623e8ce7f 100644 --- a/library/src/main/java/com/google/android/exoplayer/upstream/TeeDataSource.java +++ b/library/src/main/java/com/google/android/exoplayer/upstream/TeeDataSource.java @@ -42,8 +42,8 @@ public final class TeeDataSource implements DataSource { long dataLength = upstream.open(dataSpec); if (dataSpec.length == C.LENGTH_UNBOUNDED && dataLength != C.LENGTH_UNBOUNDED) { // Reconstruct dataSpec in order to provide the resolved length to the sink. - dataSpec = new DataSpec(dataSpec.uri, dataSpec.absoluteStreamPosition, dataLength, - dataSpec.key, dataSpec.position, dataSpec.uriIsFullStream); + dataSpec = new DataSpec(dataSpec.uri, dataSpec.absoluteStreamPosition, dataSpec.position, + dataLength, dataSpec.key, dataSpec.flags); } dataSink.open(dataSpec); return dataLength; @@ -61,8 +61,11 @@ public final class TeeDataSource implements DataSource { @Override public void close() throws IOException { - upstream.close(); - dataSink.close(); + try { + upstream.close(); + } finally { + dataSink.close(); + } } } diff --git a/library/src/main/java/com/google/android/exoplayer/upstream/UnexpectedLengthException.java b/library/src/main/java/com/google/android/exoplayer/upstream/UnexpectedLengthException.java index c7bc6c303d..6c91601485 100644 --- a/library/src/main/java/com/google/android/exoplayer/upstream/UnexpectedLengthException.java +++ b/library/src/main/java/com/google/android/exoplayer/upstream/UnexpectedLengthException.java @@ -20,6 +20,7 @@ import java.io.IOException; /** * Thrown when the length of some data does not match an expected length. */ +@Deprecated public final class UnexpectedLengthException extends IOException { /** diff --git a/library/src/main/java/com/google/android/exoplayer/upstream/cache/CacheDataSink.java b/library/src/main/java/com/google/android/exoplayer/upstream/cache/CacheDataSink.java index 942a29f0c7..01e06b5158 100644 --- a/library/src/main/java/com/google/android/exoplayer/upstream/cache/CacheDataSink.java +++ b/library/src/main/java/com/google/android/exoplayer/upstream/cache/CacheDataSink.java @@ -19,6 +19,7 @@ import com.google.android.exoplayer.C; import com.google.android.exoplayer.upstream.DataSink; import com.google.android.exoplayer.upstream.DataSpec; import com.google.android.exoplayer.util.Assertions; +import com.google.android.exoplayer.util.Util; import java.io.File; import java.io.FileNotFoundException; @@ -115,11 +116,23 @@ public class CacheDataSink implements DataSink { } private void closeCurrentOutputStream() throws IOException { - if (outputStream != null) { + if (outputStream == null) { + return; + } + + boolean success = false; + try { outputStream.flush(); - outputStream.close(); + outputStream.getFD().sync(); + success = true; + } finally { + Util.closeQuietly(outputStream); + if (success) { + cache.commitFile(file); + } else { + file.delete(); + } outputStream = null; - cache.commitFile(file); file = null; } } diff --git a/library/src/main/java/com/google/android/exoplayer/upstream/cache/CacheDataSource.java b/library/src/main/java/com/google/android/exoplayer/upstream/cache/CacheDataSource.java index 5842d742ab..63a3763133 100644 --- a/library/src/main/java/com/google/android/exoplayer/upstream/cache/CacheDataSource.java +++ b/library/src/main/java/com/google/android/exoplayer/upstream/cache/CacheDataSource.java @@ -22,7 +22,6 @@ import com.google.android.exoplayer.upstream.DataSpec; import com.google.android.exoplayer.upstream.FileDataSource; import com.google.android.exoplayer.upstream.TeeDataSource; import com.google.android.exoplayer.upstream.cache.CacheDataSink.CacheDataSinkException; -import com.google.android.exoplayer.util.Assertions; import android.net.Uri; import android.util.Log; @@ -64,6 +63,7 @@ public final class CacheDataSource implements DataSource { private DataSource currentDataSource; private Uri uri; + private int flags; private String key; private long readPosition; private long bytesRemaining; @@ -125,9 +125,9 @@ public final class CacheDataSource implements DataSource { @Override public long open(DataSpec dataSpec) throws IOException { - Assertions.checkState(dataSpec.uriIsFullStream); try { uri = dataSpec.uri; + flags = dataSpec.flags; key = dataSpec.key; readPosition = dataSpec.position; bytesRemaining = dataSpec.length; @@ -201,19 +201,19 @@ public final class CacheDataSource implements DataSource { // The data is locked in the cache, or we're ignoring the cache. Bypass the cache and read // from upstream. currentDataSource = upstreamDataSource; - dataSpec = new DataSpec(uri, readPosition, bytesRemaining, key); + dataSpec = new DataSpec(uri, readPosition, bytesRemaining, key, flags); } else if (span.isCached) { // Data is cached, read from cache. Uri fileUri = Uri.fromFile(span.file); long filePosition = readPosition - span.position; long length = Math.min(span.length - filePosition, bytesRemaining); - dataSpec = new DataSpec(fileUri, readPosition, length, key, filePosition); + dataSpec = new DataSpec(fileUri, readPosition, filePosition, length, key, flags); currentDataSource = cacheReadDataSource; } else { // Data is not cached, and data is not locked, read from upstream with cache backing. lockedSpan = span; long length = span.isOpenEnded() ? bytesRemaining : Math.min(span.length, bytesRemaining); - dataSpec = new DataSpec(uri, readPosition, length, key); + dataSpec = new DataSpec(uri, readPosition, length, key, flags); currentDataSource = cacheWriteDataSource != null ? cacheWriteDataSource : upstreamDataSource; } diff --git a/library/src/main/java/com/google/android/exoplayer/mp4/Mp4Util.java b/library/src/main/java/com/google/android/exoplayer/util/H264Util.java similarity index 55% rename from library/src/main/java/com/google/android/exoplayer/mp4/Mp4Util.java rename to library/src/main/java/com/google/android/exoplayer/util/H264Util.java index ea41e3a2cf..e9feaaefd6 100644 --- a/library/src/main/java/com/google/android/exoplayer/mp4/Mp4Util.java +++ b/library/src/main/java/com/google/android/exoplayer/util/H264Util.java @@ -13,67 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer.mp4; - -import com.google.android.exoplayer.util.Assertions; -import com.google.android.exoplayer.util.CodecSpecificDataUtil; -import com.google.android.exoplayer.util.ParsableByteArray; +package com.google.android.exoplayer.util; import java.nio.ByteBuffer; /** - * Utility methods and constants for parsing fragmented and unfragmented MP4 files. + * Utility methods for handling H264 data. */ -public final class Mp4Util { - - /** Size of an atom header, in bytes. */ - public static final int ATOM_HEADER_SIZE = 8; - - /** Size of a long atom header, in bytes. */ - public static final int LONG_ATOM_HEADER_SIZE = 16; - - /** Size of a full atom header, in bytes. */ - public static final int FULL_ATOM_HEADER_SIZE = 12; - - /** Value for the first 32 bits of atomSize when the atom size is actually a long value. */ - public static final int LONG_ATOM_SIZE = 1; - - /** Sample index when no sample is available. */ - public static final int NO_SAMPLE = -1; - - /** Track index when no track is selected. */ - public static final int NO_TRACK = -1; +public final class H264Util { /** Four initial bytes that must prefix H.264/AVC NAL units for decoding. */ - private static final byte[] NAL_START_CODE = new byte[] {0, 0, 0, 1}; - - /** Parses the version number out of the additional integer component of a full atom. */ - public static int parseFullAtomVersion(int fullAtomInt) { - return 0x000000FF & (fullAtomInt >> 24); - } - - /** Parses the atom flags out of the additional integer component of a full atom. */ - public static int parseFullAtomFlags(int fullAtomInt) { - return 0x00FFFFFF & fullAtomInt; - } - - /** - * Reads an unsigned integer into an integer. This method is suitable for use when it can be - * assumed that the top bit will always be set to zero. - * - * @throws IllegalArgumentException If the top bit of the input data is set. - */ - public static int readUnsignedIntToInt(ByteBuffer data) { - int result = 0xFF & data.get(); - for (int i = 1; i < 4; i++) { - result <<= 8; - result |= 0xFF & data.get(); - } - if (result < 0) { - throw new IllegalArgumentException("Top bit not zero: " + result); - } - return result; - } + public static final byte[] NAL_START_CODE = new byte[] {0, 0, 0, 1}; /** * Replaces length prefixes of NAL units in {@code buffer} with start code prefixes, within the @@ -92,7 +42,9 @@ public final class Mp4Util { buffer.position(sampleOffset + size); } - /** Constructs and returns a NAL unit with a start code followed by the data in {@code atom}. */ + /** + * Constructs and returns a NAL unit with a start code followed by the data in {@code atom}. + */ public static byte[] parseChildNalUnit(ParsableByteArray atom) { int length = atom.readUnsignedShort(); int offset = atom.getPosition(); @@ -101,43 +53,39 @@ public final class Mp4Util { } /** - * Finds the first NAL unit in {@code data}. - *

- * For a NAL unit to be found, its first four bytes must be contained within the part of the - * array being searched. + * Gets the type of the NAL unit in {@code data} that starts at {@code offset}. * * @param data The data to search. - * @param startOffset The offset (inclusive) in the data to start the search. - * @param endOffset The offset (exclusive) in the data to end the search. - * @param type The type of the NAL unit to search for, or -1 for any NAL unit. - * @return The offset of the NAL unit, or {@code endOffset} if a NAL unit was not found. + * @param offset The start offset of a NAL unit. Must lie between {@code -3} (inclusive) and + * {@code data.length - 3} (exclusive). + * @return The type of the unit. */ - public static int findNalUnit(byte[] data, int startOffset, int endOffset, int type) { - return findNalUnit(data, startOffset, endOffset, type, null); + public static int getNalUnitType(byte[] data, int offset) { + return data[offset + 3] & 0x1F; } /** - * Like {@link #findNalUnit(byte[], int, int, int)}, but supports finding of NAL units across - * array boundaries. + * Finds the first NAL unit in {@code data}. *

- * To use this method, pass the same {@code prefixFlags} parameter to successive calls where the - * data passed represents a contiguous stream. The state maintained in this parameter allows the - * detection of NAL units where the NAL unit prefix spans array boundaries. + * If {@code prefixFlags} is null then the first four bytes of a NAL unit must be entirely + * contained within the part of the array being searched in order for it to be found. *

- * Note that when using {@code prefixFlags} the return value may be 3, 2 or 1 less than - * {@code startOffset}, to indicate a NAL unit starting 3, 2 or 1 bytes before the first byte in - * the current array. + * When {@code prefixFlags} is non-null, this method supports finding NAL units whose first four + * bytes span {@code data} arrays passed to successive calls. To use this feature, pass the same + * {@code prefixFlags} parameter to successive calls. State maintained in this parameter enables + * the detection of such NAL units. Note that when using this feature, the return value may be 3, + * 2 or 1 less than {@code startOffset}, to indicate a NAL unit starting 3, 2 or 1 bytes before + * the first byte in the current array. * * @param data The data to search. * @param startOffset The offset (inclusive) in the data to start the search. * @param endOffset The offset (exclusive) in the data to end the search. - * @param type The type of the NAL unit to search for, or -1 for any NAL unit. * @param prefixFlags A boolean array whose first three elements are used to store the state * required to detect NAL units where the NAL unit prefix spans array boundaries. The array * must be at least 3 elements long. * @return The offset of the NAL unit, or {@code endOffset} if a NAL unit was not found. */ - public static int findNalUnit(byte[] data, int startOffset, int endOffset, int type, + public static int findNalUnit(byte[] data, int startOffset, int endOffset, boolean[] prefixFlags) { int length = endOffset - startOffset; @@ -147,15 +95,14 @@ public final class Mp4Util { } if (prefixFlags != null) { - if (prefixFlags[0] && matchesType(data, startOffset, type)) { + if (prefixFlags[0]) { clearPrefixFlags(prefixFlags); return startOffset - 3; - } else if (length > 1 && prefixFlags[1] && data[startOffset] == 1 - && matchesType(data, startOffset + 1, type)) { + } else if (length > 1 && prefixFlags[1] && data[startOffset] == 1) { clearPrefixFlags(prefixFlags); return startOffset - 2; } else if (length > 2 && prefixFlags[2] && data[startOffset] == 0 - && data[startOffset + 1] == 1 && matchesType(data, startOffset + 2, type)) { + && data[startOffset + 1] == 1) { clearPrefixFlags(prefixFlags); return startOffset - 1; } @@ -169,8 +116,7 @@ public final class Mp4Util { if ((data[i] & 0xFE) != 0) { // There isn't a NAL prefix here, or at the next two positions. Do nothing and let the // loop advance the index by three. - } else if (data[i - 2] == 0 && data[i - 1] == 0 && data[i] == 1 - && matchesType(data, i + 1, type)) { + } else if (data[i - 2] == 0 && data[i - 1] == 0 && data[i] == 1) { if (prefixFlags != null) { clearPrefixFlags(prefixFlags); } @@ -199,45 +145,25 @@ public final class Mp4Util { } /** - * Like {@link #findNalUnit(byte[], int, int, int)} with {@code type == -1}. + * Reads an unsigned integer into an integer. This method is suitable for use when it can be + * assumed that the top bit will always be set to zero. * - * @param data The data to search. - * @param startOffset The offset (inclusive) in the data to start the search. - * @param endOffset The offset (exclusive) in the data to end the search. - * @return The offset of the NAL unit, or {@code endOffset} if a NAL unit was not found. + * @throws IllegalArgumentException If the top bit of the input data is set. */ - public static int findNalUnit(byte[] data, int startOffset, int endOffset) { - return findNalUnit(data, startOffset, endOffset, null); + private static int readUnsignedIntToInt(ByteBuffer data) { + int result = 0xFF & data.get(); + for (int i = 1; i < 4; i++) { + result <<= 8; + result |= 0xFF & data.get(); + } + if (result < 0) { + throw new IllegalArgumentException("Top bit not zero: " + result); + } + return result; } /** - * Like {@link #findNalUnit(byte[], int, int, int, boolean[])} with {@code type == -1}. - * - * @param data The data to search. - * @param startOffset The offset (inclusive) in the data to start the search. - * @param endOffset The offset (exclusive) in the data to end the search. - * @param prefixFlags A boolean array of length at least 3. - * @return The offset of the NAL unit, or {@code endOffset} if a NAL unit was not found. - */ - public static int findNalUnit(byte[] data, int startOffset, int endOffset, - boolean[] prefixFlags) { - return findNalUnit(data, startOffset, endOffset, -1, prefixFlags); - } - - /** - * Gets the type of the NAL unit in {@code data} that starts at {@code offset}. - * - * @param data The data to search. - * @param offset The start offset of a NAL unit. Must lie between {@code -3} (inclusive) and - * {@code data.length - 3} (exclusive). - * @return The type of the unit. - */ - public static int getNalUnitType(byte[] data, int offset) { - return data[offset + 3] & 0x1F; - } - - /** - * Clears prefix flags, as used by {@link #findNalUnit(byte[], int, int, int, boolean[])}. + * Clears prefix flags, as used by {@link #findNalUnit(byte[], int, int, boolean[])}. * * @param prefixFlags The flags to clear. */ @@ -247,11 +173,4 @@ public final class Mp4Util { prefixFlags[2] = false; } - /** - * Returns true if the type at {@code offset} is equal to {@code type}, or if {@code type == -1}. - */ - private static boolean matchesType(byte[] data, int offset, int type) { - return type == -1 || (data[offset] & 0x1F) == type; - } - } diff --git a/library/src/main/java/com/google/android/exoplayer/util/MimeTypes.java b/library/src/main/java/com/google/android/exoplayer/util/MimeTypes.java index ac6874c216..1d66360951 100644 --- a/library/src/main/java/com/google/android/exoplayer/util/MimeTypes.java +++ b/library/src/main/java/com/google/android/exoplayer/util/MimeTypes.java @@ -37,6 +37,10 @@ public class MimeTypes { public static final String AUDIO_AC3 = BASE_TYPE_AUDIO + "/ac3"; public static final String AUDIO_EC3 = BASE_TYPE_AUDIO + "/eac3"; public static final String AUDIO_WEBM = BASE_TYPE_AUDIO + "/webm"; + public static final String AUDIO_MPEG = BASE_TYPE_AUDIO + "/mpeg"; + public static final String AUDIO_MPEG_L1 = BASE_TYPE_AUDIO + "/mpeg-L1"; + public static final String AUDIO_MPEG_L2 = BASE_TYPE_AUDIO + "/mpeg-L2"; + public static final String AUDIO_VORBIS = BASE_TYPE_AUDIO + "/vorbis"; public static final String AUDIO_OPUS = BASE_TYPE_AUDIO + "/opus"; @@ -45,6 +49,7 @@ public class MimeTypes { public static final String APPLICATION_ID3 = BASE_TYPE_APPLICATION + "/id3"; public static final String APPLICATION_EIA608 = BASE_TYPE_APPLICATION + "/eia-608"; public static final String APPLICATION_TTML = BASE_TYPE_APPLICATION + "/ttml+xml"; + public static final String APPLICATION_M3U8 = BASE_TYPE_APPLICATION + "/x-mpegURL"; private MimeTypes() {} diff --git a/library/src/main/java/com/google/android/exoplayer/util/ParsableByteArray.java b/library/src/main/java/com/google/android/exoplayer/util/ParsableByteArray.java index 72b2552722..727b310042 100644 --- a/library/src/main/java/com/google/android/exoplayer/util/ParsableByteArray.java +++ b/library/src/main/java/com/google/android/exoplayer/util/ParsableByteArray.java @@ -37,6 +37,12 @@ public final class ParsableByteArray { limit = data.length; } + /** Creates a new instance wrapping {@code data}. */ + public ParsableByteArray(byte[] data) { + this.data = data; + limit = data.length; + } + /** * Creates a new instance that wraps an existing array. * @@ -171,6 +177,13 @@ public final class ParsableByteArray { return result; } + /** Reads the next three bytes as an unsigned value. */ + public int readUnsignedInt24() { + int result = shiftIntoInt(data, position, 3); + position += 3; + return result; + } + /** Reads the next four bytes as an unsigned value. */ public long readUnsignedInt() { long result = shiftIntoLong(data, position, 4); @@ -180,9 +193,11 @@ public final class ParsableByteArray { /** Reads the next four bytes as a signed value. */ public int readInt() { - int result = shiftIntoInt(data, position, 4); - position += 4; - return result; + // shiftIntoInt inlined as performance optimization. + return (data[position++] & 0xFF) << 24 + | (data[position++] & 0xFF) << 16 + | (data[position++] & 0xFF) << 8 + | data[position++] & 0xFF; } /** Reads the next eight bytes as a signed value. */ @@ -221,8 +236,11 @@ public final class ParsableByteArray { * @throws IllegalArgumentException Thrown if the top bit of the input data is set. */ public int readUnsignedIntToInt() { - int result = shiftIntoInt(data, position, 4); - position += 4; + // shiftIntoInt inlined as performance optimization. + final int result = (data[position++] & 0xFF) << 24 + | (data[position++] & 0xFF) << 16 + | (data[position++] & 0xFF) << 8 + | data[position++] & 0xFF; if (result < 0) { throw new IllegalArgumentException("Top bit not zero: " + result); } diff --git a/library/src/main/java/com/google/android/exoplayer/util/UriUtil.java b/library/src/main/java/com/google/android/exoplayer/util/UriUtil.java new file mode 100644 index 0000000000..61eb8fa0a4 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/util/UriUtil.java @@ -0,0 +1,258 @@ +/* + * Copyright (C) 2014 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.exoplayer.util; + +import android.net.Uri; +import android.text.TextUtils; + +/** + * Utility methods for manipulating URIs. + */ +public final class UriUtil { + + /** + * The length of arrays returned by {@link #getUriIndices(String)}. + */ + private static final int INDEX_COUNT = 4; + /** + * An index into an array returned by {@link #getUriIndices(String)}. + *

+ * The value at this position in the array is the index of the ':' after the scheme. Equals -1 if + * the URI is a relative reference (no scheme). The hier-part starts at (schemeColon + 1), + * including when the URI has no scheme. + */ + private static final int SCHEME_COLON = 0; + /** + * An index into an array returned by {@link #getUriIndices(String)}. + *

+ * The value at this position in the array is the index of the path part. Equals (schemeColon + 1) + * if no authority part, (schemeColon + 3) if the authority part consists of just "//", and + * (query) if no path part. The characters starting at this index can be "//" only if the + * authority part is non-empty (in this case the double-slash means the first segment is empty). + */ + private static final int PATH = 1; + /** + * An index into an array returned by {@link #getUriIndices(String)}. + *

+ * The value at this position in the array is the index of the query part, including the '?' + * before the query. Equals fragment if no query part, and (fragment - 1) if the query part is a + * single '?' with no data. + */ + private static final int QUERY = 2; + /** + * An index into an array returned by {@link #getUriIndices(String)}. + *

+ * The value at this position in the array is the index of the fragment part, including the '#' + * before the fragment. Equal to the length of the URI if no fragment part, and (length - 1) if + * the fragment part is a single '#' with no data. + */ + private static final int FRAGMENT = 3; + + private UriUtil() {} + + /** + * Like {@link #resolve(String, String)}, but returns a {@link Uri} instead of a {@link String}. + * + * @param baseUri The base URI. + * @param referenceUri The reference URI to resolve. + */ + public static Uri resolveToUri(String baseUri, String referenceUri) { + return Uri.parse(resolve(baseUri, referenceUri)); + } + + /** + * Performs relative resolution of a {@code referenceUri} with respect to a {@code baseUri}. + *

+ * The resolution is performed as specified by RFC-3986. + * + * @param baseUri The base URI. + * @param referenceUri The reference URI to resolve. + */ + public static String resolve(String baseUri, String referenceUri) { + StringBuilder uri = new StringBuilder(); + + // Map null onto empty string, to make the following logic simpler. + baseUri = baseUri == null ? "" : baseUri; + referenceUri = referenceUri == null ? "" : referenceUri; + + int[] refIndices = getUriIndices(referenceUri); + if (refIndices[SCHEME_COLON] != -1) { + // The reference is absolute. The target Uri is the reference. + uri.append(referenceUri); + removeDotSegments(uri, refIndices[PATH], refIndices[QUERY]); + return uri.toString(); + } + + int[] baseIndices = getUriIndices(baseUri); + if (refIndices[FRAGMENT] == 0) { + // The reference is empty or contains just the fragment part, then the target Uri is the + // concatenation of the base Uri without its fragment, and the reference. + return uri.append(baseUri, 0, baseIndices[FRAGMENT]).append(referenceUri).toString(); + } + + if (refIndices[QUERY] == 0) { + // The reference starts with the query part. The target is the base up to (but excluding) the + // query, plus the reference. + return uri.append(baseUri, 0, baseIndices[QUERY]).append(referenceUri).toString(); + } + + if (refIndices[PATH] != 0) { + // The reference has authority. The target is the base scheme plus the reference. + int baseLimit = baseIndices[SCHEME_COLON] + 1; + uri.append(baseUri, 0, baseLimit).append(referenceUri); + return removeDotSegments(uri, baseLimit + refIndices[PATH], baseLimit + refIndices[QUERY]); + } + + if (refIndices[PATH] != refIndices[QUERY] && referenceUri.charAt(refIndices[PATH]) == '/') { + // The reference path is rooted. The target is the base scheme and authority (if any), plus + // the reference. + uri.append(baseUri, 0, baseIndices[PATH]).append(referenceUri); + return removeDotSegments(uri, baseIndices[PATH], baseIndices[PATH] + refIndices[QUERY]); + } + + // The target Uri is the concatenation of the base Uri up to (but excluding) the last segment, + // and the reference. This can be split into 2 cases: + if (baseIndices[SCHEME_COLON] + 2 < baseIndices[PATH] + && baseIndices[PATH] == baseIndices[QUERY]) { + // Case 1: The base hier-part is just the authority, with an empty path. An additional '/' is + // needed after the authority, before appending the reference. + uri.append(baseUri, 0, baseIndices[PATH]).append('/').append(referenceUri); + return removeDotSegments(uri, baseIndices[PATH], baseIndices[PATH] + refIndices[QUERY] + 1); + } else { + // Case 2: Otherwise, find the last '/' in the base hier-part and append the reference after + // it. If base hier-part has no '/', it could only mean that it is completely empty or + // contains only one segment, in which case the whole hier-part is excluded and the reference + // is appended right after the base scheme colon without an added '/'. + int lastSlashIndex = baseUri.lastIndexOf('/', baseIndices[QUERY] - 1); + int baseLimit = lastSlashIndex == -1 ? baseIndices[PATH] : lastSlashIndex + 1; + uri.append(baseUri, 0, baseLimit).append(referenceUri); + return removeDotSegments(uri, baseIndices[PATH], baseLimit + refIndices[QUERY]); + } + } + + /** + * Removes dot segments from the path of a URI. + * + * @param uri A {@link StringBuilder} containing the URI. + * @param offset The index of the start of the path in {@code uri}. + * @param limit The limit (exclusive) of the path in {@code uri}. + */ + private static String removeDotSegments(StringBuilder uri, int offset, int limit) { + if (offset >= limit) { + // Nothing to do. + return uri.toString(); + } + if (uri.charAt(offset) == '/') { + // If the path starts with a /, always retain it. + offset++; + } + // The first character of the current path segment. + int segmentStart = offset; + int i = offset; + while (i <= limit) { + int nextSegmentStart = -1; + if (i == limit) { + nextSegmentStart = i; + } else if (uri.charAt(i) == '/') { + nextSegmentStart = i + 1; + } else { + i++; + continue; + } + // We've encountered the end of a segment or the end of the path. If the final segment was + // "." or "..", remove the appropriate segments of the path. + if (i == segmentStart + 1 && uri.charAt(segmentStart) == '.') { + // Given "abc/def/./ghi", remove "./" to get "abc/def/ghi". + uri.delete(segmentStart, nextSegmentStart); + limit -= nextSegmentStart - segmentStart; + i = segmentStart; + } else if (i == segmentStart + 2 && uri.charAt(segmentStart) == '.' + && uri.charAt(segmentStart + 1) == '.') { + // Given "abc/def/../ghi", remove "def/../" to get "abc/ghi". + int prevSegmentStart = uri.lastIndexOf("/", segmentStart - 2) + 1; + int removeFrom = prevSegmentStart > offset ? prevSegmentStart : offset; + uri.delete(removeFrom, nextSegmentStart); + limit -= nextSegmentStart - removeFrom; + segmentStart = prevSegmentStart; + i = prevSegmentStart; + } else { + i++; + segmentStart = i; + } + } + return uri.toString(); + } + + /** + * Calculates indices of the constituent components of a URI. + * + * @param uriString The URI as a string. + * @return The corresponding indices. + */ + private static int[] getUriIndices(String uriString) { + int[] indices = new int[INDEX_COUNT]; + if (TextUtils.isEmpty(uriString)) { + indices[SCHEME_COLON] = -1; + return indices; + } + + // Determine outer structure from right to left. + // Uri = scheme ":" hier-part [ "?" query ] [ "#" fragment ] + int length = uriString.length(); + int fragmentIndex = uriString.indexOf('#'); + if (fragmentIndex == -1) { + fragmentIndex = length; + } + int queryIndex = uriString.indexOf('?'); + if (queryIndex == -1 || queryIndex > fragmentIndex) { + // '#' before '?': '?' is within the fragment. + queryIndex = fragmentIndex; + } + // Slashes are allowed only in hier-part so any colon after the first slash is part of the + // hier-part, not the scheme colon separator. + int schemeIndexLimit = uriString.indexOf('/'); + if (schemeIndexLimit == -1 || schemeIndexLimit > queryIndex) { + schemeIndexLimit = queryIndex; + } + int schemeIndex = uriString.indexOf(':'); + if (schemeIndex > schemeIndexLimit) { + // '/' before ':' + schemeIndex = -1; + } + + // Determine hier-part structure: hier-part = "//" authority path / path + // This block can also cope with schemeIndex == -1. + boolean hasAuthority = schemeIndex + 2 < queryIndex + && uriString.charAt(schemeIndex + 1) == '/' + && uriString.charAt(schemeIndex + 2) == '/'; + int pathIndex; + if (hasAuthority) { + pathIndex = uriString.indexOf('/', schemeIndex + 3); // find first '/' after "://" + if (pathIndex == -1 || pathIndex > queryIndex) { + pathIndex = queryIndex; + } + } else { + pathIndex = schemeIndex + 1; + } + + indices[SCHEME_COLON] = schemeIndex; + indices[PATH] = pathIndex; + indices[QUERY] = queryIndex; + indices[FRAGMENT] = fragmentIndex; + return indices; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/util/Util.java b/library/src/main/java/com/google/android/exoplayer/util/Util.java index 7e096cfa1b..6160427890 100644 --- a/library/src/main/java/com/google/android/exoplayer/util/Util.java +++ b/library/src/main/java/com/google/android/exoplayer/util/Util.java @@ -15,13 +15,17 @@ */ package com.google.android.exoplayer.util; +import com.google.android.exoplayer.C; import com.google.android.exoplayer.upstream.DataSource; -import android.net.Uri; import android.text.TextUtils; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.Method; import java.math.BigDecimal; +import java.net.HttpURLConnection; import java.net.URL; import java.text.ParseException; import java.util.Arrays; @@ -58,6 +62,8 @@ public final class Util { Pattern.compile("^(-)?P(([0-9]*)Y)?(([0-9]*)M)?(([0-9]*)D)?" + "(T(([0-9]*)H)?(([0-9]*)M)?(([0-9.]*)S)?)?$"); + private static final long MAX_BYTES_TO_DRAIN = 2048; + private Util() {} /** @@ -124,6 +130,19 @@ public final class Util { } } + /** + * Closes an {@link OutputStream}, suppressing any {@link IOException} that may occur. + * + * @param outputStream The {@link OutputStream} to close. + */ + public static void closeQuietly(OutputStream outputStream) { + try { + outputStream.close(); + } catch (IOException e) { + // Ignore. + } + } + /** * Converts text to lower case using {@link Locale#US}. * @@ -134,54 +153,6 @@ public final class Util { return text == null ? null : text.toLowerCase(Locale.US); } - /** - * Like {@link Uri#parse(String)}, but discards the part of the uri that follows the final - * forward slash. - * - * @param uriString An RFC 2396-compliant, encoded uri. - * @return The parsed base uri. - */ - public static Uri parseBaseUri(String uriString) { - return Uri.parse(uriString.substring(0, uriString.lastIndexOf('/'))); - } - - /** - * Merges a uri and a string to produce a new uri. - *

- * The uri is built according to the following rules: - *

    - *
  • If {@code baseUri} is null or if {@code stringUri} is absolute, then {@code baseUri} is - * ignored and the uri consists solely of {@code stringUri}. - *
  • If {@code stringUri} is null, then the uri consists solely of {@code baseUrl}. - *
  • Otherwise, the uri consists of the concatenation of {@code baseUri} and {@code stringUri}. - *
- * - * @param baseUri A uri that can form the base of the merged uri. - * @param stringUri A relative or absolute uri in string form. - * @return The merged uri. - */ - public static Uri getMergedUri(Uri baseUri, String stringUri) { - if (stringUri == null) { - return baseUri; - } - if (baseUri == null) { - return Uri.parse(stringUri); - } - if (stringUri.startsWith("/")) { - stringUri = stringUri.substring(1); - return new Uri.Builder() - .scheme(baseUri.getScheme()) - .authority(baseUri.getAuthority()) - .appendEncodedPath(stringUri) - .build(); - } - Uri uri = Uri.parse(stringUri); - if (uri.isAbsolute()) { - return uri; - } - return Uri.withAppendedPath(baseUri, stringUri); - } - /** * Returns the index of the largest value in an array that is less than (or optionally equal to) * a specified key. @@ -445,4 +416,48 @@ public final class Util { return intArray; } + /** + * On platform API levels 19 and 20, okhttp's implementation of {@link InputStream#close} can + * block for a long time if the stream has a lot of data remaining. Call this method before + * closing the input stream to make a best effort to cause the input stream to encounter an + * unexpected end of input, working around this issue. On other platform API levels, the method + * does nothing. + * + * @param connection The connection whose {@link InputStream} should be terminated. + * @param bytesRemaining The number of bytes remaining to be read from the input stream if its + * length is known. {@link C#LENGTH_UNBOUNDED} otherwise. + */ + public static void maybeTerminateInputStream(HttpURLConnection connection, long bytesRemaining) { + if (SDK_INT != 19 && SDK_INT != 20) { + return; + } + + try { + InputStream inputStream = connection.getInputStream(); + if (bytesRemaining == C.LENGTH_UNBOUNDED) { + // If the input stream has already ended, do nothing. The socket may be re-used. + if (inputStream.read() == -1) { + return; + } + } else if (bytesRemaining <= MAX_BYTES_TO_DRAIN) { + // There isn't much data left. Prefer to allow it to drain, which may allow the socket to be + // re-used. + return; + } + String className = inputStream.getClass().getName(); + if (className.equals("com.android.okhttp.internal.http.HttpTransport$ChunkedInputStream") + || className.equals( + "com.android.okhttp.internal.http.HttpTransport$FixedLengthInputStream")) { + Class superclass = inputStream.getClass().getSuperclass(); + Method unexpectedEndOfInput = superclass.getDeclaredMethod("unexpectedEndOfInput"); + unexpectedEndOfInput.setAccessible(true); + unexpectedEndOfInput.invoke(inputStream); + } + } catch (IOException e) { + // The connection didn't ever have an input stream, or it was closed already. + } catch (Exception e) { + // Something went wrong. The device probably isn't using okhttp. + } + } + } diff --git a/library/src/test/.classpath b/library/src/test/.classpath new file mode 100644 index 0000000000..171a8c3ec8 --- /dev/null +++ b/library/src/test/.classpath @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/library/src/test/.project b/library/src/test/.project new file mode 100644 index 0000000000..d63886b065 --- /dev/null +++ b/library/src/test/.project @@ -0,0 +1,62 @@ + + + ExoPlayerTests + + + ExoPlayerLib + + + + com.android.ide.eclipse.adt.ResourceManagerBuilder + + + + + com.android.ide.eclipse.adt.PreCompilerBuilder + + + + + org.eclipse.jdt.core.javabuilder + + + + + com.android.ide.eclipse.adt.ApkBuilder + + + + + + com.android.ide.eclipse.adt.AndroidNature + org.eclipse.jdt.core.javanature + + + + libs/dexmaker-1.2.jar + 1 + $%7BPARENT-3-PROJECT_LOC%7D/third_party/dexmaker/dexmaker-1.2.jar + + + libs/dexmaker-mockito-1.2.jar + 1 + $%7BPARENT-3-PROJECT_LOC%7D/third_party/dexmaker/dexmaker-mockito-1.2.jar + + + libs/mockito-all-1.9.5.jar + 1 + $%7BPARENT-3-PROJECT_LOC%7D/third_party/mockito/mockito-all-1.9.5.jar + + + + + 1425657306619 + + 14 + + org.eclipse.ui.ide.multiFilter + 1.0-name-matches-true-false-BUILD + + + + diff --git a/library/src/test/AndroidManifest.xml b/library/src/test/AndroidManifest.xml new file mode 100644 index 0000000000..517161f3b9 --- /dev/null +++ b/library/src/test/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + diff --git a/library/src/test/assets/dash/sample_mpd_1 b/library/src/test/assets/dash/sample_mpd_1 new file mode 100755 index 0000000000..07bcdd4f50 --- /dev/null +++ b/library/src/test/assets/dash/sample_mpd_1 @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + +http://www.test.com/141 + + + + + + + + + + + + + +http://www.test.com/135 + + + + + + + + + + + + + +http://www.test.com/vtt + + + + + + + + + + + diff --git a/library/src/test/assets/webm/vorbis_codec_private b/library/src/test/assets/webm/vorbis_codec_private new file mode 100644 index 0000000000..6a613449a7 Binary files /dev/null and b/library/src/test/assets/webm/vorbis_codec_private differ diff --git a/library/src/test/assets/webvtt/empty b/library/src/test/assets/webvtt/empty new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/library/src/test/assets/webvtt/empty @@ -0,0 +1 @@ + diff --git a/library/src/test/assets/webvtt/typical b/library/src/test/assets/webvtt/typical new file mode 100644 index 0000000000..d1395efe1b --- /dev/null +++ b/library/src/test/assets/webvtt/typical @@ -0,0 +1,8 @@ +WEBVTT +X-TIMESTAMP-MAP=LOCAL:00:00.000,MPEGTS:450000 + +00:00.000 --> 00:01.234 +This is the first subtitle. + +00:02.345 --> 00:03.456 +This is the second subtitle. diff --git a/library/src/test/assets/webvtt/typical_with_identifiers b/library/src/test/assets/webvtt/typical_with_identifiers new file mode 100644 index 0000000000..e2c5df065b --- /dev/null +++ b/library/src/test/assets/webvtt/typical_with_identifiers @@ -0,0 +1,10 @@ +WEBVTT +X-TIMESTAMP-MAP=LOCAL:00:00.000,MPEGTS:450000 + +1 +00:00.000 --> 00:01.234 +This is the first subtitle. + +2 +00:02.345 --> 00:03.456 +This is the second subtitle. diff --git a/library/src/test/assets/webvtt/typical_with_tags b/library/src/test/assets/webvtt/typical_with_tags new file mode 100644 index 0000000000..36e630e240 --- /dev/null +++ b/library/src/test/assets/webvtt/typical_with_tags @@ -0,0 +1,14 @@ +WEBVTT +X-TIMESTAMP-MAP=LOCAL:00:00.000,MPEGTS:450000 + +00:00.000 --> 00:01.234 +This is the first subtitle. + +00:02.345 --> 00:03.456 +This is the second subtitle. + +00:04.000 --> 00:05.000 +This is the third subtitle. + +00:06.000 --> 00:07.000 +This is the <fourth> &subtitle. diff --git a/library/src/test/java/com/google/android/exoplayer/CTest.java b/library/src/test/java/com/google/android/exoplayer/CTest.java new file mode 100644 index 0000000000..3aa97464a3 --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/CTest.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2014 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.exoplayer; + +import android.annotation.SuppressLint; +import android.media.MediaCodec; +import android.media.MediaExtractor; + +import junit.framework.TestCase; + +/** + * Unit test for {@link C}. + */ +public class CTest extends TestCase { + + @SuppressLint("InlinedApi") + public static final void testContants() { + // Sanity check that constant values match those defined by the platform. + assertEquals(MediaExtractor.SAMPLE_FLAG_SYNC, C.SAMPLE_FLAG_SYNC); + assertEquals(MediaExtractor.SAMPLE_FLAG_ENCRYPTED, C.SAMPLE_FLAG_ENCRYPTED); + assertEquals(MediaCodec.CRYPTO_MODE_AES_CTR, C.CRYPTO_MODE_AES_CTR); + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/MediaFormatTest.java b/library/src/test/java/com/google/android/exoplayer/MediaFormatTest.java new file mode 100644 index 0000000000..6da52e50f9 --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/MediaFormatTest.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2014 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.exoplayer; + +import com.google.android.exoplayer.util.Util; + +import android.annotation.TargetApi; + +import junit.framework.TestCase; + +import java.util.ArrayList; +import java.util.List; + +/** + * Unit test for {@link MediaFormat}. + */ +public class MediaFormatTest extends TestCase { + + public void testConversionToFrameworkFormat() { + if (Util.SDK_INT < 16) { + // Test doesn't apply. + return; + } + + byte[] initData1 = new byte[] {1, 2, 3}; + byte[] initData2 = new byte[] {4, 5, 6}; + List initData = new ArrayList(); + initData.add(initData1); + initData.add(initData2); + + testConversionToFrameworkFormatV16( + MediaFormat.createVideoFormat("video/xyz", 102400, 1000L, 1280, 720, 1.5f, initData)); + testConversionToFrameworkFormatV16( + MediaFormat.createAudioFormat("audio/xyz", 102400, 1000L, 5, 44100, initData)); + } + + @TargetApi(16) + private void testConversionToFrameworkFormatV16(MediaFormat format) { + // Convert to a framework MediaFormat and back again. + MediaFormat convertedFormat = MediaFormat.createFromFrameworkMediaFormatV16( + format.getFrameworkMediaFormatV16()); + // Assert that we end up with an equivalent object to the one we started with. + assertEquals(format.hashCode(), convertedFormat.hashCode()); + assertEquals(format, convertedFormat); + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/chunk/parser/webm/DefaultEbmlReaderTest.java b/library/src/test/java/com/google/android/exoplayer/chunk/parser/webm/DefaultEbmlReaderTest.java new file mode 100644 index 0000000000..5d4a30cab9 --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/chunk/parser/webm/DefaultEbmlReaderTest.java @@ -0,0 +1,350 @@ +/* + * Copyright (C) 2014 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.exoplayer.chunk.parser.webm; + +import com.google.android.exoplayer.ParserException; +import com.google.android.exoplayer.upstream.ByteArrayNonBlockingInputStream; +import com.google.android.exoplayer.upstream.NonBlockingInputStream; + +import junit.framework.TestCase; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Tests {@link DefaultEbmlReader}. + */ +public class DefaultEbmlReaderTest extends TestCase { + + private final EventCapturingEbmlEventHandler eventHandler = + new EventCapturingEbmlEventHandler(); + + public void testNothing() { + NonBlockingInputStream input = createTestInputStream(); + assertNoEvents(input, EbmlReader.READ_RESULT_END_OF_STREAM); + } + + public void testMasterElement() { + NonBlockingInputStream input = + createTestInputStream(0x1A, 0x45, 0xDF, 0xA3, 0x84, 0x42, 0x85, 0x81, 0x01); + EventCapturingEbmlEventHandler expected = new EventCapturingEbmlEventHandler(); + expected.onMasterElementStart(EventCapturingEbmlEventHandler.ID_EBML, 0, 5, 4); + expected.onIntegerElement(EventCapturingEbmlEventHandler.ID_DOC_TYPE_READ_VERSION, 1); + expected.onMasterElementEnd(EventCapturingEbmlEventHandler.ID_EBML); + assertEvents(input, EbmlReader.READ_RESULT_END_OF_STREAM, expected.events); + } + + public void testMasterElementEmpty() { + NonBlockingInputStream input = createTestInputStream(0x18, 0x53, 0x80, 0x67, 0x80); + EventCapturingEbmlEventHandler expected = new EventCapturingEbmlEventHandler(); + expected.onMasterElementStart(EventCapturingEbmlEventHandler.ID_SEGMENT, 0, 5, 0); + expected.onMasterElementEnd(EventCapturingEbmlEventHandler.ID_SEGMENT); + assertEvents(input, EbmlReader.READ_RESULT_END_OF_STREAM, expected.events); + } + + public void testUnsignedIntegerElement() { + // 0xFE is chosen because for signed integers it should be interpreted as -2 + NonBlockingInputStream input = createTestInputStream(0x42, 0xF7, 0x81, 0xFE); + EventCapturingEbmlEventHandler expected = new EventCapturingEbmlEventHandler(); + expected.onIntegerElement(EventCapturingEbmlEventHandler.ID_EBML_READ_VERSION, 254); + assertEvents(input, EbmlReader.READ_RESULT_END_OF_STREAM, expected.events); + } + + public void testUnsignedIntegerElementLarge() { + NonBlockingInputStream input = + createTestInputStream(0x42, 0xF7, 0x88, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF); + EventCapturingEbmlEventHandler expected = new EventCapturingEbmlEventHandler(); + expected.onIntegerElement(EventCapturingEbmlEventHandler.ID_EBML_READ_VERSION, Long.MAX_VALUE); + assertEvents(input, EbmlReader.READ_RESULT_END_OF_STREAM, expected.events); + } + + public void testUnsignedIntegerElementTooLargeBecomesNegative() { + NonBlockingInputStream input = + createTestInputStream(0x42, 0xF7, 0x88, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF); + EventCapturingEbmlEventHandler expected = new EventCapturingEbmlEventHandler(); + expected.onIntegerElement(EventCapturingEbmlEventHandler.ID_EBML_READ_VERSION, -1); + assertEvents(input, EbmlReader.READ_RESULT_END_OF_STREAM, expected.events); + } + + public void testStringElement() { + NonBlockingInputStream input = + createTestInputStream(0x42, 0x82, 0x86, 0x41, 0x62, 0x63, 0x31, 0x32, 0x33); + EventCapturingEbmlEventHandler expected = new EventCapturingEbmlEventHandler(); + expected.onStringElement(EventCapturingEbmlEventHandler.ID_DOC_TYPE, "Abc123"); + assertEvents(input, EbmlReader.READ_RESULT_END_OF_STREAM, expected.events); + } + + public void testStringElementEmpty() { + NonBlockingInputStream input = createTestInputStream(0x42, 0x82, 0x80); + EventCapturingEbmlEventHandler expected = new EventCapturingEbmlEventHandler(); + expected.onStringElement(EventCapturingEbmlEventHandler.ID_DOC_TYPE, ""); + assertEvents(input, EbmlReader.READ_RESULT_END_OF_STREAM, expected.events); + } + + public void testFloatElementThreeBytes() { + try { + eventHandler.read(createTestInputStream(0x44, 0x89, 0x83, 0x3F, 0x80, 0x00)); + fail(); + } catch (IllegalStateException exception) { + // Expected + } + assertNoEvents(); + } + + public void testFloatElementFourBytes() { + NonBlockingInputStream input = + createTestInputStream(0x44, 0x89, 0x84, 0x3F, 0x80, 0x00, 0x00); + EventCapturingEbmlEventHandler expected = new EventCapturingEbmlEventHandler(); + expected.onFloatElement(EventCapturingEbmlEventHandler.ID_DURATION, 1.0); + assertEvents(input, EbmlReader.READ_RESULT_END_OF_STREAM, expected.events); + } + + public void testFloatElementEightBytes() { + NonBlockingInputStream input = + createTestInputStream(0x44, 0x89, 0x88, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00); + + EventCapturingEbmlEventHandler expected = new EventCapturingEbmlEventHandler(); + expected.onFloatElement(EventCapturingEbmlEventHandler.ID_DURATION, -2.0); + assertEvents(input, EbmlReader.READ_RESULT_END_OF_STREAM, expected.events); + } + + public void testBinaryElementReadBytes() { + eventHandler.binaryElementHandler = EventCapturingEbmlEventHandler.HANDLER_READ_BYTES; + NonBlockingInputStream input = + createTestInputStream(0xA3, 0x88, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08); + + EventCapturingEbmlEventHandler expected = new EventCapturingEbmlEventHandler(); + expected.binaryElementHandler = EventCapturingEbmlEventHandler.HANDLER_READ_BYTES; + expected.onBinaryElement( + EventCapturingEbmlEventHandler.ID_SIMPLE_BLOCK, 0, 0, 8, + createTestInputStream(0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08)); + assertEvents(input, EbmlReader.READ_RESULT_END_OF_STREAM, expected.events); + } + + public void testBinaryElementReadVarint() { + eventHandler.binaryElementHandler = EventCapturingEbmlEventHandler.HANDLER_READ_VARINT; + NonBlockingInputStream input = createTestInputStream(0xA3, 0x82, 0x40, 0x2A); + + EventCapturingEbmlEventHandler expected = new EventCapturingEbmlEventHandler(); + expected.binaryElementHandler = EventCapturingEbmlEventHandler.HANDLER_READ_VARINT; + expected.onBinaryElement( + EventCapturingEbmlEventHandler.ID_SIMPLE_BLOCK, 0, 0, 0, + createTestInputStream(0x40, 0x2A)); + assertEvents(input, EbmlReader.READ_RESULT_END_OF_STREAM, expected.events); + } + + public void testBinaryElementSkipBytes() { + eventHandler.binaryElementHandler = EventCapturingEbmlEventHandler.HANDLER_SKIP_BYTES; + NonBlockingInputStream input = + createTestInputStream(0xA3, 0x88, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08); + + EventCapturingEbmlEventHandler expected = new EventCapturingEbmlEventHandler(); + expected.binaryElementHandler = EventCapturingEbmlEventHandler.HANDLER_SKIP_BYTES; + expected.onBinaryElement( + EventCapturingEbmlEventHandler.ID_SIMPLE_BLOCK, 0, 0, 8, + createTestInputStream(0, 0, 0, 0, 0, 0, 0, 0)); + assertEvents(input, EbmlReader.READ_RESULT_END_OF_STREAM, expected.events); + } + + public void testBinaryElementDoNothing() { + eventHandler.binaryElementHandler = EventCapturingEbmlEventHandler.HANDLER_DO_NOTHING; + try { + eventHandler.read( + createTestInputStream(0xA3, 0x88, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08)); + fail(); + } catch (IllegalStateException exception) { + // Expected + } + assertNoEvents(); + } + + public void testBinaryElementNotEnoughBytes() { + NonBlockingInputStream input = createTestInputStream(0xA3, 0x88, 0x01, 0x02, 0x03); + assertNoEvents(input, EbmlReader.READ_RESULT_NEED_MORE_DATA); + } + + public void testUnknownElement() { + NonBlockingInputStream input = createTestInputStream(0xEC, 0x81, 0x00); + assertNoEvents(input, EbmlReader.READ_RESULT_END_OF_STREAM); + } + + /** + * Helper to build a {@link ByteArrayNonBlockingInputStream} quickly from zero or more + * integer arguments. + * + *

Each argument must be able to cast to a byte value. + * + * @param data Zero or more integers with values between {@code 0x00} and {@code 0xFF} + * @return A {@link ByteArrayNonBlockingInputStream} containing the given byte values + */ + private NonBlockingInputStream createTestInputStream(int... data) { + byte[] bytes = new byte[data.length]; + for (int i = 0; i < data.length; i++) { + bytes[i] = (byte) data[i]; + } + return new ByteArrayNonBlockingInputStream(bytes); + } + + private void assertReads(NonBlockingInputStream input, int continues, int finalResult) { + for (int i = 0; i < continues; i++) { + assertEquals(EbmlReader.READ_RESULT_CONTINUE, eventHandler.read(input)); + } + assertEquals(finalResult, eventHandler.read(input)); + } + + private void assertNoEvents() { + assertEvents(Collections.emptyList()); + } + + private void assertEvents(List events) { + assertEquals(events.size(), eventHandler.events.size()); + for (int i = 0; i < events.size(); i++) { + assertEquals(events.get(i), eventHandler.events.get(i)); + } + } + + private void assertNoEvents(NonBlockingInputStream input, int finalResult) { + assertReads(input, 0, finalResult); + assertNoEvents(); + } + + private void assertEvents(NonBlockingInputStream input, int finalResult, List events) { + assertReads(input, events.size(), finalResult); + assertEvents(events); + } + + /** + * An {@link EbmlEventHandler} which captures all event callbacks made by + * {@link DefaultEbmlReader} for testing purposes. + */ + private static final class EventCapturingEbmlEventHandler implements EbmlEventHandler { + + // Element IDs + private static final int ID_EBML = 0x1A45DFA3; + private static final int ID_EBML_READ_VERSION = 0x42F7; + private static final int ID_DOC_TYPE = 0x4282; + private static final int ID_DOC_TYPE_READ_VERSION = 0x4285; + + private static final int ID_SEGMENT = 0x18538067; + private static final int ID_DURATION = 0x4489; + private static final int ID_SIMPLE_BLOCK = 0xA3; + + // Various ways to handle things in onBinaryElement() + private static final int HANDLER_DO_NOTHING = 0; + private static final int HANDLER_READ_BYTES = 1; + private static final int HANDLER_READ_VARINT = 2; + private static final int HANDLER_SKIP_BYTES = 3; + + private final EbmlReader reader = new DefaultEbmlReader(); + private final List events = new ArrayList(); + + private int binaryElementHandler; + + private EventCapturingEbmlEventHandler() { + reader.setEventHandler(this); + } + + private int read(NonBlockingInputStream inputStream) { + try { + return reader.read(inputStream); + } catch (ParserException e) { + // should never happen. + fail(); + return -1; + } + } + + @Override + public int getElementType(int id) { + switch (id) { + case ID_EBML: + case ID_SEGMENT: + return EbmlReader.TYPE_MASTER; + case ID_EBML_READ_VERSION: + case ID_DOC_TYPE_READ_VERSION: + return EbmlReader.TYPE_UNSIGNED_INT; + case ID_DOC_TYPE: + return EbmlReader.TYPE_STRING; + case ID_SIMPLE_BLOCK: + return EbmlReader.TYPE_BINARY; + case ID_DURATION: + return EbmlReader.TYPE_FLOAT; + default: + return EbmlReader.TYPE_UNKNOWN; + } + } + + @Override + public void onMasterElementStart( + int id, long elementOffset, int headerSize, long contentsSize) { + events.add(formatEvent(id, "start elementOffset=" + elementOffset + + " headerSize=" + headerSize + " contentsSize=" + contentsSize)); + } + + @Override + public void onMasterElementEnd(int id) { + events.add(formatEvent(id, "end")); + } + + @Override + public void onIntegerElement(int id, long value) { + events.add(formatEvent(id, "integer=" + String.valueOf(value))); + } + + @Override + public void onFloatElement(int id, double value) { + events.add(formatEvent(id, "float=" + String.valueOf(value))); + } + + @Override + public void onStringElement(int id, String value) { + events.add(formatEvent(id, "string=" + value)); + } + + @Override + public boolean onBinaryElement( + int id, long elementOffset, int headerSize, int contentsSize, + NonBlockingInputStream inputStream) { + switch (binaryElementHandler) { + case HANDLER_READ_BYTES: + byte[] bytes = new byte[contentsSize]; + reader.readBytes(inputStream, bytes, contentsSize); + events.add(formatEvent(id, "bytes=" + Arrays.toString(bytes))); + break; + case HANDLER_READ_VARINT: + long value = reader.readVarint(inputStream); + events.add(formatEvent(id, "varint=" + String.valueOf(value))); + break; + case HANDLER_SKIP_BYTES: + reader.skipBytes(inputStream, contentsSize); + events.add(formatEvent(id, "skipped " + contentsSize + " byte(s)")); + break; + case HANDLER_DO_NOTHING: + default: + // pass + } + return true; + } + + private static String formatEvent(int id, String event) { + return "[" + Integer.toHexString(id) + "] " + event; + } + + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/chunk/parser/webm/WebmExtractorTest.java b/library/src/test/java/com/google/android/exoplayer/chunk/parser/webm/WebmExtractorTest.java new file mode 100644 index 0000000000..4a916a50f5 --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/chunk/parser/webm/WebmExtractorTest.java @@ -0,0 +1,717 @@ +/* + * Copyright (C) 2014 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.exoplayer.chunk.parser.webm; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.ParserException; +import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.chunk.parser.SegmentIndex; +import com.google.android.exoplayer.drm.DrmInitData; +import com.google.android.exoplayer.upstream.ByteArrayNonBlockingInputStream; +import com.google.android.exoplayer.upstream.NonBlockingInputStream; +import com.google.android.exoplayer.util.MimeTypes; + +import android.test.InstrumentationTestCase; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.UUID; + +public class WebmExtractorTest extends InstrumentationTestCase { + + private static final int INFO_ELEMENT_BYTE_SIZE = 31; + private static final int TRACKS_ELEMENT_BYTE_SIZE = 48; + private static final int CUES_ELEMENT_BYTE_SIZE = 12; + private static final int CUE_POINT_ELEMENT_BYTE_SIZE = 31; + + private static final int DEFAULT_TIMECODE_SCALE = 1000000; + + private static final long TEST_DURATION_US = 9920000L; + private static final int TEST_WIDTH = 1280; + private static final int TEST_HEIGHT = 720; + private static final int TEST_CHANNEL_COUNT = 1; + private static final int TEST_SAMPLE_RATE = 48000; + private static final long TEST_CODEC_DELAY = 6500000; + private static final long TEST_SEEK_PRE_ROLL = 80000000; + private static final int TEST_OPUS_CODEC_PRIVATE_SIZE = 2; + private static final String TEST_VORBIS_CODEC_PRIVATE = "webm/vorbis_codec_private"; + private static final int TEST_VORBIS_INFO_SIZE = 30; + private static final int TEST_VORBIS_BOOKS_SIZE = 4140; + private static final byte[] TEST_ENCRYPTION_KEY_ID = { 0x00, 0x01, 0x02, 0x03 }; + private static final UUID WIDEVINE_UUID = new UUID(0xEDEF8BA979D64ACEL, 0xA3C827DCD51D21EDL); + private static final UUID ZERO_UUID = new UUID(0, 0); + // First 8 bytes of IV come from the container, last 8 bytes are always initialized to 0. + private static final byte[] TEST_INITIALIZATION_VECTOR = { + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; + + private static final int ID_VP9 = 0; + private static final int ID_OPUS = 1; + private static final int ID_VORBIS = 2; + + private static final int EXPECTED_INIT_RESULT = WebmExtractor.RESULT_READ_INIT + | WebmExtractor.RESULT_READ_INDEX | WebmExtractor.RESULT_END_OF_STREAM; + private static final int EXPECTED_INIT_AND_SAMPLE_RESULT = WebmExtractor.RESULT_READ_INIT + | WebmExtractor.RESULT_READ_INDEX | WebmExtractor.RESULT_READ_SAMPLE; + + private final WebmExtractor extractor = new WebmExtractor(); + private final SampleHolder sampleHolder = + new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_DISABLED); + + @Override + public void setUp() { + sampleHolder.data = ByteBuffer.allocate(1024); + } + + public void testPrepare() throws ParserException { + NonBlockingInputStream testInputStream = new ByteArrayNonBlockingInputStream( + createInitializationSegment(1, 0, true, DEFAULT_TIMECODE_SCALE, ID_VP9, null)); + assertEquals(EXPECTED_INIT_RESULT, extractor.read(testInputStream, sampleHolder)); + assertFormat(); + assertIndex(new IndexPoint(0, 0, TEST_DURATION_US)); + } + + public void testPrepareOpus() throws ParserException { + NonBlockingInputStream testInputStream = new ByteArrayNonBlockingInputStream( + createInitializationSegment(1, 0, true, DEFAULT_TIMECODE_SCALE, ID_OPUS, null)); + assertEquals(EXPECTED_INIT_RESULT, extractor.read(testInputStream, sampleHolder)); + assertAudioFormat(ID_OPUS); + assertIndex(new IndexPoint(0, 0, TEST_DURATION_US)); + } + + public void testPrepareVorbis() throws ParserException { + NonBlockingInputStream testInputStream = new ByteArrayNonBlockingInputStream( + createInitializationSegment(1, 0, true, DEFAULT_TIMECODE_SCALE, ID_VORBIS, null)); + assertEquals(EXPECTED_INIT_RESULT, extractor.read(testInputStream, sampleHolder)); + assertAudioFormat(ID_VORBIS); + assertIndex(new IndexPoint(0, 0, TEST_DURATION_US)); + } + + public void testPrepareContentEncodingEncryption() throws ParserException { + ContentEncodingSettings settings = new ContentEncodingSettings(0, 1, 1, 5, 1); + NonBlockingInputStream testInputStream = new ByteArrayNonBlockingInputStream( + createInitializationSegment(1, 0, true, DEFAULT_TIMECODE_SCALE, ID_VP9, settings)); + assertEquals(EXPECTED_INIT_RESULT, extractor.read(testInputStream, sampleHolder)); + assertFormat(); + assertIndex(new IndexPoint(0, 0, TEST_DURATION_US)); + DrmInitData drmInitData = extractor.getDrmInitData(); + assertNotNull(drmInitData); + android.test.MoreAsserts.assertEquals(TEST_ENCRYPTION_KEY_ID, drmInitData.get(WIDEVINE_UUID)); + android.test.MoreAsserts.assertEquals(TEST_ENCRYPTION_KEY_ID, drmInitData.get(ZERO_UUID)); + } + + public void testPrepareThreeCuePoints() throws ParserException { + NonBlockingInputStream testInputStream = new ByteArrayNonBlockingInputStream( + createInitializationSegment(3, 0, true, DEFAULT_TIMECODE_SCALE, ID_VP9, null)); + assertEquals(EXPECTED_INIT_RESULT, extractor.read(testInputStream, sampleHolder)); + assertFormat(); + assertIndex( + new IndexPoint(0, 0, 10000), + new IndexPoint(10000, 0, 10000), + new IndexPoint(20000, 0, TEST_DURATION_US - 20000)); + } + + public void testPrepareCustomTimecodeScale() throws ParserException { + NonBlockingInputStream testInputStream = new ByteArrayNonBlockingInputStream( + createInitializationSegment(3, 0, true, 1000, ID_VP9, null)); + assertEquals(EXPECTED_INIT_RESULT, extractor.read(testInputStream, sampleHolder)); + assertFormat(); + assertIndex( + new IndexPoint(0, 0, 10), + new IndexPoint(10, 0, 10), + new IndexPoint(20, 0, (TEST_DURATION_US / 1000) - 20)); + } + + public void testPrepareNoCuePoints() { + NonBlockingInputStream testInputStream = new ByteArrayNonBlockingInputStream( + createInitializationSegment(0, 0, true, DEFAULT_TIMECODE_SCALE, ID_VP9, null)); + try { + extractor.read(testInputStream, sampleHolder); + fail(); + } catch (ParserException exception) { + assertEquals("Invalid/missing cue points", exception.getMessage()); + } + } + + public void testPrepareInvalidDocType() { + NonBlockingInputStream testInputStream = new ByteArrayNonBlockingInputStream( + createInitializationSegment(1, 0, false, DEFAULT_TIMECODE_SCALE, ID_VP9, null)); + try { + extractor.read(testInputStream, sampleHolder); + fail(); + } catch (ParserException exception) { + assertEquals("DocType webB not supported", exception.getMessage()); + } + } + + public void testPrepareInvalidContentEncodingOrder() { + ContentEncodingSettings settings = new ContentEncodingSettings(1, 1, 1, 5, 1); + NonBlockingInputStream testInputStream = new ByteArrayNonBlockingInputStream( + createInitializationSegment(1, 0, true, DEFAULT_TIMECODE_SCALE, ID_VP9, settings)); + try { + extractor.read(testInputStream, sampleHolder); + fail(); + } catch (ParserException exception) { + assertEquals("ContentEncodingOrder 1 not supported", exception.getMessage()); + } + } + + public void testPrepareInvalidContentEncodingScope() { + ContentEncodingSettings settings = new ContentEncodingSettings(0, 0, 1, 5, 1); + NonBlockingInputStream testInputStream = new ByteArrayNonBlockingInputStream( + createInitializationSegment(1, 0, true, DEFAULT_TIMECODE_SCALE, ID_VP9, settings)); + try { + extractor.read(testInputStream, sampleHolder); + fail(); + } catch (ParserException exception) { + assertEquals("ContentEncodingScope 0 not supported", exception.getMessage()); + } + } + + public void testPrepareInvalidContentEncodingType() { + ContentEncodingSettings settings = new ContentEncodingSettings(0, 1, 0, 5, 1); + NonBlockingInputStream testInputStream = new ByteArrayNonBlockingInputStream( + createInitializationSegment(1, 0, true, DEFAULT_TIMECODE_SCALE, ID_VP9, settings)); + try { + extractor.read(testInputStream, sampleHolder); + fail(); + } catch (ParserException exception) { + assertEquals("ContentEncodingType 0 not supported", exception.getMessage()); + } + } + + public void testPrepareInvalidContentEncAlgo() { + ContentEncodingSettings settings = new ContentEncodingSettings(0, 1, 1, 4, 1); + NonBlockingInputStream testInputStream = new ByteArrayNonBlockingInputStream( + createInitializationSegment(1, 0, true, DEFAULT_TIMECODE_SCALE, ID_VP9, settings)); + try { + extractor.read(testInputStream, sampleHolder); + fail(); + } catch (ParserException exception) { + assertEquals("ContentEncAlgo 4 not supported", exception.getMessage()); + } + } + + public void testPrepareInvalidAESSettingsCipherMode() { + ContentEncodingSettings settings = new ContentEncodingSettings(0, 1, 1, 5, 0); + NonBlockingInputStream testInputStream = new ByteArrayNonBlockingInputStream( + createInitializationSegment(1, 0, true, DEFAULT_TIMECODE_SCALE, ID_VP9, settings)); + try { + extractor.read(testInputStream, sampleHolder); + fail(); + } catch (ParserException exception) { + assertEquals("AESSettingsCipherMode 0 not supported", exception.getMessage()); + } + } + + public void testReadSampleKeyframe() throws ParserException { + MediaSegment mediaSegment = createMediaSegment(100, 0, 0, true, false, true, false, false); + byte[] testInputData = joinByteArrays( + createInitializationSegment( + 1, mediaSegment.clusterBytes.length, true, DEFAULT_TIMECODE_SCALE, ID_VP9, null), + mediaSegment.clusterBytes); + NonBlockingInputStream testInputStream = new ByteArrayNonBlockingInputStream(testInputData); + assertEquals(EXPECTED_INIT_AND_SAMPLE_RESULT, extractor.read(testInputStream, sampleHolder)); + assertFormat(); + assertSample(mediaSegment, 0, true, false, false); + assertEquals(WebmExtractor.RESULT_END_OF_STREAM, extractor.read(testInputStream, sampleHolder)); + } + + public void testReadBlock() throws ParserException { + MediaSegment mediaSegment = createMediaSegment(100, 0, 0, true, false, false, false, false); + byte[] testInputData = joinByteArrays( + createInitializationSegment( + 1, mediaSegment.clusterBytes.length, true, DEFAULT_TIMECODE_SCALE, ID_OPUS, null), + mediaSegment.clusterBytes); + NonBlockingInputStream testInputStream = new ByteArrayNonBlockingInputStream(testInputData); + assertEquals(EXPECTED_INIT_AND_SAMPLE_RESULT, extractor.read(testInputStream, sampleHolder)); + assertAudioFormat(ID_OPUS); + assertSample(mediaSegment, 0, true, false, false); + assertEquals(WebmExtractor.RESULT_END_OF_STREAM, extractor.read(testInputStream, sampleHolder)); + } + + public void testReadEncryptedFrame() throws ParserException { + MediaSegment mediaSegment = createMediaSegment(100, 0, 0, true, false, true, true, true); + ContentEncodingSettings settings = new ContentEncodingSettings(0, 1, 1, 5, 1); + byte[] testInputData = joinByteArrays( + createInitializationSegment( + 1, mediaSegment.clusterBytes.length, true, DEFAULT_TIMECODE_SCALE, ID_VP9, settings), + mediaSegment.clusterBytes); + NonBlockingInputStream testInputStream = new ByteArrayNonBlockingInputStream(testInputData); + assertEquals(EXPECTED_INIT_AND_SAMPLE_RESULT, extractor.read(testInputStream, sampleHolder)); + assertFormat(); + assertSample(mediaSegment, 0, true, false, true); + assertEquals(WebmExtractor.RESULT_END_OF_STREAM, extractor.read(testInputStream, sampleHolder)); + } + + public void testReadEncryptedFrameWithInvalidSignalByte() { + MediaSegment mediaSegment = createMediaSegment(100, 0, 0, true, false, true, true, false); + ContentEncodingSettings settings = new ContentEncodingSettings(0, 1, 1, 5, 1); + byte[] testInputData = joinByteArrays( + createInitializationSegment( + 1, mediaSegment.clusterBytes.length, true, DEFAULT_TIMECODE_SCALE, ID_VP9, settings), + mediaSegment.clusterBytes); + NonBlockingInputStream testInputStream = new ByteArrayNonBlockingInputStream(testInputData); + try { + extractor.read(testInputStream, sampleHolder); + fail(); + } catch (ParserException exception) { + assertEquals("Extension bit is set in signal byte", exception.getMessage()); + } + } + + public void testReadSampleInvisible() throws ParserException { + MediaSegment mediaSegment = createMediaSegment(100, 12, 13, false, true, true, false, false); + byte[] testInputData = joinByteArrays( + createInitializationSegment( + 1, mediaSegment.clusterBytes.length, true, DEFAULT_TIMECODE_SCALE, ID_VP9, null), + mediaSegment.clusterBytes); + NonBlockingInputStream testInputStream = new ByteArrayNonBlockingInputStream(testInputData); + assertEquals(EXPECTED_INIT_AND_SAMPLE_RESULT, extractor.read(testInputStream, sampleHolder)); + assertFormat(); + assertSample(mediaSegment, 25000, false, true, false); + assertEquals(WebmExtractor.RESULT_END_OF_STREAM, extractor.read(testInputStream, sampleHolder)); + } + + public void testReadSampleCustomTimescale() throws ParserException { + MediaSegment mediaSegment = createMediaSegment(100, 12, 13, false, false, true, false, false); + byte[] testInputData = joinByteArrays( + createInitializationSegment( + 1, mediaSegment.clusterBytes.length, true, 1000, ID_VP9, null), + mediaSegment.clusterBytes); + NonBlockingInputStream testInputStream = new ByteArrayNonBlockingInputStream(testInputData); + assertEquals(EXPECTED_INIT_AND_SAMPLE_RESULT, extractor.read(testInputStream, sampleHolder)); + assertFormat(); + assertSample(mediaSegment, 25, false, false, false); + assertEquals(WebmExtractor.RESULT_END_OF_STREAM, extractor.read(testInputStream, sampleHolder)); + } + + public void testReadSampleNegativeSimpleBlockTimecode() throws ParserException { + MediaSegment mediaSegment = createMediaSegment(100, 13, -12, true, true, true, false, false); + byte[] testInputData = joinByteArrays( + createInitializationSegment( + 1, mediaSegment.clusterBytes.length, true, DEFAULT_TIMECODE_SCALE, ID_VP9, null), + mediaSegment.clusterBytes); + NonBlockingInputStream testInputStream = new ByteArrayNonBlockingInputStream(testInputData); + assertEquals(EXPECTED_INIT_AND_SAMPLE_RESULT, extractor.read(testInputStream, sampleHolder)); + assertFormat(); + assertSample(mediaSegment, 1000, true, true, false); + assertEquals(WebmExtractor.RESULT_END_OF_STREAM, extractor.read(testInputStream, sampleHolder)); + } + + private void assertFormat() { + MediaFormat format = extractor.getFormat(); + assertEquals(TEST_WIDTH, format.width); + assertEquals(TEST_HEIGHT, format.height); + assertEquals(MimeTypes.VIDEO_VP9, format.mimeType); + } + + private void assertAudioFormat(int codecId) { + MediaFormat format = extractor.getFormat(); + assertEquals(TEST_CHANNEL_COUNT, format.channelCount); + assertEquals(TEST_SAMPLE_RATE, format.sampleRate); + if (codecId == ID_OPUS) { + assertEquals(MimeTypes.AUDIO_OPUS, format.mimeType); + assertEquals(3, format.initializationData.size()); + assertEquals(TEST_OPUS_CODEC_PRIVATE_SIZE, format.initializationData.get(0).length); + assertEquals(TEST_CODEC_DELAY, ByteBuffer.wrap(format.initializationData.get(1)).getLong()); + assertEquals(TEST_SEEK_PRE_ROLL, ByteBuffer.wrap(format.initializationData.get(2)).getLong()); + } else if (codecId == ID_VORBIS) { + assertEquals(MimeTypes.AUDIO_VORBIS, format.mimeType); + assertEquals(2, format.initializationData.size()); + assertEquals(TEST_VORBIS_INFO_SIZE, format.initializationData.get(0).length); + assertEquals(TEST_VORBIS_BOOKS_SIZE, format.initializationData.get(1).length); + } + } + + private void assertIndex(IndexPoint... indexPoints) { + SegmentIndex index = extractor.getIndex(); + assertEquals(CUES_ELEMENT_BYTE_SIZE + CUE_POINT_ELEMENT_BYTE_SIZE * indexPoints.length, + index.sizeBytes); + assertEquals(indexPoints.length, index.length); + for (int i = 0; i < indexPoints.length; i++) { + IndexPoint indexPoint = indexPoints[i]; + assertEquals(indexPoint.timeUs, index.timesUs[i]); + assertEquals(indexPoint.size, index.sizes[i]); + assertEquals(indexPoint.durationUs, index.durationsUs[i]); + } + } + + private void assertSample( + MediaSegment mediaSegment, int timeUs, boolean keyframe, boolean invisible, + boolean encrypted) { + assertTrue(Arrays.equals( + mediaSegment.videoBytes, Arrays.copyOf(sampleHolder.data.array(), sampleHolder.size))); + assertEquals(timeUs, sampleHolder.timeUs); + assertEquals(keyframe, sampleHolder.isSyncFrame()); + assertEquals(invisible, sampleHolder.isDecodeOnly()); + assertEquals(encrypted, sampleHolder.isEncrypted()); + if (encrypted) { + android.test.MoreAsserts.assertEquals(TEST_INITIALIZATION_VECTOR, sampleHolder.cryptoInfo.iv); + assertEquals(C.CRYPTO_MODE_AES_CTR, sampleHolder.cryptoInfo.mode); + assertEquals(1, sampleHolder.cryptoInfo.numSubSamples); + assertEquals(100, sampleHolder.cryptoInfo.numBytesOfEncryptedData[0]); + assertEquals(0, sampleHolder.cryptoInfo.numBytesOfClearData[0]); + } + } + + private byte[] createInitializationSegment( + int cuePoints, int mediaSegmentSize, boolean docTypeIsWebm, int timecodeScale, + int codecId, ContentEncodingSettings contentEncodingSettings) { + int initalizationSegmentSize = INFO_ELEMENT_BYTE_SIZE + TRACKS_ELEMENT_BYTE_SIZE + + CUES_ELEMENT_BYTE_SIZE + CUE_POINT_ELEMENT_BYTE_SIZE * cuePoints; + byte[] tracksElement = null; + switch (codecId) { + case ID_VP9: + tracksElement = createTracksElementWithVideo( + true, TEST_WIDTH, TEST_HEIGHT, contentEncodingSettings); + break; + case ID_OPUS: + tracksElement = createTracksElementWithOpusAudio(TEST_CHANNEL_COUNT); + break; + case ID_VORBIS: + tracksElement = createTracksElementWithVorbisAudio(TEST_CHANNEL_COUNT); + break; + } + byte[] bytes = joinByteArrays(createEbmlElement(1, docTypeIsWebm, 2), + createSegmentElement(initalizationSegmentSize + mediaSegmentSize), + createInfoElement(timecodeScale), + tracksElement, + createCuesElement(CUE_POINT_ELEMENT_BYTE_SIZE * cuePoints)); + for (int i = 0; i < cuePoints; i++) { + bytes = joinByteArrays(bytes, createCuePointElement(10 * i, initalizationSegmentSize)); + } + return bytes; + } + + private static MediaSegment createMediaSegment(int videoBytesLength, int clusterTimecode, + int blockTimecode, boolean keyframe, boolean invisible, boolean simple, + boolean encrypted, boolean validSignalByte) { + byte[] videoBytes = createVideoBytes(videoBytesLength); + byte[] blockBytes; + if (simple) { + blockBytes = createSimpleBlockElement(videoBytes.length, blockTimecode, + keyframe, invisible, true, encrypted, validSignalByte); + } else { + blockBytes = createBlockElement(videoBytes.length, blockTimecode, invisible, true); + } + byte[] clusterBytes = + createClusterElement(blockBytes.length + videoBytes.length, clusterTimecode); + return new MediaSegment(joinByteArrays(clusterBytes, blockBytes, videoBytes), videoBytes); + } + + private static byte[] joinByteArrays(byte[]... byteArrays) { + int length = 0; + for (byte[] byteArray : byteArrays) { + length += byteArray.length; + } + byte[] joined = new byte[length]; + length = 0; + for (byte[] byteArray : byteArrays) { + System.arraycopy(byteArray, 0, joined, length, byteArray.length); + length += byteArray.length; + } + return joined; + } + + private static byte[] createEbmlElement( + int ebmlReadVersion, boolean docTypeIsWebm, int docTypeReadVersion) { + return createByteArray( + 0x1A, 0x45, 0xDF, 0xA3, // EBML + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0F, // size=15 + 0x42, 0xF7, // EBMLReadVersion + 0x81, ebmlReadVersion, // size=1 + 0x42, 0x82, // DocType + 0x84, 0x77, 0x65, 0x62, docTypeIsWebm ? 0x6D : 0x42, // size=4 value=webm/B + 0x42, 0x85, // DocTypeReadVersion + 0x81, docTypeReadVersion); // size=1 + } + + private static byte[] createSegmentElement(int size) { + byte[] sizeBytes = getIntegerBytes(size); + return createByteArray( + 0x18, 0x53, 0x80, 0x67, // Segment + 0x01, 0x00, 0x00, 0x00, sizeBytes[0], sizeBytes[1], sizeBytes[2], sizeBytes[3]); + } + + private static byte[] createInfoElement(int timecodeScale) { + byte[] scaleBytes = getIntegerBytes(timecodeScale); + return createByteArray( + 0x15, 0x49, 0xA9, 0x66, // Info + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x13, // size=19 + 0x2A, 0xD7, 0xB1, // TimecodeScale + 0x84, scaleBytes[0], scaleBytes[1], scaleBytes[2], scaleBytes[3], // size=4 + 0x44, 0x89, // Duration + 0x88, 0x40, 0xC3, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00); // size=8 value=9920.0 + } + + private static byte[] createTracksElementWithVideo( + boolean codecIsVp9, int pixelWidth, int pixelHeight, + ContentEncodingSettings contentEncodingSettings) { + byte[] widthBytes = getIntegerBytes(pixelWidth); + byte[] heightBytes = getIntegerBytes(pixelHeight); + if (contentEncodingSettings != null) { + byte[] orderBytes = getIntegerBytes(contentEncodingSettings.order); + byte[] scopeBytes = getIntegerBytes(contentEncodingSettings.scope); + byte[] typeBytes = getIntegerBytes(contentEncodingSettings.type); + byte[] algorithmBytes = getIntegerBytes(contentEncodingSettings.algorithm); + byte[] cipherModeBytes = getIntegerBytes(contentEncodingSettings.aesCipherMode); + return createByteArray( + 0x16, 0x54, 0xAE, 0x6B, // Tracks + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x48, // size=72 + 0xAE, // TrackEntry + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3F, // size=63 + 0x86, // CodecID + 0x85, 0x56, 0x5F, 0x56, 0x50, codecIsVp9 ? 0x39 : 0x30, // size=5 value=V_VP9/0 + 0x6D, 0x80, // ContentEncodings + 0xA4, // size=36 + 0x62, 0x40, // ContentEncoding + 0xA1, // size=33 + 0x50, 0x31, // ContentEncodingOrder + 0x81, orderBytes[3], + 0x50, 0x32, // ContentEncodingScope + 0x81, scopeBytes[3], + 0x50, 0x33, // ContentEncodingType + 0x81, typeBytes[3], + 0x50, 0x35, // ContentEncryption + 0x92, // size=18 + 0x47, 0xE1, // ContentEncAlgo + 0x81, algorithmBytes[3], + 0x47, 0xE2, // ContentEncKeyID + 0x84, // size=4 + TEST_ENCRYPTION_KEY_ID[0], TEST_ENCRYPTION_KEY_ID[1], + TEST_ENCRYPTION_KEY_ID[2], TEST_ENCRYPTION_KEY_ID[3], // value=binary + 0x47, 0xE7, // ContentEncAESSettings + 0x84, // size=4 + 0x47, 0xE8, // AESSettingsCipherMode + 0x81, cipherModeBytes[3], + 0xE0, // Video + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, // size=8 + 0xB0, // PixelWidth + 0x82, widthBytes[2], widthBytes[3], // size=2 + 0xBA, // PixelHeight + 0x82, heightBytes[2], heightBytes[3]); // size=2 + } else { + return createByteArray( + 0x16, 0x54, 0xAE, 0x6B, // Tracks + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x24, // size=36 + 0xAE, // TrackEntry + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1B, // size=27 + 0x86, // CodecID + 0x85, 0x56, 0x5F, 0x56, 0x50, codecIsVp9 ? 0x39 : 0x30, // size=5 value=V_VP9/0 + 0xE0, // Video + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, // size=8 + 0xB0, // PixelWidth + 0x82, widthBytes[2], widthBytes[3], // size=2 + 0xBA, // PixelHeight + 0x82, heightBytes[2], heightBytes[3]); // size=2 + } + } + + private static byte[] createTracksElementWithOpusAudio(int channelCount) { + byte[] channelCountBytes = getIntegerBytes(channelCount); + return createByteArray( + 0x16, 0x54, 0xAE, 0x6B, // Tracks + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x39, // size=57 + 0xAE, // TrackEntry + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, // size=48 + 0x86, // CodecID + 0x86, 0x41, 0x5F, 0x4F, 0x50, 0x55, 0x53, // size=6 value=A_OPUS + 0x56, 0xAA, // CodecDelay + 0x83, 0x63, 0x2E, 0xA0, // size=3 value=6500000 + 0x56, 0xBB, // SeekPreRoll + 0x84, 0x04, 0xC4, 0xB4, 0x00, // size=4 value=80000000 + 0xE1, // Audio + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0D, // size=13 + 0x9F, // Channels + 0x81, channelCountBytes[3], // size=1 + 0xB5, // SamplingFrequency + 0x88, 0x40, 0xE7, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, // size=8 value=48000 + 0x63, 0xA2, // CodecPrivate + 0x82, 0x00, 0x00); // size=2 + } + + private byte[] createTracksElementWithVorbisAudio(int channelCount) { + byte[] channelCountBytes = getIntegerBytes(channelCount); + byte[] tracksElement = createByteArray( + 0x16, 0x54, 0xAE, 0x6B, // Tracks + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x9C, // size=4252 + 0xAE, // TrackEntry + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x93, // size=4243 (36+4207) + 0x86, // CodecID + 0x88, 0x41, 0x5f, 0x56, 0x4f, 0x52, 0x42, 0x49, 0x53, // size=8 value=A_VORBIS + 0xE1, // Audio + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0D, // size=13 + 0x9F, // Channels + 0x81, channelCountBytes[3], // size=1 + 0xB5, // SamplingFrequency + 0x88, 0x40, 0xE7, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, // size=8 value=48000 + 0x63, 0xA2, // CodecPrivate + 0x50, 0x6F); // size=4207 + byte[] codecPrivate = new byte[4207]; + try { + getInstrumentation().getContext().getResources().getAssets().open(TEST_VORBIS_CODEC_PRIVATE) + .read(codecPrivate); + } catch (IOException e) { + fail(); // should never happen + } + return joinByteArrays(tracksElement, codecPrivate); + } + + private static byte[] createCuesElement(int size) { + byte[] sizeBytes = getIntegerBytes(size); + return createByteArray( + 0x1C, 0x53, 0xBB, 0x6B, // Cues + 0x01, 0x00, 0x00, 0x00, sizeBytes[0], sizeBytes[1], sizeBytes[2], sizeBytes[3]); // size=31 + } + + private static byte[] createCuePointElement(int cueTime, int cueClusterPosition) { + byte[] positionBytes = getIntegerBytes(cueClusterPosition); + return createByteArray( + 0xBB, // CuePoint + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x16, // size=22 + 0xB3, // CueTime + 0x81, cueTime, // size=1 + 0xB7, // CueTrackPositions + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0A, // size=10 + 0xF1, // CueClusterPosition + 0x88, 0x00, 0x00, 0x00, 0x00, positionBytes[0], positionBytes[1], + positionBytes[2], positionBytes[3]); // size=8 + } + + private static byte[] createClusterElement(int size, int timecode) { + byte[] sizeBytes = getIntegerBytes(size); + byte[] timeBytes = getIntegerBytes(timecode); + return createByteArray( + 0x1F, 0x43, 0xB6, 0x75, // Cluster + 0x01, 0x00, 0x00, 0x00, sizeBytes[0], sizeBytes[1], sizeBytes[2], sizeBytes[3], + 0xE7, // Timecode + 0x84, timeBytes[0], timeBytes[1], timeBytes[2], timeBytes[3]); // size=4 + } + + private static byte[] createSimpleBlockElement( + int size, int timecode, boolean keyframe, boolean invisible, boolean noLacing, + boolean encrypted, boolean validSignalByte) { + byte[] sizeBytes = getIntegerBytes(size + 4 + (encrypted ? 9 : 0)); + byte[] timeBytes = getIntegerBytes(timecode); + byte flags = (byte) + ((keyframe ? 0x80 : 0x00) | (invisible ? 0x08 : 0x00) | (noLacing ? 0x00 : 0x06)); + byte[] simpleBlock = createByteArray( + 0xA3, // SimpleBlock + 0x01, 0x00, 0x00, 0x00, sizeBytes[0], sizeBytes[1], sizeBytes[2], sizeBytes[3], + 0x81, // Track number value=1 + timeBytes[2], timeBytes[3], flags); // Timecode and flags + if (encrypted) { + simpleBlock = joinByteArrays( + simpleBlock, createByteArray(validSignalByte ? 0x01 : 0x80), + Arrays.copyOfRange(TEST_INITIALIZATION_VECTOR, 0, 8)); + } + return simpleBlock; + } + + private static byte[] createBlockElement( + int size, int timecode, boolean invisible, boolean noLacing) { + int blockSize = size + 4; + byte[] blockSizeBytes = getIntegerBytes(blockSize); + byte[] timeBytes = getIntegerBytes(timecode); + int blockElementSize = 1 + 8 + blockSize; // id + size + length of data + byte[] sizeBytes = getIntegerBytes(blockElementSize); + byte flags = (byte) ((invisible ? 0x08 : 0x00) | (noLacing ? 0x00 : 0x06)); + return createByteArray( + 0xA0, // BlockGroup + 0x01, 0x00, 0x00, 0x00, sizeBytes[0], sizeBytes[1], sizeBytes[2], sizeBytes[3], + 0xA1, // Block + 0x01, 0x00, 0x00, 0x00, + blockSizeBytes[0], blockSizeBytes[1], blockSizeBytes[2], blockSizeBytes[3], + 0x81, // Track number value=1 + timeBytes[2], timeBytes[3], flags); // Timecode and flags + } + + private static byte[] createVideoBytes(int size) { + byte[] videoBytes = new byte[size]; + for (int i = 0; i < size; i++) { + videoBytes[i] = (byte) i; + } + return videoBytes; + } + + private static byte[] getIntegerBytes(int value) { + return createByteArray( + (value & 0xFF000000) >> 24, + (value & 0x00FF0000) >> 16, + (value & 0x0000FF00) >> 8, + (value & 0x000000FF)); + } + + private static byte[] createByteArray(int... intArray) { + byte[] byteArray = new byte[intArray.length]; + for (int i = 0; i < byteArray.length; i++) { + byteArray[i] = (byte) intArray[i]; + } + return byteArray; + } + + /** Used by {@link #createMediaSegment} to return both cluster and video bytes together. */ + private static final class MediaSegment { + + private final byte[] clusterBytes; + private final byte[] videoBytes; + + private MediaSegment(byte[] clusterBytes, byte[] videoBytes) { + this.clusterBytes = clusterBytes; + this.videoBytes = videoBytes; + } + + } + + /** Used by {@link #assertIndex(IndexPoint...)} to validate index elements. */ + private static final class IndexPoint { + + private final long timeUs; + private final int size; + private final long durationUs; + + private IndexPoint(long timeUs, int size, long durationUs) { + this.timeUs = timeUs; + this.size = size; + this.durationUs = durationUs; + } + + } + + /** Used by {@link #createTracksElementWithVideo} to create a Track header with Encryption. */ + private static final class ContentEncodingSettings { + + private final int order; + private final int scope; + private final int type; + private final int algorithm; + private final int aesCipherMode; + + private ContentEncodingSettings(int order, int scope, int type, int algorithm, + int aesCipherMode) { + this.order = order; + this.scope = scope; + this.type = type; + this.algorithm = algorithm; + this.aesCipherMode = aesCipherMode; + } + + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/dash/DashChunkSourceTest.java b/library/src/test/java/com/google/android/exoplayer/dash/DashChunkSourceTest.java new file mode 100644 index 0000000000..d4e116e833 --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/dash/DashChunkSourceTest.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2014 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.exoplayer.dash; + +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.chunk.Format; +import com.google.android.exoplayer.dash.mpd.Representation; +import com.google.android.exoplayer.dash.mpd.SegmentBase.SingleSegmentBase; + +import junit.framework.TestCase; + +/** + * Tests {@link DashChunkSource}. + */ +public class DashChunkSourceTest extends TestCase { + + public void testMaxVideoDimensions() { + SingleSegmentBase segmentBase1 = new SingleSegmentBase("https://example.com/1.mp4"); + Format format1 = new Format("1", "video/mp4", 100, 200, -1, -1, 1000); + Representation representation1 = + Representation.newInstance(0, 0, null, 0, format1, segmentBase1); + + SingleSegmentBase segmentBase2 = new SingleSegmentBase("https://example.com/2.mp4"); + Format format2 = new Format("2", "video/mp4", 400, 50, -1, -1, 1000); + Representation representation2 = + Representation.newInstance(0, 0, null, 0, format2, segmentBase2); + + DashChunkSource chunkSource = new DashChunkSource(null, null, representation1, representation2); + MediaFormat out = MediaFormat.createVideoFormat("video/h264", 1, 1, 1, 1, null); + chunkSource.getMaxVideoDimensions(out); + + assertEquals(400, out.getMaxVideoWidth()); + assertEquals(200, out.getMaxVideoHeight()); + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/dash/mpd/MediaPresentationDescriptionParserTest.java b/library/src/test/java/com/google/android/exoplayer/dash/mpd/MediaPresentationDescriptionParserTest.java new file mode 100644 index 0000000000..7aa65564a0 --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/dash/mpd/MediaPresentationDescriptionParserTest.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2014 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.exoplayer.dash.mpd; + +import android.test.InstrumentationTestCase; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Unit tests for {@link MediaPresentationDescriptionParser}. + */ +public class MediaPresentationDescriptionParserTest extends InstrumentationTestCase { + + private static final String SAMPLE_MPD_1 = "dash/sample_mpd_1"; + + public void testParseMediaPresentationDescription() throws IOException { + MediaPresentationDescriptionParser parser = new MediaPresentationDescriptionParser(); + InputStream inputStream = + getInstrumentation().getContext().getResources().getAssets().open(SAMPLE_MPD_1); + // Simple test to ensure that the sample manifest parses without throwing any exceptions. + parser.parse("https://example.com/test.mpd", inputStream); + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/dash/mpd/RangedUriTest.java b/library/src/test/java/com/google/android/exoplayer/dash/mpd/RangedUriTest.java new file mode 100644 index 0000000000..52d5c1dd07 --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/dash/mpd/RangedUriTest.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2014 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.exoplayer.dash.mpd; + +import junit.framework.TestCase; + +/** + * Unit test for {@link RangedUri}. + */ +public class RangedUriTest extends TestCase { + + private static final String FULL_URI = "http://www.test.com/path/file.ext"; + + public void testMerge() { + RangedUri rangeA = new RangedUri(null, FULL_URI, 0, 10); + RangedUri rangeB = new RangedUri(null, FULL_URI, 10, 10); + RangedUri expected = new RangedUri(null, FULL_URI, 0, 20); + assertMerge(rangeA, rangeB, expected); + } + + public void testMergeUnbounded() { + RangedUri rangeA = new RangedUri(null, FULL_URI, 0, 10); + RangedUri rangeB = new RangedUri(null, FULL_URI, 10, -1); + RangedUri expected = new RangedUri(null, FULL_URI, 0, -1); + assertMerge(rangeA, rangeB, expected); + } + + public void testNonMerge() { + // A and B do not overlap, so should not merge + RangedUri rangeA = new RangedUri(null, FULL_URI, 0, 10); + RangedUri rangeB = new RangedUri(null, FULL_URI, 11, 10); + assertNonMerge(rangeA, rangeB); + + // A and B do not overlap, so should not merge + rangeA = new RangedUri(null, FULL_URI, 0, 10); + rangeB = new RangedUri(null, FULL_URI, 11, -1); + assertNonMerge(rangeA, rangeB); + + // A and B are bounded but overlap, so should not merge + rangeA = new RangedUri(null, FULL_URI, 0, 11); + rangeB = new RangedUri(null, FULL_URI, 10, 10); + assertNonMerge(rangeA, rangeB); + + // A and B overlap due to unboundedness, so should not merge + rangeA = new RangedUri(null, FULL_URI, 0, -1); + rangeB = new RangedUri(null, FULL_URI, 10, -1); + assertNonMerge(rangeA, rangeB); + + } + + private void assertMerge(RangedUri rangeA, RangedUri rangeB, RangedUri expected) { + RangedUri merged = rangeA.attemptMerge(rangeB); + assertEquals(expected, merged); + merged = rangeB.attemptMerge(rangeA); + assertEquals(expected, merged); + } + + private void assertNonMerge(RangedUri rangeA, RangedUri rangeB) { + RangedUri merged = rangeA.attemptMerge(rangeB); + assertNull(merged); + merged = rangeB.attemptMerge(rangeA); + assertNull(merged); + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/dash/mpd/RepresentationTest.java b/library/src/test/java/com/google/android/exoplayer/dash/mpd/RepresentationTest.java new file mode 100644 index 0000000000..19d2226014 --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/dash/mpd/RepresentationTest.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2014 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.exoplayer.dash.mpd; + +import com.google.android.exoplayer.chunk.Format; +import com.google.android.exoplayer.dash.mpd.SegmentBase.SingleSegmentBase; +import com.google.android.exoplayer.util.MimeTypes; + +import junit.framework.TestCase; + +/** + * Unit test for {@link Representation}. + */ +public class RepresentationTest extends TestCase { + + public void testGetCacheKey() { + String uri = "http://www.google.com"; + SegmentBase base = new SingleSegmentBase(new RangedUri(uri, null, 0, 1), 1, 0, uri, 1, 1); + Format format = new Format("0", MimeTypes.VIDEO_MP4, 1920, 1080, 0, 0, 2500000); + Representation representation = Representation.newInstance(-1, -1, "test_stream_1", 3, + format, base); + assertEquals("test_stream_1.0.3", representation.getCacheKey()); + + format = new Format("150", MimeTypes.VIDEO_MP4, 1920, 1080, 0, 0, 2500000); + representation = Representation.newInstance(-1, -1, "test_stream_1", -1, format, base); + assertEquals("test_stream_1.150.-1", representation.getCacheKey()); + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/dash/mpd/UrlTemplateTest.java b/library/src/test/java/com/google/android/exoplayer/dash/mpd/UrlTemplateTest.java new file mode 100644 index 0000000000..55084cc4c9 --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/dash/mpd/UrlTemplateTest.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2014 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.exoplayer.dash.mpd; + +import junit.framework.TestCase; + +/** + * Unit test for {@link UrlTemplate}. + */ +public class UrlTemplateTest extends TestCase { + + public void testRealExamples() { + String template = "QualityLevels($Bandwidth$)/Fragments(video=$Time$,format=mpd-time-csf)"; + UrlTemplate urlTemplate = UrlTemplate.compile(template); + String url = urlTemplate.buildUri("abc1", 10, 650000, 5000); + assertEquals("QualityLevels(650000)/Fragments(video=5000,format=mpd-time-csf)", url); + + template = "$RepresentationID$/$Number$"; + urlTemplate = UrlTemplate.compile(template); + url = urlTemplate.buildUri("abc1", 10, 650000, 5000); + assertEquals("abc1/10", url); + + template = "chunk_ctvideo_cfm4s_rid$RepresentationID$_cn$Number$_w2073857842_mpd.m4s"; + urlTemplate = UrlTemplate.compile(template); + url = urlTemplate.buildUri("abc1", 10, 650000, 5000); + assertEquals("chunk_ctvideo_cfm4s_ridabc1_cn10_w2073857842_mpd.m4s", url); + } + + public void testFull() { + String template = "$Bandwidth$_a_$RepresentationID$_b_$Time$_c_$Number$"; + UrlTemplate urlTemplate = UrlTemplate.compile(template); + String url = urlTemplate.buildUri("abc1", 10, 650000, 5000); + assertEquals("650000_a_abc1_b_5000_c_10", url); + } + + public void testFullWithDollarEscaping() { + String template = "$$$Bandwidth$$$_a$$_$RepresentationID$_b_$Time$_c_$Number$$$"; + UrlTemplate urlTemplate = UrlTemplate.compile(template); + String url = urlTemplate.buildUri("abc1", 10, 650000, 5000); + assertEquals("$650000$_a$_abc1_b_5000_c_10$", url); + } + + public void testInvalidSubstitution() { + String template = "$IllegalId$"; + try { + UrlTemplate.compile(template); + assertTrue(false); + } catch (IllegalArgumentException e) { + // Expected. + } + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/hls/HlsMasterPlaylistParserTest.java b/library/src/test/java/com/google/android/exoplayer/hls/HlsMasterPlaylistParserTest.java new file mode 100644 index 0000000000..bd5126160a --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/hls/HlsMasterPlaylistParserTest.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2014 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.exoplayer.hls; + +import com.google.android.exoplayer.C; + +import junit.framework.TestCase; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.List; + +/** + * Test for {@link HlsMasterPlaylistParserTest} + */ +public class HlsMasterPlaylistParserTest extends TestCase { + + public void testParseMasterPlaylist() { + String playlistUrl = "https://example.com/test.m3u8"; + String playlistString = "#EXTM3U\n" + + "\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS=\"mp4a.40.2,avc1.66.30\",RESOLUTION=304x128\n" + + "http://example.com/low.m3u8\n" + + "\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS=\"mp4a.40.2 , avc1.66.30 \"\n" + + "http://example.com/spaces_in_codecs.m3u8\n" + + "\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=2560000,RESOLUTION=384x160\n" + + "http://example.com/mid.m3u8\n" + + "\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=7680000\n" + + "http://example.com/hi.m3u8\n" + + "\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS=\"mp4a.40.5\"\n" + + "http://example.com/audio-only.m3u8"; + ByteArrayInputStream inputStream = new ByteArrayInputStream( + playlistString.getBytes(Charset.forName(C.UTF8_NAME))); + try { + HlsPlaylist playlist = new HlsPlaylistParser().parse(playlistUrl, inputStream); + assertNotNull(playlist); + assertEquals(HlsPlaylist.TYPE_MASTER, playlist.type); + + HlsMasterPlaylist masterPlaylist = (HlsMasterPlaylist) playlist; + + List variants = masterPlaylist.variants; + assertNotNull(variants); + assertEquals(5, variants.size()); + + assertEquals(0, variants.get(0).index); + assertEquals(1280000, variants.get(0).bandwidth); + assertNotNull(variants.get(0).codecs); + assertEquals(2, variants.get(0).codecs.length); + assertEquals("mp4a.40.2", variants.get(0).codecs[0]); + assertEquals("avc1.66.30", variants.get(0).codecs[1]); + assertEquals(304, variants.get(0).width); + assertEquals(128, variants.get(0).height); + assertEquals("http://example.com/low.m3u8", variants.get(0).url); + + assertEquals(1, variants.get(1).index); + assertEquals(1280000, variants.get(1).bandwidth); + assertNotNull(variants.get(1).codecs); + assertEquals(2, variants.get(1).codecs.length); + assertEquals("mp4a.40.2", variants.get(1).codecs[0]); + assertEquals("avc1.66.30", variants.get(1).codecs[1]); + assertEquals("http://example.com/spaces_in_codecs.m3u8", variants.get(1).url); + + assertEquals(2, variants.get(2).index); + assertEquals(2560000, variants.get(2).bandwidth); + assertEquals(null, variants.get(2).codecs); + assertEquals(384, variants.get(2).width); + assertEquals(160, variants.get(2).height); + assertEquals("http://example.com/mid.m3u8", variants.get(2).url); + + assertEquals(3, variants.get(3).index); + assertEquals(7680000, variants.get(3).bandwidth); + assertEquals(null, variants.get(3).codecs); + assertEquals(-1, variants.get(3).width); + assertEquals(-1, variants.get(3).height); + assertEquals("http://example.com/hi.m3u8", variants.get(3).url); + + assertEquals(4, variants.get(4).index); + assertEquals(65000, variants.get(4).bandwidth); + assertNotNull(variants.get(4).codecs); + assertEquals(1, variants.get(4).codecs.length); + assertEquals("mp4a.40.5", variants.get(4).codecs[0]); + assertEquals(-1, variants.get(4).width); + assertEquals(-1, variants.get(4).height); + assertEquals("http://example.com/audio-only.m3u8", variants.get(4).url); + } catch (IOException exception) { + fail(exception.getMessage()); + } + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/hls/HlsMediaPlaylistParserTest.java b/library/src/test/java/com/google/android/exoplayer/hls/HlsMediaPlaylistParserTest.java new file mode 100644 index 0000000000..beec89e4a6 --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/hls/HlsMediaPlaylistParserTest.java @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2014 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.exoplayer.hls; + +import com.google.android.exoplayer.C; + +import junit.framework.TestCase; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.List; +import java.util.Locale; + +/** + * Test for {@link HlsMediaPlaylistParserTest} + */ +public class HlsMediaPlaylistParserTest extends TestCase { + + public void testParseMediaPlaylist() { + String playlistUrl = "https://example.com/test.m3u8"; + String playlistString = "#EXTM3U\n" + + "#EXT-X-VERSION:3\n" + + "#EXT-X-TARGETDURATION:8\n" + + "#EXT-X-MEDIA-SEQUENCE:2679\n" + + "#EXT-X-ALLOW-CACHE:YES\n" + + "\n" + + "#EXTINF:7.975,\n" + + "#EXT-X-BYTERANGE:51370@0\n" + + "https://priv.example.com/fileSequence2679.ts\n" + + "\n" + + "#EXT-X-KEY:METHOD=AES-128,URI=\"https://priv.example.com/key.php?r=2680\",IV=0x1566B\n" + + "#EXTINF:7.975,\n" + + "#EXT-X-BYTERANGE:51501@51370\n" + + "https://priv.example.com/fileSequence2680.ts\n" + + "\n" + + "#EXT-X-KEY:METHOD=NONE\n" + + "#EXTINF:7.941,\n" + + "#EXT-X-BYTERANGE:51501\n" // @102871 + + "https://priv.example.com/fileSequence2681.ts\n" + + "\n" + + "#EXT-X-DISCONTINUITY\n" + + "#EXT-X-KEY:METHOD=AES-128,URI=\"https://priv.example.com/key.php?r=2682\"\n" + + "#EXTINF:7.975,\n" + + "#EXT-X-BYTERANGE:51740\n" // @154372 + + "https://priv.example.com/fileSequence2682.ts\n" + + "\n" + + "#EXTINF:7.975,\n" + + "https://priv.example.com/fileSequence2683.ts\n" + + "#EXT-X-ENDLIST"; + InputStream inputStream = new ByteArrayInputStream( + playlistString.getBytes(Charset.forName(C.UTF8_NAME))); + try { + HlsPlaylist playlist = new HlsPlaylistParser().parse(playlistUrl, inputStream); + assertNotNull(playlist); + assertEquals(HlsPlaylist.TYPE_MEDIA, playlist.type); + + HlsMediaPlaylist mediaPlaylist = (HlsMediaPlaylist) playlist; + + assertEquals(2679, mediaPlaylist.mediaSequence); + assertEquals(8, mediaPlaylist.targetDurationSecs); + assertEquals(3, mediaPlaylist.version); + assertEquals(false, mediaPlaylist.live); + List segments = mediaPlaylist.segments; + assertNotNull(segments); + assertEquals(5, segments.size()); + + assertEquals(false, segments.get(0).discontinuity); + assertEquals(7.975, segments.get(0).durationSecs); + assertEquals(false, segments.get(0).isEncrypted); + assertEquals(null, segments.get(0).encryptionKeyUri); + assertEquals(null, segments.get(0).encryptionIV); + assertEquals(51370, segments.get(0).byterangeLength); + assertEquals(0, segments.get(0).byterangeOffset); + assertEquals("https://priv.example.com/fileSequence2679.ts", segments.get(0).url); + + assertEquals(false, segments.get(1).discontinuity); + assertEquals(7.975, segments.get(1).durationSecs); + assertEquals(true, segments.get(1).isEncrypted); + assertEquals("https://priv.example.com/key.php?r=2680", segments.get(1).encryptionKeyUri); + assertEquals("0x1566B", segments.get(1).encryptionIV); + assertEquals(51501, segments.get(1).byterangeLength); + assertEquals(51370, segments.get(1).byterangeOffset); + assertEquals("https://priv.example.com/fileSequence2680.ts", segments.get(1).url); + + assertEquals(false, segments.get(2).discontinuity); + assertEquals(7.941, segments.get(2).durationSecs); + assertEquals(false, segments.get(2).isEncrypted); + assertEquals(null, segments.get(2).encryptionKeyUri); + assertEquals(null, segments.get(2).encryptionIV); + assertEquals(51501, segments.get(2).byterangeLength); + assertEquals(102871, segments.get(2).byterangeOffset); + assertEquals("https://priv.example.com/fileSequence2681.ts", segments.get(2).url); + + assertEquals(true, segments.get(3).discontinuity); + assertEquals(7.975, segments.get(3).durationSecs); + assertEquals(true, segments.get(3).isEncrypted); + assertEquals("https://priv.example.com/key.php?r=2682", segments.get(3).encryptionKeyUri); + // 0xA7A == 2682. + assertNotNull(segments.get(3).encryptionIV); + assertEquals("A7A", segments.get(3).encryptionIV.toUpperCase(Locale.getDefault())); + assertEquals(51740, segments.get(3).byterangeLength); + assertEquals(154372, segments.get(3).byterangeOffset); + assertEquals("https://priv.example.com/fileSequence2682.ts", segments.get(3).url); + + assertEquals(false, segments.get(4).discontinuity); + assertEquals(7.975, segments.get(4).durationSecs); + assertEquals(true, segments.get(4).isEncrypted); + assertEquals("https://priv.example.com/key.php?r=2682", segments.get(4).encryptionKeyUri); + // 0xA7B == 2683. + assertNotNull(segments.get(4).encryptionIV); + assertEquals("A7B", segments.get(4).encryptionIV.toUpperCase(Locale.getDefault())); + assertEquals(C.LENGTH_UNBOUNDED, segments.get(4).byterangeLength); + assertEquals(0, segments.get(4).byterangeOffset); + assertEquals("https://priv.example.com/fileSequence2683.ts", segments.get(4).url); + } catch (IOException exception) { + fail(exception.getMessage()); + } + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/metadata/Id3ParserTest.java b/library/src/test/java/com/google/android/exoplayer/metadata/Id3ParserTest.java new file mode 100644 index 0000000000..1dead0a139 --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/metadata/Id3ParserTest.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2014 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.exoplayer.metadata; + +import junit.framework.TestCase; + +import java.util.Map; + +/** + * Test for {@link Id3Parser} + */ +public class Id3ParserTest extends TestCase { + + public void testParseTxxxFrames() { + byte[] rawId3 = new byte[] { 73, 68, 51, 4, 0, 0, 0, 0, 0, 41, 84, 88, 88, 88, 0, 0, 0, 31, + 0, 0, 3, 0, 109, 100, 105, 97, 108, 111, 103, 95, 86, 73, 78, 68, 73, 67, 79, 49, 53, 50, + 55, 54, 54, 52, 95, 115, 116, 97, 114, 116, 0 }; + + Id3Parser parser = new Id3Parser(); + try { + Map metadata = parser.parse(rawId3, rawId3.length); + assertNotNull(metadata); + assertEquals(1, metadata.size()); + TxxxMetadata txxx = (TxxxMetadata) metadata.get(TxxxMetadata.TYPE); + assertNotNull(txxx); + assertEquals("", txxx.description); + assertEquals("mdialog_VINDICO1527664_start", txxx.value); + } catch (Exception exception) { + fail(exception.getMessage()); + } + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/source/DefaultSampleSourceTest.java b/library/src/test/java/com/google/android/exoplayer/source/DefaultSampleSourceTest.java new file mode 100644 index 0000000000..57a139e88c --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/source/DefaultSampleSourceTest.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2014 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.exoplayer.source; + +import static org.mockito.Matchers.anyInt; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.MediaFormatHolder; +import com.google.android.exoplayer.SampleSource; +import com.google.android.exoplayer.util.MimeTypes; + +import junit.framework.TestCase; + +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +/** Tests for {@link DefaultSampleSource}. */ +public final class DefaultSampleSourceTest extends TestCase { + + private static final int RENDERER_COUNT = 2; + private static final MediaFormat FAKE_MEDIA_FORMAT = + MediaFormat.createFormatForMimeType(MimeTypes.AUDIO_AAC); + private DefaultSampleSource defaultSampleSource; + @Mock SampleExtractor mockSampleExtractor; + + @Override + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + + when(mockSampleExtractor.prepare()).thenReturn(true); + when(mockSampleExtractor.getTrackCount()).thenReturn(2); + when(mockSampleExtractor.getMediaFormat(anyInt())).thenReturn(FAKE_MEDIA_FORMAT); + + defaultSampleSource = new DefaultSampleSource(mockSampleExtractor, RENDERER_COUNT); + } + + public void testSourceReleasedWhenRenderersReleased() throws Exception { + // Given a prepared sample source + defaultSampleSource.prepare(); + + // When releasing it once, it is not released. + defaultSampleSource.release(); + verify(mockSampleExtractor, never()).release(); + + // When releasing RENDERER_COUNT times, it is released. + defaultSampleSource.release(); + verify(mockSampleExtractor).release(); + } + + public void testEnablingTracksAtStartDoesNotSeek() throws Exception { + // Given a prepared sample source + defaultSampleSource.prepare(); + + // When the first track is enabled at t=0, the sample extractor does not seek. + defaultSampleSource.enable(0, 0L); + verify(mockSampleExtractor, never()).seekTo(0); + + // When the second track is enabled at t=0, the sample extractor does not seek. + defaultSampleSource.enable(1, 0L); + verify(mockSampleExtractor, never()).seekTo(0); + } + + public void testEnablingTracksInMiddleDoesSeek() throws Exception { + // Given a prepared sample source + defaultSampleSource.prepare(); + + // When the first track is enabled at t!=0, the sample extractor does seek. + defaultSampleSource.enable(0, 1000L); + verify(mockSampleExtractor, times(1)).seekTo(1000L); + + // When the second track is enabled at t!=0, the sample extractor does seek. + defaultSampleSource.enable(1, 1000L); + verify(mockSampleExtractor, times(2)).seekTo(1000L); + } + + public void testEnablingTrackSelectsTrack() throws Exception { + // Given a prepared sample source + defaultSampleSource.prepare(); + + // When the first track is enabled, it selects the first track. + defaultSampleSource.enable(0, 0L); + verify(mockSampleExtractor).selectTrack(0); + } + + public void testReadDataInitiallyReadsFormat() throws Exception { + // Given a prepared sample source with the first track selected + defaultSampleSource.prepare(); + defaultSampleSource.enable(0, 0L); + + // A format is read. + MediaFormatHolder mediaFormatHolder = new MediaFormatHolder(); + assertEquals(SampleSource.FORMAT_READ, + defaultSampleSource.readData(0, 0, mediaFormatHolder, null, false)); + } + + public void testSeekAndReadDataReadsDiscontinuity() throws Exception { + // Given a prepared sample source with the first track selected + defaultSampleSource.prepare(); + defaultSampleSource.enable(0, 1L); + + // A discontinuity is read. + assertEquals( + SampleSource.DISCONTINUITY_READ, defaultSampleSource.readData(0, 0, null, null, false)); + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/source/Mp4SampleExtractorTest.java b/library/src/test/java/com/google/android/exoplayer/source/Mp4SampleExtractorTest.java new file mode 100644 index 0000000000..1045b7b7dc --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/source/Mp4SampleExtractorTest.java @@ -0,0 +1,622 @@ +/* + * Copyright (C) 2014 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.exoplayer.source; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.SampleSource; +import com.google.android.exoplayer.mp4.Atom; +import com.google.android.exoplayer.upstream.ByteArrayDataSource; +import com.google.android.exoplayer.upstream.DataSource; +import com.google.android.exoplayer.upstream.DataSpec; +import com.google.android.exoplayer.util.Assertions; +import com.google.android.exoplayer.util.MimeTypes; +import com.google.android.exoplayer.util.Util; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.media.MediaExtractor; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; + +import junit.framework.TestCase; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; + +/** + * Tests for {@link Mp4SampleExtractor}. + */ +@TargetApi(16) +public class Mp4SampleExtractorTest extends TestCase { + + /** String of hexadecimal bytes containing the video stsd payload from an AVC video. */ + private static final byte[] VIDEO_STSD_PAYLOAD = getByteArray( + "00000000000000010000009961766331000000000000000100000000000000000000000000000000050002" + + "d00048000000480000000000000001000000000000000000000000000000000000000000000000000000" + + "00000000000018ffff0000002f617663430164001fffe100186764001facb402802dd808800000030080" + + "00001e078c195001000468ee3cb000000014627472740000e35c0042a61000216cb8"); + private static final byte[] VIDEO_HDLR_PAYLOAD = getByteArray("000000000000000076696465"); + private static final byte[] VIDEO_MDHD_PAYLOAD = + getByteArray("0000000000000000cf6c48890000001e00001c8a55c40000"); + private static final int TIMESCALE = 30; + private static final int VIDEO_WIDTH = 1280; + private static final int VIDEO_HEIGHT = 720; + + /** String of hexadecimal bytes containing the video stsd payload for an mp4v track. */ + private static final byte[] VIDEO_STSD_MP4V_PAYLOAD = getByteArray( + "0000000000000001000000A36D703476000000000000000100000000000000000000000000000000014000" + + "B40048000000480000000000000001000000000000000000000000000000000000000000000000000000" + + "00000000000018FFFF0000004D6573647300000000033F00000004372011001A400004CF280002F11805" + + "28000001B001000001B58913000001000000012000C48D8800F50A04169463000001B2476F6F676C6506" + + "0102"); + private static final int VIDEO_MP4V_WIDTH = 320; + private static final int VIDEO_MP4V_HEIGHT = 180; + + /** String of hexadecimal bytes containing the audio stsd payload from an AAC track. */ + private static final byte[] AUDIO_STSD_PAYLOAD = getByteArray( + "0000000000000001000000596d703461000000000000000100000000000000000001001000000000ac4400" + + "000000003565736473000000000327000000041f401500023e00024bc000023280051012080000000000" + + "000000000000000000060102"); + private static final byte[] AUDIO_HDLR_PAYLOAD = getByteArray("0000000000000000736f756e"); + private static final byte[] AUDIO_MDHD_PAYLOAD = + getByteArray("00000000cf6c4889cf6c488a0000ac4400a3e40055c40000"); + + /** String of hexadecimal bytes containing an mvhd payload from an AVC/AAC video. */ + private static final byte[] MVHD_PAYLOAD = getByteArray( + "00000000cf6c4888cf6c48880000025800023ad40001000001000000000000000000000000010000000000" + + "000000000000000000000100000000000000000000000000004000000000000000000000000000000000" + + "000000000000000000000000000003"); + + /** String of hexadecimal bytes containing a tkhd payload with an unknown duration. */ + private static final byte[] TKHD_PAYLOAD = + getByteArray("0000000000000000000000000000000000000000FFFFFFFF"); + + /** Video frame timestamps in time units. */ + private static final int[] SAMPLE_TIMESTAMPS = {0, 2, 3, 5, 6, 7}; + /** Video frame sizes in bytes, including a very large sample. */ + private static final int[] SAMPLE_SIZES = {100, 20, 20, 44, 100, 1 * 1024 * 1024}; + /** Indices of key-frames. */ + private static final int[] SYNCHRONIZATION_SAMPLE_INDICES = {0, 4, 5}; + /** Indices of video frame chunk offsets. */ + private static final int[] CHUNK_OFFSETS = {1000, 2000, 3000, 4000}; + /** Numbers of video frames in each chunk. */ + private static final int[] SAMPLES_IN_CHUNK = {2, 2, 1, 1}; + /** The mdat box must be large enough to avoid reading chunk sample data out of bounds. */ + private static final int MDAT_SIZE = 10 * 1024 * 1024; + /** Fake HTTP URI that can't be opened. */ + private static final Uri FAKE_URI = Uri.parse("http://"); + /** Empty byte array. */ + private static final byte[] EMPTY = new byte[0]; + + public void testParsesValidMp4File() throws Exception { + // Given an extractor with an AVC/AAC file + Mp4ExtractorWrapper extractor = + prepareSampleExtractor(getFakeDataSource(true /* includeStss */, false /* mp4vFormat */)); + + // The MIME type and metadata are set correctly. + assertEquals(MimeTypes.VIDEO_H264, extractor.mediaFormats[0].mimeType); + assertEquals(MimeTypes.AUDIO_AAC, extractor.mediaFormats[1].mimeType); + + assertEquals(VIDEO_WIDTH, extractor.selectedTrackMediaFormat.width); + assertEquals(VIDEO_HEIGHT, extractor.selectedTrackMediaFormat.height); + } + + public void testParsesValidMp4vFile() throws Exception { + // Given an extractor with an mp4v file + Mp4ExtractorWrapper extractor = + prepareSampleExtractor(getFakeDataSource(true /* includeStss */, true /* mp4vFormat */)); + + // The MIME type and metadata are set correctly. + assertEquals(MimeTypes.VIDEO_MP4V, extractor.selectedTrackMediaFormat.mimeType); + assertEquals(VIDEO_MP4V_WIDTH, extractor.selectedTrackMediaFormat.width); + assertEquals(VIDEO_MP4V_HEIGHT, extractor.selectedTrackMediaFormat.height); + } + + public void testSampleTimestampsMatch() throws Exception { + // Given an extractor + Mp4ExtractorWrapper extractor = + prepareSampleExtractor(getFakeDataSource(true /* includeStss */, false /* mp4vFormat */)); + + // The timestamps are set correctly. + SampleHolder sampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_NORMAL); + for (int i = 0; i < SAMPLE_TIMESTAMPS.length; i++) { + extractor.readSample(0, sampleHolder); + assertEquals(getVideoTimestampUs(SAMPLE_TIMESTAMPS[i]), sampleHolder.timeUs); + } + assertEquals(SampleSource.END_OF_STREAM, extractor.readSample(0, sampleHolder)); + } + + public void testSeekToStart() throws Exception { + // When seeking to the start + int timestampTimeUnits = SAMPLE_TIMESTAMPS[0]; + long sampleTimestampUs = + getTimestampUsResultingFromSeek(getVideoTimestampUs(timestampTimeUnits)); + + // The timestamp is at the start. + assertEquals(getVideoTimestampUs(timestampTimeUnits), sampleTimestampUs); + } + + public void testSeekToEnd() throws Exception { + // When seeking to the end + int timestampTimeUnits = SAMPLE_TIMESTAMPS[SAMPLE_TIMESTAMPS.length - 1]; + long sampleTimestampUs = + getTimestampUsResultingFromSeek(getVideoTimestampUs(timestampTimeUnits)); + + // The timestamp is at the end. + assertEquals(getVideoTimestampUs(timestampTimeUnits), sampleTimestampUs); + } + + public void testSeekToNearStart() throws Exception { + // When seeking to just after the start + int timestampTimeUnits = SAMPLE_TIMESTAMPS[0]; + long sampleTimestampUs = + getTimestampUsResultingFromSeek(getVideoTimestampUs(timestampTimeUnits) + 1); + + // The timestamp is at the start. + assertEquals(getVideoTimestampUs(timestampTimeUnits), sampleTimestampUs); + } + + public void testSeekToBeforeLastSynchronizationSample() throws Exception { + // When seeking to just after the start + long sampleTimestampUs = + getTimestampUsResultingFromSeek(getVideoTimestampUs(SAMPLE_TIMESTAMPS[4]) - 1); + + // The timestamp is at the start. + assertEquals(getVideoTimestampUs(SAMPLE_TIMESTAMPS[0]), sampleTimestampUs); + } + + public void testAllSamplesAreSynchronizationSamplesWhenStssIsMissing() throws Exception { + // Given an extractor without an stss box + Mp4ExtractorWrapper extractor = + prepareSampleExtractor(getFakeDataSource(false /* includeStss */, false /* mp4vFormat */)); + // All samples are synchronization samples. + SampleHolder sampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_NORMAL); + int sampleIndex = 0; + while (true) { + int result = extractor.readSample(0, sampleHolder); + if (result == SampleSource.SAMPLE_READ) { + assertTrue((sampleHolder.flags & MediaExtractor.SAMPLE_FLAG_SYNC) != 0); + sampleHolder.clearData(); + sampleIndex++; + } else if (result == SampleSource.END_OF_STREAM) { + break; + } + } + assertTrue(sampleIndex == SAMPLE_SIZES.length); + } + + public void testReadAllSamplesSucceeds() throws Exception { + // Given an extractor + Mp4ExtractorWrapper extractor = + prepareSampleExtractor(getFakeDataSource(true /* includeStss */, false /* mp4vFormat */)); + + // The sample sizes are set correctly. + SampleHolder sampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_NORMAL); + int sampleIndex = 0; + while (true) { + int result = extractor.readSample(0, sampleHolder); + if (result == SampleSource.SAMPLE_READ) { + assertEquals(SAMPLE_SIZES[sampleIndex], sampleHolder.size); + sampleHolder.clearData(); + sampleIndex++; + } else if (result == SampleSource.END_OF_STREAM) { + break; + } + } + assertEquals(SAMPLE_SIZES.length, sampleIndex); + } + + /** Returns the sample time read after seeking to {@code timestampTimeUnits}. */ + private static long getTimestampUsResultingFromSeek(long timestampTimeUnits) throws Exception { + Mp4ExtractorWrapper extractor = + prepareSampleExtractor(getFakeDataSource(true /* includeStss */, false /* mp4vFormat */)); + + extractor.seekTo(timestampTimeUnits); + + SampleHolder sampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_NORMAL); + while (true) { + int result = extractor.readSample(0, sampleHolder); + if (result == SampleSource.SAMPLE_READ) { + return sampleHolder.timeUs; + } else if (result == SampleSource.END_OF_STREAM) { + return -1; + } + } + } + + private static Mp4ExtractorWrapper prepareSampleExtractor(DataSource dataSource) + throws Exception { + Mp4ExtractorWrapper extractor = new Mp4ExtractorWrapper(dataSource); + extractor.prepare(); + return extractor; + } + + /** Returns a video timestamp in microseconds corresponding to {@code timeUnits}. */ + private static long getVideoTimestampUs(int timeUnits) { + return Util.scaleLargeTimestamp(timeUnits, C.MICROS_PER_SECOND, TIMESCALE); + } + + private static byte[] getStco() { + byte[] result = new byte[4 + 4 + 4 * CHUNK_OFFSETS.length]; + ByteBuffer buffer = ByteBuffer.wrap(result); + buffer.putInt(0); // Version (skipped) + buffer.putInt(CHUNK_OFFSETS.length); + for (int chunkOffset : CHUNK_OFFSETS) { + buffer.putInt(chunkOffset); + } + return result; + } + + private static byte[] getStsc() { + int samplesPerChunk = -1; + List samplesInChunkChangeIndices = new ArrayList(); + for (int i = 0; i < SAMPLES_IN_CHUNK.length; i++) { + if (SAMPLES_IN_CHUNK[i] != samplesPerChunk) { + samplesInChunkChangeIndices.add(i); + samplesPerChunk = SAMPLES_IN_CHUNK[i]; + } + } + + byte[] result = new byte[4 + 4 + 3 * 4 * samplesInChunkChangeIndices.size()]; + ByteBuffer buffer = ByteBuffer.wrap(result); + buffer.putInt(0); // Version (skipped) + buffer.putInt(samplesInChunkChangeIndices.size()); + for (int index : samplesInChunkChangeIndices) { + buffer.putInt(index + 1); + buffer.putInt(SAMPLES_IN_CHUNK[index]); + buffer.putInt(0); // Sample description index (skipped) + } + return result; + } + + private static byte[] getStsz() { + byte[] result = new byte[4 + 4 + 4 + 4 * SAMPLE_SIZES.length]; + ByteBuffer buffer = ByteBuffer.wrap(result); + buffer.putInt(0); // Version (skipped) + buffer.putInt(0); // No fixed sample size. + buffer.putInt(SAMPLE_SIZES.length); + for (int size : SAMPLE_SIZES) { + buffer.putInt(size); + } + return result; + } + + private static byte[] getStss() { + byte[] result = new byte[4 + 4 + 4 * SYNCHRONIZATION_SAMPLE_INDICES.length]; + ByteBuffer buffer = ByteBuffer.wrap(result); + buffer.putInt(0); // Version (skipped) + buffer.putInt(SYNCHRONIZATION_SAMPLE_INDICES.length); + for (int synchronizationSampleIndex : SYNCHRONIZATION_SAMPLE_INDICES) { + buffer.putInt(synchronizationSampleIndex + 1); + } + return result; + } + + private static byte[] getStts() { + int sampleTimestampDeltaChanges = 0; + int currentSampleTimestampDelta = -1; + for (int i = 1; i < SAMPLE_TIMESTAMPS.length; i++) { + int timestampDelta = SAMPLE_TIMESTAMPS[i] - SAMPLE_TIMESTAMPS[i - 1]; + if (timestampDelta != currentSampleTimestampDelta) { + sampleTimestampDeltaChanges++; + currentSampleTimestampDelta = timestampDelta; + } + } + + byte[] result = new byte[4 + 4 + 2 * 4 * sampleTimestampDeltaChanges]; + ByteBuffer buffer = ByteBuffer.wrap(result); + buffer.putInt(0); // Version (skipped); + buffer.putInt(sampleTimestampDeltaChanges); + int lastTimestampDeltaChangeIndex = 1; + currentSampleTimestampDelta = SAMPLE_TIMESTAMPS[1] - SAMPLE_TIMESTAMPS[0]; + for (int i = 2; i < SAMPLE_TIMESTAMPS.length; i++) { + int timestampDelta = SAMPLE_TIMESTAMPS[i] - SAMPLE_TIMESTAMPS[i - 1]; + if (timestampDelta != currentSampleTimestampDelta) { + buffer.putInt(i - lastTimestampDeltaChangeIndex); + lastTimestampDeltaChangeIndex = i; + buffer.putInt(currentSampleTimestampDelta); + currentSampleTimestampDelta = timestampDelta; + } + } + // The last sample also has a duration, so the number of entries is the number of samples. + buffer.putInt(SAMPLE_TIMESTAMPS.length - lastTimestampDeltaChangeIndex + 1); + buffer.putInt(currentSampleTimestampDelta); + return result; + } + + private static byte[] getMdat() { + // TODO: Put NAL length tags in at each sample position so the sample lengths don't have to + // be multiples of four. + return new byte[MDAT_SIZE]; + } + + private static final DataSource getFakeDataSource(boolean includeStss, boolean mp4vFormat) { + return new ByteArrayDataSource(includeStss + ? getTestMp4File(mp4vFormat) : getTestMp4FileWithoutSynchronizationData(mp4vFormat)); + } + + /** Gets a valid MP4 file with audio/video tracks and synchronization data. */ + private static byte[] getTestMp4File(boolean mp4vFormat) { + return Mp4Atom.serialize( + atom(Atom.TYPE_ftyp, EMPTY), + atom(Atom.TYPE_moov, + atom(Atom.TYPE_mvhd, MVHD_PAYLOAD), + atom(Atom.TYPE_trak, + atom(Atom.TYPE_tkhd, TKHD_PAYLOAD), + atom(Atom.TYPE_mdia, + atom(Atom.TYPE_mdhd, VIDEO_MDHD_PAYLOAD), + atom(Atom.TYPE_hdlr, VIDEO_HDLR_PAYLOAD), + atom(Atom.TYPE_minf, + atom(Atom.TYPE_vmhd, EMPTY), + atom(Atom.TYPE_stbl, + atom(Atom.TYPE_stsd, + mp4vFormat ? VIDEO_STSD_MP4V_PAYLOAD : VIDEO_STSD_PAYLOAD), + atom(Atom.TYPE_stts, getStts()), + atom(Atom.TYPE_stss, getStss()), + atom(Atom.TYPE_stsc, getStsc()), + atom(Atom.TYPE_stsz, getStsz()), + atom(Atom.TYPE_stco, getStco()))))), + atom(Atom.TYPE_trak, + atom(Atom.TYPE_tkhd, TKHD_PAYLOAD), + atom(Atom.TYPE_mdia, + atom(Atom.TYPE_mdhd, AUDIO_MDHD_PAYLOAD), + atom(Atom.TYPE_hdlr, AUDIO_HDLR_PAYLOAD), + atom(Atom.TYPE_minf, + atom(Atom.TYPE_vmhd, EMPTY), + atom(Atom.TYPE_stbl, + atom(Atom.TYPE_stsd, AUDIO_STSD_PAYLOAD), + atom(Atom.TYPE_stts, getStts()), + atom(Atom.TYPE_stss, getStss()), + atom(Atom.TYPE_stsc, getStsc()), + atom(Atom.TYPE_stsz, getStsz()), + atom(Atom.TYPE_stco, getStco())))))), + atom(Atom.TYPE_mdat, getMdat())); + } + + /** Gets a valid MP4 file with audio/video tracks and without a synchronization table. */ + private static byte[] getTestMp4FileWithoutSynchronizationData(boolean mp4vFormat) { + return Mp4Atom.serialize( + atom(Atom.TYPE_ftyp, EMPTY), + atom(Atom.TYPE_moov, + atom(Atom.TYPE_mvhd, MVHD_PAYLOAD), + atom(Atom.TYPE_trak, + atom(Atom.TYPE_tkhd, TKHD_PAYLOAD), + atom(Atom.TYPE_mdia, + atom(Atom.TYPE_mdhd, VIDEO_MDHD_PAYLOAD), + atom(Atom.TYPE_hdlr, VIDEO_HDLR_PAYLOAD), + atom(Atom.TYPE_minf, + atom(Atom.TYPE_vmhd, EMPTY), + atom(Atom.TYPE_stbl, + atom(Atom.TYPE_stsd, + mp4vFormat ? VIDEO_STSD_MP4V_PAYLOAD : VIDEO_STSD_PAYLOAD), + atom(Atom.TYPE_stts, getStts()), + atom(Atom.TYPE_stsc, getStsc()), + atom(Atom.TYPE_stsz, getStsz()), + atom(Atom.TYPE_stco, getStco()))))), + atom(Atom.TYPE_trak, + atom(Atom.TYPE_tkhd, TKHD_PAYLOAD), + atom(Atom.TYPE_mdia, + atom(Atom.TYPE_mdhd, AUDIO_MDHD_PAYLOAD), + atom(Atom.TYPE_hdlr, AUDIO_HDLR_PAYLOAD), + atom(Atom.TYPE_minf, + atom(Atom.TYPE_vmhd, EMPTY), + atom(Atom.TYPE_stbl, + atom(Atom.TYPE_stsd, AUDIO_STSD_PAYLOAD), + atom(Atom.TYPE_stts, getStts()), + atom(Atom.TYPE_stsc, getStsc()), + atom(Atom.TYPE_stsz, getStsz()), + atom(Atom.TYPE_stco, getStco())))))), + atom(Atom.TYPE_mdat, getMdat())); + } + + private static Mp4Atom atom(int type, Mp4Atom... containedMp4Atoms) { + return new Mp4Atom(type, containedMp4Atoms); + } + + private static Mp4Atom atom(int type, byte[] payload) { + return new Mp4Atom(type, payload); + } + + private static byte[] getByteArray(String hexBytes) { + byte[] result = new byte[hexBytes.length() / 2]; + for (int i = 0; i < result.length; i++) { + result[i] = (byte) ((Character.digit(hexBytes.charAt(i * 2), 16) << 4) + + Character.digit(hexBytes.charAt(i * 2 + 1), 16)); + } + return result; + } + + /** MP4 atom that can be serialized as a byte array. */ + private static final class Mp4Atom { + + public static byte[] serialize(Mp4Atom... atoms) { + int size = 0; + for (Mp4Atom atom : atoms) { + size += atom.getSize(); + } + ByteBuffer buffer = ByteBuffer.allocate(size); + for (Mp4Atom atom : atoms) { + atom.getData(buffer); + } + return buffer.array(); + } + + private static final int HEADER_SIZE = 8; + + private final int type; + private final Mp4Atom[] containedMp4Atoms; + private final byte[] payload; + + private Mp4Atom(int type, Mp4Atom... containedMp4Atoms) { + this.type = type; + this.containedMp4Atoms = containedMp4Atoms; + payload = null; + } + + private Mp4Atom(int type, byte[] payload) { + this.type = type; + this.payload = payload; + containedMp4Atoms = null; + } + + private int getSize() { + int size = HEADER_SIZE; + if (payload != null) { + size += payload.length; + } else { + for (Mp4Atom atom : containedMp4Atoms) { + size += atom.getSize(); + } + } + return size; + } + + private void getData(ByteBuffer byteBuffer) { + byteBuffer.putInt(getSize()); + byteBuffer.putInt(type); + + if (payload != null) { + byteBuffer.put(payload); + } else { + for (Mp4Atom atom : containedMp4Atoms) { + atom.getData(byteBuffer); + } + } + } + + } + + /** + * Creates a {@link Mp4SampleExtractor} on a separate thread with a looper, so that it can use a + * handler for loading, and provides blocking operations like {@link #seekTo} and + * {@link #readSample}. + */ + private static final class Mp4ExtractorWrapper extends Thread { + + private static final int MSG_PREPARE = 0; + private static final int MSG_SEEK_TO = 1; + private static final int MSG_READ_SAMPLE = 2; + private final DataSource dataSource; + + // Written by the handler's thread and read by the main thread. + public volatile MediaFormat[] mediaFormats; + public volatile MediaFormat selectedTrackMediaFormat; + private volatile Handler handler; + private volatile int readSampleResult; + private volatile Exception exception; + private volatile CountDownLatch pendingOperationLatch; + + public Mp4ExtractorWrapper(DataSource dataSource) { + super("Mp4SampleExtractorTest"); + this.dataSource = Assertions.checkNotNull(dataSource); + pendingOperationLatch = new CountDownLatch(1); + start(); + } + + public void prepare() throws Exception { + // Block until the handler has been created. + pendingOperationLatch.await(); + + // Block until the extractor has been prepared. + pendingOperationLatch = new CountDownLatch(1); + handler.sendEmptyMessage(MSG_PREPARE); + pendingOperationLatch.await(); + if (exception != null) { + throw exception; + } + } + + public void seekTo(long timestampUs) { + handler.obtainMessage(MSG_SEEK_TO, timestampUs).sendToTarget(); + } + + public int readSample(int trackIndex, SampleHolder sampleHolder) throws Exception { + // Block until the extractor has completed readSample. + pendingOperationLatch = new CountDownLatch(1); + handler.obtainMessage(MSG_READ_SAMPLE, trackIndex, 0, sampleHolder).sendToTarget(); + pendingOperationLatch.await(); + if (exception != null) { + throw exception; + } + return readSampleResult; + } + + @SuppressLint("HandlerLeak") + @Override + public void run() { + final Mp4SampleExtractor mp4SampleExtractor = + new Mp4SampleExtractor(dataSource, new DataSpec(FAKE_URI)); + Looper.prepare(); + handler = new Handler() { + @Override + public void handleMessage(Message message) { + try { + switch (message.what) { + case MSG_PREPARE: + if (!mp4SampleExtractor.prepare()) { + sendEmptyMessage(MSG_PREPARE); + } else { + // Select the video track and get its metadata. + mediaFormats = new MediaFormat[mp4SampleExtractor.getTrackCount()]; + for (int track = 0; track < mp4SampleExtractor.getTrackCount(); track++) { + MediaFormat mediaFormat = mp4SampleExtractor.getMediaFormat(track); + mediaFormats[track] = mediaFormat; + if (MimeTypes.isVideo(mediaFormat.mimeType)) { + mp4SampleExtractor.selectTrack(track); + selectedTrackMediaFormat = mediaFormat; + } + } + pendingOperationLatch.countDown(); + } + break; + case MSG_SEEK_TO: + long timestampUs = (long) message.obj; + mp4SampleExtractor.seekTo(timestampUs); + break; + case MSG_READ_SAMPLE: + int trackIndex = message.arg1; + SampleHolder sampleHolder = (SampleHolder) message.obj; + sampleHolder.clearData(); + readSampleResult = mp4SampleExtractor.readSample(trackIndex, sampleHolder); + if (readSampleResult == SampleSource.NOTHING_READ) { + Message.obtain(message).sendToTarget(); + return; + } + pendingOperationLatch.countDown(); + break; + } + } catch (Exception e) { + exception = e; + pendingOperationLatch.countDown(); + } + } + }; + + // Unblock waiting for the handler. + pendingOperationLatch.countDown(); + + Looper.loop(); + } + + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/testutil/Util.java b/library/src/test/java/com/google/android/exoplayer/testutil/Util.java new file mode 100644 index 0000000000..5f86b51829 --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/testutil/Util.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2014 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.exoplayer.testutil; + +import java.util.Random; + +/** + * Utility methods for tests. + */ +public class Util { + + private Util() {} + + public static byte[] buildTestData(int length) { + return buildTestData(length, length); + } + + public static byte[] buildTestData(int length, int seed) { + Random random = new Random(seed); + byte[] source = new byte[length]; + random.nextBytes(source); + return source; + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/text/webvtt/WebvttParserTest.java b/library/src/test/java/com/google/android/exoplayer/text/webvtt/WebvttParserTest.java new file mode 100644 index 0000000000..426c5152df --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/text/webvtt/WebvttParserTest.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2014 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.exoplayer.text.webvtt; + +import com.google.android.exoplayer.C; + +import android.test.InstrumentationTestCase; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Unit test for {@link WebvttParser}. + */ +public class WebvttParserTest extends InstrumentationTestCase { + + private static final String TYPICAL_WEBVTT_FILE = "webvtt/typical"; + private static final String TYPICAL_WITH_IDS_WEBVTT_FILE = "webvtt/typical_with_identifiers"; + private static final String TYPICAL_WITH_TAGS_WEBVTT_FILE = "webvtt/typical_with_tags"; + private static final String EMPTY_WEBVTT_FILE = "webvtt/empty"; + + public void testParseNullWebvttFile() throws IOException { + WebvttParser parser = new WebvttParser(); + InputStream inputStream = + getInstrumentation().getContext().getResources().getAssets().open(EMPTY_WEBVTT_FILE); + + try { + parser.parse(inputStream, C.UTF8_NAME, 0); + fail("Expected IOException"); + } catch (IOException expected) { + // Do nothing. + } + } + + public void testParseTypicalWebvttFile() throws IOException { + WebvttParser parser = new WebvttParser(); + InputStream inputStream = + getInstrumentation().getContext().getResources().getAssets().open(TYPICAL_WEBVTT_FILE); + WebvttSubtitle subtitle = parser.parse(inputStream, C.UTF8_NAME, 0); + + // test start time and event count + long startTimeUs = 5000000; + assertEquals(startTimeUs, subtitle.getStartTime()); + assertEquals(4, subtitle.getEventTimeCount()); + + // test first cue + assertEquals(startTimeUs, subtitle.getEventTime(0)); + assertEquals("This is the first subtitle.", + subtitle.getText(subtitle.getEventTime(0))); + assertEquals(startTimeUs + 1234000, subtitle.getEventTime(1)); + + // test second cue + assertEquals(startTimeUs + 2345000, subtitle.getEventTime(2)); + assertEquals("This is the second subtitle.", + subtitle.getText(subtitle.getEventTime(2))); + assertEquals(startTimeUs + 3456000, subtitle.getEventTime(3)); + } + + public void testParseTypicalWithIdsWebvttFile() throws IOException { + WebvttParser parser = new WebvttParser(); + InputStream inputStream = + getInstrumentation().getContext().getResources().getAssets() + .open(TYPICAL_WITH_IDS_WEBVTT_FILE); + WebvttSubtitle subtitle = parser.parse(inputStream, C.UTF8_NAME, 0); + + // test start time and event count + long startTimeUs = 5000000; + assertEquals(startTimeUs, subtitle.getStartTime()); + assertEquals(4, subtitle.getEventTimeCount()); + + // test first cue + assertEquals(startTimeUs, subtitle.getEventTime(0)); + assertEquals("This is the first subtitle.", + subtitle.getText(subtitle.getEventTime(0))); + assertEquals(startTimeUs + 1234000, subtitle.getEventTime(1)); + + // test second cue + assertEquals(startTimeUs + 2345000, subtitle.getEventTime(2)); + assertEquals("This is the second subtitle.", + subtitle.getText(subtitle.getEventTime(2))); + assertEquals(startTimeUs + 3456000, subtitle.getEventTime(3)); + } + + public void testParseTypicalWithTagsWebvttFile() throws IOException { + WebvttParser parser = new WebvttParser(); + InputStream inputStream = + getInstrumentation().getContext().getResources().getAssets() + .open(TYPICAL_WITH_TAGS_WEBVTT_FILE); + WebvttSubtitle subtitle = parser.parse(inputStream, C.UTF8_NAME, 0); + + // test start time and event count + long startTimeUs = 5000000; + assertEquals(startTimeUs, subtitle.getStartTime()); + assertEquals(8, subtitle.getEventTimeCount()); + + // test first cue + assertEquals(startTimeUs, subtitle.getEventTime(0)); + assertEquals("This is the first subtitle.", + subtitle.getText(subtitle.getEventTime(0))); + assertEquals(startTimeUs + 1234000, subtitle.getEventTime(1)); + + // test second cue + assertEquals(startTimeUs + 2345000, subtitle.getEventTime(2)); + assertEquals("This is the second subtitle.", + subtitle.getText(subtitle.getEventTime(2))); + assertEquals(startTimeUs + 3456000, subtitle.getEventTime(3)); + + // test third cue + assertEquals(startTimeUs + 4000000, subtitle.getEventTime(4)); + assertEquals("This is the third subtitle.", + subtitle.getText(subtitle.getEventTime(4))); + assertEquals(startTimeUs + 5000000, subtitle.getEventTime(5)); + + // test fourth cue + assertEquals(startTimeUs + 6000000, subtitle.getEventTime(6)); + assertEquals("This is the &subtitle.", + subtitle.getText(subtitle.getEventTime(6))); + assertEquals(startTimeUs + 7000000, subtitle.getEventTime(7)); + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/text/webvtt/WebvttSubtitleTest.java b/library/src/test/java/com/google/android/exoplayer/text/webvtt/WebvttSubtitleTest.java new file mode 100644 index 0000000000..e95482f0fb --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/text/webvtt/WebvttSubtitleTest.java @@ -0,0 +1,204 @@ +/* + * Copyright (C) 2014 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.exoplayer.text.webvtt; + +import junit.framework.TestCase; + +/** + * Unit test for {@link WebvttSubtitle}. + */ +public class WebvttSubtitleTest extends TestCase { + + private static final String FIRST_SUBTITLE_STRING = "This is the first subtitle."; + private static final String SECOND_SUBTITLE_STRING = "This is the second subtitle."; + private static final String FIRST_AND_SECOND_SUBTITLE_STRING = + FIRST_SUBTITLE_STRING + SECOND_SUBTITLE_STRING; + + private WebvttSubtitle emptySubtitle = new WebvttSubtitle(new String[] {}, 0, new long[] {}); + + private WebvttSubtitle simpleSubtitle = new WebvttSubtitle( + new String[] {FIRST_SUBTITLE_STRING, SECOND_SUBTITLE_STRING}, 0, + new long[] {1000000, 2000000, 3000000, 4000000}); + + private WebvttSubtitle overlappingSubtitle = new WebvttSubtitle( + new String[] {FIRST_SUBTITLE_STRING, SECOND_SUBTITLE_STRING}, 0, + new long[] {1000000, 3000000, 2000000, 4000000}); + + private WebvttSubtitle nestedSubtitle = new WebvttSubtitle( + new String[] {FIRST_SUBTITLE_STRING, SECOND_SUBTITLE_STRING}, 0, + new long[] {1000000, 4000000, 2000000, 3000000}); + + public void testEventCount() { + assertEquals(0, emptySubtitle.getEventTimeCount()); + assertEquals(4, simpleSubtitle.getEventTimeCount()); + assertEquals(4, overlappingSubtitle.getEventTimeCount()); + assertEquals(4, nestedSubtitle.getEventTimeCount()); + } + + public void testStartTime() { + assertEquals(0, emptySubtitle.getStartTime()); + assertEquals(0, simpleSubtitle.getStartTime()); + assertEquals(0, overlappingSubtitle.getStartTime()); + assertEquals(0, nestedSubtitle.getStartTime()); + } + + public void testLastEventTime() { + assertEquals(-1, emptySubtitle.getLastEventTime()); + assertEquals(4000000, simpleSubtitle.getLastEventTime()); + assertEquals(4000000, overlappingSubtitle.getLastEventTime()); + assertEquals(4000000, nestedSubtitle.getLastEventTime()); + } + + public void testSimpleSubtitleEventTimes() { + testSubtitleEventTimesHelper(simpleSubtitle); + } + + public void testSimpleSubtitleEventIndices() { + testSubtitleEventIndicesHelper(simpleSubtitle); + } + + public void testSimpleSubtitleText() { + // Test before first subtitle + assertNull(simpleSubtitle.getText(0)); + assertNull(simpleSubtitle.getText(500000)); + assertNull(simpleSubtitle.getText(999999)); + + // Test first subtitle + assertEquals(FIRST_SUBTITLE_STRING, simpleSubtitle.getText(1000000)); + assertEquals(FIRST_SUBTITLE_STRING, simpleSubtitle.getText(1500000)); + assertEquals(FIRST_SUBTITLE_STRING, simpleSubtitle.getText(1999999)); + + // Test after first subtitle, before second subtitle + assertNull(simpleSubtitle.getText(2000000)); + assertNull(simpleSubtitle.getText(2500000)); + assertNull(simpleSubtitle.getText(2999999)); + + // Test second subtitle + assertEquals(SECOND_SUBTITLE_STRING, simpleSubtitle.getText(3000000)); + assertEquals(SECOND_SUBTITLE_STRING, simpleSubtitle.getText(3500000)); + assertEquals(SECOND_SUBTITLE_STRING, simpleSubtitle.getText(3999999)); + + // Test after second subtitle + assertNull(simpleSubtitle.getText(4000000)); + assertNull(simpleSubtitle.getText(4500000)); + assertNull(simpleSubtitle.getText(Long.MAX_VALUE)); + } + + public void testOverlappingSubtitleEventTimes() { + testSubtitleEventTimesHelper(overlappingSubtitle); + } + + public void testOverlappingSubtitleEventIndices() { + testSubtitleEventIndicesHelper(overlappingSubtitle); + } + + public void testOverlappingSubtitleText() { + // Test before first subtitle + assertNull(overlappingSubtitle.getText(0)); + assertNull(overlappingSubtitle.getText(500000)); + assertNull(overlappingSubtitle.getText(999999)); + + // Test first subtitle + assertEquals(FIRST_SUBTITLE_STRING, overlappingSubtitle.getText(1000000)); + assertEquals(FIRST_SUBTITLE_STRING, overlappingSubtitle.getText(1500000)); + assertEquals(FIRST_SUBTITLE_STRING, overlappingSubtitle.getText(1999999)); + + // Test after first and second subtitle + assertEquals(FIRST_AND_SECOND_SUBTITLE_STRING, overlappingSubtitle.getText(2000000)); + assertEquals(FIRST_AND_SECOND_SUBTITLE_STRING, overlappingSubtitle.getText(2500000)); + assertEquals(FIRST_AND_SECOND_SUBTITLE_STRING, overlappingSubtitle.getText(2999999)); + + // Test second subtitle + assertEquals(SECOND_SUBTITLE_STRING, overlappingSubtitle.getText(3000000)); + assertEquals(SECOND_SUBTITLE_STRING, overlappingSubtitle.getText(3500000)); + assertEquals(SECOND_SUBTITLE_STRING, overlappingSubtitle.getText(3999999)); + + // Test after second subtitle + assertNull(overlappingSubtitle.getText(4000000)); + assertNull(overlappingSubtitle.getText(4500000)); + assertNull(overlappingSubtitle.getText(Long.MAX_VALUE)); + } + + public void testNestedSubtitleEventTimes() { + testSubtitleEventTimesHelper(nestedSubtitle); + } + + public void testNestedSubtitleEventIndices() { + testSubtitleEventIndicesHelper(nestedSubtitle); + } + + public void testNestedSubtitleText() { + // Test before first subtitle + assertNull(nestedSubtitle.getText(0)); + assertNull(nestedSubtitle.getText(500000)); + assertNull(nestedSubtitle.getText(999999)); + + // Test first subtitle + assertEquals(FIRST_SUBTITLE_STRING, nestedSubtitle.getText(1000000)); + assertEquals(FIRST_SUBTITLE_STRING, nestedSubtitle.getText(1500000)); + assertEquals(FIRST_SUBTITLE_STRING, nestedSubtitle.getText(1999999)); + + // Test after first and second subtitle + assertEquals(FIRST_AND_SECOND_SUBTITLE_STRING, nestedSubtitle.getText(2000000)); + assertEquals(FIRST_AND_SECOND_SUBTITLE_STRING, nestedSubtitle.getText(2500000)); + assertEquals(FIRST_AND_SECOND_SUBTITLE_STRING, nestedSubtitle.getText(2999999)); + + // Test first subtitle + assertEquals(FIRST_SUBTITLE_STRING, nestedSubtitle.getText(3000000)); + assertEquals(FIRST_SUBTITLE_STRING, nestedSubtitle.getText(3500000)); + assertEquals(FIRST_SUBTITLE_STRING, nestedSubtitle.getText(3999999)); + + // Test after second subtitle + assertNull(nestedSubtitle.getText(4000000)); + assertNull(nestedSubtitle.getText(4500000)); + assertNull(nestedSubtitle.getText(Long.MAX_VALUE)); + } + + private void testSubtitleEventTimesHelper(WebvttSubtitle subtitle) { + assertEquals(1000000, subtitle.getEventTime(0)); + assertEquals(2000000, subtitle.getEventTime(1)); + assertEquals(3000000, subtitle.getEventTime(2)); + assertEquals(4000000, subtitle.getEventTime(3)); + } + + private void testSubtitleEventIndicesHelper(WebvttSubtitle subtitle) { + // Test first event + assertEquals(0, subtitle.getNextEventTimeIndex(0)); + assertEquals(0, subtitle.getNextEventTimeIndex(500000)); + assertEquals(0, subtitle.getNextEventTimeIndex(999999)); + + // Test second event + assertEquals(1, subtitle.getNextEventTimeIndex(1000000)); + assertEquals(1, subtitle.getNextEventTimeIndex(1500000)); + assertEquals(1, subtitle.getNextEventTimeIndex(1999999)); + + // Test third event + assertEquals(2, subtitle.getNextEventTimeIndex(2000000)); + assertEquals(2, subtitle.getNextEventTimeIndex(2500000)); + assertEquals(2, subtitle.getNextEventTimeIndex(2999999)); + + // Test fourth event + assertEquals(3, subtitle.getNextEventTimeIndex(3000000)); + assertEquals(3, subtitle.getNextEventTimeIndex(3500000)); + assertEquals(3, subtitle.getNextEventTimeIndex(3999999)); + + // Test null event (i.e. look for events after the last event) + assertEquals(-1, subtitle.getNextEventTimeIndex(4000000)); + assertEquals(-1, subtitle.getNextEventTimeIndex(4500000)); + assertEquals(-1, subtitle.getNextEventTimeIndex(Long.MAX_VALUE)); + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/upstream/BufferedNonBlockingInputStreamTest.java b/library/src/test/java/com/google/android/exoplayer/upstream/BufferedNonBlockingInputStreamTest.java new file mode 100644 index 0000000000..5b102e12d1 --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/upstream/BufferedNonBlockingInputStreamTest.java @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2014 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.exoplayer.upstream; + +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyInt; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.android.exoplayer.SampleSource; + +import junit.framework.TestCase; + +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import java.util.Arrays; + +/** + * Tests for {@link BufferedNonBlockingInputStream}. + */ +public class BufferedNonBlockingInputStreamTest extends TestCase { + + private static final int BUFFER_SIZE_BYTES = 16; + + @Mock private NonBlockingInputStream mockInputStream; + private BufferedNonBlockingInputStream bufferedInputStream; + + @Override + public void setUp() { + MockitoAnnotations.initMocks(this); + + bufferedInputStream = new BufferedNonBlockingInputStream(mockInputStream, BUFFER_SIZE_BYTES); + } + + public void testSkipClipsCountToBufferSizeWhenMarkSet() { + // When marking and skipping more than the buffer size + bufferedInputStream.mark(); + bufferedInputStream.skip(BUFFER_SIZE_BYTES + 1); + + // Then BUFFER_SIZE_BYTES are read. + verify(mockInputStream).read((byte[]) any(), eq(0), eq(BUFFER_SIZE_BYTES)); + } + + public void testSkipResetSkipUsesBufferedData() { + // Given a buffered input stream that has already read BUFFER_SIZE_BYTES + stubInputStreamForReadingBytes(); + bufferedInputStream.mark(); + bufferedInputStream.skip(BUFFER_SIZE_BYTES); + verify(mockInputStream).read((byte[]) any(), eq(0), eq(BUFFER_SIZE_BYTES)); + + // When resetting and reading the same amount, no extra data are read. + bufferedInputStream.returnToMark(); + bufferedInputStream.skip(BUFFER_SIZE_BYTES); + verify(mockInputStream).read((byte[]) any(), eq(0), eq(BUFFER_SIZE_BYTES)); + } + + public void testReturnsEndOfStreamAfterBufferedData() { + // Given a buffered input stream that has read 1 byte (to end-of-stream) and has been reset + stubInputStreamForReadingBytes(); + bufferedInputStream.mark(); + bufferedInputStream.skip(1); + stubInputStreamForReadingEndOfStream(); + bufferedInputStream.returnToMark(); + + // When skipping, first 1 byte is returned, then end-of-stream. + assertEquals(1, bufferedInputStream.skip(1)); + assertEquals(SampleSource.END_OF_STREAM, bufferedInputStream.skip(1)); + } + + public void testReadAtOffset() { + // Given a mock input stream that provide non-zero data + stubInputStreamForReadingBytes(); + + // When reading a byte at offset 1 + byte[] bytes = new byte[2]; + bufferedInputStream.mark(); + bufferedInputStream.read(bytes, 1, 1); + + // Then only the second byte is set. + assertTrue(Arrays.equals(new byte[] {(byte) 0, (byte) 0xFF}, bytes)); + } + + public void testSkipAfterMark() { + // Given a mock input stream that provides non-zero data, with three bytes read + stubInputStreamForReadingBytes(); + bufferedInputStream.skip(1); + bufferedInputStream.mark(); + bufferedInputStream.skip(2); + bufferedInputStream.returnToMark(); + + // Then it is possible to skip one byte after the mark and read two bytes. + assertEquals(1, bufferedInputStream.skip(1)); + assertEquals(2, bufferedInputStream.read(new byte[2], 0, 2)); + verify(mockInputStream).read((byte[]) any(), eq(0), eq(1)); + verify(mockInputStream).read((byte[]) any(), eq(0), eq(2)); + verify(mockInputStream).read((byte[]) any(), eq(2), eq(1)); + } + + /** Stubs the input stream to read 0xFF for all requests. */ + private void stubInputStreamForReadingBytes() { + when(mockInputStream.read((byte[]) any(), anyInt(), anyInt())).thenAnswer( + new Answer() { + + @Override + public Integer answer(InvocationOnMock invocation) throws Throwable { + byte[] bytes = (byte[]) invocation.getArguments()[0]; + int offset = (int) invocation.getArguments()[1]; + int length = (int) invocation.getArguments()[2]; + for (int i = 0; i < length; i++) { + bytes[i + offset] = (byte) 0xFF; + } + return length; + } + + }); + when(mockInputStream.skip(anyInt())).thenAnswer(new Answer() { + + @Override + public Integer answer(InvocationOnMock invocation) throws Throwable { + return (int) invocation.getArguments()[0]; + } + + }); + } + + /** Stubs the input stream to read end-of-stream for all requests. */ + private void stubInputStreamForReadingEndOfStream() { + when(mockInputStream.read((byte[]) any(), anyInt(), anyInt())) + .thenReturn(SampleSource.END_OF_STREAM); + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/upstream/ByteArrayDataSourceTest.java b/library/src/test/java/com/google/android/exoplayer/upstream/ByteArrayDataSourceTest.java new file mode 100644 index 0000000000..550cb149a8 --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/upstream/ByteArrayDataSourceTest.java @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2014 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.exoplayer.upstream; + +import com.google.android.exoplayer.C; + +import junit.framework.TestCase; + +import java.io.IOException; + +/** + * Unit tests for {@link ByteArrayDataSource}. + */ +public class ByteArrayDataSourceTest extends TestCase { + + private static final byte[] TEST_DATA = new byte[] {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; + private static final byte[] TEST_DATA_ODD = new byte[] {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; + + public void testFullReadSingleBytes() { + readTestData(TEST_DATA, 0, C.LENGTH_UNBOUNDED, 1, 0, 1, false); + } + + public void testFullReadAllBytes() { + readTestData(TEST_DATA, 0, C.LENGTH_UNBOUNDED, 100, 0, 100, false); + } + + public void testLimitReadSingleBytes() { + // Limit set to the length of the data. + readTestData(TEST_DATA, 0, TEST_DATA.length, 1, 0, 1, false); + // And less. + readTestData(TEST_DATA, 0, 6, 1, 0, 1, false); + } + + public void testFullReadTwoBytes() { + // Try with the total data length an exact multiple of the size of each individual read. + readTestData(TEST_DATA, 0, C.LENGTH_UNBOUNDED, 2, 0, 2, false); + // And not. + readTestData(TEST_DATA_ODD, 0, C.LENGTH_UNBOUNDED, 2, 0, 2, false); + } + + public void testLimitReadTwoBytes() { + // Try with the limit an exact multiple of the size of each individual read. + readTestData(TEST_DATA, 0, 6, 2, 0, 2, false); + // And not. + readTestData(TEST_DATA, 0, 7, 2, 0, 2, false); + } + + public void testReadFromValidOffsets() { + // Read from an offset without bound. + readTestData(TEST_DATA, 1, C.LENGTH_UNBOUNDED, 1, 0, 1, false); + // And with bound. + readTestData(TEST_DATA, 1, 6, 1, 0, 1, false); + // Read from the last possible offset without bound. + readTestData(TEST_DATA, TEST_DATA.length - 1, C.LENGTH_UNBOUNDED, 1, 0, 1, false); + // And with bound. + readTestData(TEST_DATA, TEST_DATA.length - 1, 1, 1, 0, 1, false); + } + + public void testReadFromInvalidOffsets() { + // Read from first invalid offset and check failure without bound. + readTestData(TEST_DATA, TEST_DATA.length, C.LENGTH_UNBOUNDED, 1, 0, 1, true); + // And with bound. + readTestData(TEST_DATA, TEST_DATA.length, 1, 1, 0, 1, true); + } + + public void testReadWithInvalidLength() { + // Read more data than is available. + readTestData(TEST_DATA, 0, TEST_DATA.length + 1, 1, 0, 1, true); + // And with bound. + readTestData(TEST_DATA, 1, TEST_DATA.length, 1, 0, 1, true); + } + + /** + * Tests reading from a {@link ByteArrayDataSource} with various parameters. + * + * @param testData The data that the {@link ByteArrayDataSource} will wrap. + * @param dataOffset The offset from which to read data. + * @param dataLength The total length of data to read. + * @param outputBufferLength The length of the target buffer for each read. + * @param writeOffset The offset into {@code outputBufferLength} for each read. + * @param maxReadLength The maximum length of each read. + * @param expectFailOnOpen Whether it is expected that opening the source will fail. + */ + private void readTestData(byte[] testData, int dataOffset, int dataLength, int outputBufferLength, + int writeOffset, int maxReadLength, boolean expectFailOnOpen) { + int expectedFinalBytesRead = + dataLength == C.LENGTH_UNBOUNDED ? (testData.length - dataOffset) : dataLength; + ByteArrayDataSource dataSource = new ByteArrayDataSource(testData); + boolean opened = false; + try { + // Open the source. + long length = dataSource.open(new DataSpec(null, dataOffset, dataLength, null)); + opened = true; + assertFalse(expectFailOnOpen); + + // Verify the resolved length is as we expect. + assertEquals(expectedFinalBytesRead, length); + + byte[] outputBuffer = new byte[outputBufferLength]; + int accumulatedBytesRead = 0; + while (true) { + // Calculate a valid length for the next read, constraining by the specified output buffer + // length, write offset and maximum write length input parameters. + int requestedReadLength = Math.min(maxReadLength, outputBufferLength - writeOffset); + assertTrue(requestedReadLength > 0); + + int bytesRead = dataSource.read(outputBuffer, writeOffset, requestedReadLength); + if (bytesRead != -1) { + assertTrue(bytesRead > 0); + assertTrue(bytesRead <= requestedReadLength); + // Check the data read was correct. + for (int i = 0; i < bytesRead; i++) { + assertEquals(testData[dataOffset + accumulatedBytesRead + i], + outputBuffer[writeOffset + i]); + } + // Check that we haven't read more data than we were expecting. + accumulatedBytesRead += bytesRead; + assertTrue(accumulatedBytesRead <= expectedFinalBytesRead); + // If we haven't read all of the bytes the request should have been satisfied in full. + assertTrue(accumulatedBytesRead == expectedFinalBytesRead + || bytesRead == requestedReadLength); + } else { + // We're done. Check we read the expected number of bytes. + assertEquals(expectedFinalBytesRead, accumulatedBytesRead); + return; + } + } + } catch (IOException e) { + if (expectFailOnOpen && !opened) { + // Expected. + return; + } + // Unexpected failure. + fail(); + } + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/upstream/DataSourceStreamTest.java b/library/src/test/java/com/google/android/exoplayer/upstream/DataSourceStreamTest.java new file mode 100644 index 0000000000..7c40378116 --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/upstream/DataSourceStreamTest.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2014 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.exoplayer.upstream; + +import com.google.android.exoplayer.testutil.Util; + +import junit.framework.TestCase; + +import java.io.IOException; +import java.util.Arrays; + +/** + * Unit tests for {@link DataSourceStream}. + */ +public class DataSourceStreamTest extends TestCase { + + private static final int DATA_LENGTH = 1024; + private static final int BUFFER_LENGTH = 128; + + public void testGetLoadedData() throws IOException, InterruptedException { + byte[] testData = Util.buildTestData(DATA_LENGTH); + DataSource dataSource = new ByteArrayDataSource(testData); + DataSpec dataSpec = new DataSpec(null, 0, DATA_LENGTH, null); + DataSourceStream dataSourceStream = new DataSourceStream(dataSource, dataSpec, + new BufferPool(BUFFER_LENGTH)); + + dataSourceStream.load(); + // Assert that the read and load positions are correct. + assertEquals(0, dataSourceStream.getReadPosition()); + assertEquals(testData.length, dataSourceStream.getLoadPosition()); + + int halfTestDataLength = testData.length / 2; + byte[] readData = new byte[testData.length]; + int bytesRead = dataSourceStream.read(readData, 0, halfTestDataLength); + // Assert that the read position is updated correctly. + assertEquals(halfTestDataLength, bytesRead); + assertEquals(halfTestDataLength, dataSourceStream.getReadPosition()); + + bytesRead += dataSourceStream.read(readData, bytesRead, testData.length - bytesRead); + // Assert that the read position was updated correctly. + assertEquals(testData.length, bytesRead); + assertEquals(testData.length, dataSourceStream.getReadPosition()); + // Assert that the data read using the two read calls either side of getLoadedData is correct. + assertTrue(Arrays.equals(testData, readData)); + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/util/H264UtilTest.java b/library/src/test/java/com/google/android/exoplayer/util/H264UtilTest.java new file mode 100644 index 0000000000..78bfa0c3fb --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/util/H264UtilTest.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2014 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.exoplayer.util; + +import junit.framework.TestCase; + +import java.util.Arrays; + +/** + * Tests for {@link H264Util}. + */ +public class H264UtilTest extends TestCase { + + private static final int TEST_PARTIAL_NAL_POSITION = 4; + private static final int TEST_NAL_POSITION = 10; + + public void testFindNalUnit() { + byte[] data = buildTestData(); + + // Should find NAL unit. + int result = H264Util.findNalUnit(data, 0, data.length, null); + assertEquals(TEST_NAL_POSITION, result); + // Should find NAL unit whose prefix ends one byte before the limit. + result = H264Util.findNalUnit(data, 0, TEST_NAL_POSITION + 4, null); + assertEquals(TEST_NAL_POSITION, result); + // Shouldn't find NAL unit whose prefix ends at the limit (since the limit is exclusive). + result = H264Util.findNalUnit(data, 0, TEST_NAL_POSITION + 3, null); + assertEquals(TEST_NAL_POSITION + 3, result); + // Should find NAL unit whose prefix starts at the offset. + result = H264Util.findNalUnit(data, TEST_NAL_POSITION, data.length, null); + assertEquals(TEST_NAL_POSITION, result); + // Shouldn't find NAL unit whose prefix starts one byte past the offset. + result = H264Util.findNalUnit(data, TEST_NAL_POSITION + 1, data.length, null); + assertEquals(data.length, result); + } + + public void testFindNalUnitWithPrefix() { + byte[] data = buildTestData(); + + // First byte of NAL unit in data1, rest in data2. + boolean[] prefixFlags = new boolean[3]; + byte[] data1 = Arrays.copyOfRange(data, 0, TEST_NAL_POSITION + 1); + byte[] data2 = Arrays.copyOfRange(data, TEST_NAL_POSITION + 1, data.length); + int result = H264Util.findNalUnit(data1, 0, data1.length, prefixFlags); + assertEquals(data1.length, result); + result = H264Util.findNalUnit(data2, 0, data2.length, prefixFlags); + assertEquals(-1, result); + assertPrefixFlagsCleared(prefixFlags); + + // First three bytes of NAL unit in data1, rest in data2. + prefixFlags = new boolean[3]; + data1 = Arrays.copyOfRange(data, 0, TEST_NAL_POSITION + 3); + data2 = Arrays.copyOfRange(data, TEST_NAL_POSITION + 3, data.length); + result = H264Util.findNalUnit(data1, 0, data1.length, prefixFlags); + assertEquals(data1.length, result); + result = H264Util.findNalUnit(data2, 0, data2.length, prefixFlags); + assertEquals(-3, result); + assertPrefixFlagsCleared(prefixFlags); + + // First byte of NAL unit in data1, second byte in data2, rest in data3. + prefixFlags = new boolean[3]; + data1 = Arrays.copyOfRange(data, 0, TEST_NAL_POSITION + 1); + data2 = Arrays.copyOfRange(data, TEST_NAL_POSITION + 1, TEST_NAL_POSITION + 2); + byte[] data3 = Arrays.copyOfRange(data, TEST_NAL_POSITION + 2, data.length); + result = H264Util.findNalUnit(data1, 0, data1.length, prefixFlags); + assertEquals(data1.length, result); + result = H264Util.findNalUnit(data2, 0, data2.length, prefixFlags); + assertEquals(data2.length, result); + result = H264Util.findNalUnit(data3, 0, data3.length, prefixFlags); + assertEquals(-2, result); + assertPrefixFlagsCleared(prefixFlags); + + // NAL unit split with one byte in four arrays. + prefixFlags = new boolean[3]; + data1 = Arrays.copyOfRange(data, 0, TEST_NAL_POSITION + 1); + data2 = Arrays.copyOfRange(data, TEST_NAL_POSITION + 1, TEST_NAL_POSITION + 2); + data3 = Arrays.copyOfRange(data, TEST_NAL_POSITION + 2, TEST_NAL_POSITION + 3); + byte[] data4 = Arrays.copyOfRange(data, TEST_NAL_POSITION + 2, data.length); + result = H264Util.findNalUnit(data1, 0, data1.length, prefixFlags); + assertEquals(data1.length, result); + result = H264Util.findNalUnit(data2, 0, data2.length, prefixFlags); + assertEquals(data2.length, result); + result = H264Util.findNalUnit(data3, 0, data3.length, prefixFlags); + assertEquals(data3.length, result); + result = H264Util.findNalUnit(data4, 0, data4.length, prefixFlags); + assertEquals(-3, result); + assertPrefixFlagsCleared(prefixFlags); + + // NAL unit entirely in data2. data1 ends with partial prefix. + prefixFlags = new boolean[3]; + data1 = Arrays.copyOfRange(data, 0, TEST_PARTIAL_NAL_POSITION + 2); + data2 = Arrays.copyOfRange(data, TEST_PARTIAL_NAL_POSITION + 2, data.length); + result = H264Util.findNalUnit(data1, 0, data1.length, prefixFlags); + assertEquals(data1.length, result); + result = H264Util.findNalUnit(data2, 0, data2.length, prefixFlags); + assertEquals(4, result); + assertPrefixFlagsCleared(prefixFlags); + } + + private static byte[] buildTestData() { + byte[] data = new byte[20]; + for (int i = 0; i < data.length; i++) { + data[i] = (byte) 0xFF; + } + // Insert an incomplete NAL unit start code. + data[TEST_PARTIAL_NAL_POSITION] = 0; + data[TEST_PARTIAL_NAL_POSITION + 1] = 0; + // Insert a complete NAL unit start code. + data[TEST_NAL_POSITION] = 0; + data[TEST_NAL_POSITION + 1] = 0; + data[TEST_NAL_POSITION + 2] = 1; + data[TEST_NAL_POSITION + 3] = 5; + return data; + } + + private static void assertPrefixFlagsCleared(boolean[] flags) { + assertEquals(false, flags[0] || flags[1] || flags[2]); + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/util/ParsableByteArrayTest.java b/library/src/test/java/com/google/android/exoplayer/util/ParsableByteArrayTest.java new file mode 100644 index 0000000000..950710cf1d --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/util/ParsableByteArrayTest.java @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2014 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.exoplayer.util; + +import junit.framework.TestCase; + +import java.util.Arrays; + +/** + * Tests for {@link ParsableByteArray}. + */ +public class ParsableByteArrayTest extends TestCase { + + private static final byte[] ARRAY_ELEMENTS = + new byte[] {0x0F, (byte) 0xFF, (byte) 0x42, (byte) 0x0F, 0x00, 0x00, 0x00, 0x00}; + + private ParsableByteArray parsableByteArray; + + @Override + public void setUp() { + parsableByteArray = new ParsableByteArray(ARRAY_ELEMENTS.length); + System.arraycopy(ARRAY_ELEMENTS, 0, parsableByteArray.data, 0, ARRAY_ELEMENTS.length); + } + + public void testReadInt() { + // When reading a signed integer + int value = parsableByteArray.readInt(); + + // Then the read value is equal to the array elements interpreted as an int. + assertEquals((0xFF & ARRAY_ELEMENTS[0]) << 24 | (0xFF & ARRAY_ELEMENTS[1]) << 16 + | (0xFF & ARRAY_ELEMENTS[2]) << 8 | (0xFF & ARRAY_ELEMENTS[3]), value); + } + + public void testSkipBack() { + // When reading an unsigned integer + long value = parsableByteArray.readUnsignedInt(); + + // Then skipping back and reading gives the same value. + parsableByteArray.skip(-4); + assertEquals(value, parsableByteArray.readUnsignedInt()); + } + + public void testReadingMovesPosition() { + // Given an array at the start + assertEquals(0, parsableByteArray.getPosition()); + + // When reading an integer, the position advances + parsableByteArray.readUnsignedInt(); + assertEquals(4, parsableByteArray.getPosition()); + } + + public void testOutOfBoundsThrows() { + // Given an array at the end + parsableByteArray.readUnsignedLongToLong(); + assertEquals(ARRAY_ELEMENTS.length, parsableByteArray.getPosition()); + + // Then reading more data throws. + try { + parsableByteArray.readUnsignedInt(); + fail(); + } catch (Exception e) { + // Expected. + } + } + + public void testModificationsAffectParsableArray() { + // When modifying the wrapped byte array + byte[] data = parsableByteArray.data; + long readValue = parsableByteArray.readUnsignedInt(); + data[0] = (byte) (ARRAY_ELEMENTS[0] + 1); + parsableByteArray.setPosition(0); + + // Then the parsed value changes. + assertFalse(parsableByteArray.readUnsignedInt() == readValue); + } + + public void testReadingUnsignedLongWithMsbSetThrows() { + // Given an array with the most-significant bit set on the top byte + byte[] data = parsableByteArray.data; + data[0] = (byte) 0x80; + + // Then reading an unsigned long throws. + try { + parsableByteArray.readUnsignedLongToLong(); + fail(); + } catch (Exception e) { + // Expected. + } + } + + public void testReadUnsignedFixedPoint1616() { + // When reading the integer part of a 16.16 fixed point value + int value = parsableByteArray.readUnsignedFixedPoint1616(); + + // Then the read value is equal to the array elements interpreted as a short. + assertEquals((0xFF & ARRAY_ELEMENTS[0]) << 8 | (ARRAY_ELEMENTS[1] & 0xFF), value); + assertEquals(4, parsableByteArray.getPosition()); + } + + public void testReadingBytesReturnsCopy() { + // When reading all the bytes back + int length = parsableByteArray.limit(); + assertEquals(ARRAY_ELEMENTS.length, length); + byte[] copy = new byte[length]; + parsableByteArray.readBytes(copy, 0, length); + + // Then the array elements are the same. + assertTrue(Arrays.equals(parsableByteArray.data, copy)); + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/util/UriUtilTest.java b/library/src/test/java/com/google/android/exoplayer/util/UriUtilTest.java new file mode 100644 index 0000000000..f482d99c47 --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/util/UriUtilTest.java @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2014 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.exoplayer.util; + +import junit.framework.TestCase; + +/** + * Unit tests for {@link UriUtil}. + */ +public class UriUtilTest extends TestCase { + + /** + * Tests normal usage of {@link UriUtil#resolve(String, String)}. + *

+ * The test cases are taken from RFC-3986 5.4.1. + */ + public void testResolveNormal() { + String base = "http://a/b/c/d;p?q"; + + assertEquals("g:h", UriUtil.resolve(base, "g:h")); + assertEquals("http://a/b/c/g", UriUtil.resolve(base, "g")); + assertEquals("http://a/b/c/g/", UriUtil.resolve(base, "g/")); + assertEquals("http://a/g", UriUtil.resolve(base, "/g")); + assertEquals("http://g", UriUtil.resolve(base, "//g")); + assertEquals("http://a/b/c/d;p?y", UriUtil.resolve(base, "?y")); + assertEquals("http://a/b/c/g?y", UriUtil.resolve(base, "g?y")); + assertEquals("http://a/b/c/d;p?q#s", UriUtil.resolve(base, "#s")); + assertEquals("http://a/b/c/g#s", UriUtil.resolve(base, "g#s")); + assertEquals("http://a/b/c/g?y#s", UriUtil.resolve(base, "g?y#s")); + assertEquals("http://a/b/c/;x", UriUtil.resolve(base, ";x")); + assertEquals("http://a/b/c/g;x", UriUtil.resolve(base, "g;x")); + assertEquals("http://a/b/c/g;x?y#s", UriUtil.resolve(base, "g;x?y#s")); + assertEquals("http://a/b/c/d;p?q", UriUtil.resolve(base, "")); + assertEquals("http://a/b/c/", UriUtil.resolve(base, ".")); + assertEquals("http://a/b/c/", UriUtil.resolve(base, "./")); + assertEquals("http://a/b/", UriUtil.resolve(base, "..")); + assertEquals("http://a/b/", UriUtil.resolve(base, "../")); + assertEquals("http://a/b/g", UriUtil.resolve(base, "../g")); + assertEquals("http://a/", UriUtil.resolve(base, "../..")); + assertEquals("http://a/", UriUtil.resolve(base, "../../")); + assertEquals("http://a/g", UriUtil.resolve(base, "../../g")); + } + + /** + * Tests abnormal usage of {@link UriUtil#resolve(String, String)}. + *

+ * The test cases are taken from RFC-3986 5.4.2. + */ + public void testResolveAbnormal() { + String base = "http://a/b/c/d;p?q"; + + assertEquals("http://a/g", UriUtil.resolve(base, "../../../g")); + assertEquals("http://a/g", UriUtil.resolve(base, "../../../../g")); + + assertEquals("http://a/g", UriUtil.resolve(base, "/./g")); + assertEquals("http://a/g", UriUtil.resolve(base, "/../g")); + assertEquals("http://a/b/c/g.", UriUtil.resolve(base, "g.")); + assertEquals("http://a/b/c/.g", UriUtil.resolve(base, ".g")); + assertEquals("http://a/b/c/g..", UriUtil.resolve(base, "g..")); + assertEquals("http://a/b/c/..g", UriUtil.resolve(base, "..g")); + + assertEquals("http://a/b/g", UriUtil.resolve(base, "./../g")); + assertEquals("http://a/b/c/g/", UriUtil.resolve(base, "./g/.")); + assertEquals("http://a/b/c/g/h", UriUtil.resolve(base, "g/./h")); + assertEquals("http://a/b/c/h", UriUtil.resolve(base, "g/../h")); + assertEquals("http://a/b/c/g;x=1/y", UriUtil.resolve(base, "g;x=1/./y")); + assertEquals("http://a/b/c/y", UriUtil.resolve(base, "g;x=1/../y")); + + assertEquals("http://a/b/c/g?y/./x", UriUtil.resolve(base, "g?y/./x")); + assertEquals("http://a/b/c/g?y/../x", UriUtil.resolve(base, "g?y/../x")); + assertEquals("http://a/b/c/g#s/./x", UriUtil.resolve(base, "g#s/./x")); + assertEquals("http://a/b/c/g#s/../x", UriUtil.resolve(base, "g#s/../x")); + + assertEquals("http:g", UriUtil.resolve(base, "http:g")); + } + + /** + * Tests additional abnormal usage of {@link UriUtil#resolve(String, String)}. + */ + public void testResolveAbnormalAdditional() { + assertEquals("c:e", UriUtil.resolve("http://a/b", "c:d/../e")); + assertEquals("a:c", UriUtil.resolve("a:b", "../c")); + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/util/UtilTest.java b/library/src/test/java/com/google/android/exoplayer/util/UtilTest.java new file mode 100644 index 0000000000..351e93c339 --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/util/UtilTest.java @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2014 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.exoplayer.util; + +import junit.framework.TestCase; + +import java.text.ParseException; +import java.util.ArrayList; +import java.util.List; + +/** + * Unit tests for {@link Util}. + */ +public class UtilTest extends TestCase { + + public void testArrayBinarySearchFloor() { + long[] values = new long[0]; + assertEquals(-1, Util.binarySearchFloor(values, 0, false, false)); + assertEquals(0, Util.binarySearchFloor(values, 0, false, true)); + + values = new long[] {1, 3, 5}; + assertEquals(-1, Util.binarySearchFloor(values, 0, false, false)); + assertEquals(-1, Util.binarySearchFloor(values, 0, true, false)); + assertEquals(0, Util.binarySearchFloor(values, 0, false, true)); + assertEquals(0, Util.binarySearchFloor(values, 0, true, true)); + + assertEquals(-1, Util.binarySearchFloor(values, 1, false, false)); + assertEquals(0, Util.binarySearchFloor(values, 1, true, false)); + assertEquals(0, Util.binarySearchFloor(values, 1, false, true)); + assertEquals(0, Util.binarySearchFloor(values, 1, true, true)); + + assertEquals(1, Util.binarySearchFloor(values, 4, false, false)); + assertEquals(1, Util.binarySearchFloor(values, 4, true, false)); + + assertEquals(1, Util.binarySearchFloor(values, 5, false, false)); + assertEquals(2, Util.binarySearchFloor(values, 5, true, false)); + + assertEquals(2, Util.binarySearchFloor(values, 6, false, false)); + assertEquals(2, Util.binarySearchFloor(values, 6, true, false)); + } + + public void testListBinarySearchFloor() { + List values = new ArrayList(); + assertEquals(-1, Util.binarySearchFloor(values, 0, false, false)); + assertEquals(0, Util.binarySearchFloor(values, 0, false, true)); + + values.add(1); + values.add(3); + values.add(5); + assertEquals(-1, Util.binarySearchFloor(values, 0, false, false)); + assertEquals(-1, Util.binarySearchFloor(values, 0, true, false)); + assertEquals(0, Util.binarySearchFloor(values, 0, false, true)); + assertEquals(0, Util.binarySearchFloor(values, 0, true, true)); + + assertEquals(-1, Util.binarySearchFloor(values, 1, false, false)); + assertEquals(0, Util.binarySearchFloor(values, 1, true, false)); + assertEquals(0, Util.binarySearchFloor(values, 1, false, true)); + assertEquals(0, Util.binarySearchFloor(values, 1, true, true)); + + assertEquals(1, Util.binarySearchFloor(values, 4, false, false)); + assertEquals(1, Util.binarySearchFloor(values, 4, true, false)); + + assertEquals(1, Util.binarySearchFloor(values, 5, false, false)); + assertEquals(2, Util.binarySearchFloor(values, 5, true, false)); + + assertEquals(2, Util.binarySearchFloor(values, 6, false, false)); + assertEquals(2, Util.binarySearchFloor(values, 6, true, false)); + } + + public void testArrayBinarySearchCeil() { + long[] values = new long[0]; + assertEquals(0, Util.binarySearchCeil(values, 0, false, false)); + assertEquals(-1, Util.binarySearchCeil(values, 0, false, true)); + + values = new long[] {1, 3, 5}; + assertEquals(0, Util.binarySearchCeil(values, 0, false, false)); + assertEquals(0, Util.binarySearchCeil(values, 0, true, false)); + + assertEquals(1, Util.binarySearchCeil(values, 1, false, false)); + assertEquals(0, Util.binarySearchCeil(values, 1, true, false)); + + assertEquals(1, Util.binarySearchCeil(values, 2, false, false)); + assertEquals(1, Util.binarySearchCeil(values, 2, true, false)); + + assertEquals(3, Util.binarySearchCeil(values, 5, false, false)); + assertEquals(2, Util.binarySearchCeil(values, 5, true, false)); + assertEquals(2, Util.binarySearchCeil(values, 5, false, true)); + assertEquals(2, Util.binarySearchCeil(values, 5, true, true)); + + assertEquals(3, Util.binarySearchCeil(values, 6, false, false)); + assertEquals(3, Util.binarySearchCeil(values, 6, true, false)); + assertEquals(2, Util.binarySearchCeil(values, 6, false, true)); + assertEquals(2, Util.binarySearchCeil(values, 6, true, true)); + } + + public void testListBinarySearchCeil() { + List values = new ArrayList(); + assertEquals(0, Util.binarySearchCeil(values, 0, false, false)); + assertEquals(-1, Util.binarySearchCeil(values, 0, false, true)); + + values.add(1); + values.add(3); + values.add(5); + assertEquals(0, Util.binarySearchCeil(values, 0, false, false)); + assertEquals(0, Util.binarySearchCeil(values, 0, true, false)); + + assertEquals(1, Util.binarySearchCeil(values, 1, false, false)); + assertEquals(0, Util.binarySearchCeil(values, 1, true, false)); + + assertEquals(1, Util.binarySearchCeil(values, 2, false, false)); + assertEquals(1, Util.binarySearchCeil(values, 2, true, false)); + + assertEquals(3, Util.binarySearchCeil(values, 5, false, false)); + assertEquals(2, Util.binarySearchCeil(values, 5, true, false)); + assertEquals(2, Util.binarySearchCeil(values, 5, false, true)); + assertEquals(2, Util.binarySearchCeil(values, 5, true, true)); + + assertEquals(3, Util.binarySearchCeil(values, 6, false, false)); + assertEquals(3, Util.binarySearchCeil(values, 6, true, false)); + assertEquals(2, Util.binarySearchCeil(values, 6, false, true)); + assertEquals(2, Util.binarySearchCeil(values, 6, true, true)); + } + + public void testParseXsDuration() { + assertEquals(150279L, Util.parseXsDuration("PT150.279S")); + assertEquals(1500L, Util.parseXsDuration("PT1.500S")); + } + + public void testParseXsDateTime() throws ParseException { + assertEquals(1403219262000L, Util.parseXsDateTime("2014-06-19T23:07:42")); + assertEquals(1407322800000L, Util.parseXsDateTime("2014-08-06T11:00:00Z")); + } + +} diff --git a/library/src/test/libs/.README.txt b/library/src/test/libs/.README.txt new file mode 100644 index 0000000000..3f37353a9d --- /dev/null +++ b/library/src/test/libs/.README.txt @@ -0,0 +1 @@ +This file is needed to make sure the libs directory is present. diff --git a/library/src/test/project.properties b/library/src/test/project.properties new file mode 100644 index 0000000000..6e18427a42 --- /dev/null +++ b/library/src/test/project.properties @@ -0,0 +1,14 @@ +# This file is automatically generated by Android Tools. +# Do not modify this file -- YOUR CHANGES WILL BE ERASED! +# +# This file must be checked in Version Control Systems. +# +# To customize properties used by the Ant build system edit +# "ant.properties", and override values to adapt the script to your +# project structure. +# +# To enable ProGuard to shrink and obfuscate your code, uncomment this (available properties: sdk.dir, user.home): +#proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-project.txt + +# Project target. +target=android-21 diff --git a/library/src/test/res/.README.txt b/library/src/test/res/.README.txt new file mode 100644 index 0000000000..c27147ce56 --- /dev/null +++ b/library/src/test/res/.README.txt @@ -0,0 +1,2 @@ +This file is needed to make sure the res directory is present. +The file is ignored by the Android toolchain because its name starts with a dot. diff --git a/third_party/dexmaker/LICENSE b/third_party/dexmaker/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/third_party/dexmaker/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/third_party/dexmaker/dexmaker-1.2.jar b/third_party/dexmaker/dexmaker-1.2.jar new file mode 100644 index 0000000000..08d1b85f18 Binary files /dev/null and b/third_party/dexmaker/dexmaker-1.2.jar differ diff --git a/third_party/dexmaker/dexmaker-mockito-1.2.jar b/third_party/dexmaker/dexmaker-mockito-1.2.jar new file mode 100644 index 0000000000..a3e19759c2 Binary files /dev/null and b/third_party/dexmaker/dexmaker-mockito-1.2.jar differ diff --git a/third_party/mockito/LICENSE b/third_party/mockito/LICENSE new file mode 100644 index 0000000000..2a5730a7ce --- /dev/null +++ b/third_party/mockito/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2008-2010 Mockito contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/third_party/mockito/mockito-all-1.9.5.jar b/third_party/mockito/mockito-all-1.9.5.jar new file mode 100644 index 0000000000..00416eb387 Binary files /dev/null and b/third_party/mockito/mockito-all-1.9.5.jar differ