mediaQueue;
+ private final Listener listener;
+
+ private TrackGroupArray lastSeenTrackGroupArray;
+ private int currentItemIndex;
+ private Player currentPlayer;
+
+ /**
+ * Creates a new manager for {@link SimpleExoPlayer} and {@link CastPlayer}.
+ *
+ * @param listener A {@link Listener} for queue position changes.
+ * @param localPlayerView The {@link PlayerView} for local playback.
+ * @param castControlView The {@link PlayerControlView} to control remote playback.
+ * @param context A {@link Context}.
+ * @param castContext The {@link CastContext}.
+ */
+ public PlayerManager(
+ Listener listener,
+ PlayerView localPlayerView,
+ PlayerControlView castControlView,
+ Context context,
+ CastContext castContext) {
+ this.listener = listener;
+ this.localPlayerView = localPlayerView;
+ this.castControlView = castControlView;
+ mediaQueue = new ArrayList<>();
+ currentItemIndex = C.INDEX_UNSET;
+
+ trackSelector = new DefaultTrackSelector(context);
+ exoPlayer = new SimpleExoPlayer.Builder(context).setTrackSelector(trackSelector).build();
+ exoPlayer.addListener(this);
+ localPlayerView.setPlayer(exoPlayer);
+
+ castPlayer = new CastPlayer(castContext);
+ castPlayer.addListener(this);
+ castPlayer.setSessionAvailabilityListener(this);
+ castControlView.setPlayer(castPlayer);
+
+ setCurrentPlayer(castPlayer.isCastSessionAvailable() ? castPlayer : exoPlayer);
+ }
+
+ // Queue manipulation methods.
+
+ /**
+ * Plays a specified queue item in the current player.
+ *
+ * @param itemIndex The index of the item to play.
+ */
+ public void selectQueueItem(int itemIndex) {
+ setCurrentItem(itemIndex);
+ }
+
+ /** Returns the index of the currently played item. */
+ public int getCurrentItemIndex() {
+ return currentItemIndex;
+ }
+
+ /**
+ * Appends {@code item} to the media queue.
+ *
+ * @param item The {@link MediaItem} to append.
+ */
+ public void addItem(MediaItem item) {
+ mediaQueue.add(item);
+ currentPlayer.addMediaItem(item);
+ }
+
+ /** Returns the size of the media queue. */
+ public int getMediaQueueSize() {
+ return mediaQueue.size();
+ }
+
+ /**
+ * Returns the item at the given index in the media queue.
+ *
+ * @param position The index of the item.
+ * @return The item at the given index in the media queue.
+ */
+ public MediaItem getItem(int position) {
+ return mediaQueue.get(position);
+ }
+
+ /**
+ * Removes the item at the given index from the media queue.
+ *
+ * @param item The item to remove.
+ * @return Whether the removal was successful.
+ */
+ public boolean removeItem(MediaItem item) {
+ int itemIndex = mediaQueue.indexOf(item);
+ if (itemIndex == -1) {
+ return false;
+ }
+ currentPlayer.removeMediaItem(itemIndex);
+ mediaQueue.remove(itemIndex);
+ if (itemIndex == currentItemIndex && itemIndex == mediaQueue.size()) {
+ maybeSetCurrentItemAndNotify(C.INDEX_UNSET);
+ } else if (itemIndex < currentItemIndex) {
+ maybeSetCurrentItemAndNotify(currentItemIndex - 1);
+ }
+ return true;
+ }
+
+ /**
+ * Moves an item within the queue.
+ *
+ * @param item The item to move.
+ * @param newIndex The target index of the item in the queue.
+ * @return Whether the item move was successful.
+ */
+ public boolean moveItem(MediaItem item, int newIndex) {
+ int fromIndex = mediaQueue.indexOf(item);
+ if (fromIndex == -1) {
+ return false;
+ }
+
+ // Player update.
+ currentPlayer.moveMediaItem(fromIndex, newIndex);
+ mediaQueue.add(newIndex, mediaQueue.remove(fromIndex));
+
+ // Index update.
+ if (fromIndex == currentItemIndex) {
+ maybeSetCurrentItemAndNotify(newIndex);
+ } else if (fromIndex < currentItemIndex && newIndex >= currentItemIndex) {
+ maybeSetCurrentItemAndNotify(currentItemIndex - 1);
+ } else if (fromIndex > currentItemIndex && newIndex <= currentItemIndex) {
+ maybeSetCurrentItemAndNotify(currentItemIndex + 1);
+ }
+
+ return true;
+ }
+
+ /**
+ * Dispatches a given {@link KeyEvent} to the corresponding view of the current player.
+ *
+ * @param event The {@link KeyEvent}.
+ * @return Whether the event was handled by the target view.
+ */
+ public boolean dispatchKeyEvent(KeyEvent event) {
+ if (currentPlayer == exoPlayer) {
+ return localPlayerView.dispatchKeyEvent(event);
+ } else /* currentPlayer == castPlayer */ {
+ return castControlView.dispatchKeyEvent(event);
+ }
+ }
+
+ /** Releases the manager and the players that it holds. */
+ public void release() {
+ currentItemIndex = C.INDEX_UNSET;
+ mediaQueue.clear();
+ castPlayer.setSessionAvailabilityListener(null);
+ castPlayer.release();
+ localPlayerView.setPlayer(null);
+ exoPlayer.release();
+ }
+
+ // Player.EventListener implementation.
+
+ @Override
+ public void onPlaybackStateChanged(@Player.State int playbackState) {
+ updateCurrentItemIndex();
+ }
+
+ @Override
+ public void onPositionDiscontinuity(@DiscontinuityReason int reason) {
+ updateCurrentItemIndex();
+ }
+
+ @Override
+ public void onTimelineChanged(@NonNull Timeline timeline, @TimelineChangeReason int reason) {
+ updateCurrentItemIndex();
+ }
+
+ @Override
+ public void onTracksChanged(
+ @NonNull TrackGroupArray trackGroups, @NonNull TrackSelectionArray trackSelections) {
+ if (currentPlayer == exoPlayer && trackGroups != lastSeenTrackGroupArray) {
+ MappingTrackSelector.MappedTrackInfo mappedTrackInfo =
+ trackSelector.getCurrentMappedTrackInfo();
+ if (mappedTrackInfo != null) {
+ if (mappedTrackInfo.getTypeSupport(C.TRACK_TYPE_VIDEO)
+ == MappingTrackSelector.MappedTrackInfo.RENDERER_SUPPORT_UNSUPPORTED_TRACKS) {
+ listener.onUnsupportedTrack(C.TRACK_TYPE_VIDEO);
+ }
+ if (mappedTrackInfo.getTypeSupport(C.TRACK_TYPE_AUDIO)
+ == MappingTrackSelector.MappedTrackInfo.RENDERER_SUPPORT_UNSUPPORTED_TRACKS) {
+ listener.onUnsupportedTrack(C.TRACK_TYPE_AUDIO);
+ }
+ }
+ lastSeenTrackGroupArray = trackGroups;
+ }
+ }
+
+ // CastPlayer.SessionAvailabilityListener implementation.
+
+ @Override
+ public void onCastSessionAvailable() {
+ setCurrentPlayer(castPlayer);
+ }
+
+ @Override
+ public void onCastSessionUnavailable() {
+ setCurrentPlayer(exoPlayer);
+ }
+
+ // Internal methods.
+
+ private void updateCurrentItemIndex() {
+ int playbackState = currentPlayer.getPlaybackState();
+ maybeSetCurrentItemAndNotify(
+ playbackState != Player.STATE_IDLE && playbackState != Player.STATE_ENDED
+ ? currentPlayer.getCurrentWindowIndex()
+ : C.INDEX_UNSET);
+ }
+
+ private void setCurrentPlayer(Player currentPlayer) {
+ if (this.currentPlayer == currentPlayer) {
+ return;
+ }
+
+ // View management.
+ if (currentPlayer == exoPlayer) {
+ localPlayerView.setVisibility(View.VISIBLE);
+ castControlView.hide();
+ } else /* currentPlayer == castPlayer */ {
+ localPlayerView.setVisibility(View.GONE);
+ castControlView.show();
+ }
+
+ // Player state management.
+ long playbackPositionMs = C.TIME_UNSET;
+ int windowIndex = C.INDEX_UNSET;
+ boolean playWhenReady = false;
+
+ Player previousPlayer = this.currentPlayer;
+ if (previousPlayer != null) {
+ // Save state from the previous player.
+ int playbackState = previousPlayer.getPlaybackState();
+ if (playbackState != Player.STATE_ENDED) {
+ playbackPositionMs = previousPlayer.getCurrentPosition();
+ playWhenReady = previousPlayer.getPlayWhenReady();
+ windowIndex = previousPlayer.getCurrentWindowIndex();
+ if (windowIndex != currentItemIndex) {
+ playbackPositionMs = C.TIME_UNSET;
+ windowIndex = currentItemIndex;
+ }
+ }
+ previousPlayer.stop(true);
+ }
+
+ this.currentPlayer = currentPlayer;
+
+ // Media queue management.
+ currentPlayer.setMediaItems(mediaQueue, windowIndex, playbackPositionMs);
+ currentPlayer.setPlayWhenReady(playWhenReady);
+ currentPlayer.prepare();
+ }
+
+ /**
+ * Starts playback of the item at the given index.
+ *
+ * @param itemIndex The index of the item to play.
+ */
+ private void setCurrentItem(int itemIndex) {
+ maybeSetCurrentItemAndNotify(itemIndex);
+ if (currentPlayer.getCurrentTimeline().getWindowCount() != mediaQueue.size()) {
+ // This only happens with the cast player. The receiver app in the cast device clears the
+ // timeline when the last item of the timeline has been played to end.
+ currentPlayer.setMediaItems(mediaQueue, itemIndex, C.TIME_UNSET);
+ } else {
+ currentPlayer.seekTo(itemIndex, C.TIME_UNSET);
+ }
+ currentPlayer.setPlayWhenReady(true);
+ }
+
+ private void maybeSetCurrentItemAndNotify(int currentItemIndex) {
+ if (this.currentItemIndex != currentItemIndex) {
+ int oldIndex = this.currentItemIndex;
+ this.currentItemIndex = currentItemIndex;
+ listener.onQueuePositionChanged(oldIndex, currentItemIndex);
+ }
+ }
+}
diff --git a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/package-info.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/package-info.java
new file mode 100644
index 0000000000..70e2af79df
--- /dev/null
+++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2020 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.
+ */
+@NonNullApi
+package com.google.android.exoplayer2.castdemo;
+
+import com.google.android.exoplayer2.util.NonNullApi;
diff --git a/demos/cast/src/main/res/drawable/ic_plus.xml b/demos/cast/src/main/res/drawable/ic_plus.xml
new file mode 100644
index 0000000000..5a5a5154c9
--- /dev/null
+++ b/demos/cast/src/main/res/drawable/ic_plus.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/demos/cast/src/main/res/layout/cast_context_error.xml b/demos/cast/src/main/res/layout/cast_context_error.xml
new file mode 100644
index 0000000000..0b3fdb63d2
--- /dev/null
+++ b/demos/cast/src/main/res/layout/cast_context_error.xml
@@ -0,0 +1,22 @@
+
+
+
diff --git a/demos/cast/src/main/res/layout/main_activity.xml b/demos/cast/src/main/res/layout/main_activity.xml
new file mode 100644
index 0000000000..71dbcdcd9c
--- /dev/null
+++ b/demos/cast/src/main/res/layout/main_activity.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/cast/src/main/res/layout/sample_list.xml b/demos/cast/src/main/res/layout/sample_list.xml
new file mode 100644
index 0000000000..183c74eb3a
--- /dev/null
+++ b/demos/cast/src/main/res/layout/sample_list.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/demos/cast/src/main/res/menu/menu.xml b/demos/cast/src/main/res/menu/menu.xml
new file mode 100644
index 0000000000..95419adf3c
--- /dev/null
+++ b/demos/cast/src/main/res/menu/menu.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/demos/cast/src/main/res/mipmap-hdpi/ic_launcher.png b/demos/cast/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000000..52e8dc93d9
Binary files /dev/null and b/demos/cast/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/demos/cast/src/main/res/mipmap-mdpi/ic_launcher.png b/demos/cast/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000000..b55576eff3
Binary files /dev/null and b/demos/cast/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/demos/cast/src/main/res/mipmap-xhdpi/ic_launcher.png b/demos/cast/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000000..ca84d6a60e
Binary files /dev/null and b/demos/cast/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/demos/cast/src/main/res/mipmap-xxhdpi/ic_launcher.png b/demos/cast/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..27ab9b1054
Binary files /dev/null and b/demos/cast/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/demos/cast/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/demos/cast/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..d1eb9b78cf
Binary files /dev/null and b/demos/cast/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/demos/cast/src/main/res/values/strings.xml b/demos/cast/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..69f0691630
--- /dev/null
+++ b/demos/cast/src/main/res/values/strings.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+ Exo Cast Demo
+
+ Cast
+
+ Add samples
+
+ Failed to get Cast context. Try updating Google Play Services and restart the app.
+
+ Media includes video tracks, but none are playable by this device
+
+ Media includes audio tracks, but none are playable by this device
+
+
diff --git a/demos/gl/README.md b/demos/gl/README.md
new file mode 100644
index 0000000000..9bffc3edea
--- /dev/null
+++ b/demos/gl/README.md
@@ -0,0 +1,14 @@
+# ExoPlayer GL demo
+
+This app demonstrates how to render video to a [GLSurfaceView][] while applying
+a GL shader.
+
+The shader shows an overlap bitmap on top of the video. The overlay bitmap is
+drawn using an Android canvas, and includes the current frame's presentation
+timestamp, to show how to get the timestamp of the frame currently in the
+off-screen surface texture.
+
+Please see the [demos README](../README.md) for instructions on how to build and
+run this demo.
+
+[GLSurfaceView]: https://developer.android.com/reference/android/opengl/GLSurfaceView
diff --git a/demos/gl/build.gradle b/demos/gl/build.gradle
new file mode 100644
index 0000000000..e065f9b8f2
--- /dev/null
+++ b/demos/gl/build.gradle
@@ -0,0 +1,53 @@
+// Copyright (C) 2020 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.
+apply from: '../../constants.gradle'
+apply plugin: 'com.android.application'
+
+android {
+ compileSdkVersion project.ext.compileSdkVersion
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ defaultConfig {
+ versionName project.ext.releaseVersion
+ versionCode project.ext.releaseVersionCode
+ minSdkVersion project.ext.minSdkVersion
+ targetSdkVersion project.ext.appTargetSdkVersion
+ }
+
+ buildTypes {
+ release {
+ shrinkResources true
+ minifyEnabled true
+ proguardFiles getDefaultProguardFile('proguard-android.txt')
+ }
+ }
+
+ lintOptions {
+ // This demo app does not have translations.
+ disable 'MissingTranslation'
+ }
+}
+
+dependencies {
+ implementation project(modulePrefix + 'library-core')
+ implementation project(modulePrefix + 'library-ui')
+ implementation project(modulePrefix + 'library-dash')
+ implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
+ compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
+ compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion
+}
diff --git a/demos/gl/src/main/AndroidManifest.xml b/demos/gl/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..4c95d1ec2f
--- /dev/null
+++ b/demos/gl/src/main/AndroidManifest.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/gl/src/main/assets/bitmap_overlay_video_processor_fragment.glsl b/demos/gl/src/main/assets/bitmap_overlay_video_processor_fragment.glsl
new file mode 100644
index 0000000000..17fec0601d
--- /dev/null
+++ b/demos/gl/src/main/assets/bitmap_overlay_video_processor_fragment.glsl
@@ -0,0 +1,34 @@
+// Copyright 2020 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.
+
+#extension GL_OES_EGL_image_external : require
+precision mediump float;
+// External texture containing video decoder output.
+uniform samplerExternalOES tex_sampler_0;
+// Texture containing the overlap bitmap.
+uniform sampler2D tex_sampler_1;
+// Horizontal scaling factor for the overlap bitmap.
+uniform float scaleX;
+// Vertical scaling factory for the overlap bitmap.
+uniform float scaleY;
+varying vec2 v_texcoord;
+void main() {
+ vec4 videoColor = texture2D(tex_sampler_0, v_texcoord);
+ vec4 overlayColor = texture2D(tex_sampler_1,
+ vec2(v_texcoord.x * scaleX,
+ v_texcoord.y * scaleY));
+ // Blend the video decoder output and the overlay bitmap.
+ gl_FragColor = videoColor * (1.0 - overlayColor.a)
+ + overlayColor * overlayColor.a;
+}
diff --git a/demos/gl/src/main/assets/bitmap_overlay_video_processor_vertex.glsl b/demos/gl/src/main/assets/bitmap_overlay_video_processor_vertex.glsl
new file mode 100644
index 0000000000..0c07c12a70
--- /dev/null
+++ b/demos/gl/src/main/assets/bitmap_overlay_video_processor_vertex.glsl
@@ -0,0 +1,20 @@
+// Copyright 2020 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.
+attribute vec2 a_position;
+attribute vec2 a_texcoord;
+varying vec2 v_texcoord;
+void main() {
+ gl_Position = vec4(a_position.x, a_position.y, 0, 1);
+ v_texcoord = a_texcoord;
+}
diff --git a/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/BitmapOverlayVideoProcessor.java b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/BitmapOverlayVideoProcessor.java
new file mode 100644
index 0000000000..89bea32581
--- /dev/null
+++ b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/BitmapOverlayVideoProcessor.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.gldemo;
+
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.drawable.BitmapDrawable;
+import android.opengl.GLES20;
+import android.opengl.GLUtils;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.GlUtil;
+import com.google.android.exoplayer2.util.Util;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Locale;
+import javax.microedition.khronos.opengles.GL10;
+
+/**
+ * Video processor that demonstrates how to overlay a bitmap on video output using a GL shader. The
+ * bitmap is drawn using an Android {@link Canvas}.
+ */
+/* package */ final class BitmapOverlayVideoProcessor
+ implements VideoProcessingGLSurfaceView.VideoProcessor {
+
+ private static final int OVERLAY_WIDTH = 512;
+ private static final int OVERLAY_HEIGHT = 256;
+
+ private final Context context;
+ private final Paint paint;
+ private final int[] textures;
+ private final Bitmap overlayBitmap;
+ private final Bitmap logoBitmap;
+ private final Canvas overlayCanvas;
+
+ private int program;
+ @Nullable private GlUtil.Attribute[] attributes;
+ @Nullable private GlUtil.Uniform[] uniforms;
+
+ private float bitmapScaleX;
+ private float bitmapScaleY;
+
+ public BitmapOverlayVideoProcessor(Context context) {
+ this.context = context.getApplicationContext();
+ paint = new Paint();
+ paint.setTextSize(64);
+ paint.setAntiAlias(true);
+ paint.setARGB(0xFF, 0xFF, 0xFF, 0xFF);
+ textures = new int[1];
+ overlayBitmap = Bitmap.createBitmap(OVERLAY_WIDTH, OVERLAY_HEIGHT, Bitmap.Config.ARGB_8888);
+ overlayCanvas = new Canvas(overlayBitmap);
+ try {
+ logoBitmap =
+ ((BitmapDrawable)
+ context.getPackageManager().getApplicationIcon(context.getPackageName()))
+ .getBitmap();
+ } catch (PackageManager.NameNotFoundException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ @Override
+ public void initialize() {
+ String vertexShaderCode =
+ loadAssetAsString(context, "bitmap_overlay_video_processor_vertex.glsl");
+ String fragmentShaderCode =
+ loadAssetAsString(context, "bitmap_overlay_video_processor_fragment.glsl");
+ program = GlUtil.compileProgram(vertexShaderCode, fragmentShaderCode);
+ GlUtil.Attribute[] attributes = GlUtil.getAttributes(program);
+ GlUtil.Uniform[] uniforms = GlUtil.getUniforms(program);
+ for (GlUtil.Attribute attribute : attributes) {
+ if (attribute.name.equals("a_position")) {
+ attribute.setBuffer(new float[] {-1, -1, 1, -1, -1, 1, 1, 1}, 2);
+ } else if (attribute.name.equals("a_texcoord")) {
+ attribute.setBuffer(new float[] {0, 1, 1, 1, 0, 0, 1, 0}, 2);
+ }
+ }
+ this.attributes = attributes;
+ this.uniforms = uniforms;
+ GLES20.glGenTextures(1, textures, 0);
+ GLES20.glBindTexture(GL10.GL_TEXTURE_2D, textures[0]);
+ GLES20.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MIN_FILTER, GL10.GL_NEAREST);
+ GLES20.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MAG_FILTER, GL10.GL_LINEAR);
+ GLES20.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_S, GL10.GL_REPEAT);
+ GLES20.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_T, GL10.GL_REPEAT);
+ GLUtils.texImage2D(GL10.GL_TEXTURE_2D, /* level= */ 0, overlayBitmap, /* border= */ 0);
+ }
+
+ @Override
+ public void setSurfaceSize(int width, int height) {
+ bitmapScaleX = (float) width / OVERLAY_WIDTH;
+ bitmapScaleY = (float) height / OVERLAY_HEIGHT;
+ }
+
+ @Override
+ public void draw(int frameTexture, long frameTimestampUs) {
+ // Draw to the canvas and store it in a texture.
+ String text = String.format(Locale.US, "%.02f", frameTimestampUs / (float) C.MICROS_PER_SECOND);
+ overlayBitmap.eraseColor(Color.TRANSPARENT);
+ overlayCanvas.drawBitmap(logoBitmap, /* left= */ 32, /* top= */ 32, paint);
+ overlayCanvas.drawText(text, /* x= */ 200, /* y= */ 130, paint);
+ GLES20.glBindTexture(GL10.GL_TEXTURE_2D, textures[0]);
+ GLUtils.texSubImage2D(
+ GL10.GL_TEXTURE_2D, /* level= */ 0, /* xoffset= */ 0, /* yoffset= */ 0, overlayBitmap);
+ GlUtil.checkGlError();
+
+ // Run the shader program.
+ GlUtil.Uniform[] uniforms = Assertions.checkNotNull(this.uniforms);
+ GlUtil.Attribute[] attributes = Assertions.checkNotNull(this.attributes);
+ GLES20.glUseProgram(program);
+ for (GlUtil.Uniform uniform : uniforms) {
+ switch (uniform.name) {
+ case "tex_sampler_0":
+ uniform.setSamplerTexId(frameTexture, /* unit= */ 0);
+ break;
+ case "tex_sampler_1":
+ uniform.setSamplerTexId(textures[0], /* unit= */ 1);
+ break;
+ case "scaleX":
+ uniform.setFloat(bitmapScaleX);
+ break;
+ case "scaleY":
+ uniform.setFloat(bitmapScaleY);
+ break;
+ }
+ }
+ for (GlUtil.Attribute copyExternalAttribute : attributes) {
+ copyExternalAttribute.bind();
+ }
+ for (GlUtil.Uniform copyExternalUniform : uniforms) {
+ copyExternalUniform.bind();
+ }
+ GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
+ GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, /* first= */ 0, /* count= */ 4);
+ GlUtil.checkGlError();
+ }
+
+ private static String loadAssetAsString(Context context, String assetFileName) {
+ @Nullable InputStream inputStream = null;
+ try {
+ inputStream = context.getAssets().open(assetFileName);
+ return Util.fromUtf8Bytes(Util.toByteArray(inputStream));
+ } catch (IOException e) {
+ throw new IllegalStateException(e);
+ } finally {
+ Util.closeQuietly(inputStream);
+ }
+ }
+}
diff --git a/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/MainActivity.java b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/MainActivity.java
new file mode 100644
index 0000000000..dc0a8b990a
--- /dev/null
+++ b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/MainActivity.java
@@ -0,0 +1,197 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.gldemo;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.widget.FrameLayout;
+import android.widget.Toast;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.SimpleExoPlayer;
+import com.google.android.exoplayer2.drm.DefaultDrmSessionManager;
+import com.google.android.exoplayer2.drm.DrmSessionManager;
+import com.google.android.exoplayer2.drm.FrameworkMediaDrm;
+import com.google.android.exoplayer2.drm.HttpMediaDrmCallback;
+import com.google.android.exoplayer2.source.MediaSource;
+import com.google.android.exoplayer2.source.ProgressiveMediaSource;
+import com.google.android.exoplayer2.source.dash.DashMediaSource;
+import com.google.android.exoplayer2.ui.PlayerView;
+import com.google.android.exoplayer2.upstream.DataSource;
+import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
+import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory;
+import com.google.android.exoplayer2.upstream.HttpDataSource;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.EventLogger;
+import com.google.android.exoplayer2.util.GlUtil;
+import com.google.android.exoplayer2.util.Util;
+import java.util.UUID;
+
+/**
+ * Activity that demonstrates playback of video to an {@link android.opengl.GLSurfaceView} with
+ * postprocessing of the video content using GL.
+ */
+public final class MainActivity extends Activity {
+
+ private static final String TAG = "MainActivity";
+
+ private static final String DEFAULT_MEDIA_URI =
+ "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv";
+
+ private static final String ACTION_VIEW = "com.google.android.exoplayer.gldemo.action.VIEW";
+ private static final String EXTENSION_EXTRA = "extension";
+ private static final String DRM_SCHEME_EXTRA = "drm_scheme";
+ private static final String DRM_LICENSE_URL_EXTRA = "drm_license_url";
+
+ @Nullable private PlayerView playerView;
+ @Nullable private VideoProcessingGLSurfaceView videoProcessingGLSurfaceView;
+
+ @Nullable private SimpleExoPlayer player;
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.main_activity);
+ playerView = findViewById(R.id.player_view);
+
+ Context context = getApplicationContext();
+ boolean requestSecureSurface = getIntent().hasExtra(DRM_SCHEME_EXTRA);
+ if (requestSecureSurface && !GlUtil.isProtectedContentExtensionSupported(context)) {
+ Toast.makeText(
+ context, R.string.error_protected_content_extension_not_supported, Toast.LENGTH_LONG)
+ .show();
+ }
+
+ VideoProcessingGLSurfaceView videoProcessingGLSurfaceView =
+ new VideoProcessingGLSurfaceView(
+ context, requestSecureSurface, new BitmapOverlayVideoProcessor(context));
+ FrameLayout contentFrame = findViewById(R.id.exo_content_frame);
+ contentFrame.addView(videoProcessingGLSurfaceView);
+ this.videoProcessingGLSurfaceView = videoProcessingGLSurfaceView;
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ if (Util.SDK_INT > 23) {
+ initializePlayer();
+ if (playerView != null) {
+ playerView.onResume();
+ }
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ if (Util.SDK_INT <= 23 || player == null) {
+ initializePlayer();
+ if (playerView != null) {
+ playerView.onResume();
+ }
+ }
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ if (Util.SDK_INT <= 23) {
+ if (playerView != null) {
+ playerView.onPause();
+ }
+ releasePlayer();
+ }
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ if (Util.SDK_INT > 23) {
+ if (playerView != null) {
+ playerView.onPause();
+ }
+ releasePlayer();
+ }
+ }
+
+ private void initializePlayer() {
+ Intent intent = getIntent();
+ String action = intent.getAction();
+ Uri uri =
+ ACTION_VIEW.equals(action)
+ ? Assertions.checkNotNull(intent.getData())
+ : Uri.parse(DEFAULT_MEDIA_URI);
+ DrmSessionManager drmSessionManager;
+ if (Util.SDK_INT >= 18 && intent.hasExtra(DRM_SCHEME_EXTRA)) {
+ String drmScheme = Assertions.checkNotNull(intent.getStringExtra(DRM_SCHEME_EXTRA));
+ String drmLicenseUrl = Assertions.checkNotNull(intent.getStringExtra(DRM_LICENSE_URL_EXTRA));
+ UUID drmSchemeUuid = Assertions.checkNotNull(Util.getDrmUuid(drmScheme));
+ HttpDataSource.Factory licenseDataSourceFactory = new DefaultHttpDataSourceFactory();
+ HttpMediaDrmCallback drmCallback =
+ new HttpMediaDrmCallback(drmLicenseUrl, licenseDataSourceFactory);
+ drmSessionManager =
+ new DefaultDrmSessionManager.Builder()
+ .setUuidAndExoMediaDrmProvider(drmSchemeUuid, FrameworkMediaDrm.DEFAULT_PROVIDER)
+ .build(drmCallback);
+ } else {
+ drmSessionManager = DrmSessionManager.getDummyDrmSessionManager();
+ }
+
+ DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(this);
+ MediaSource mediaSource;
+ @C.ContentType int type = Util.inferContentType(uri, intent.getStringExtra(EXTENSION_EXTRA));
+ if (type == C.TYPE_DASH) {
+ mediaSource =
+ new DashMediaSource.Factory(dataSourceFactory)
+ .setDrmSessionManager(drmSessionManager)
+ .createMediaSource(MediaItem.fromUri(uri));
+ } else if (type == C.TYPE_OTHER) {
+ mediaSource =
+ new ProgressiveMediaSource.Factory(dataSourceFactory)
+ .setDrmSessionManager(drmSessionManager)
+ .createMediaSource(MediaItem.fromUri(uri));
+ } else {
+ throw new IllegalStateException();
+ }
+
+ SimpleExoPlayer player = new SimpleExoPlayer.Builder(getApplicationContext()).build();
+ player.setRepeatMode(Player.REPEAT_MODE_ALL);
+ player.setMediaSource(mediaSource);
+ player.prepare();
+ player.play();
+ VideoProcessingGLSurfaceView videoProcessingGLSurfaceView =
+ Assertions.checkNotNull(this.videoProcessingGLSurfaceView);
+ videoProcessingGLSurfaceView.setVideoComponent(
+ Assertions.checkNotNull(player.getVideoComponent()));
+ Assertions.checkNotNull(playerView).setPlayer(player);
+ player.addAnalyticsListener(new EventLogger(/* trackSelector= */ null));
+ this.player = player;
+ }
+
+ private void releasePlayer() {
+ Assertions.checkNotNull(playerView).setPlayer(null);
+ if (player != null) {
+ player.release();
+ Assertions.checkNotNull(videoProcessingGLSurfaceView).setVideoComponent(null);
+ player = null;
+ }
+ }
+}
diff --git a/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/VideoProcessingGLSurfaceView.java b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/VideoProcessingGLSurfaceView.java
new file mode 100644
index 0000000000..7aee74801f
--- /dev/null
+++ b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/VideoProcessingGLSurfaceView.java
@@ -0,0 +1,292 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.gldemo;
+
+import android.content.Context;
+import android.graphics.SurfaceTexture;
+import android.media.MediaFormat;
+import android.opengl.EGL14;
+import android.opengl.GLES20;
+import android.opengl.GLSurfaceView;
+import android.os.Handler;
+import android.view.Surface;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.Format;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.GlUtil;
+import com.google.android.exoplayer2.util.TimedValueQueue;
+import com.google.android.exoplayer2.video.VideoFrameMetadataListener;
+import java.util.concurrent.atomic.AtomicBoolean;
+import javax.microedition.khronos.egl.EGL10;
+import javax.microedition.khronos.egl.EGLConfig;
+import javax.microedition.khronos.egl.EGLContext;
+import javax.microedition.khronos.egl.EGLDisplay;
+import javax.microedition.khronos.egl.EGLSurface;
+import javax.microedition.khronos.opengles.GL10;
+
+/**
+ * {@link GLSurfaceView} that creates a GL context (optionally for protected content) and passes
+ * video frames to a {@link VideoProcessor} for drawing to the view.
+ *
+ * This view must be created programmatically, as it is necessary to specify whether a context
+ * supporting protected content should be created at construction time.
+ */
+public final class VideoProcessingGLSurfaceView extends GLSurfaceView {
+
+ /** Processes video frames, provided via a GL texture. */
+ public interface VideoProcessor {
+ /** Performs any required GL initialization. */
+ void initialize();
+
+ /** Sets the size of the output surface in pixels. */
+ void setSurfaceSize(int width, int height);
+
+ /**
+ * Draws using GL operations.
+ *
+ * @param frameTexture The ID of a GL texture containing a video frame.
+ * @param frameTimestampUs The presentation timestamp of the frame, in microseconds.
+ */
+ void draw(int frameTexture, long frameTimestampUs);
+ }
+
+ private static final int EGL_PROTECTED_CONTENT_EXT = 0x32C0;
+
+ private final VideoRenderer renderer;
+ private final Handler mainHandler;
+
+ @Nullable private SurfaceTexture surfaceTexture;
+ @Nullable private Surface surface;
+ @Nullable private Player.VideoComponent videoComponent;
+
+ /**
+ * Creates a new instance. Pass {@code true} for {@code requireSecureContext} if the {@link
+ * GLSurfaceView GLSurfaceView's} associated GL context should handle secure content (if the
+ * device supports it).
+ *
+ * @param context The {@link Context}.
+ * @param requireSecureContext Whether a GL context supporting protected content should be
+ * created, if supported by the device.
+ * @param videoProcessor Processor that draws to the view.
+ */
+ @SuppressWarnings("InlinedApi")
+ public VideoProcessingGLSurfaceView(
+ Context context, boolean requireSecureContext, VideoProcessor videoProcessor) {
+ super(context);
+ renderer = new VideoRenderer(videoProcessor);
+ mainHandler = new Handler();
+ setEGLContextClientVersion(2);
+ setEGLConfigChooser(
+ /* redSize= */ 8,
+ /* greenSize= */ 8,
+ /* blueSize= */ 8,
+ /* alphaSize= */ 8,
+ /* depthSize= */ 0,
+ /* stencilSize= */ 0);
+ setEGLContextFactory(
+ new EGLContextFactory() {
+ @Override
+ public EGLContext createContext(EGL10 egl, EGLDisplay display, EGLConfig eglConfig) {
+ int[] glAttributes;
+ if (requireSecureContext) {
+ glAttributes =
+ new int[] {
+ EGL14.EGL_CONTEXT_CLIENT_VERSION,
+ 2,
+ EGL_PROTECTED_CONTENT_EXT,
+ EGL14.EGL_TRUE,
+ EGL14.EGL_NONE
+ };
+ } else {
+ glAttributes = new int[] {EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE};
+ }
+ return egl.eglCreateContext(
+ display, eglConfig, /* share_context= */ EGL10.EGL_NO_CONTEXT, glAttributes);
+ }
+
+ @Override
+ public void destroyContext(EGL10 egl, EGLDisplay display, EGLContext context) {
+ egl.eglDestroyContext(display, context);
+ }
+ });
+ setEGLWindowSurfaceFactory(
+ new EGLWindowSurfaceFactory() {
+ @Override
+ public EGLSurface createWindowSurface(
+ EGL10 egl, EGLDisplay display, EGLConfig config, Object nativeWindow) {
+ int[] attribsList =
+ requireSecureContext
+ ? new int[] {EGL_PROTECTED_CONTENT_EXT, EGL14.EGL_TRUE, EGL10.EGL_NONE}
+ : new int[] {EGL10.EGL_NONE};
+ return egl.eglCreateWindowSurface(display, config, nativeWindow, attribsList);
+ }
+
+ @Override
+ public void destroySurface(EGL10 egl, EGLDisplay display, EGLSurface surface) {
+ egl.eglDestroySurface(display, surface);
+ }
+ });
+ setRenderer(renderer);
+ setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY);
+ }
+
+ /**
+ * Attaches or detaches (if {@code newVideoComponent} is {@code null}) this view from the video
+ * component of the player.
+ *
+ * @param newVideoComponent The new video component, or {@code null} to detach this view.
+ */
+ public void setVideoComponent(@Nullable Player.VideoComponent newVideoComponent) {
+ if (newVideoComponent == videoComponent) {
+ return;
+ }
+ if (videoComponent != null) {
+ if (surface != null) {
+ videoComponent.clearVideoSurface(surface);
+ }
+ videoComponent.clearVideoFrameMetadataListener(renderer);
+ }
+ videoComponent = newVideoComponent;
+ if (videoComponent != null) {
+ videoComponent.setVideoFrameMetadataListener(renderer);
+ videoComponent.setVideoSurface(surface);
+ }
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ // Post to make sure we occur in order with any onSurfaceTextureAvailable calls.
+ mainHandler.post(
+ () -> {
+ if (surface != null) {
+ if (videoComponent != null) {
+ videoComponent.setVideoSurface(null);
+ }
+ releaseSurface(surfaceTexture, surface);
+ surfaceTexture = null;
+ surface = null;
+ }
+ });
+ }
+
+ private void onSurfaceTextureAvailable(SurfaceTexture surfaceTexture) {
+ mainHandler.post(
+ () -> {
+ SurfaceTexture oldSurfaceTexture = this.surfaceTexture;
+ Surface oldSurface = VideoProcessingGLSurfaceView.this.surface;
+ this.surfaceTexture = surfaceTexture;
+ this.surface = new Surface(surfaceTexture);
+ releaseSurface(oldSurfaceTexture, oldSurface);
+ if (videoComponent != null) {
+ videoComponent.setVideoSurface(surface);
+ }
+ });
+ }
+
+ private static void releaseSurface(
+ @Nullable SurfaceTexture oldSurfaceTexture, @Nullable Surface oldSurface) {
+ if (oldSurfaceTexture != null) {
+ oldSurfaceTexture.release();
+ }
+ if (oldSurface != null) {
+ oldSurface.release();
+ }
+ }
+
+ private final class VideoRenderer implements GLSurfaceView.Renderer, VideoFrameMetadataListener {
+
+ private final VideoProcessor videoProcessor;
+ private final AtomicBoolean frameAvailable;
+ private final TimedValueQueue sampleTimestampQueue;
+
+ private int texture;
+ @Nullable private SurfaceTexture surfaceTexture;
+
+ private boolean initialized;
+ private int width;
+ private int height;
+ private long frameTimestampUs;
+
+ public VideoRenderer(VideoProcessor videoProcessor) {
+ this.videoProcessor = videoProcessor;
+ frameAvailable = new AtomicBoolean();
+ sampleTimestampQueue = new TimedValueQueue<>();
+ width = -1;
+ height = -1;
+ }
+
+ @Override
+ public synchronized void onSurfaceCreated(GL10 gl, EGLConfig config) {
+ texture = GlUtil.createExternalTexture();
+ surfaceTexture = new SurfaceTexture(texture);
+ surfaceTexture.setOnFrameAvailableListener(
+ surfaceTexture -> {
+ frameAvailable.set(true);
+ requestRender();
+ });
+ onSurfaceTextureAvailable(surfaceTexture);
+ }
+
+ @Override
+ public void onSurfaceChanged(GL10 gl, int width, int height) {
+ GLES20.glViewport(0, 0, width, height);
+ this.width = width;
+ this.height = height;
+ }
+
+ @Override
+ public void onDrawFrame(GL10 gl) {
+ if (videoProcessor == null) {
+ return;
+ }
+
+ if (!initialized) {
+ videoProcessor.initialize();
+ initialized = true;
+ }
+
+ if (width != -1 && height != -1) {
+ videoProcessor.setSurfaceSize(width, height);
+ width = -1;
+ height = -1;
+ }
+
+ if (frameAvailable.compareAndSet(true, false)) {
+ SurfaceTexture surfaceTexture = Assertions.checkNotNull(this.surfaceTexture);
+ surfaceTexture.updateTexImage();
+ long lastFrameTimestampNs = surfaceTexture.getTimestamp();
+ Long frameTimestampUs = sampleTimestampQueue.poll(lastFrameTimestampNs);
+ if (frameTimestampUs != null) {
+ this.frameTimestampUs = frameTimestampUs;
+ }
+ }
+
+ videoProcessor.draw(texture, frameTimestampUs);
+ }
+
+ @Override
+ public void onVideoFrameAboutToBeRendered(
+ long presentationTimeUs,
+ long releaseTimeNs,
+ @NonNull Format format,
+ @Nullable MediaFormat mediaFormat) {
+ sampleTimestampQueue.add(releaseTimeNs, presentationTimeUs);
+ }
+ }
+}
diff --git a/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/package-info.java b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/package-info.java
new file mode 100644
index 0000000000..59ad052449
--- /dev/null
+++ b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2020 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.
+ */
+@NonNullApi
+package com.google.android.exoplayer2.gldemo;
+
+import com.google.android.exoplayer2.util.NonNullApi;
diff --git a/demos/gl/src/main/res/layout/main_activity.xml b/demos/gl/src/main/res/layout/main_activity.xml
new file mode 100644
index 0000000000..4728dc2d49
--- /dev/null
+++ b/demos/gl/src/main/res/layout/main_activity.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
diff --git a/demos/gl/src/main/res/mipmap-hdpi/ic_launcher.png b/demos/gl/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000000..adaa93220e
Binary files /dev/null and b/demos/gl/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/demos/gl/src/main/res/mipmap-mdpi/ic_launcher.png b/demos/gl/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000000..9b6f7d5e80
Binary files /dev/null and b/demos/gl/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/demos/gl/src/main/res/mipmap-xhdpi/ic_launcher.png b/demos/gl/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000000..2101026c9f
Binary files /dev/null and b/demos/gl/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/demos/gl/src/main/res/mipmap-xxhdpi/ic_launcher.png b/demos/gl/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..223ec8bd11
Binary files /dev/null and b/demos/gl/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/demos/gl/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/demos/gl/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..698ed68c42
Binary files /dev/null and b/demos/gl/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/demos/gl/src/main/res/values/strings.xml b/demos/gl/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..7e9e5d9961
--- /dev/null
+++ b/demos/gl/src/main/res/values/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+
+ ExoPlayer GL demo
+
+ The GL protected content extension is not supported.
+
+
diff --git a/demos/main/README.md b/demos/main/README.md
new file mode 100644
index 0000000000..00072c070b
--- /dev/null
+++ b/demos/main/README.md
@@ -0,0 +1,8 @@
+# ExoPlayer main demo #
+
+This is the main ExoPlayer demo application. It uses ExoPlayer to play a number
+of test streams. It can be used as a starting point or reference project when
+developing other applications that make use of the ExoPlayer library.
+
+Please see the [demos README](../README.md) for instructions on how to build and
+run this demo.
diff --git a/demos/main/build.gradle b/demos/main/build.gradle
new file mode 100644
index 0000000000..716b3c1f99
--- /dev/null
+++ b/demos/main/build.gradle
@@ -0,0 +1,95 @@
+// Copyright (C) 2016 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.
+apply from: '../../constants.gradle'
+apply plugin: 'com.android.application'
+
+android {
+ compileSdkVersion project.ext.compileSdkVersion
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ defaultConfig {
+ versionName project.ext.releaseVersion
+ versionCode project.ext.releaseVersionCode
+ minSdkVersion project.ext.minSdkVersion
+ targetSdkVersion project.ext.appTargetSdkVersion
+ multiDexEnabled true
+ }
+
+ buildTypes {
+ release {
+ shrinkResources true
+ minifyEnabled true
+ proguardFiles = [
+ "proguard-rules.txt",
+ getDefaultProguardFile('proguard-android.txt')
+ ]
+ }
+ debug {
+ jniDebuggable = true
+ }
+ }
+
+ lintOptions {
+ // The demo app isn't indexed, doesn't have translations, and has a
+ // banner for AndroidTV that's only in xhdpi density.
+ disable 'GoogleAppIndexingWarning','MissingTranslation','IconDensities'
+ }
+
+ flavorDimensions "decoderExtensions"
+
+ productFlavors {
+ noDecoderExtensions {
+ dimension "decoderExtensions"
+ buildConfigField "boolean", "USE_DECODER_EXTENSIONS", "false"
+ }
+ withDecoderExtensions {
+ dimension "decoderExtensions"
+ buildConfigField "boolean", "USE_DECODER_EXTENSIONS", "true"
+ }
+ }
+}
+
+dependencies {
+ compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
+ implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
+ implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion
+ implementation 'androidx.multidex:multidex:' + androidxMultidexVersion
+ implementation 'com.google.android.material:material:1.2.1'
+ implementation ('com.google.guava:guava:' + guavaVersion) {
+ exclude group: 'com.google.code.findbugs', module: 'jsr305'
+ exclude group: 'org.checkerframework', module: 'checker-compat-qual'
+ exclude group: 'com.google.errorprone', module: 'error_prone_annotations'
+ exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
+ exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations'
+ }
+ implementation project(modulePrefix + 'library-core')
+ implementation project(modulePrefix + 'library-dash')
+ implementation project(modulePrefix + 'library-hls')
+ implementation project(modulePrefix + 'library-smoothstreaming')
+ implementation project(modulePrefix + 'library-ui')
+ implementation project(modulePrefix + 'extension-cronet')
+ implementation project(modulePrefix + 'extension-ima')
+ withDecoderExtensionsImplementation project(modulePrefix + 'extension-av1')
+ withDecoderExtensionsImplementation project(modulePrefix + 'extension-ffmpeg')
+ withDecoderExtensionsImplementation project(modulePrefix + 'extension-flac')
+ withDecoderExtensionsImplementation project(modulePrefix + 'extension-opus')
+ withDecoderExtensionsImplementation project(modulePrefix + 'extension-vp9')
+ withDecoderExtensionsImplementation project(modulePrefix + 'extension-rtmp')
+}
+
+apply plugin: 'com.google.android.gms.strict-version-matcher-plugin'
diff --git a/demos/main/proguard-rules.txt b/demos/main/proguard-rules.txt
new file mode 100644
index 0000000000..5358f3cec7
--- /dev/null
+++ b/demos/main/proguard-rules.txt
@@ -0,0 +1,2 @@
+# Proguard rules specific to the main demo app.
+
diff --git a/demo/src/main/AndroidManifest.xml b/demos/main/src/main/AndroidManifest.xml
similarity index 73%
rename from demo/src/main/AndroidManifest.xml
rename to demos/main/src/main/AndroidManifest.xml
index afcddccac9..053665502b 100644
--- a/demo/src/main/AndroidManifest.xml
+++ b/demos/main/src/main/AndroidManifest.xml
@@ -15,15 +15,18 @@
-->
+ xmlns:tools="http://schemas.android.com/tools"
+ package="com.google.android.exoplayer2.demo">
+
+
+
+
-
+
+ android:requestLegacyExternalStorage="true"
+ android:name="androidx.multidex.MultiDexApplication"
+ tools:targetApi="29">
+ android:label="@string/application_name"
+ android:theme="@style/Theme.AppCompat">
@@ -75,6 +81,18 @@
+
+
+
+
+
+
+
+
+
diff --git a/demos/main/src/main/assets/media.exolist.json b/demos/main/src/main/assets/media.exolist.json
new file mode 100644
index 0000000000..ce1854db85
--- /dev/null
+++ b/demos/main/src/main/assets/media.exolist.json
@@ -0,0 +1,558 @@
+[
+ {
+ "name": "YouTube DASH",
+ "samples": [
+ {
+ "name": "Google Glass H264 (MP4)",
+ "uri": "https://www.youtube.com/api/manifest/dash/id/bf5bb2419360daf1/source/youtube?as=fmp4_audio_clear,fmp4_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=51AF5F39AB0CEC3E5497CD9C900EBFEAECCCB5C7.8506521BFC350652163895D4C26DEE124209AA9E&key=ik0",
+ "extension": "mpd"
+ },
+ {
+ "name": "Google Play H264 (MP4)",
+ "uri": "https://www.youtube.com/api/manifest/dash/id/3aa39fa2cc27967f/source/youtube?as=fmp4_audio_clear,fmp4_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=A2716F75795F5D2AF0E88962FFCD10DB79384F29.84308FF04844498CE6FBCE4731507882B8307798&key=ik0",
+ "extension": "mpd"
+ },
+ {
+ "name": "Google Glass VP9 (WebM)",
+ "uri": "https://www.youtube.com/api/manifest/dash/id/bf5bb2419360daf1/source/youtube?as=fmp4_audio_clear,webm2_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=249B04F79E984D7F86B4D8DB48AE6FAF41C17AB3.7B9F0EC0505E1566E59B8E488E9419F253DDF413&key=ik0",
+ "extension": "mpd"
+ },
+ {
+ "name": "Google Play VP9 (WebM)",
+ "uri": "https://www.youtube.com/api/manifest/dash/id/3aa39fa2cc27967f/source/youtube?as=fmp4_audio_clear,webm2_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=B1C2A74783AC1CC4865EB312D7DD2D48230CC9FD.BD153B9882175F1F94BFE5141A5482313EA38E8D&key=ik0",
+ "extension": "mpd"
+ }
+ ]
+ },
+ {
+ "name": "Widevine GTS policy tests",
+ "samples": [
+ {
+ "name": "SW secure crypto (L3)",
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO&provider=widevine_test"
+ },
+ {
+ "name": "SW secure decode",
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_DECODE&provider=widevine_test"
+ },
+ {
+ "name": "HW secure crypto",
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_HW_SECURE_CRYPTO&provider=widevine_test"
+ },
+ {
+ "name": "HW secure decode",
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_HW_SECURE_DECODE&provider=widevine_test"
+ },
+ {
+ "name": "HW secure all (L1)",
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_HW_SECURE_ALL&provider=widevine_test"
+ },
+ {
+ "name": "30s license (fails at ~30s)",
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_CAN_RENEW_FALSE_LICENSE_30S_PLAYBACK_30S&provider=widevine_test"
+ },
+ {
+ "name": "HDCP not required",
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_NONE&provider=widevine_test"
+ },
+ {
+ "name": "HDCP 1.0 required",
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_V1&provider=widevine_test"
+ },
+ {
+ "name": "HDCP 2.0 required",
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_V2&provider=widevine_test"
+ },
+ {
+ "name": "HDCP 2.1 required",
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_V2_1&provider=widevine_test"
+ },
+ {
+ "name": "HDCP 2.2 required",
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_V2_2&provider=widevine_test"
+ },
+ {
+ "name": "HDCP no digital output",
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_NO_DIGITAL_OUTPUT&provider=widevine_test"
+ }
+ ]
+ },
+ {
+ "name": "Widevine DASH H264 (MP4)",
+ "samples": [
+ {
+ "name": "Clear",
+ "uri": "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd"
+ },
+ {
+ "name": "Clear UHD",
+ "uri": "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears_uhd.mpd"
+ },
+ {
+ "name": "Secure (cenc)",
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test"
+ },
+ {
+ "name": "Secure UHD (cenc)",
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_uhd.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test"
+ },
+ {
+ "name": "Secure (cbcs)",
+ "uri": "https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test"
+ },
+ {
+ "name": "Secure UHD (cbcs)",
+ "uri": "https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs_uhd.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test"
+ },
+ {
+ "name": "Secure -> Clear -> Secure (cenc)",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/widevine/tears_enc_clear_enc.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test",
+ "drm_session_for_clear_content": true
+ }
+ ]
+ },
+ {
+ "name": "Widevine DASH VP9 (WebM)",
+ "samples": [
+ {
+ "name": "Clear",
+ "uri": "https://storage.googleapis.com/wvmedia/clear/vp9/tears/tears.mpd"
+ },
+ {
+ "name": "Clear UHD",
+ "uri": "https://storage.googleapis.com/wvmedia/clear/vp9/tears/tears_uhd.mpd"
+ },
+ {
+ "name": "Secure (full-sample)",
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/tears/tears.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test"
+ },
+ {
+ "name": "Secure UHD (full-sample)",
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/tears/tears_uhd.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test"
+ },
+ {
+ "name": "Secure (sub-sample)",
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/subsample/24fps/tears/tears.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test"
+ },
+ {
+ "name": "Secure UHD (sub-sample)",
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/subsample/24fps/tears/tears_uhd.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test"
+ }
+ ]
+ },
+ {
+ "name": "Widevine DASH H265 (MP4)",
+ "samples": [
+ {
+ "name": "Clear",
+ "uri": "https://storage.googleapis.com/wvmedia/clear/hevc/tears/tears.mpd"
+ },
+ {
+ "name": "Clear UHD",
+ "uri": "https://storage.googleapis.com/wvmedia/clear/hevc/tears/tears_uhd.mpd"
+ },
+ {
+ "name": "Secure",
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test"
+ },
+ {
+ "name": "Secure UHD",
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears_uhd.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test"
+ }
+ ]
+ },
+ {
+ "name": "Widevine AV1 (WebM)",
+ "samples": [
+ {
+ "name": "Clear",
+ "uri": "https://storage.googleapis.com/wvmedia/2019/clear/av1/24/webm/llama_av1_480p_400.webm"
+ },
+ {
+ "name": "Secure L3",
+ "uri": "https://storage.googleapis.com/wvmedia/2019/cenc/av1/24/webm/llama_av1_480p_400.webm",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO&provider=widevine_test"
+ },
+ {
+ "name": "Secure L1",
+ "uri": "https://storage.googleapis.com/wvmedia/2019/cenc/av1/24/webm/llama_av1_480p_400.webm",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_HW_SECURE_ALL&provider=widevine_test"
+ }
+ ]
+ },
+ {
+ "name": "SmoothStreaming",
+ "samples": [
+ {
+ "name": "Super speed",
+ "uri": "https://playready.directtaps.net/smoothstreaming/SSWSS720H264/SuperSpeedway_720.ism/Manifest"
+ },
+ {
+ "name": "Super speed (PlayReady)",
+ "uri": "https://playready.directtaps.net/smoothstreaming/SSWSS720H264PR/SuperSpeedway_720.ism/Manifest",
+ "drm_scheme": "playready"
+ }
+ ]
+ },
+ {
+ "name": "HLS",
+ "samples": [
+ {
+ "name": "Apple 4x3 basic stream",
+ "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8"
+ },
+ {
+ "name": "Apple 16x9 basic stream",
+ "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8"
+ },
+ {
+ "name": "Apple master playlist advanced (TS)",
+ "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8"
+ },
+ {
+ "name": "Apple master playlist advanced (FMP4)",
+ "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8"
+ },
+ {
+ "name": "Apple TS media playlist",
+ "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/gear1/prog_index.m3u8"
+ },
+ {
+ "name": "Apple AAC media playlist",
+ "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/gear0/prog_index.m3u8"
+ }
+ ]
+ },
+ {
+ "name": "Misc",
+ "samples": [
+ {
+ "name": "Dizzy (MP4)",
+ "uri": "https://html5demos.com/assets/dizzy.mp4"
+ },
+ {
+ "name": "Apple 10s (AAC)",
+ "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/gear0/fileSequence0.aac"
+ },
+ {
+ "name": "Apple 10s (TS)",
+ "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/gear1/fileSequence0.ts"
+ },
+ {
+ "name": "Android screens (MKV)",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv"
+ },
+ {
+ "name": "Screens 360p video (WebM,VP9)",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/video-vp9-360.webm"
+ },
+ {
+ "name": "Screens 480p video (FMP4,H264)",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/video-avc-baseline-480.mp4"
+ },
+ {
+ "name": "Screens 1080p video (FMP4,H264)",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/video-137.mp4"
+ },
+ {
+ "name": "Screens audio (FMP4)",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/audio-141.mp4"
+ },
+ {
+ "name": "Google Play (MP3)",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-0/play.mp3"
+ },
+ {
+ "name": "Google Play (Ogg/Vorbis)",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/ogg/play.ogg"
+ },
+ {
+ "name": "Google Play (FLAC)",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/flac/play.flac"
+ },
+ {
+ "name": "Big Buck Bunny video (FLV)",
+ "uri": "https://vod.leasewebcdn.com/bbb.flv?ri=1024&rs=150&start=0"
+ },
+ {
+ "name": "Big Buck Bunny 480p video (MP4,AV1)",
+ "uri": "https://storage.googleapis.com/downloads.webmproject.org/av1/exoplayer/bbb-av1-480p.mp4"
+ },
+ {
+ "name": "One hour frame counter (MP4)",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mp4/frame-counter-one-hour.mp4"
+ }
+ ]
+ },
+ {
+ "name": "Playlists",
+ "samples": [
+ {
+ "name": "Cats -> Dogs",
+ "playlist": [
+ {
+ "uri": "https://html5demos.com/assets/dizzy.mp4"
+ },
+ {
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv"
+ }
+ ]
+ },
+ {
+ "name": "Audio -> Video -> Audio",
+ "playlist": [
+ {
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/audio-141.mp4"
+ },
+ {
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv"
+ },
+ {
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/audio-141.mp4"
+ }
+ ]
+ },
+ {
+ "name": "Clear -> Enc -> Clear -> Enc -> Enc",
+ "playlist": [
+ {
+ "uri": "https://html5demos.com/assets/dizzy.mp4"
+ },
+ {
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_sd.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test"
+ },
+ {
+ "uri": "https://html5demos.com/assets/dizzy.mp4"
+ },
+ {
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_sd.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test"
+ },
+ {
+ "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_sd.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test"
+ }
+ ]
+ },
+ {
+ "name": "Manual ad insertion",
+ "playlist": [
+ {
+ "uri": "https://html5demos.com/assets/dizzy.mp4",
+ "clip_end_position_ms": 10000
+ },
+ {
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mp4/frame-counter-one-hour.mp4",
+ "clip_end_position_ms": 5000
+ },
+ {
+ "uri": "https://html5demos.com/assets/dizzy.mp4",
+ "clip_start_position_ms": 10000
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "IMA sample ad tags",
+ "samples": [
+ {
+ "name": "Single inline linear",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",
+ "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/single_ad_samples&ciu_szs=300x250&impl=s&gdfp_req=1&env=vp&output=vast&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ct%3Dlinear&correlator="
+ },
+ {
+ "name": "Single skippable inline",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",
+ "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/single_ad_samples&ciu_szs=300x250&impl=s&gdfp_req=1&env=vp&output=vast&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ct%3Dskippablelinear&correlator="
+ },
+ {
+ "name": "Single redirect linear",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",
+ "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/single_ad_samples&ciu_szs=300x250&impl=s&gdfp_req=1&env=vp&output=vast&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ct%3Dredirectlinear&correlator="
+ },
+ {
+ "name": "Single redirect error",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",
+ "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/single_ad_samples&ciu_szs=300x250&impl=s&gdfp_req=1&env=vp&output=vast&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ct%3Dredirecterror&nofb=1&correlator="
+ },
+ {
+ "name": "Single redirect broken (fallback)",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",
+ "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/single_ad_samples&ciu_szs=300x250&impl=s&gdfp_req=1&env=vp&output=vast&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ct%3Dredirecterror&correlator="
+ },
+ {
+ "name": "VMAP pre-roll",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",
+ "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ar%3Dpreonly&cmsid=496&vid=short_onecue&correlator="
+ },
+ {
+ "name": "VMAP pre-roll + bumper",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",
+ "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ar%3Dpreonlybumper&cmsid=496&vid=short_onecue&correlator="
+ },
+ {
+ "name": "VMAP post-roll",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",
+ "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ar%3Dpostonly&cmsid=496&vid=short_onecue&correlator="
+ },
+ {
+ "name": "VMAP post-roll + bumper",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",
+ "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ar%3Dpostonlybumper&cmsid=496&vid=short_onecue&correlator="
+ },
+ {
+ "name": "VMAP pre-, mid- and post-rolls, single ads",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",
+ "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ar%3Dpremidpost&cmsid=496&vid=short_onecue&correlator="
+ },
+ {
+ "name": "VMAP pre-roll single ad, mid-roll standard pod with 3 ads, post-roll single ad",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",
+ "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ar%3Dpremidpostpod&cmsid=496&vid=short_onecue&correlator="
+ },
+ {
+ "name": "VMAP pre-roll single ad, mid-roll optimized pod with 3 ads, post-roll single ad",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",
+ "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ar%3Dpremidpostoptimizedpod&cmsid=496&vid=short_onecue&correlator="
+ },
+ {
+ "name": "VMAP pre-roll single ad, mid-roll standard pod with 3 ads, post-roll single ad (bumpers around all ad breaks)",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",
+ "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ar%3Dpremidpostpodbumper&cmsid=496&vid=short_onecue&correlator="
+ },
+ {
+ "name": "VMAP pre-roll single ad, mid-roll optimized pod with 3 ads, post-roll single ad (bumpers around all ad breaks)",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",
+ "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ar%3Dpremidpostoptimizedpodbumper&cmsid=496&vid=short_onecue&correlator="
+ },
+ {
+ "name": "VMAP pre-roll single ad, mid-roll standard pods with 5 ads every 10 seconds for 1:40, post-roll single ad",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",
+ "ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ar%3Dpremidpostlongpod&cmsid=496&vid=short_tencue&correlator="
+ },
+ {
+ "name": "VMAP empty midroll",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",
+ "ad_tag_uri": "https://vastsynthesizer.appspot.com/empty-midroll"
+ },
+ {
+ "name": "VMAP full, empty, full midrolls",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",
+ "ad_tag_uri": "https://vastsynthesizer.appspot.com/empty-midroll-2"
+ },
+ {
+ "name": "VMAP midroll at 1765 s",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mp4/frame-counter-one-hour.mp4",
+ "ad_tag_uri": "https://vastsynthesizer.appspot.com/midroll-large"
+ }
+ ]
+ },
+ {
+ "name": "Subtitles",
+ "samples": [
+ {
+ "name": "TTML",
+ "uri": "https://html5demos.com/assets/dizzy.mp4",
+ "subtitle_uri": "https://storage.googleapis.com/exoplayer-test-media-1/ttml/netflix_ttml_sample.xml",
+ "subtitle_mime_type": "application/ttml+xml",
+ "subtitle_language": "en"
+ },
+ {
+ "name": "WebVTT line positioning",
+ "uri": "https://html5demos.com/assets/dizzy.mp4",
+ "subtitle_uri": "https://storage.googleapis.com/exoplayer-test-media-1/webvtt/numeric-lines.vtt",
+ "subtitle_mime_type": "text/vtt",
+ "subtitle_language": "en"
+ },
+ {
+ "name": "SSA/ASS position & alignment",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/video-avc-baseline-480.mp4",
+ "subtitle_uri": "https://storage.googleapis.com/exoplayer-test-media-1/ssa/test-subs-position.ass",
+ "subtitle_mime_type": "text/x-ssa",
+ "subtitle_language": "en"
+ },
+ {
+ "name": "MPEG-4 Timed Text (tx3g, mov_text)",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mp4/dizzy-with-tx3g.mp4"
+ }
+ ]
+ },
+ {
+ "name": "60fps",
+ "samples": [
+ {
+ "name": "Big Buck Bunny (DASH,H264,1080p,Clear)",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/60fps/bbb-clear-1080/manifest.mpd"
+ },
+ {
+ "name": "Big Buck Bunny (DASH,H264,4K,Clear)",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/60fps/bbb-clear-2160/manifest.mpd"
+ },
+ {
+ "name": "Big Buck Bunny (DASH,H264,1080p,Widevine)",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/60fps/bbb-wv-1080/manifest.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test"
+ },
+ {
+ "name": "Big Buck Bunny (DASH,H264,4K,Widevine)",
+ "uri": "https://storage.googleapis.com/exoplayer-test-media-1/60fps/bbb-wv-2160/manifest.mpd",
+ "drm_scheme": "widevine",
+ "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test"
+ }
+ ]
+ }
+]
diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoDownloadService.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoDownloadService.java
new file mode 100644
index 0000000000..c462c14c75
--- /dev/null
+++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoDownloadService.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.demo;
+
+import static com.google.android.exoplayer2.demo.DemoUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID;
+
+import android.app.Notification;
+import android.content.Context;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.offline.Download;
+import com.google.android.exoplayer2.offline.DownloadManager;
+import com.google.android.exoplayer2.offline.DownloadService;
+import com.google.android.exoplayer2.scheduler.PlatformScheduler;
+import com.google.android.exoplayer2.ui.DownloadNotificationHelper;
+import com.google.android.exoplayer2.util.NotificationUtil;
+import com.google.android.exoplayer2.util.Util;
+import java.util.List;
+
+/** A service for downloading media. */
+public class DemoDownloadService extends DownloadService {
+
+ private static final int JOB_ID = 1;
+ private static final int FOREGROUND_NOTIFICATION_ID = 1;
+
+ public DemoDownloadService() {
+ super(
+ FOREGROUND_NOTIFICATION_ID,
+ DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL,
+ DOWNLOAD_NOTIFICATION_CHANNEL_ID,
+ R.string.exo_download_notification_channel_name,
+ /* channelDescriptionResourceId= */ 0);
+ }
+
+ @Override
+ @NonNull
+ protected DownloadManager getDownloadManager() {
+ // This will only happen once, because getDownloadManager is guaranteed to be called only once
+ // in the life cycle of the process.
+ DownloadManager downloadManager = DemoUtil.getDownloadManager(/* context= */ this);
+ DownloadNotificationHelper downloadNotificationHelper =
+ DemoUtil.getDownloadNotificationHelper(/* context= */ this);
+ downloadManager.addListener(
+ new TerminalStateNotificationHelper(
+ this, downloadNotificationHelper, FOREGROUND_NOTIFICATION_ID + 1));
+ return downloadManager;
+ }
+
+ @Override
+ protected PlatformScheduler getScheduler() {
+ return Util.SDK_INT >= 21 ? new PlatformScheduler(this, JOB_ID) : null;
+ }
+
+ @Override
+ @NonNull
+ protected Notification getForegroundNotification(@NonNull List downloads) {
+ return DemoUtil.getDownloadNotificationHelper(/* context= */ this)
+ .buildProgressNotification(
+ /* context= */ this,
+ R.drawable.ic_download,
+ /* contentIntent= */ null,
+ /* message= */ null,
+ downloads);
+ }
+
+ /**
+ * Creates and displays notifications for downloads when they complete or fail.
+ *
+ * This helper will outlive the lifespan of a single instance of {@link DemoDownloadService}.
+ * It is static to avoid leaking the first {@link DemoDownloadService} instance.
+ */
+ private static final class TerminalStateNotificationHelper implements DownloadManager.Listener {
+
+ private final Context context;
+ private final DownloadNotificationHelper notificationHelper;
+
+ private int nextNotificationId;
+
+ public TerminalStateNotificationHelper(
+ Context context, DownloadNotificationHelper notificationHelper, int firstNotificationId) {
+ this.context = context.getApplicationContext();
+ this.notificationHelper = notificationHelper;
+ nextNotificationId = firstNotificationId;
+ }
+
+ @Override
+ public void onDownloadChanged(
+ DownloadManager downloadManager, Download download, @Nullable Exception finalException) {
+ Notification notification;
+ if (download.state == Download.STATE_COMPLETED) {
+ notification =
+ notificationHelper.buildDownloadCompletedNotification(
+ context,
+ R.drawable.ic_download_done,
+ /* contentIntent= */ null,
+ Util.fromUtf8Bytes(download.request.data));
+ } else if (download.state == Download.STATE_FAILED) {
+ notification =
+ notificationHelper.buildDownloadFailedNotification(
+ context,
+ R.drawable.ic_download_done,
+ /* contentIntent= */ null,
+ Util.fromUtf8Bytes(download.request.data));
+ } else {
+ return;
+ }
+ NotificationUtil.setNotification(context, nextNotificationId++, notification);
+ }
+ }
+}
diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java
new file mode 100644
index 0000000000..2d15dfcbb4
--- /dev/null
+++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java
@@ -0,0 +1,196 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.demo;
+
+import android.content.Context;
+import com.google.android.exoplayer2.DefaultRenderersFactory;
+import com.google.android.exoplayer2.RenderersFactory;
+import com.google.android.exoplayer2.database.DatabaseProvider;
+import com.google.android.exoplayer2.database.ExoDatabaseProvider;
+import com.google.android.exoplayer2.ext.cronet.CronetDataSourceFactory;
+import com.google.android.exoplayer2.ext.cronet.CronetEngineWrapper;
+import com.google.android.exoplayer2.offline.ActionFileUpgradeUtil;
+import com.google.android.exoplayer2.offline.DefaultDownloadIndex;
+import com.google.android.exoplayer2.offline.DownloadManager;
+import com.google.android.exoplayer2.ui.DownloadNotificationHelper;
+import com.google.android.exoplayer2.upstream.DataSource;
+import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
+import com.google.android.exoplayer2.upstream.HttpDataSource;
+import com.google.android.exoplayer2.upstream.cache.Cache;
+import com.google.android.exoplayer2.upstream.cache.CacheDataSource;
+import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor;
+import com.google.android.exoplayer2.upstream.cache.SimpleCache;
+import com.google.android.exoplayer2.util.Log;
+import java.io.File;
+import java.io.IOException;
+import java.util.concurrent.Executors;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+
+/** Utility methods for the demo app. */
+public final class DemoUtil {
+
+ public static final String DOWNLOAD_NOTIFICATION_CHANNEL_ID = "download_channel";
+
+ private static final String TAG = "DemoUtil";
+ private static final String DOWNLOAD_ACTION_FILE = "actions";
+ private static final String DOWNLOAD_TRACKER_ACTION_FILE = "tracked_actions";
+ private static final String DOWNLOAD_CONTENT_DIRECTORY = "downloads";
+
+ private static DataSource.@MonotonicNonNull Factory dataSourceFactory;
+ private static HttpDataSource.@MonotonicNonNull Factory httpDataSourceFactory;
+ private static @MonotonicNonNull DatabaseProvider databaseProvider;
+ private static @MonotonicNonNull File downloadDirectory;
+ private static @MonotonicNonNull Cache downloadCache;
+ private static @MonotonicNonNull DownloadManager downloadManager;
+ private static @MonotonicNonNull DownloadTracker downloadTracker;
+ private static @MonotonicNonNull DownloadNotificationHelper downloadNotificationHelper;
+
+ /** Returns whether extension renderers should be used. */
+ public static boolean useExtensionRenderers() {
+ return BuildConfig.USE_DECODER_EXTENSIONS;
+ }
+
+ public static RenderersFactory buildRenderersFactory(
+ Context context, boolean preferExtensionRenderer) {
+ @DefaultRenderersFactory.ExtensionRendererMode
+ int extensionRendererMode =
+ useExtensionRenderers()
+ ? (preferExtensionRenderer
+ ? DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
+ : DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
+ : DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF;
+ return new DefaultRenderersFactory(context.getApplicationContext())
+ .setExtensionRendererMode(extensionRendererMode);
+ }
+
+ public static synchronized HttpDataSource.Factory getHttpDataSourceFactory(Context context) {
+ if (httpDataSourceFactory == null) {
+ context = context.getApplicationContext();
+ CronetEngineWrapper cronetEngineWrapper = new CronetEngineWrapper(context);
+ httpDataSourceFactory =
+ new CronetDataSourceFactory(cronetEngineWrapper, Executors.newSingleThreadExecutor());
+ }
+ return httpDataSourceFactory;
+ }
+
+ /** Returns a {@link DataSource.Factory}. */
+ public static synchronized DataSource.Factory getDataSourceFactory(Context context) {
+ if (dataSourceFactory == null) {
+ context = context.getApplicationContext();
+ DefaultDataSourceFactory upstreamFactory =
+ new DefaultDataSourceFactory(context, getHttpDataSourceFactory(context));
+ dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context));
+ }
+ return dataSourceFactory;
+ }
+
+ public static synchronized DownloadNotificationHelper getDownloadNotificationHelper(
+ Context context) {
+ if (downloadNotificationHelper == null) {
+ downloadNotificationHelper =
+ new DownloadNotificationHelper(context, DOWNLOAD_NOTIFICATION_CHANNEL_ID);
+ }
+ return downloadNotificationHelper;
+ }
+
+ public static synchronized DownloadManager getDownloadManager(Context context) {
+ ensureDownloadManagerInitialized(context);
+ return downloadManager;
+ }
+
+ public static synchronized DownloadTracker getDownloadTracker(Context context) {
+ ensureDownloadManagerInitialized(context);
+ return downloadTracker;
+ }
+
+ private static synchronized Cache getDownloadCache(Context context) {
+ if (downloadCache == null) {
+ File downloadContentDirectory =
+ new File(getDownloadDirectory(context), DOWNLOAD_CONTENT_DIRECTORY);
+ downloadCache =
+ new SimpleCache(
+ downloadContentDirectory, new NoOpCacheEvictor(), getDatabaseProvider(context));
+ }
+ return downloadCache;
+ }
+
+ private static synchronized void ensureDownloadManagerInitialized(Context context) {
+ if (downloadManager == null) {
+ DefaultDownloadIndex downloadIndex = new DefaultDownloadIndex(getDatabaseProvider(context));
+ upgradeActionFile(
+ context, DOWNLOAD_ACTION_FILE, downloadIndex, /* addNewDownloadsAsCompleted= */ false);
+ upgradeActionFile(
+ context,
+ DOWNLOAD_TRACKER_ACTION_FILE,
+ downloadIndex,
+ /* addNewDownloadsAsCompleted= */ true);
+ downloadManager =
+ new DownloadManager(
+ context,
+ getDatabaseProvider(context),
+ getDownloadCache(context),
+ getHttpDataSourceFactory(context),
+ Executors.newFixedThreadPool(/* nThreads= */ 6));
+ downloadTracker =
+ new DownloadTracker(context, getHttpDataSourceFactory(context), downloadManager);
+ }
+ }
+
+ private static synchronized void upgradeActionFile(
+ Context context,
+ String fileName,
+ DefaultDownloadIndex downloadIndex,
+ boolean addNewDownloadsAsCompleted) {
+ try {
+ ActionFileUpgradeUtil.upgradeAndDelete(
+ new File(getDownloadDirectory(context), fileName),
+ /* downloadIdProvider= */ null,
+ downloadIndex,
+ /* deleteOnFailure= */ true,
+ addNewDownloadsAsCompleted);
+ } catch (IOException e) {
+ Log.e(TAG, "Failed to upgrade action file: " + fileName, e);
+ }
+ }
+
+ private static synchronized DatabaseProvider getDatabaseProvider(Context context) {
+ if (databaseProvider == null) {
+ databaseProvider = new ExoDatabaseProvider(context);
+ }
+ return databaseProvider;
+ }
+
+ private static synchronized File getDownloadDirectory(Context context) {
+ if (downloadDirectory == null) {
+ downloadDirectory = context.getExternalFilesDir(/* type= */ null);
+ if (downloadDirectory == null) {
+ downloadDirectory = context.getFilesDir();
+ }
+ }
+ return downloadDirectory;
+ }
+
+ private static CacheDataSource.Factory buildReadOnlyCacheDataSource(
+ DataSource.Factory upstreamFactory, Cache cache) {
+ return new CacheDataSource.Factory()
+ .setCache(cache)
+ .setUpstreamDataSourceFactory(upstreamFactory)
+ .setCacheWriteDataSinkFactory(null)
+ .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR);
+ }
+
+ private DemoUtil() {}
+}
diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java
new file mode 100644
index 0000000000..07f4dd2f6e
--- /dev/null
+++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java
@@ -0,0 +1,423 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.demo;
+
+import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
+import static com.google.android.exoplayer2.util.Assertions.checkStateNotNull;
+
+import android.content.Context;
+import android.content.DialogInterface;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.widget.Toast;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.fragment.app.FragmentManager;
+import com.google.android.exoplayer2.Format;
+import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.RenderersFactory;
+import com.google.android.exoplayer2.drm.DrmInitData;
+import com.google.android.exoplayer2.drm.DrmSession;
+import com.google.android.exoplayer2.drm.DrmSessionEventListener;
+import com.google.android.exoplayer2.drm.OfflineLicenseHelper;
+import com.google.android.exoplayer2.offline.Download;
+import com.google.android.exoplayer2.offline.DownloadCursor;
+import com.google.android.exoplayer2.offline.DownloadHelper;
+import com.google.android.exoplayer2.offline.DownloadHelper.LiveContentUnsupportedException;
+import com.google.android.exoplayer2.offline.DownloadIndex;
+import com.google.android.exoplayer2.offline.DownloadManager;
+import com.google.android.exoplayer2.offline.DownloadRequest;
+import com.google.android.exoplayer2.offline.DownloadService;
+import com.google.android.exoplayer2.source.TrackGroup;
+import com.google.android.exoplayer2.source.TrackGroupArray;
+import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
+import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo;
+import com.google.android.exoplayer2.upstream.HttpDataSource;
+import com.google.android.exoplayer2.util.Log;
+import com.google.android.exoplayer2.util.Util;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.concurrent.CopyOnWriteArraySet;
+
+/** Tracks media that has been downloaded. */
+public class DownloadTracker {
+
+ /** Listens for changes in the tracked downloads. */
+ public interface Listener {
+
+ /** Called when the tracked downloads changed. */
+ void onDownloadsChanged();
+ }
+
+ private static final String TAG = "DownloadTracker";
+
+ private final Context context;
+ private final HttpDataSource.Factory httpDataSourceFactory;
+ private final CopyOnWriteArraySet listeners;
+ private final HashMap downloads;
+ private final DownloadIndex downloadIndex;
+ private final DefaultTrackSelector.Parameters trackSelectorParameters;
+
+ @Nullable private StartDownloadDialogHelper startDownloadDialogHelper;
+
+ public DownloadTracker(
+ Context context,
+ HttpDataSource.Factory httpDataSourceFactory,
+ DownloadManager downloadManager) {
+ this.context = context.getApplicationContext();
+ this.httpDataSourceFactory = httpDataSourceFactory;
+ listeners = new CopyOnWriteArraySet<>();
+ downloads = new HashMap<>();
+ downloadIndex = downloadManager.getDownloadIndex();
+ trackSelectorParameters = DownloadHelper.getDefaultTrackSelectorParameters(context);
+ downloadManager.addListener(new DownloadManagerListener());
+ loadDownloads();
+ }
+
+ public void addListener(Listener listener) {
+ checkNotNull(listener);
+ listeners.add(listener);
+ }
+
+ public void removeListener(Listener listener) {
+ listeners.remove(listener);
+ }
+
+ public boolean isDownloaded(MediaItem mediaItem) {
+ Download download = downloads.get(checkNotNull(mediaItem.playbackProperties).uri);
+ return download != null && download.state != Download.STATE_FAILED;
+ }
+
+ @Nullable
+ public DownloadRequest getDownloadRequest(Uri uri) {
+ Download download = downloads.get(uri);
+ return download != null && download.state != Download.STATE_FAILED ? download.request : null;
+ }
+
+ public void toggleDownload(
+ FragmentManager fragmentManager, MediaItem mediaItem, RenderersFactory renderersFactory) {
+ Download download = downloads.get(checkNotNull(mediaItem.playbackProperties).uri);
+ if (download != null) {
+ DownloadService.sendRemoveDownload(
+ context, DemoDownloadService.class, download.request.id, /* foreground= */ false);
+ } else {
+ if (startDownloadDialogHelper != null) {
+ startDownloadDialogHelper.release();
+ }
+ startDownloadDialogHelper =
+ new StartDownloadDialogHelper(
+ fragmentManager,
+ DownloadHelper.forMediaItem(
+ context, mediaItem, renderersFactory, httpDataSourceFactory),
+ mediaItem);
+ }
+ }
+
+ private void loadDownloads() {
+ try (DownloadCursor loadedDownloads = downloadIndex.getDownloads()) {
+ while (loadedDownloads.moveToNext()) {
+ Download download = loadedDownloads.getDownload();
+ downloads.put(download.request.uri, download);
+ }
+ } catch (IOException e) {
+ Log.w(TAG, "Failed to query downloads", e);
+ }
+ }
+
+ private class DownloadManagerListener implements DownloadManager.Listener {
+
+ @Override
+ public void onDownloadChanged(
+ @NonNull DownloadManager downloadManager,
+ @NonNull Download download,
+ @Nullable Exception finalException) {
+ downloads.put(download.request.uri, download);
+ for (Listener listener : listeners) {
+ listener.onDownloadsChanged();
+ }
+ }
+
+ @Override
+ public void onDownloadRemoved(
+ @NonNull DownloadManager downloadManager, @NonNull Download download) {
+ downloads.remove(download.request.uri);
+ for (Listener listener : listeners) {
+ listener.onDownloadsChanged();
+ }
+ }
+ }
+
+ private final class StartDownloadDialogHelper
+ implements DownloadHelper.Callback,
+ DialogInterface.OnClickListener,
+ DialogInterface.OnDismissListener {
+
+ private final FragmentManager fragmentManager;
+ private final DownloadHelper downloadHelper;
+ private final MediaItem mediaItem;
+
+ private TrackSelectionDialog trackSelectionDialog;
+ private MappedTrackInfo mappedTrackInfo;
+ private WidevineOfflineLicenseFetchTask widevineOfflineLicenseFetchTask;
+ @Nullable private byte[] keySetId;
+
+ public StartDownloadDialogHelper(
+ FragmentManager fragmentManager, DownloadHelper downloadHelper, MediaItem mediaItem) {
+ this.fragmentManager = fragmentManager;
+ this.downloadHelper = downloadHelper;
+ this.mediaItem = mediaItem;
+ downloadHelper.prepare(this);
+ }
+
+ public void release() {
+ downloadHelper.release();
+ if (trackSelectionDialog != null) {
+ trackSelectionDialog.dismiss();
+ }
+ if (widevineOfflineLicenseFetchTask != null) {
+ widevineOfflineLicenseFetchTask.cancel(false);
+ }
+ }
+
+ // DownloadHelper.Callback implementation.
+
+ @Override
+ public void onPrepared(@NonNull DownloadHelper helper) {
+ @Nullable Format format = getFirstFormatWithDrmInitData(helper);
+ if (format == null) {
+ onDownloadPrepared(helper);
+ return;
+ }
+
+ // The content is DRM protected. We need to acquire an offline license.
+ if (Util.SDK_INT < 18) {
+ Toast.makeText(context, R.string.error_drm_unsupported_before_api_18, Toast.LENGTH_LONG)
+ .show();
+ Log.e(TAG, "Downloading DRM protected content is not supported on API versions below 18");
+ return;
+ }
+ // TODO(internal b/163107948): Support cases where DrmInitData are not in the manifest.
+ if (!hasSchemaData(format.drmInitData)) {
+ Toast.makeText(context, R.string.download_start_error_offline_license, Toast.LENGTH_LONG)
+ .show();
+ Log.e(
+ TAG,
+ "Downloading content where DRM scheme data is not located in the manifest is not"
+ + " supported");
+ return;
+ }
+ widevineOfflineLicenseFetchTask =
+ new WidevineOfflineLicenseFetchTask(
+ format,
+ mediaItem.playbackProperties.drmConfiguration.licenseUri,
+ httpDataSourceFactory,
+ /* dialogHelper= */ this,
+ helper);
+ widevineOfflineLicenseFetchTask.execute();
+ }
+
+ @Override
+ public void onPrepareError(@NonNull DownloadHelper helper, @NonNull IOException e) {
+ boolean isLiveContent = e instanceof LiveContentUnsupportedException;
+ int toastStringId =
+ isLiveContent ? R.string.download_live_unsupported : R.string.download_start_error;
+ String logMessage =
+ isLiveContent ? "Downloading live content unsupported" : "Failed to start download";
+ Toast.makeText(context, toastStringId, Toast.LENGTH_LONG).show();
+ Log.e(TAG, logMessage, e);
+ }
+
+ // DialogInterface.OnClickListener implementation.
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ for (int periodIndex = 0; periodIndex < downloadHelper.getPeriodCount(); periodIndex++) {
+ downloadHelper.clearTrackSelections(periodIndex);
+ for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) {
+ if (!trackSelectionDialog.getIsDisabled(/* rendererIndex= */ i)) {
+ downloadHelper.addTrackSelectionForSingleRenderer(
+ periodIndex,
+ /* rendererIndex= */ i,
+ trackSelectorParameters,
+ trackSelectionDialog.getOverrides(/* rendererIndex= */ i));
+ }
+ }
+ }
+ DownloadRequest downloadRequest = buildDownloadRequest();
+ if (downloadRequest.streamKeys.isEmpty()) {
+ // All tracks were deselected in the dialog. Don't start the download.
+ return;
+ }
+ startDownload(downloadRequest);
+ }
+
+ // DialogInterface.OnDismissListener implementation.
+
+ @Override
+ public void onDismiss(DialogInterface dialogInterface) {
+ trackSelectionDialog = null;
+ downloadHelper.release();
+ }
+
+ // Internal methods.
+
+ /**
+ * Returns the first {@link Format} with a non-null {@link Format#drmInitData} found in the
+ * content's tracks, or null if none is found.
+ */
+ @Nullable
+ private Format getFirstFormatWithDrmInitData(DownloadHelper helper) {
+ for (int periodIndex = 0; periodIndex < helper.getPeriodCount(); periodIndex++) {
+ MappedTrackInfo mappedTrackInfo = helper.getMappedTrackInfo(periodIndex);
+ for (int rendererIndex = 0;
+ rendererIndex < mappedTrackInfo.getRendererCount();
+ rendererIndex++) {
+ TrackGroupArray trackGroups = mappedTrackInfo.getTrackGroups(rendererIndex);
+ for (int trackGroupIndex = 0; trackGroupIndex < trackGroups.length; trackGroupIndex++) {
+ TrackGroup trackGroup = trackGroups.get(trackGroupIndex);
+ for (int formatIndex = 0; formatIndex < trackGroup.length; formatIndex++) {
+ Format format = trackGroup.getFormat(formatIndex);
+ if (format.drmInitData != null) {
+ return format;
+ }
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ private void onOfflineLicenseFetched(DownloadHelper helper, byte[] keySetId) {
+ this.keySetId = keySetId;
+ onDownloadPrepared(helper);
+ }
+
+ private void onOfflineLicenseFetchedError(DrmSession.DrmSessionException e) {
+ Toast.makeText(context, R.string.download_start_error_offline_license, Toast.LENGTH_LONG)
+ .show();
+ Log.e(TAG, "Failed to fetch offline DRM license", e);
+ }
+
+ private void onDownloadPrepared(DownloadHelper helper) {
+ if (helper.getPeriodCount() == 0) {
+ Log.d(TAG, "No periods found. Downloading entire stream.");
+ startDownload();
+ downloadHelper.release();
+ return;
+ }
+
+ mappedTrackInfo = downloadHelper.getMappedTrackInfo(/* periodIndex= */ 0);
+ if (!TrackSelectionDialog.willHaveContent(mappedTrackInfo)) {
+ Log.d(TAG, "No dialog content. Downloading entire stream.");
+ startDownload();
+ downloadHelper.release();
+ return;
+ }
+ trackSelectionDialog =
+ TrackSelectionDialog.createForMappedTrackInfoAndParameters(
+ /* titleId= */ R.string.exo_download_description,
+ mappedTrackInfo,
+ trackSelectorParameters,
+ /* allowAdaptiveSelections =*/ false,
+ /* allowMultipleOverrides= */ true,
+ /* onClickListener= */ this,
+ /* onDismissListener= */ this);
+ trackSelectionDialog.show(fragmentManager, /* tag= */ null);
+ }
+
+ /**
+ * Returns whether any the {@link DrmInitData.SchemeData} contained in {@code drmInitData} has
+ * non-null {@link DrmInitData.SchemeData#data}.
+ */
+ private boolean hasSchemaData(DrmInitData drmInitData) {
+ for (int i = 0; i < drmInitData.schemeDataCount; i++) {
+ if (drmInitData.get(i).hasData()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void startDownload() {
+ startDownload(buildDownloadRequest());
+ }
+
+ private void startDownload(DownloadRequest downloadRequest) {
+ DownloadService.sendAddDownload(
+ context, DemoDownloadService.class, downloadRequest, /* foreground= */ false);
+ }
+
+ private DownloadRequest buildDownloadRequest() {
+ return downloadHelper
+ .getDownloadRequest(Util.getUtf8Bytes(checkNotNull(mediaItem.mediaMetadata.title)))
+ .copyWithKeySetId(keySetId);
+ }
+ }
+
+ /** Downloads a Widevine offline license in a background thread. */
+ @RequiresApi(18)
+ private static final class WidevineOfflineLicenseFetchTask extends AsyncTask {
+
+ private final Format format;
+ private final Uri licenseUri;
+ private final HttpDataSource.Factory httpDataSourceFactory;
+ private final StartDownloadDialogHelper dialogHelper;
+ private final DownloadHelper downloadHelper;
+
+ @Nullable private byte[] keySetId;
+ @Nullable private DrmSession.DrmSessionException drmSessionException;
+
+ public WidevineOfflineLicenseFetchTask(
+ Format format,
+ Uri licenseUri,
+ HttpDataSource.Factory httpDataSourceFactory,
+ StartDownloadDialogHelper dialogHelper,
+ DownloadHelper downloadHelper) {
+ this.format = format;
+ this.licenseUri = licenseUri;
+ this.httpDataSourceFactory = httpDataSourceFactory;
+ this.dialogHelper = dialogHelper;
+ this.downloadHelper = downloadHelper;
+ }
+
+ @Override
+ protected Void doInBackground(Void... voids) {
+ OfflineLicenseHelper offlineLicenseHelper =
+ OfflineLicenseHelper.newWidevineInstance(
+ licenseUri.toString(),
+ httpDataSourceFactory,
+ new DrmSessionEventListener.EventDispatcher());
+ try {
+ keySetId = offlineLicenseHelper.downloadLicense(format);
+ } catch (DrmSession.DrmSessionException e) {
+ drmSessionException = e;
+ } finally {
+ offlineLicenseHelper.release();
+ }
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void aVoid) {
+ if (drmSessionException != null) {
+ dialogHelper.onOfflineLicenseFetchedError(drmSessionException);
+ } else {
+ dialogHelper.onOfflineLicenseFetched(downloadHelper, checkStateNotNull(keySetId));
+ }
+ }
+ }
+}
diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java
new file mode 100644
index 0000000000..d2d962c568
--- /dev/null
+++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java
@@ -0,0 +1,230 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.demo;
+
+import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
+import static com.google.android.exoplayer2.util.Assertions.checkState;
+
+import android.content.Intent;
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.Util;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/** Util to read from and populate an intent. */
+public class IntentUtil {
+
+ // Actions.
+
+ public static final String ACTION_VIEW = "com.google.android.exoplayer.demo.action.VIEW";
+ public static final String ACTION_VIEW_LIST =
+ "com.google.android.exoplayer.demo.action.VIEW_LIST";
+
+ // Activity extras.
+
+ public static final String PREFER_EXTENSION_DECODERS_EXTRA = "prefer_extension_decoders";
+
+ // Media item configuration extras.
+
+ public static final String URI_EXTRA = "uri";
+ public static final String MIME_TYPE_EXTRA = "mime_type";
+ public static final String CLIP_START_POSITION_MS_EXTRA = "clip_start_position_ms";
+ public static final String CLIP_END_POSITION_MS_EXTRA = "clip_end_position_ms";
+
+ public static final String AD_TAG_URI_EXTRA = "ad_tag_uri";
+
+ public static final String DRM_SCHEME_EXTRA = "drm_scheme";
+ public static final String DRM_LICENSE_URI_EXTRA = "drm_license_uri";
+ public static final String DRM_KEY_REQUEST_PROPERTIES_EXTRA = "drm_key_request_properties";
+ public static final String DRM_SESSION_FOR_CLEAR_CONTENT = "drm_session_for_clear_content";
+ public static final String DRM_MULTI_SESSION_EXTRA = "drm_multi_session";
+ public static final String DRM_FORCE_DEFAULT_LICENSE_URI_EXTRA = "drm_force_default_license_uri";
+
+ public static final String SUBTITLE_URI_EXTRA = "subtitle_uri";
+ public static final String SUBTITLE_MIME_TYPE_EXTRA = "subtitle_mime_type";
+ public static final String SUBTITLE_LANGUAGE_EXTRA = "subtitle_language";
+
+ /** Creates a list of {@link MediaItem media items} from an {@link Intent}. */
+ public static List createMediaItemsFromIntent(Intent intent) {
+ List mediaItems = new ArrayList<>();
+ if (ACTION_VIEW_LIST.equals(intent.getAction())) {
+ int index = 0;
+ while (intent.hasExtra(URI_EXTRA + "_" + index)) {
+ Uri uri = Uri.parse(intent.getStringExtra(URI_EXTRA + "_" + index));
+ mediaItems.add(createMediaItemFromIntent(uri, intent, /* extrasKeySuffix= */ "_" + index));
+ index++;
+ }
+ } else {
+ Uri uri = intent.getData();
+ mediaItems.add(createMediaItemFromIntent(uri, intent, /* extrasKeySuffix= */ ""));
+ }
+ return mediaItems;
+ }
+
+ /** Populates the intent with the given list of {@link MediaItem media items}. */
+ public static void addToIntent(List mediaItems, Intent intent) {
+ Assertions.checkArgument(!mediaItems.isEmpty());
+ if (mediaItems.size() == 1) {
+ MediaItem mediaItem = mediaItems.get(0);
+ MediaItem.PlaybackProperties playbackProperties = checkNotNull(mediaItem.playbackProperties);
+ intent.setAction(ACTION_VIEW).setData(mediaItem.playbackProperties.uri);
+ addPlaybackPropertiesToIntent(playbackProperties, intent, /* extrasKeySuffix= */ "");
+ addClippingPropertiesToIntent(
+ mediaItem.clippingProperties, intent, /* extrasKeySuffix= */ "");
+ } else {
+ intent.setAction(ACTION_VIEW_LIST);
+ for (int i = 0; i < mediaItems.size(); i++) {
+ MediaItem mediaItem = mediaItems.get(i);
+ MediaItem.PlaybackProperties playbackProperties =
+ checkNotNull(mediaItem.playbackProperties);
+ intent.putExtra(URI_EXTRA + ("_" + i), playbackProperties.uri.toString());
+ addPlaybackPropertiesToIntent(playbackProperties, intent, /* extrasKeySuffix= */ "_" + i);
+ addClippingPropertiesToIntent(
+ mediaItem.clippingProperties, intent, /* extrasKeySuffix= */ "_" + i);
+ }
+ }
+ }
+
+ private static MediaItem createMediaItemFromIntent(
+ Uri uri, Intent intent, String extrasKeySuffix) {
+ @Nullable String mimeType = intent.getStringExtra(MIME_TYPE_EXTRA + extrasKeySuffix);
+ MediaItem.Builder builder =
+ new MediaItem.Builder()
+ .setUri(uri)
+ .setMimeType(mimeType)
+ .setAdTagUri(intent.getStringExtra(AD_TAG_URI_EXTRA + extrasKeySuffix))
+ .setSubtitles(createSubtitlesFromIntent(intent, extrasKeySuffix))
+ .setClipStartPositionMs(
+ intent.getLongExtra(CLIP_START_POSITION_MS_EXTRA + extrasKeySuffix, 0))
+ .setClipEndPositionMs(
+ intent.getLongExtra(
+ CLIP_END_POSITION_MS_EXTRA + extrasKeySuffix, C.TIME_END_OF_SOURCE));
+
+ return populateDrmPropertiesFromIntent(builder, intent, extrasKeySuffix).build();
+ }
+
+ private static List createSubtitlesFromIntent(
+ Intent intent, String extrasKeySuffix) {
+ if (!intent.hasExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix)) {
+ return Collections.emptyList();
+ }
+ return Collections.singletonList(
+ new MediaItem.Subtitle(
+ Uri.parse(intent.getStringExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix)),
+ checkNotNull(intent.getStringExtra(SUBTITLE_MIME_TYPE_EXTRA + extrasKeySuffix)),
+ intent.getStringExtra(SUBTITLE_LANGUAGE_EXTRA + extrasKeySuffix),
+ C.SELECTION_FLAG_DEFAULT));
+ }
+
+ private static MediaItem.Builder populateDrmPropertiesFromIntent(
+ MediaItem.Builder builder, Intent intent, String extrasKeySuffix) {
+ String schemeKey = DRM_SCHEME_EXTRA + extrasKeySuffix;
+ @Nullable String drmSchemeExtra = intent.getStringExtra(schemeKey);
+ if (drmSchemeExtra == null) {
+ return builder;
+ }
+ Map headers = new HashMap<>();
+ @Nullable
+ String[] keyRequestPropertiesArray =
+ intent.getStringArrayExtra(DRM_KEY_REQUEST_PROPERTIES_EXTRA + extrasKeySuffix);
+ if (keyRequestPropertiesArray != null) {
+ for (int i = 0; i < keyRequestPropertiesArray.length; i += 2) {
+ headers.put(keyRequestPropertiesArray[i], keyRequestPropertiesArray[i + 1]);
+ }
+ }
+ builder
+ .setDrmUuid(Util.getDrmUuid(Util.castNonNull(drmSchemeExtra)))
+ .setDrmLicenseUri(intent.getStringExtra(DRM_LICENSE_URI_EXTRA + extrasKeySuffix))
+ .setDrmMultiSession(
+ intent.getBooleanExtra(DRM_MULTI_SESSION_EXTRA + extrasKeySuffix, false))
+ .setDrmForceDefaultLicenseUri(
+ intent.getBooleanExtra(DRM_FORCE_DEFAULT_LICENSE_URI_EXTRA + extrasKeySuffix, false))
+ .setDrmLicenseRequestHeaders(headers);
+ if (intent.getBooleanExtra(DRM_SESSION_FOR_CLEAR_CONTENT + extrasKeySuffix, false)) {
+ builder.setDrmSessionForClearTypes(ImmutableList.of(C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_AUDIO));
+ }
+ return builder;
+ }
+
+ private static void addPlaybackPropertiesToIntent(
+ MediaItem.PlaybackProperties playbackProperties, Intent intent, String extrasKeySuffix) {
+ intent
+ .putExtra(MIME_TYPE_EXTRA + extrasKeySuffix, playbackProperties.mimeType)
+ .putExtra(
+ AD_TAG_URI_EXTRA + extrasKeySuffix,
+ playbackProperties.adTagUri != null ? playbackProperties.adTagUri.toString() : null);
+ if (playbackProperties.drmConfiguration != null) {
+ addDrmConfigurationToIntent(playbackProperties.drmConfiguration, intent, extrasKeySuffix);
+ }
+ if (!playbackProperties.subtitles.isEmpty()) {
+ checkState(playbackProperties.subtitles.size() == 1);
+ MediaItem.Subtitle subtitle = playbackProperties.subtitles.get(0);
+ intent.putExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix, subtitle.uri.toString());
+ intent.putExtra(SUBTITLE_MIME_TYPE_EXTRA + extrasKeySuffix, subtitle.mimeType);
+ intent.putExtra(SUBTITLE_LANGUAGE_EXTRA + extrasKeySuffix, subtitle.language);
+ }
+ }
+
+ private static void addDrmConfigurationToIntent(
+ MediaItem.DrmConfiguration drmConfiguration, Intent intent, String extrasKeySuffix) {
+ intent.putExtra(DRM_SCHEME_EXTRA + extrasKeySuffix, drmConfiguration.uuid.toString());
+ intent.putExtra(
+ DRM_LICENSE_URI_EXTRA + extrasKeySuffix,
+ drmConfiguration.licenseUri != null ? drmConfiguration.licenseUri.toString() : null);
+ intent.putExtra(DRM_MULTI_SESSION_EXTRA + extrasKeySuffix, drmConfiguration.multiSession);
+ intent.putExtra(
+ DRM_FORCE_DEFAULT_LICENSE_URI_EXTRA + extrasKeySuffix,
+ drmConfiguration.forceDefaultLicenseUri);
+
+ String[] drmKeyRequestProperties = new String[drmConfiguration.requestHeaders.size() * 2];
+ int index = 0;
+ for (Map.Entry entry : drmConfiguration.requestHeaders.entrySet()) {
+ drmKeyRequestProperties[index++] = entry.getKey();
+ drmKeyRequestProperties[index++] = entry.getValue();
+ }
+ intent.putExtra(DRM_KEY_REQUEST_PROPERTIES_EXTRA + extrasKeySuffix, drmKeyRequestProperties);
+
+ List drmSessionForClearTypes = drmConfiguration.sessionForClearTypes;
+ if (!drmSessionForClearTypes.isEmpty()) {
+ // Only video and audio together are supported.
+ Assertions.checkState(
+ drmSessionForClearTypes.size() == 2
+ && drmSessionForClearTypes.contains(C.TRACK_TYPE_VIDEO)
+ && drmSessionForClearTypes.contains(C.TRACK_TYPE_AUDIO));
+ intent.putExtra(DRM_SESSION_FOR_CLEAR_CONTENT + extrasKeySuffix, true);
+ }
+ }
+
+ private static void addClippingPropertiesToIntent(
+ MediaItem.ClippingProperties clippingProperties, Intent intent, String extrasKeySuffix) {
+ if (clippingProperties.startPositionMs != 0) {
+ intent.putExtra(
+ CLIP_START_POSITION_MS_EXTRA + extrasKeySuffix, clippingProperties.startPositionMs);
+ }
+ if (clippingProperties.endPositionMs != C.TIME_END_OF_SOURCE) {
+ intent.putExtra(
+ CLIP_END_POSITION_MS_EXTRA + extrasKeySuffix, clippingProperties.endPositionMs);
+ }
+ }
+}
diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java
new file mode 100644
index 0000000000..eae302887e
--- /dev/null
+++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java
@@ -0,0 +1,562 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.demo;
+
+import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
+
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.Pair;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.ExoPlaybackException;
+import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.PlaybackPreparer;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.RenderersFactory;
+import com.google.android.exoplayer2.SimpleExoPlayer;
+import com.google.android.exoplayer2.audio.AudioAttributes;
+import com.google.android.exoplayer2.drm.FrameworkMediaDrm;
+import com.google.android.exoplayer2.ext.ima.ImaAdsLoader;
+import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer.DecoderInitializationException;
+import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException;
+import com.google.android.exoplayer2.offline.DownloadRequest;
+import com.google.android.exoplayer2.source.BehindLiveWindowException;
+import com.google.android.exoplayer2.source.DefaultMediaSourceFactory;
+import com.google.android.exoplayer2.source.MediaSourceFactory;
+import com.google.android.exoplayer2.source.TrackGroupArray;
+import com.google.android.exoplayer2.source.ads.AdsLoader;
+import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
+import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo;
+import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
+import com.google.android.exoplayer2.ui.DebugTextViewHelper;
+import com.google.android.exoplayer2.ui.StyledPlayerControlView;
+import com.google.android.exoplayer2.ui.StyledPlayerView;
+import com.google.android.exoplayer2.upstream.DataSource;
+import com.google.android.exoplayer2.util.ErrorMessageProvider;
+import com.google.android.exoplayer2.util.EventLogger;
+import com.google.android.exoplayer2.util.Util;
+import java.net.CookieHandler;
+import java.net.CookieManager;
+import java.net.CookiePolicy;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** An activity that plays media using {@link SimpleExoPlayer}. */
+public class PlayerActivity extends AppCompatActivity
+ implements OnClickListener, PlaybackPreparer, StyledPlayerControlView.VisibilityListener {
+
+ // Saved instance state keys.
+
+ private static final String KEY_TRACK_SELECTOR_PARAMETERS = "track_selector_parameters";
+ private static final String KEY_WINDOW = "window";
+ private static final String KEY_POSITION = "position";
+ private static final String KEY_AUTO_PLAY = "auto_play";
+
+ private static final CookieManager DEFAULT_COOKIE_MANAGER;
+
+ static {
+ DEFAULT_COOKIE_MANAGER = new CookieManager();
+ DEFAULT_COOKIE_MANAGER.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER);
+ }
+
+ protected StyledPlayerView playerView;
+ protected LinearLayout debugRootView;
+ protected TextView debugTextView;
+ protected SimpleExoPlayer player;
+
+ private boolean isShowingTrackSelectionDialog;
+ private Button selectTracksButton;
+ private DataSource.Factory dataSourceFactory;
+ private List mediaItems;
+ private DefaultTrackSelector trackSelector;
+ private DefaultTrackSelector.Parameters trackSelectorParameters;
+ private DebugTextViewHelper debugViewHelper;
+ private TrackGroupArray lastSeenTrackGroupArray;
+ private boolean startAutoPlay;
+ private int startWindow;
+ private long startPosition;
+
+ // Fields used only for ad playback. The ads loader is loaded via reflection.
+
+ private AdsLoader adsLoader;
+ private Uri loadedAdTagUri;
+
+ // Activity lifecycle
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ dataSourceFactory = DemoUtil.getDataSourceFactory(/* context= */ this);
+ if (CookieHandler.getDefault() != DEFAULT_COOKIE_MANAGER) {
+ CookieHandler.setDefault(DEFAULT_COOKIE_MANAGER);
+ }
+
+ setContentView();
+ debugRootView = findViewById(R.id.controls_root);
+ debugTextView = findViewById(R.id.debug_text_view);
+ selectTracksButton = findViewById(R.id.select_tracks_button);
+ selectTracksButton.setOnClickListener(this);
+
+ playerView = findViewById(R.id.player_view);
+ playerView.setControllerVisibilityListener(this);
+ playerView.setErrorMessageProvider(new PlayerErrorMessageProvider());
+ playerView.requestFocus();
+
+ if (savedInstanceState != null) {
+ trackSelectorParameters = savedInstanceState.getParcelable(KEY_TRACK_SELECTOR_PARAMETERS);
+ startAutoPlay = savedInstanceState.getBoolean(KEY_AUTO_PLAY);
+ startWindow = savedInstanceState.getInt(KEY_WINDOW);
+ startPosition = savedInstanceState.getLong(KEY_POSITION);
+ } else {
+ DefaultTrackSelector.ParametersBuilder builder =
+ new DefaultTrackSelector.ParametersBuilder(/* context= */ this);
+ trackSelectorParameters = builder.build();
+ clearStartPosition();
+ }
+ }
+
+ @Override
+ public void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ releasePlayer();
+ releaseAdsLoader();
+ clearStartPosition();
+ setIntent(intent);
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ if (Util.SDK_INT > 23) {
+ initializePlayer();
+ if (playerView != null) {
+ playerView.onResume();
+ }
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ if (Util.SDK_INT <= 23 || player == null) {
+ initializePlayer();
+ if (playerView != null) {
+ playerView.onResume();
+ }
+ }
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ if (Util.SDK_INT <= 23) {
+ if (playerView != null) {
+ playerView.onPause();
+ }
+ releasePlayer();
+ }
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ if (Util.SDK_INT > 23) {
+ if (playerView != null) {
+ playerView.onPause();
+ }
+ releasePlayer();
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ releaseAdsLoader();
+ }
+
+ @Override
+ public void onRequestPermissionsResult(
+ int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (grantResults.length == 0) {
+ // Empty results are triggered if a permission is requested while another request was already
+ // pending and can be safely ignored in this case.
+ return;
+ }
+ if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ initializePlayer();
+ } else {
+ showToast(R.string.storage_permission_denied);
+ finish();
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(@NonNull Bundle outState) {
+ super.onSaveInstanceState(outState);
+ updateTrackSelectorParameters();
+ updateStartPosition();
+ outState.putParcelable(KEY_TRACK_SELECTOR_PARAMETERS, trackSelectorParameters);
+ outState.putBoolean(KEY_AUTO_PLAY, startAutoPlay);
+ outState.putInt(KEY_WINDOW, startWindow);
+ outState.putLong(KEY_POSITION, startPosition);
+ }
+
+ // Activity input
+
+ @Override
+ public boolean dispatchKeyEvent(KeyEvent event) {
+ // See whether the player view wants to handle media or DPAD keys events.
+ return playerView.dispatchKeyEvent(event) || super.dispatchKeyEvent(event);
+ }
+
+ // OnClickListener methods
+
+ @Override
+ public void onClick(View view) {
+ if (view == selectTracksButton
+ && !isShowingTrackSelectionDialog
+ && TrackSelectionDialog.willHaveContent(trackSelector)) {
+ isShowingTrackSelectionDialog = true;
+ TrackSelectionDialog trackSelectionDialog =
+ TrackSelectionDialog.createForTrackSelector(
+ trackSelector,
+ /* onDismissListener= */ dismissedDialog -> isShowingTrackSelectionDialog = false);
+ trackSelectionDialog.show(getSupportFragmentManager(), /* tag= */ null);
+ }
+ }
+
+ // PlaybackPreparer implementation
+
+ @Override
+ public void preparePlayback() {
+ player.prepare();
+ }
+
+ // PlayerControlView.VisibilityListener implementation
+
+ @Override
+ public void onVisibilityChange(int visibility) {
+ debugRootView.setVisibility(visibility);
+ }
+
+ // Internal methods
+
+ protected void setContentView() {
+ setContentView(R.layout.player_activity);
+ }
+
+ /** @return Whether initialization was successful. */
+ protected boolean initializePlayer() {
+ if (player == null) {
+ Intent intent = getIntent();
+
+ mediaItems = createMediaItems(intent);
+ if (mediaItems.isEmpty()) {
+ return false;
+ }
+
+ boolean preferExtensionDecoders =
+ intent.getBooleanExtra(IntentUtil.PREFER_EXTENSION_DECODERS_EXTRA, false);
+ RenderersFactory renderersFactory =
+ DemoUtil.buildRenderersFactory(/* context= */ this, preferExtensionDecoders);
+ MediaSourceFactory mediaSourceFactory =
+ new DefaultMediaSourceFactory(dataSourceFactory)
+ .setAdsLoaderProvider(this::getAdsLoader)
+ .setAdViewProvider(playerView);
+
+ trackSelector = new DefaultTrackSelector(/* context= */ this);
+ trackSelector.setParameters(trackSelectorParameters);
+ lastSeenTrackGroupArray = null;
+ player =
+ new SimpleExoPlayer.Builder(/* context= */ this, renderersFactory)
+ .setMediaSourceFactory(mediaSourceFactory)
+ .setTrackSelector(trackSelector)
+ .build();
+ player.addListener(new PlayerEventListener());
+ player.addAnalyticsListener(new EventLogger(trackSelector));
+ player.setAudioAttributes(AudioAttributes.DEFAULT, /* handleAudioFocus= */ true);
+ player.setPlayWhenReady(startAutoPlay);
+ playerView.setPlayer(player);
+ playerView.setPlaybackPreparer(this);
+ debugViewHelper = new DebugTextViewHelper(player, debugTextView);
+ debugViewHelper.start();
+ }
+ boolean haveStartPosition = startWindow != C.INDEX_UNSET;
+ if (haveStartPosition) {
+ player.seekTo(startWindow, startPosition);
+ }
+ player.setMediaItems(mediaItems, /* resetPosition= */ !haveStartPosition);
+ player.prepare();
+ updateButtonVisibility();
+ return true;
+ }
+
+ private List createMediaItems(Intent intent) {
+ String action = intent.getAction();
+ boolean actionIsListView = IntentUtil.ACTION_VIEW_LIST.equals(action);
+ if (!actionIsListView && !IntentUtil.ACTION_VIEW.equals(action)) {
+ showToast(getString(R.string.unexpected_intent_action, action));
+ finish();
+ return Collections.emptyList();
+ }
+
+ List mediaItems =
+ createMediaItems(intent, DemoUtil.getDownloadTracker(/* context= */ this));
+ boolean hasAds = false;
+ for (int i = 0; i < mediaItems.size(); i++) {
+ MediaItem mediaItem = mediaItems.get(i);
+
+ if (!Util.checkCleartextTrafficPermitted(mediaItem)) {
+ showToast(R.string.error_cleartext_not_permitted);
+ return Collections.emptyList();
+ }
+ if (Util.maybeRequestReadExternalStoragePermission(/* activity= */ this, mediaItem)) {
+ // The player will be reinitialized if the permission is granted.
+ return Collections.emptyList();
+ }
+
+ MediaItem.DrmConfiguration drmConfiguration =
+ checkNotNull(mediaItem.playbackProperties).drmConfiguration;
+ if (drmConfiguration != null) {
+ if (Util.SDK_INT < 18) {
+ showToast(R.string.error_drm_unsupported_before_api_18);
+ finish();
+ return Collections.emptyList();
+ } else if (!FrameworkMediaDrm.isCryptoSchemeSupported(drmConfiguration.uuid)) {
+ showToast(R.string.error_drm_unsupported_scheme);
+ finish();
+ return Collections.emptyList();
+ }
+ }
+ hasAds |= mediaItem.playbackProperties.adTagUri != null;
+ }
+ if (!hasAds) {
+ releaseAdsLoader();
+ }
+ return mediaItems;
+ }
+
+ private AdsLoader getAdsLoader(Uri adTagUri) {
+ if (mediaItems.size() > 1) {
+ showToast(R.string.unsupported_ads_in_playlist);
+ releaseAdsLoader();
+ return null;
+ }
+ if (!adTagUri.equals(loadedAdTagUri)) {
+ releaseAdsLoader();
+ loadedAdTagUri = adTagUri;
+ }
+ // The ads loader is reused for multiple playbacks, so that ad playback can resume.
+ if (adsLoader == null) {
+ adsLoader = new ImaAdsLoader(/* context= */ PlayerActivity.this, adTagUri);
+ }
+ adsLoader.setPlayer(player);
+ return adsLoader;
+ }
+
+ protected void releasePlayer() {
+ if (player != null) {
+ updateTrackSelectorParameters();
+ updateStartPosition();
+ debugViewHelper.stop();
+ debugViewHelper = null;
+ player.release();
+ player = null;
+ mediaItems = Collections.emptyList();
+ trackSelector = null;
+ }
+ if (adsLoader != null) {
+ adsLoader.setPlayer(null);
+ }
+ }
+
+ private void releaseAdsLoader() {
+ if (adsLoader != null) {
+ adsLoader.release();
+ adsLoader = null;
+ loadedAdTagUri = null;
+ playerView.getOverlayFrameLayout().removeAllViews();
+ }
+ }
+
+ private void updateTrackSelectorParameters() {
+ if (trackSelector != null) {
+ trackSelectorParameters = trackSelector.getParameters();
+ }
+ }
+
+ private void updateStartPosition() {
+ if (player != null) {
+ startAutoPlay = player.getPlayWhenReady();
+ startWindow = player.getCurrentWindowIndex();
+ startPosition = Math.max(0, player.getContentPosition());
+ }
+ }
+
+ protected void clearStartPosition() {
+ startAutoPlay = true;
+ startWindow = C.INDEX_UNSET;
+ startPosition = C.TIME_UNSET;
+ }
+
+ // User controls
+
+ private void updateButtonVisibility() {
+ selectTracksButton.setEnabled(
+ player != null && TrackSelectionDialog.willHaveContent(trackSelector));
+ }
+
+ private void showControls() {
+ debugRootView.setVisibility(View.VISIBLE);
+ }
+
+ private void showToast(int messageId) {
+ showToast(getString(messageId));
+ }
+
+ private void showToast(String message) {
+ Toast.makeText(getApplicationContext(), message, Toast.LENGTH_LONG).show();
+ }
+
+ private static boolean isBehindLiveWindow(ExoPlaybackException e) {
+ if (e.type != ExoPlaybackException.TYPE_SOURCE) {
+ return false;
+ }
+ Throwable cause = e.getSourceException();
+ while (cause != null) {
+ if (cause instanceof BehindLiveWindowException) {
+ return true;
+ }
+ cause = cause.getCause();
+ }
+ return false;
+ }
+
+ private class PlayerEventListener implements Player.EventListener {
+
+ @Override
+ public void onPlaybackStateChanged(@Player.State int playbackState) {
+ if (playbackState == Player.STATE_ENDED) {
+ showControls();
+ }
+ updateButtonVisibility();
+ }
+
+ @Override
+ public void onPlayerError(@NonNull ExoPlaybackException e) {
+ if (isBehindLiveWindow(e)) {
+ clearStartPosition();
+ initializePlayer();
+ } else {
+ updateButtonVisibility();
+ showControls();
+ }
+ }
+
+ @Override
+ @SuppressWarnings("ReferenceEquality")
+ public void onTracksChanged(
+ @NonNull TrackGroupArray trackGroups, @NonNull TrackSelectionArray trackSelections) {
+ updateButtonVisibility();
+ if (trackGroups != lastSeenTrackGroupArray) {
+ MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo();
+ if (mappedTrackInfo != null) {
+ if (mappedTrackInfo.getTypeSupport(C.TRACK_TYPE_VIDEO)
+ == MappedTrackInfo.RENDERER_SUPPORT_UNSUPPORTED_TRACKS) {
+ showToast(R.string.error_unsupported_video);
+ }
+ if (mappedTrackInfo.getTypeSupport(C.TRACK_TYPE_AUDIO)
+ == MappedTrackInfo.RENDERER_SUPPORT_UNSUPPORTED_TRACKS) {
+ showToast(R.string.error_unsupported_audio);
+ }
+ }
+ lastSeenTrackGroupArray = trackGroups;
+ }
+ }
+ }
+
+ private class PlayerErrorMessageProvider implements ErrorMessageProvider {
+
+ @Override
+ @NonNull
+ public Pair getErrorMessage(@NonNull ExoPlaybackException e) {
+ String errorString = getString(R.string.error_generic);
+ if (e.type == ExoPlaybackException.TYPE_RENDERER) {
+ Exception cause = e.getRendererException();
+ if (cause instanceof DecoderInitializationException) {
+ // Special case for decoder initialization failures.
+ DecoderInitializationException decoderInitializationException =
+ (DecoderInitializationException) cause;
+ if (decoderInitializationException.codecInfo == null) {
+ if (decoderInitializationException.getCause() instanceof DecoderQueryException) {
+ errorString = getString(R.string.error_querying_decoders);
+ } else if (decoderInitializationException.secureDecoderRequired) {
+ errorString =
+ getString(
+ R.string.error_no_secure_decoder, decoderInitializationException.mimeType);
+ } else {
+ errorString =
+ getString(R.string.error_no_decoder, decoderInitializationException.mimeType);
+ }
+ } else {
+ errorString =
+ getString(
+ R.string.error_instantiating_decoder,
+ decoderInitializationException.codecInfo.name);
+ }
+ }
+ }
+ return Pair.create(0, errorString);
+ }
+ }
+
+ private static List createMediaItems(Intent intent, DownloadTracker downloadTracker) {
+ List mediaItems = new ArrayList<>();
+ for (MediaItem item : IntentUtil.createMediaItemsFromIntent(intent)) {
+ @Nullable
+ DownloadRequest downloadRequest =
+ downloadTracker.getDownloadRequest(checkNotNull(item.playbackProperties).uri);
+ if (downloadRequest != null) {
+ MediaItem.Builder builder = item.buildUpon();
+ builder
+ .setMediaId(downloadRequest.id)
+ .setUri(downloadRequest.uri)
+ .setCustomCacheKey(downloadRequest.customCacheKey)
+ .setMimeType(downloadRequest.mimeType)
+ .setStreamKeys(downloadRequest.streamKeys)
+ .setDrmKeySetId(downloadRequest.keySetId);
+ mediaItems.add(builder.build());
+ } else {
+ mediaItems.add(item);
+ }
+ }
+ return mediaItems;
+ }
+}
diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java
new file mode 100644
index 0000000000..ea5b38ce8e
--- /dev/null
+++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java
@@ -0,0 +1,591 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.demo;
+
+import static com.google.android.exoplayer2.util.Assertions.checkArgument;
+import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
+import static com.google.android.exoplayer2.util.Assertions.checkState;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.content.res.AssetManager;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.util.JsonReader;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.BaseExpandableListAdapter;
+import android.widget.ExpandableListView;
+import android.widget.ExpandableListView.OnChildClickListener;
+import android.widget.ImageButton;
+import android.widget.TextView;
+import android.widget.Toast;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.MediaMetadata;
+import com.google.android.exoplayer2.ParserException;
+import com.google.android.exoplayer2.RenderersFactory;
+import com.google.android.exoplayer2.offline.DownloadService;
+import com.google.android.exoplayer2.upstream.DataSource;
+import com.google.android.exoplayer2.upstream.DataSourceInputStream;
+import com.google.android.exoplayer2.upstream.DataSpec;
+import com.google.android.exoplayer2.util.Log;
+import com.google.android.exoplayer2.util.Util;
+import com.google.common.collect.ImmutableList;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/** An activity for selecting from a list of media samples. */
+public class SampleChooserActivity extends AppCompatActivity
+ implements DownloadTracker.Listener, OnChildClickListener {
+
+ private static final String TAG = "SampleChooserActivity";
+ private static final String GROUP_POSITION_PREFERENCE_KEY = "sample_chooser_group_position";
+ private static final String CHILD_POSITION_PREFERENCE_KEY = "sample_chooser_child_position";
+
+ private String[] uris;
+ private boolean useExtensionRenderers;
+ private DownloadTracker downloadTracker;
+ private SampleAdapter sampleAdapter;
+ private MenuItem preferExtensionDecodersMenuItem;
+ private ExpandableListView sampleListView;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.sample_chooser_activity);
+ sampleAdapter = new SampleAdapter();
+ sampleListView = findViewById(R.id.sample_list);
+
+ sampleListView.setAdapter(sampleAdapter);
+ sampleListView.setOnChildClickListener(this);
+
+ Intent intent = getIntent();
+ String dataUri = intent.getDataString();
+ if (dataUri != null) {
+ uris = new String[] {dataUri};
+ } else {
+ ArrayList uriList = new ArrayList<>();
+ AssetManager assetManager = getAssets();
+ try {
+ for (String asset : assetManager.list("")) {
+ if (asset.endsWith(".exolist.json")) {
+ uriList.add("asset:///" + asset);
+ }
+ }
+ } catch (IOException e) {
+ Toast.makeText(getApplicationContext(), R.string.sample_list_load_error, Toast.LENGTH_LONG)
+ .show();
+ }
+ uris = new String[uriList.size()];
+ uriList.toArray(uris);
+ Arrays.sort(uris);
+ }
+
+ useExtensionRenderers = DemoUtil.useExtensionRenderers();
+ downloadTracker = DemoUtil.getDownloadTracker(/* context= */ this);
+ loadSample();
+
+ // Start the download service if it should be running but it's not currently.
+ // Starting the service in the foreground causes notification flicker if there is no scheduled
+ // action. Starting it in the background throws an exception if the app is in the background too
+ // (e.g. if device screen is locked).
+ try {
+ DownloadService.start(this, DemoDownloadService.class);
+ } catch (IllegalStateException e) {
+ DownloadService.startForeground(this, DemoDownloadService.class);
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.sample_chooser_menu, menu);
+ preferExtensionDecodersMenuItem = menu.findItem(R.id.prefer_extension_decoders);
+ preferExtensionDecodersMenuItem.setVisible(useExtensionRenderers);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ item.setChecked(!item.isChecked());
+ return true;
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ downloadTracker.addListener(this);
+ sampleAdapter.notifyDataSetChanged();
+ }
+
+ @Override
+ public void onStop() {
+ downloadTracker.removeListener(this);
+ super.onStop();
+ }
+
+ @Override
+ public void onDownloadsChanged() {
+ sampleAdapter.notifyDataSetChanged();
+ }
+
+ @Override
+ public void onRequestPermissionsResult(
+ int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (grantResults.length == 0) {
+ // Empty results are triggered if a permission is requested while another request was already
+ // pending and can be safely ignored in this case.
+ return;
+ }
+ if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ loadSample();
+ } else {
+ Toast.makeText(getApplicationContext(), R.string.sample_list_load_error, Toast.LENGTH_LONG)
+ .show();
+ finish();
+ }
+ }
+
+ private void loadSample() {
+ checkNotNull(uris);
+
+ for (int i = 0; i < uris.length; i++) {
+ Uri uri = Uri.parse(uris[i]);
+ if (Util.maybeRequestReadExternalStoragePermission(this, uri)) {
+ return;
+ }
+ }
+
+ SampleListLoader loaderTask = new SampleListLoader();
+ loaderTask.execute(uris);
+ }
+
+ private void onPlaylistGroups(final List groups, boolean sawError) {
+ if (sawError) {
+ Toast.makeText(getApplicationContext(), R.string.sample_list_load_error, Toast.LENGTH_LONG)
+ .show();
+ }
+ sampleAdapter.setPlaylistGroups(groups);
+
+ SharedPreferences preferences = getPreferences(MODE_PRIVATE);
+ int groupPosition = preferences.getInt(GROUP_POSITION_PREFERENCE_KEY, /* defValue= */ -1);
+ int childPosition = preferences.getInt(CHILD_POSITION_PREFERENCE_KEY, /* defValue= */ -1);
+ // Clear the group and child position if either are unset or if either are out of bounds.
+ if (groupPosition != -1
+ && childPosition != -1
+ && groupPosition < groups.size()
+ && childPosition < groups.get(groupPosition).playlists.size()) {
+ sampleListView.expandGroup(groupPosition); // shouldExpandGroup does not work without this.
+ sampleListView.setSelectedChild(groupPosition, childPosition, /* shouldExpandGroup= */ true);
+ }
+ }
+
+ @Override
+ public boolean onChildClick(
+ ExpandableListView parent, View view, int groupPosition, int childPosition, long id) {
+ // Save the selected item first to be able to restore it if the tested code crashes.
+ SharedPreferences.Editor prefEditor = getPreferences(MODE_PRIVATE).edit();
+ prefEditor.putInt(GROUP_POSITION_PREFERENCE_KEY, groupPosition);
+ prefEditor.putInt(CHILD_POSITION_PREFERENCE_KEY, childPosition);
+ prefEditor.apply();
+
+ PlaylistHolder playlistHolder = (PlaylistHolder) view.getTag();
+ Intent intent = new Intent(this, PlayerActivity.class);
+ intent.putExtra(
+ IntentUtil.PREFER_EXTENSION_DECODERS_EXTRA,
+ isNonNullAndChecked(preferExtensionDecodersMenuItem));
+ IntentUtil.addToIntent(playlistHolder.mediaItems, intent);
+ startActivity(intent);
+ return true;
+ }
+
+ private void onSampleDownloadButtonClicked(PlaylistHolder playlistHolder) {
+ int downloadUnsupportedStringId = getDownloadUnsupportedStringId(playlistHolder);
+ if (downloadUnsupportedStringId != 0) {
+ Toast.makeText(getApplicationContext(), downloadUnsupportedStringId, Toast.LENGTH_LONG)
+ .show();
+ } else {
+ RenderersFactory renderersFactory =
+ DemoUtil.buildRenderersFactory(
+ /* context= */ this, isNonNullAndChecked(preferExtensionDecodersMenuItem));
+ downloadTracker.toggleDownload(
+ getSupportFragmentManager(), playlistHolder.mediaItems.get(0), renderersFactory);
+ }
+ }
+
+ private int getDownloadUnsupportedStringId(PlaylistHolder playlistHolder) {
+ if (playlistHolder.mediaItems.size() > 1) {
+ return R.string.download_playlist_unsupported;
+ }
+ MediaItem.PlaybackProperties playbackProperties =
+ checkNotNull(playlistHolder.mediaItems.get(0).playbackProperties);
+ if (playbackProperties.adTagUri != null) {
+ return R.string.download_ads_unsupported;
+ }
+ String scheme = playbackProperties.uri.getScheme();
+ if (!("http".equals(scheme) || "https".equals(scheme))) {
+ return R.string.download_scheme_unsupported;
+ }
+ return 0;
+ }
+
+ private static boolean isNonNullAndChecked(@Nullable MenuItem menuItem) {
+ // Temporary workaround for layouts that do not inflate the options menu.
+ return menuItem != null && menuItem.isChecked();
+ }
+
+ private final class SampleListLoader extends AsyncTask> {
+
+ private boolean sawError;
+
+ @Override
+ protected List doInBackground(String... uris) {
+ List result = new ArrayList<>();
+ Context context = getApplicationContext();
+ DataSource dataSource = DemoUtil.getDataSourceFactory(context).createDataSource();
+ for (String uri : uris) {
+ DataSpec dataSpec = new DataSpec(Uri.parse(uri));
+ InputStream inputStream = new DataSourceInputStream(dataSource, dataSpec);
+ try {
+ readPlaylistGroups(new JsonReader(new InputStreamReader(inputStream, "UTF-8")), result);
+ } catch (Exception e) {
+ Log.e(TAG, "Error loading sample list: " + uri, e);
+ sawError = true;
+ } finally {
+ Util.closeQuietly(dataSource);
+ }
+ }
+ return result;
+ }
+
+ @Override
+ protected void onPostExecute(List result) {
+ onPlaylistGroups(result, sawError);
+ }
+
+ private void readPlaylistGroups(JsonReader reader, List groups)
+ throws IOException {
+ reader.beginArray();
+ while (reader.hasNext()) {
+ readPlaylistGroup(reader, groups);
+ }
+ reader.endArray();
+ }
+
+ private void readPlaylistGroup(JsonReader reader, List groups)
+ throws IOException {
+ String groupName = "";
+ ArrayList playlistHolders = new ArrayList<>();
+
+ reader.beginObject();
+ while (reader.hasNext()) {
+ String name = reader.nextName();
+ switch (name) {
+ case "name":
+ groupName = reader.nextString();
+ break;
+ case "samples":
+ reader.beginArray();
+ while (reader.hasNext()) {
+ playlistHolders.add(readEntry(reader, false));
+ }
+ reader.endArray();
+ break;
+ case "_comment":
+ reader.nextString(); // Ignore.
+ break;
+ default:
+ throw new ParserException("Unsupported name: " + name);
+ }
+ }
+ reader.endObject();
+
+ PlaylistGroup group = getGroup(groupName, groups);
+ group.playlists.addAll(playlistHolders);
+ }
+
+ private PlaylistHolder readEntry(JsonReader reader, boolean insidePlaylist) throws IOException {
+ Uri uri = null;
+ String extension = null;
+ String title = null;
+ ArrayList children = null;
+ Uri subtitleUri = null;
+ String subtitleMimeType = null;
+ String subtitleLanguage = null;
+
+ MediaItem.Builder mediaItem = new MediaItem.Builder();
+ reader.beginObject();
+ while (reader.hasNext()) {
+ String name = reader.nextName();
+ switch (name) {
+ case "name":
+ title = reader.nextString();
+ break;
+ case "uri":
+ uri = Uri.parse(reader.nextString());
+ break;
+ case "extension":
+ extension = reader.nextString();
+ break;
+ case "clip_start_position_ms":
+ mediaItem.setClipStartPositionMs(reader.nextLong());
+ break;
+ case "clip_end_position_ms":
+ mediaItem.setClipEndPositionMs(reader.nextLong());
+ break;
+ case "ad_tag_uri":
+ mediaItem.setAdTagUri(reader.nextString());
+ break;
+ case "drm_scheme":
+ mediaItem.setDrmUuid(Util.getDrmUuid(reader.nextString()));
+ break;
+ case "drm_license_uri":
+ case "drm_license_url": // For backward compatibility only.
+ mediaItem.setDrmLicenseUri(reader.nextString());
+ break;
+ case "drm_key_request_properties":
+ Map requestHeaders = new HashMap<>();
+ reader.beginObject();
+ while (reader.hasNext()) {
+ requestHeaders.put(reader.nextName(), reader.nextString());
+ }
+ reader.endObject();
+ mediaItem.setDrmLicenseRequestHeaders(requestHeaders);
+ break;
+ case "drm_session_for_clear_content":
+ if (reader.nextBoolean()) {
+ mediaItem.setDrmSessionForClearTypes(
+ ImmutableList.of(C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_AUDIO));
+ }
+ break;
+ case "drm_multi_session":
+ mediaItem.setDrmMultiSession(reader.nextBoolean());
+ break;
+ case "drm_force_default_license_uri":
+ mediaItem.setDrmForceDefaultLicenseUri(reader.nextBoolean());
+ break;
+ case "subtitle_uri":
+ subtitleUri = Uri.parse(reader.nextString());
+ break;
+ case "subtitle_mime_type":
+ subtitleMimeType = reader.nextString();
+ break;
+ case "subtitle_language":
+ subtitleLanguage = reader.nextString();
+ break;
+ case "playlist":
+ checkState(!insidePlaylist, "Invalid nesting of playlists");
+ children = new ArrayList<>();
+ reader.beginArray();
+ while (reader.hasNext()) {
+ children.add(readEntry(reader, /* insidePlaylist= */ true));
+ }
+ reader.endArray();
+ break;
+ default:
+ throw new ParserException("Unsupported attribute name: " + name);
+ }
+ }
+ reader.endObject();
+
+ if (children != null) {
+ List mediaItems = new ArrayList<>();
+ for (int i = 0; i < children.size(); i++) {
+ mediaItems.addAll(children.get(i).mediaItems);
+ }
+ return new PlaylistHolder(title, mediaItems);
+ } else {
+ @Nullable
+ String adaptiveMimeType =
+ Util.getAdaptiveMimeTypeForContentType(Util.inferContentType(uri, extension));
+ mediaItem
+ .setUri(uri)
+ .setMediaMetadata(new MediaMetadata.Builder().setTitle(title).build())
+ .setMimeType(adaptiveMimeType);
+ if (subtitleUri != null) {
+ MediaItem.Subtitle subtitle =
+ new MediaItem.Subtitle(
+ subtitleUri,
+ checkNotNull(
+ subtitleMimeType, "subtitle_mime_type is required if subtitle_uri is set."),
+ subtitleLanguage);
+ mediaItem.setSubtitles(Collections.singletonList(subtitle));
+ }
+ return new PlaylistHolder(title, Collections.singletonList(mediaItem.build()));
+ }
+ }
+
+ private PlaylistGroup getGroup(String groupName, List groups) {
+ for (int i = 0; i < groups.size(); i++) {
+ if (Util.areEqual(groupName, groups.get(i).title)) {
+ return groups.get(i);
+ }
+ }
+ PlaylistGroup group = new PlaylistGroup(groupName);
+ groups.add(group);
+ return group;
+ }
+ }
+
+ private final class SampleAdapter extends BaseExpandableListAdapter implements OnClickListener {
+
+ private List playlistGroups;
+
+ public SampleAdapter() {
+ playlistGroups = Collections.emptyList();
+ }
+
+ public void setPlaylistGroups(List playlistGroups) {
+ this.playlistGroups = playlistGroups;
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public PlaylistHolder getChild(int groupPosition, int childPosition) {
+ return getGroup(groupPosition).playlists.get(childPosition);
+ }
+
+ @Override
+ public long getChildId(int groupPosition, int childPosition) {
+ return childPosition;
+ }
+
+ @Override
+ public View getChildView(
+ int groupPosition,
+ int childPosition,
+ boolean isLastChild,
+ View convertView,
+ ViewGroup parent) {
+ View view = convertView;
+ if (view == null) {
+ view = getLayoutInflater().inflate(R.layout.sample_list_item, parent, false);
+ View downloadButton = view.findViewById(R.id.download_button);
+ downloadButton.setOnClickListener(this);
+ downloadButton.setFocusable(false);
+ }
+ initializeChildView(view, getChild(groupPosition, childPosition));
+ return view;
+ }
+
+ @Override
+ public int getChildrenCount(int groupPosition) {
+ return getGroup(groupPosition).playlists.size();
+ }
+
+ @Override
+ public PlaylistGroup getGroup(int groupPosition) {
+ return playlistGroups.get(groupPosition);
+ }
+
+ @Override
+ public long getGroupId(int groupPosition) {
+ return groupPosition;
+ }
+
+ @Override
+ public View getGroupView(
+ int groupPosition, boolean isExpanded, View convertView, ViewGroup parent) {
+ View view = convertView;
+ if (view == null) {
+ view =
+ getLayoutInflater()
+ .inflate(android.R.layout.simple_expandable_list_item_1, parent, false);
+ }
+ ((TextView) view).setText(getGroup(groupPosition).title);
+ return view;
+ }
+
+ @Override
+ public int getGroupCount() {
+ return playlistGroups.size();
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return false;
+ }
+
+ @Override
+ public boolean isChildSelectable(int groupPosition, int childPosition) {
+ return true;
+ }
+
+ @Override
+ public void onClick(View view) {
+ onSampleDownloadButtonClicked((PlaylistHolder) view.getTag());
+ }
+
+ private void initializeChildView(View view, PlaylistHolder playlistHolder) {
+ view.setTag(playlistHolder);
+ TextView sampleTitle = view.findViewById(R.id.sample_title);
+ sampleTitle.setText(playlistHolder.title);
+
+ boolean canDownload = getDownloadUnsupportedStringId(playlistHolder) == 0;
+ boolean isDownloaded =
+ canDownload && downloadTracker.isDownloaded(playlistHolder.mediaItems.get(0));
+ ImageButton downloadButton = view.findViewById(R.id.download_button);
+ downloadButton.setTag(playlistHolder);
+ downloadButton.setColorFilter(
+ canDownload ? (isDownloaded ? 0xFF42A5F5 : 0xFFBDBDBD) : 0xFF666666);
+ downloadButton.setImageResource(
+ isDownloaded ? R.drawable.ic_download_done : R.drawable.ic_download);
+ }
+ }
+
+ private static final class PlaylistHolder {
+
+ public final String title;
+ public final List mediaItems;
+
+ private PlaylistHolder(String title, List mediaItems) {
+ checkArgument(!mediaItems.isEmpty());
+ this.title = title;
+ this.mediaItems = Collections.unmodifiableList(new ArrayList<>(mediaItems));
+ }
+ }
+
+ private static final class PlaylistGroup {
+
+ public final String title;
+ public final List playlists;
+
+ public PlaylistGroup(String title) {
+ this.title = title;
+ this.playlists = new ArrayList<>();
+ }
+ }
+}
diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionDialog.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionDialog.java
new file mode 100644
index 0000000000..5cf2353f21
--- /dev/null
+++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionDialog.java
@@ -0,0 +1,368 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.demo;
+
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.util.SparseArray;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatDialog;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentPagerAdapter;
+import androidx.viewpager.widget.ViewPager;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.source.TrackGroupArray;
+import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
+import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.SelectionOverride;
+import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo;
+import com.google.android.exoplayer2.ui.TrackSelectionView;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.material.tabs.TabLayout;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** Dialog to select tracks. */
+public final class TrackSelectionDialog extends DialogFragment {
+
+ private final SparseArray tabFragments;
+ private final ArrayList tabTrackTypes;
+
+ private int titleId;
+ private DialogInterface.OnClickListener onClickListener;
+ private DialogInterface.OnDismissListener onDismissListener;
+
+ /**
+ * Returns whether a track selection dialog will have content to display if initialized with the
+ * specified {@link DefaultTrackSelector} in its current state.
+ */
+ public static boolean willHaveContent(DefaultTrackSelector trackSelector) {
+ MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo();
+ return mappedTrackInfo != null && willHaveContent(mappedTrackInfo);
+ }
+
+ /**
+ * Returns whether a track selection dialog will have content to display if initialized with the
+ * specified {@link MappedTrackInfo}.
+ */
+ public static boolean willHaveContent(MappedTrackInfo mappedTrackInfo) {
+ for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) {
+ if (showTabForRenderer(mappedTrackInfo, i)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Creates a dialog for a given {@link DefaultTrackSelector}, whose parameters will be
+ * automatically updated when tracks are selected.
+ *
+ * @param trackSelector The {@link DefaultTrackSelector}.
+ * @param onDismissListener A {@link DialogInterface.OnDismissListener} to call when the dialog is
+ * dismissed.
+ */
+ public static TrackSelectionDialog createForTrackSelector(
+ DefaultTrackSelector trackSelector, DialogInterface.OnDismissListener onDismissListener) {
+ MappedTrackInfo mappedTrackInfo =
+ Assertions.checkNotNull(trackSelector.getCurrentMappedTrackInfo());
+ TrackSelectionDialog trackSelectionDialog = new TrackSelectionDialog();
+ DefaultTrackSelector.Parameters parameters = trackSelector.getParameters();
+ trackSelectionDialog.init(
+ /* titleId= */ R.string.track_selection_title,
+ mappedTrackInfo,
+ /* initialParameters = */ parameters,
+ /* allowAdaptiveSelections =*/ true,
+ /* allowMultipleOverrides= */ false,
+ /* onClickListener= */ (dialog, which) -> {
+ DefaultTrackSelector.ParametersBuilder builder = parameters.buildUpon();
+ for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) {
+ builder
+ .clearSelectionOverrides(/* rendererIndex= */ i)
+ .setRendererDisabled(
+ /* rendererIndex= */ i,
+ trackSelectionDialog.getIsDisabled(/* rendererIndex= */ i));
+ List overrides =
+ trackSelectionDialog.getOverrides(/* rendererIndex= */ i);
+ if (!overrides.isEmpty()) {
+ builder.setSelectionOverride(
+ /* rendererIndex= */ i,
+ mappedTrackInfo.getTrackGroups(/* rendererIndex= */ i),
+ overrides.get(0));
+ }
+ }
+ trackSelector.setParameters(builder);
+ },
+ onDismissListener);
+ return trackSelectionDialog;
+ }
+
+ /**
+ * Creates a dialog for given {@link MappedTrackInfo} and {@link DefaultTrackSelector.Parameters}.
+ *
+ * @param titleId The resource id of the dialog title.
+ * @param mappedTrackInfo The {@link MappedTrackInfo} to display.
+ * @param initialParameters The {@link DefaultTrackSelector.Parameters} describing the initial
+ * track selection.
+ * @param allowAdaptiveSelections Whether adaptive selections (consisting of more than one track)
+ * can be made.
+ * @param allowMultipleOverrides Whether tracks from multiple track groups can be selected.
+ * @param onClickListener {@link DialogInterface.OnClickListener} called when tracks are selected.
+ * @param onDismissListener {@link DialogInterface.OnDismissListener} called when the dialog is
+ * dismissed.
+ */
+ public static TrackSelectionDialog createForMappedTrackInfoAndParameters(
+ int titleId,
+ MappedTrackInfo mappedTrackInfo,
+ DefaultTrackSelector.Parameters initialParameters,
+ boolean allowAdaptiveSelections,
+ boolean allowMultipleOverrides,
+ DialogInterface.OnClickListener onClickListener,
+ DialogInterface.OnDismissListener onDismissListener) {
+ TrackSelectionDialog trackSelectionDialog = new TrackSelectionDialog();
+ trackSelectionDialog.init(
+ titleId,
+ mappedTrackInfo,
+ initialParameters,
+ allowAdaptiveSelections,
+ allowMultipleOverrides,
+ onClickListener,
+ onDismissListener);
+ return trackSelectionDialog;
+ }
+
+ public TrackSelectionDialog() {
+ tabFragments = new SparseArray<>();
+ tabTrackTypes = new ArrayList<>();
+ // Retain instance across activity re-creation to prevent losing access to init data.
+ setRetainInstance(true);
+ }
+
+ private void init(
+ int titleId,
+ MappedTrackInfo mappedTrackInfo,
+ DefaultTrackSelector.Parameters initialParameters,
+ boolean allowAdaptiveSelections,
+ boolean allowMultipleOverrides,
+ DialogInterface.OnClickListener onClickListener,
+ DialogInterface.OnDismissListener onDismissListener) {
+ this.titleId = titleId;
+ this.onClickListener = onClickListener;
+ this.onDismissListener = onDismissListener;
+ for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) {
+ if (showTabForRenderer(mappedTrackInfo, i)) {
+ int trackType = mappedTrackInfo.getRendererType(/* rendererIndex= */ i);
+ TrackGroupArray trackGroupArray = mappedTrackInfo.getTrackGroups(i);
+ TrackSelectionViewFragment tabFragment = new TrackSelectionViewFragment();
+ tabFragment.init(
+ mappedTrackInfo,
+ /* rendererIndex= */ i,
+ initialParameters.getRendererDisabled(/* rendererIndex= */ i),
+ initialParameters.getSelectionOverride(/* rendererIndex= */ i, trackGroupArray),
+ allowAdaptiveSelections,
+ allowMultipleOverrides);
+ tabFragments.put(i, tabFragment);
+ tabTrackTypes.add(trackType);
+ }
+ }
+ }
+
+ /**
+ * Returns whether a renderer is disabled.
+ *
+ * @param rendererIndex Renderer index.
+ * @return Whether the renderer is disabled.
+ */
+ public boolean getIsDisabled(int rendererIndex) {
+ TrackSelectionViewFragment rendererView = tabFragments.get(rendererIndex);
+ return rendererView != null && rendererView.isDisabled;
+ }
+
+ /**
+ * Returns the list of selected track selection overrides for the specified renderer. There will
+ * be at most one override for each track group.
+ *
+ * @param rendererIndex Renderer index.
+ * @return The list of track selection overrides for this renderer.
+ */
+ public List getOverrides(int rendererIndex) {
+ TrackSelectionViewFragment rendererView = tabFragments.get(rendererIndex);
+ return rendererView == null ? Collections.emptyList() : rendererView.overrides;
+ }
+
+ @Override
+ @NonNull
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ // We need to own the view to let tab layout work correctly on all API levels. We can't use
+ // AlertDialog because it owns the view itself, so we use AppCompatDialog instead, themed using
+ // the AlertDialog theme overlay with force-enabled title.
+ AppCompatDialog dialog =
+ new AppCompatDialog(getActivity(), R.style.TrackSelectionDialogThemeOverlay);
+ dialog.setTitle(titleId);
+ return dialog;
+ }
+
+ @Override
+ public void onDismiss(@NonNull DialogInterface dialog) {
+ super.onDismiss(dialog);
+ onDismissListener.onDismiss(dialog);
+ }
+
+ @Override
+ public View onCreateView(
+ LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ View dialogView = inflater.inflate(R.layout.track_selection_dialog, container, false);
+ TabLayout tabLayout = dialogView.findViewById(R.id.track_selection_dialog_tab_layout);
+ ViewPager viewPager = dialogView.findViewById(R.id.track_selection_dialog_view_pager);
+ Button cancelButton = dialogView.findViewById(R.id.track_selection_dialog_cancel_button);
+ Button okButton = dialogView.findViewById(R.id.track_selection_dialog_ok_button);
+ viewPager.setAdapter(new FragmentAdapter(getChildFragmentManager()));
+ tabLayout.setupWithViewPager(viewPager);
+ tabLayout.setVisibility(tabFragments.size() > 1 ? View.VISIBLE : View.GONE);
+ cancelButton.setOnClickListener(view -> dismiss());
+ okButton.setOnClickListener(
+ view -> {
+ onClickListener.onClick(getDialog(), DialogInterface.BUTTON_POSITIVE);
+ dismiss();
+ });
+ return dialogView;
+ }
+
+ private static boolean showTabForRenderer(MappedTrackInfo mappedTrackInfo, int rendererIndex) {
+ TrackGroupArray trackGroupArray = mappedTrackInfo.getTrackGroups(rendererIndex);
+ if (trackGroupArray.length == 0) {
+ return false;
+ }
+ int trackType = mappedTrackInfo.getRendererType(rendererIndex);
+ return isSupportedTrackType(trackType);
+ }
+
+ private static boolean isSupportedTrackType(int trackType) {
+ switch (trackType) {
+ case C.TRACK_TYPE_VIDEO:
+ case C.TRACK_TYPE_AUDIO:
+ case C.TRACK_TYPE_TEXT:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private static String getTrackTypeString(Resources resources, int trackType) {
+ switch (trackType) {
+ case C.TRACK_TYPE_VIDEO:
+ return resources.getString(R.string.exo_track_selection_title_video);
+ case C.TRACK_TYPE_AUDIO:
+ return resources.getString(R.string.exo_track_selection_title_audio);
+ case C.TRACK_TYPE_TEXT:
+ return resources.getString(R.string.exo_track_selection_title_text);
+ default:
+ throw new IllegalArgumentException();
+ }
+ }
+
+ private final class FragmentAdapter extends FragmentPagerAdapter {
+
+ public FragmentAdapter(FragmentManager fragmentManager) {
+ super(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
+ }
+
+ @Override
+ @NonNull
+ public Fragment getItem(int position) {
+ return tabFragments.valueAt(position);
+ }
+
+ @Override
+ public int getCount() {
+ return tabFragments.size();
+ }
+
+ @Override
+ public CharSequence getPageTitle(int position) {
+ return getTrackTypeString(getResources(), tabTrackTypes.get(position));
+ }
+ }
+
+ /** Fragment to show a track selection in tab of the track selection dialog. */
+ public static final class TrackSelectionViewFragment extends Fragment
+ implements TrackSelectionView.TrackSelectionListener {
+
+ private MappedTrackInfo mappedTrackInfo;
+ private int rendererIndex;
+ private boolean allowAdaptiveSelections;
+ private boolean allowMultipleOverrides;
+
+ /* package */ boolean isDisabled;
+ /* package */ List overrides;
+
+ public TrackSelectionViewFragment() {
+ // Retain instance across activity re-creation to prevent losing access to init data.
+ setRetainInstance(true);
+ }
+
+ public void init(
+ MappedTrackInfo mappedTrackInfo,
+ int rendererIndex,
+ boolean initialIsDisabled,
+ @Nullable SelectionOverride initialOverride,
+ boolean allowAdaptiveSelections,
+ boolean allowMultipleOverrides) {
+ this.mappedTrackInfo = mappedTrackInfo;
+ this.rendererIndex = rendererIndex;
+ this.isDisabled = initialIsDisabled;
+ this.overrides =
+ initialOverride == null
+ ? Collections.emptyList()
+ : Collections.singletonList(initialOverride);
+ this.allowAdaptiveSelections = allowAdaptiveSelections;
+ this.allowMultipleOverrides = allowMultipleOverrides;
+ }
+
+ @Override
+ public View onCreateView(
+ LayoutInflater inflater,
+ @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ View rootView =
+ inflater.inflate(
+ R.layout.exo_track_selection_dialog, container, /* attachToRoot= */ false);
+ TrackSelectionView trackSelectionView = rootView.findViewById(R.id.exo_track_selection_view);
+ trackSelectionView.setShowDisableOption(true);
+ trackSelectionView.setAllowMultipleOverrides(allowMultipleOverrides);
+ trackSelectionView.setAllowAdaptiveSelections(allowAdaptiveSelections);
+ trackSelectionView.init(
+ mappedTrackInfo, rendererIndex, isDisabled, overrides, /* listener= */ this);
+ return rootView;
+ }
+
+ @Override
+ public void onTrackSelectionChanged(
+ boolean isDisabled, @NonNull List overrides) {
+ this.isDisabled = isDisabled;
+ this.overrides = overrides;
+ }
+ }
+}
diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/package-info.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/package-info.java
new file mode 100644
index 0000000000..cc22be27e0
--- /dev/null
+++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2020 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.
+ */
+@NonNullApi
+package com.google.android.exoplayer2.demo;
+
+import com.google.android.exoplayer2.util.NonNullApi;
diff --git a/demos/main/src/main/res/drawable-hdpi/ic_download.png b/demos/main/src/main/res/drawable-hdpi/ic_download.png
new file mode 100644
index 0000000000..fa3ebbb310
Binary files /dev/null and b/demos/main/src/main/res/drawable-hdpi/ic_download.png differ
diff --git a/demos/main/src/main/res/drawable-hdpi/ic_download_done.png b/demos/main/src/main/res/drawable-hdpi/ic_download_done.png
new file mode 100644
index 0000000000..fa0ec9dd68
Binary files /dev/null and b/demos/main/src/main/res/drawable-hdpi/ic_download_done.png differ
diff --git a/demos/main/src/main/res/drawable-mdpi/ic_download.png b/demos/main/src/main/res/drawable-mdpi/ic_download.png
new file mode 100644
index 0000000000..c8a2039c58
Binary files /dev/null and b/demos/main/src/main/res/drawable-mdpi/ic_download.png differ
diff --git a/demos/main/src/main/res/drawable-mdpi/ic_download_done.png b/demos/main/src/main/res/drawable-mdpi/ic_download_done.png
new file mode 100644
index 0000000000..08073a2a6d
Binary files /dev/null and b/demos/main/src/main/res/drawable-mdpi/ic_download_done.png differ
diff --git a/demos/main/src/main/res/drawable-xhdpi/ic_banner.png b/demos/main/src/main/res/drawable-xhdpi/ic_banner.png
new file mode 100644
index 0000000000..09de177387
Binary files /dev/null and b/demos/main/src/main/res/drawable-xhdpi/ic_banner.png differ
diff --git a/demos/main/src/main/res/drawable-xhdpi/ic_download.png b/demos/main/src/main/res/drawable-xhdpi/ic_download.png
new file mode 100644
index 0000000000..671e0b3ece
Binary files /dev/null and b/demos/main/src/main/res/drawable-xhdpi/ic_download.png differ
diff --git a/demos/main/src/main/res/drawable-xhdpi/ic_download_done.png b/demos/main/src/main/res/drawable-xhdpi/ic_download_done.png
new file mode 100644
index 0000000000..2339c0bf16
Binary files /dev/null and b/demos/main/src/main/res/drawable-xhdpi/ic_download_done.png differ
diff --git a/demos/main/src/main/res/drawable-xxhdpi/ic_download.png b/demos/main/src/main/res/drawable-xxhdpi/ic_download.png
new file mode 100644
index 0000000000..4e04a30198
Binary files /dev/null and b/demos/main/src/main/res/drawable-xxhdpi/ic_download.png differ
diff --git a/demos/main/src/main/res/drawable-xxhdpi/ic_download_done.png b/demos/main/src/main/res/drawable-xxhdpi/ic_download_done.png
new file mode 100644
index 0000000000..b631a00088
Binary files /dev/null and b/demos/main/src/main/res/drawable-xxhdpi/ic_download_done.png differ
diff --git a/demos/main/src/main/res/drawable-xxxhdpi/ic_download.png b/demos/main/src/main/res/drawable-xxxhdpi/ic_download.png
new file mode 100644
index 0000000000..f9bfb5edba
Binary files /dev/null and b/demos/main/src/main/res/drawable-xxxhdpi/ic_download.png differ
diff --git a/demos/main/src/main/res/drawable-xxxhdpi/ic_download_done.png b/demos/main/src/main/res/drawable-xxxhdpi/ic_download_done.png
new file mode 100644
index 0000000000..52fe8f6990
Binary files /dev/null and b/demos/main/src/main/res/drawable-xxxhdpi/ic_download_done.png differ
diff --git a/demo/src/main/res/layout/player_activity.xml b/demos/main/src/main/res/layout/player_activity.xml
similarity index 81%
rename from demo/src/main/res/layout/player_activity.xml
rename to demos/main/src/main/res/layout/player_activity.xml
index 3f8cdaa7d6..5b897fa7ea 100644
--- a/demo/src/main/res/layout/player_activity.xml
+++ b/demos/main/src/main/res/layout/player_activity.xml
@@ -15,14 +15,17 @@
-->
-
+ android:layout_height="match_parent"
+ app:show_shuffle_button="true"
+ app:show_subtitle_button="true"/>
-
+ android:text="@string/track_selection_title"
+ android:enabled="false"/>
diff --git a/demo/src/main/res/layout/sample_chooser_activity.xml b/demos/main/src/main/res/layout/sample_chooser_activity.xml
similarity index 100%
rename from demo/src/main/res/layout/sample_chooser_activity.xml
rename to demos/main/src/main/res/layout/sample_chooser_activity.xml
diff --git a/demos/main/src/main/res/layout/sample_list_item.xml b/demos/main/src/main/res/layout/sample_list_item.xml
new file mode 100644
index 0000000000..cdb0058688
--- /dev/null
+++ b/demos/main/src/main/res/layout/sample_list_item.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
diff --git a/demos/main/src/main/res/layout/track_selection_dialog.xml b/demos/main/src/main/res/layout/track_selection_dialog.xml
new file mode 100644
index 0000000000..7f6c45e131
--- /dev/null
+++ b/demos/main/src/main/res/layout/track_selection_dialog.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/main/src/main/res/menu/sample_chooser_menu.xml b/demos/main/src/main/res/menu/sample_chooser_menu.xml
new file mode 100644
index 0000000000..259b2f0f38
--- /dev/null
+++ b/demos/main/src/main/res/menu/sample_chooser_menu.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
diff --git a/demos/main/src/main/res/mipmap-hdpi/ic_launcher.png b/demos/main/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000000..adaa93220e
Binary files /dev/null and b/demos/main/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/demos/main/src/main/res/mipmap-mdpi/ic_launcher.png b/demos/main/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000000..9b6f7d5e80
Binary files /dev/null and b/demos/main/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/demos/main/src/main/res/mipmap-xhdpi/ic_launcher.png b/demos/main/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000000..2101026c9f
Binary files /dev/null and b/demos/main/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/demos/main/src/main/res/mipmap-xxhdpi/ic_launcher.png b/demos/main/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..223ec8bd11
Binary files /dev/null and b/demos/main/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/demos/main/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/demos/main/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..698ed68c42
Binary files /dev/null and b/demos/main/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/demo/src/main/res/values/strings.xml b/demos/main/src/main/res/values/strings.xml
similarity index 63%
rename from demo/src/main/res/values/strings.xml
rename to demos/main/src/main/res/values/strings.xml
index 4eb2b89324..bd5cd63467 100644
--- a/demo/src/main/res/values/strings.xml
+++ b/demos/main/src/main/res/values/strings.xml
@@ -13,33 +13,22 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
-
ExoPlayer
- Video
-
- Audio
-
- Text
-
- Retry
-
- Disabled
-
- Default
+ Select tracks
Unexpected intent action: %1$s
- Enable random adaptation
+ Cleartext traffic not permitted
- Protected content not supported on API levels below 18
+ Playback failed
+
+ DRM content not supported on API levels below 18
This device does not support the required DRM scheme
- An unknown DRM error occurred
-
This device does not provide a decoder for %1$s
This device does not provide a secure decoder for %1$s
@@ -56,4 +45,20 @@
One or more sample lists failed to load
+ Playing without ads, as ads are not supported in playlists
+
+ Failed to start download
+
+ Failed to obtain offline license
+
+ This demo app does not support downloading playlists
+
+ This demo app only supports downloading http streams
+
+ This demo app does not support downloading live content
+
+ IMA does not support offline ads
+
+ Prefer extension decoders
+
diff --git a/demo/src/main/res/values/styles.xml b/demos/main/src/main/res/values/styles.xml
similarity index 79%
rename from demo/src/main/res/values/styles.xml
rename to demos/main/src/main/res/values/styles.xml
index 751a224210..3a8740d80a 100644
--- a/demo/src/main/res/values/styles.xml
+++ b/demos/main/src/main/res/values/styles.xml
@@ -13,11 +13,13 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
-
-
+
+
diff --git a/demos/surface/README.md b/demos/surface/README.md
new file mode 100644
index 0000000000..3febb23feb
--- /dev/null
+++ b/demos/surface/README.md
@@ -0,0 +1,24 @@
+# ExoPlayer SurfaceControl demo
+
+This app demonstrates how to use the [SurfaceControl][] API to redirect video
+output from ExoPlayer between different views or off-screen. `SurfaceControl`
+is new in Android 10, so the app requires `minSdkVersion` 29.
+
+The app layout has a grid of `SurfaceViews`. Initially video is output to one
+of the views. Tap a `SurfaceView` to move video output to it. You can also tap
+the buttons at the top of the activity to move video output off-screen, to a
+full-screen `SurfaceView` or to a new activity.
+
+When using `SurfaceControl`, the `MediaCodec` always has the same surface
+attached to it, which can be freely 'reparented' to any `SurfaceView` (or
+off-screen) without any interruptions to playback. This works better than
+calling `MediaCodec.setOutputSurface` to change the output surface of the codec
+because `MediaCodec` does not re-render its last frame when that method is
+called, and because you can move output off-screen easily (`setOutputSurface`
+can't take a `null` surface, so the player has to use a `DummySurface`, which
+doesn't handle protected output on all devices).
+
+Please see the [demos README](../README.md) for instructions on how to build and
+run this demo.
+
+[SurfaceControl]: https://developer.android.com/reference/android/view/SurfaceControl
diff --git a/demo/build.gradle b/demos/surface/build.gradle
similarity index 55%
rename from demo/build.gradle
rename to demos/surface/build.gradle
index be5e52a25c..bff05901b5 100644
--- a/demo/build.gradle
+++ b/demos/surface/build.gradle
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2019 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -11,15 +11,22 @@
// 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.
+apply from: '../../constants.gradle'
apply plugin: 'com.android.application'
android {
compileSdkVersion project.ext.compileSdkVersion
- buildToolsVersion project.ext.buildToolsVersion
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
defaultConfig {
- minSdkVersion 16
- targetSdkVersion project.ext.targetSdkVersion
+ versionName project.ext.releaseVersion
+ versionCode project.ext.releaseVersionCode
+ minSdkVersion 29
+ targetSdkVersion project.ext.appTargetSdkVersion
}
buildTypes {
@@ -28,30 +35,17 @@ android {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt')
}
- debug {
- jniDebuggable = true
- }
}
lintOptions {
- // The demo app does not have translations.
+ // This demo app does not have translations.
disable 'MissingTranslation'
}
-
- productFlavors {
- noExtensions
- withExtensions
- }
}
dependencies {
- compile project(':library-core')
- compile project(':library-dash')
- compile project(':library-hls')
- compile project(':library-smoothstreaming')
- compile project(':library-ui')
- withExtensionsCompile project(path: ':extension-ffmpeg')
- withExtensionsCompile project(path: ':extension-flac')
- withExtensionsCompile project(path: ':extension-opus')
- withExtensionsCompile project(path: ':extension-vp9')
+ implementation project(modulePrefix + 'library-core')
+ implementation project(modulePrefix + 'library-ui')
+ implementation project(modulePrefix + 'library-dash')
+ implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
}
diff --git a/demos/surface/src/main/AndroidManifest.xml b/demos/surface/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..c33a9e646b
--- /dev/null
+++ b/demos/surface/src/main/AndroidManifest.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/MainActivity.java b/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/MainActivity.java
new file mode 100644
index 0000000000..eb669ecf94
--- /dev/null
+++ b/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/MainActivity.java
@@ -0,0 +1,280 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.surfacedemo;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.Surface;
+import android.view.SurfaceControl;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import android.view.View;
+import android.widget.Button;
+import android.widget.GridLayout;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.SimpleExoPlayer;
+import com.google.android.exoplayer2.drm.DefaultDrmSessionManager;
+import com.google.android.exoplayer2.drm.DrmSessionManager;
+import com.google.android.exoplayer2.drm.FrameworkMediaDrm;
+import com.google.android.exoplayer2.drm.HttpMediaDrmCallback;
+import com.google.android.exoplayer2.source.MediaSource;
+import com.google.android.exoplayer2.source.ProgressiveMediaSource;
+import com.google.android.exoplayer2.source.dash.DashMediaSource;
+import com.google.android.exoplayer2.ui.PlayerControlView;
+import com.google.android.exoplayer2.upstream.DataSource;
+import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
+import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory;
+import com.google.android.exoplayer2.upstream.HttpDataSource;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.Util;
+import java.util.UUID;
+
+/** Activity that demonstrates use of {@link SurfaceControl} with ExoPlayer. */
+public final class MainActivity extends Activity {
+
+ private static final String DEFAULT_MEDIA_URI =
+ "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv";
+ private static final String SURFACE_CONTROL_NAME = "surfacedemo";
+
+ private static final String ACTION_VIEW = "com.google.android.exoplayer.surfacedemo.action.VIEW";
+ private static final String EXTENSION_EXTRA = "extension";
+ private static final String DRM_SCHEME_EXTRA = "drm_scheme";
+ private static final String DRM_LICENSE_URL_EXTRA = "drm_license_url";
+ private static final String OWNER_EXTRA = "owner";
+
+ private boolean isOwner;
+ @Nullable private PlayerControlView playerControlView;
+ @Nullable private SurfaceView fullScreenView;
+ @Nullable private SurfaceView nonFullScreenView;
+ @Nullable private SurfaceView currentOutputView;
+
+ @Nullable private static SimpleExoPlayer player;
+ @Nullable private static SurfaceControl surfaceControl;
+ @Nullable private static Surface videoSurface;
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.main_activity);
+ playerControlView = findViewById(R.id.player_control_view);
+ fullScreenView = findViewById(R.id.full_screen_view);
+ fullScreenView.setOnClickListener(
+ v -> {
+ setCurrentOutputView(nonFullScreenView);
+ Assertions.checkNotNull(fullScreenView).setVisibility(View.GONE);
+ });
+ attachSurfaceListener(fullScreenView);
+ isOwner = getIntent().getBooleanExtra(OWNER_EXTRA, /* defaultValue= */ true);
+ GridLayout gridLayout = findViewById(R.id.grid_layout);
+ for (int i = 0; i < 9; i++) {
+ View view;
+ if (i == 0) {
+ Button button = new Button(/* context= */ this);
+ view = button;
+ button.setText(getString(R.string.no_output_label));
+ button.setOnClickListener(v -> reparent(/* surfaceView= */ null));
+ } else if (i == 1) {
+ Button button = new Button(/* context= */ this);
+ view = button;
+ button.setText(getString(R.string.full_screen_label));
+ button.setOnClickListener(
+ v -> {
+ setCurrentOutputView(fullScreenView);
+ Assertions.checkNotNull(fullScreenView).setVisibility(View.VISIBLE);
+ });
+ } else if (i == 2) {
+ Button button = new Button(/* context= */ this);
+ view = button;
+ button.setText(getString(R.string.new_activity_label));
+ button.setOnClickListener(
+ v ->
+ startActivity(
+ new Intent(MainActivity.this, MainActivity.class)
+ .putExtra(OWNER_EXTRA, /* value= */ false)));
+ } else {
+ SurfaceView surfaceView = new SurfaceView(this);
+ view = surfaceView;
+ attachSurfaceListener(surfaceView);
+ surfaceView.setOnClickListener(
+ v -> {
+ setCurrentOutputView(surfaceView);
+ nonFullScreenView = surfaceView;
+ });
+ if (nonFullScreenView == null) {
+ nonFullScreenView = surfaceView;
+ }
+ }
+ gridLayout.addView(view);
+ GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams();
+ layoutParams.width = 0;
+ layoutParams.height = 0;
+ layoutParams.columnSpec = GridLayout.spec(i % 3, 1f);
+ layoutParams.rowSpec = GridLayout.spec(i / 3, 1f);
+ layoutParams.bottomMargin = 10;
+ layoutParams.leftMargin = 10;
+ layoutParams.topMargin = 10;
+ layoutParams.rightMargin = 10;
+ view.setLayoutParams(layoutParams);
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ if (isOwner && player == null) {
+ initializePlayer();
+ }
+
+ setCurrentOutputView(nonFullScreenView);
+
+ PlayerControlView playerControlView = Assertions.checkNotNull(this.playerControlView);
+ playerControlView.setPlayer(player);
+ playerControlView.show();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+
+ Assertions.checkNotNull(playerControlView).setPlayer(null);
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ if (isOwner && isFinishing()) {
+ if (surfaceControl != null) {
+ surfaceControl.release();
+ surfaceControl = null;
+ }
+ if (videoSurface != null) {
+ videoSurface.release();
+ videoSurface = null;
+ }
+ if (player != null) {
+ player.release();
+ player = null;
+ }
+ }
+ }
+
+ private void initializePlayer() {
+ Intent intent = getIntent();
+ String action = intent.getAction();
+ Uri uri =
+ ACTION_VIEW.equals(action)
+ ? Assertions.checkNotNull(intent.getData())
+ : Uri.parse(DEFAULT_MEDIA_URI);
+ DrmSessionManager drmSessionManager;
+ if (intent.hasExtra(DRM_SCHEME_EXTRA)) {
+ String drmScheme = Assertions.checkNotNull(intent.getStringExtra(DRM_SCHEME_EXTRA));
+ String drmLicenseUrl = Assertions.checkNotNull(intent.getStringExtra(DRM_LICENSE_URL_EXTRA));
+ UUID drmSchemeUuid = Assertions.checkNotNull(Util.getDrmUuid(drmScheme));
+ HttpDataSource.Factory licenseDataSourceFactory = new DefaultHttpDataSourceFactory();
+ HttpMediaDrmCallback drmCallback =
+ new HttpMediaDrmCallback(drmLicenseUrl, licenseDataSourceFactory);
+ drmSessionManager =
+ new DefaultDrmSessionManager.Builder()
+ .setUuidAndExoMediaDrmProvider(drmSchemeUuid, FrameworkMediaDrm.DEFAULT_PROVIDER)
+ .build(drmCallback);
+ } else {
+ drmSessionManager = DrmSessionManager.getDummyDrmSessionManager();
+ }
+
+ DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(this);
+ MediaSource mediaSource;
+ @C.ContentType int type = Util.inferContentType(uri, intent.getStringExtra(EXTENSION_EXTRA));
+ if (type == C.TYPE_DASH) {
+ mediaSource =
+ new DashMediaSource.Factory(dataSourceFactory)
+ .setDrmSessionManager(drmSessionManager)
+ .createMediaSource(MediaItem.fromUri(uri));
+ } else if (type == C.TYPE_OTHER) {
+ mediaSource =
+ new ProgressiveMediaSource.Factory(dataSourceFactory)
+ .setDrmSessionManager(drmSessionManager)
+ .createMediaSource(MediaItem.fromUri(uri));
+ } else {
+ throw new IllegalStateException();
+ }
+ SimpleExoPlayer player = new SimpleExoPlayer.Builder(getApplicationContext()).build();
+ player.setMediaSource(mediaSource);
+ player.prepare();
+ player.play();
+ player.setRepeatMode(Player.REPEAT_MODE_ALL);
+
+ surfaceControl =
+ new SurfaceControl.Builder()
+ .setName(SURFACE_CONTROL_NAME)
+ .setBufferSize(/* width= */ 0, /* height= */ 0)
+ .build();
+ videoSurface = new Surface(surfaceControl);
+ player.setVideoSurface(videoSurface);
+ MainActivity.player = player;
+ }
+
+ private void setCurrentOutputView(@Nullable SurfaceView surfaceView) {
+ currentOutputView = surfaceView;
+ if (surfaceView != null && surfaceView.getHolder().getSurface() != null) {
+ reparent(surfaceView);
+ }
+ }
+
+ private void attachSurfaceListener(SurfaceView surfaceView) {
+ surfaceView
+ .getHolder()
+ .addCallback(
+ new SurfaceHolder.Callback() {
+ @Override
+ public void surfaceCreated(SurfaceHolder surfaceHolder) {
+ if (surfaceView == currentOutputView) {
+ reparent(surfaceView);
+ }
+ }
+
+ @Override
+ public void surfaceChanged(
+ SurfaceHolder surfaceHolder, int format, int width, int height) {}
+
+ @Override
+ public void surfaceDestroyed(SurfaceHolder surfaceHolder) {}
+ });
+ }
+
+ private static void reparent(@Nullable SurfaceView surfaceView) {
+ SurfaceControl surfaceControl = Assertions.checkNotNull(MainActivity.surfaceControl);
+ if (surfaceView == null) {
+ new SurfaceControl.Transaction()
+ .reparent(surfaceControl, /* newParent= */ null)
+ .setBufferSize(surfaceControl, /* w= */ 0, /* h= */ 0)
+ .setVisibility(surfaceControl, /* visible= */ false)
+ .apply();
+ } else {
+ SurfaceControl newParentSurfaceControl = surfaceView.getSurfaceControl();
+ new SurfaceControl.Transaction()
+ .reparent(surfaceControl, newParentSurfaceControl)
+ .setBufferSize(surfaceControl, surfaceView.getWidth(), surfaceView.getHeight())
+ .setVisibility(surfaceControl, /* visible= */ true)
+ .apply();
+ }
+ }
+}
diff --git a/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/package-info.java b/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/package-info.java
new file mode 100644
index 0000000000..0f632a6e3c
--- /dev/null
+++ b/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2020 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.
+ */
+@NonNullApi
+package com.google.android.exoplayer2.surfacedemo;
+
+import com.google.android.exoplayer2.util.NonNullApi;
diff --git a/demos/surface/src/main/res/layout/main_activity.xml b/demos/surface/src/main/res/layout/main_activity.xml
new file mode 100644
index 0000000000..829602275d
--- /dev/null
+++ b/demos/surface/src/main/res/layout/main_activity.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/surface/src/main/res/mipmap-hdpi/ic_launcher.png b/demos/surface/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000000..adaa93220e
Binary files /dev/null and b/demos/surface/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/demos/surface/src/main/res/mipmap-mdpi/ic_launcher.png b/demos/surface/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000000..9b6f7d5e80
Binary files /dev/null and b/demos/surface/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/demos/surface/src/main/res/mipmap-xhdpi/ic_launcher.png b/demos/surface/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000000..2101026c9f
Binary files /dev/null and b/demos/surface/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/demos/surface/src/main/res/mipmap-xxhdpi/ic_launcher.png b/demos/surface/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..223ec8bd11
Binary files /dev/null and b/demos/surface/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/demos/surface/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/demos/surface/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..698ed68c42
Binary files /dev/null and b/demos/surface/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/demos/surface/src/main/res/values/strings.xml b/demos/surface/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..9ba24bd368
--- /dev/null
+++ b/demos/surface/src/main/res/values/strings.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ ExoPlayer SurfaceControl demo
+ No output
+ Full screen
+ New activity
+
+
diff --git a/extensions/README.md b/extensions/README.md
new file mode 100644
index 0000000000..bf0effb358
--- /dev/null
+++ b/extensions/README.md
@@ -0,0 +1,5 @@
+# ExoPlayer extensions #
+
+ExoPlayer extensions are modules that depend on external libraries to provide
+additional functionality. Browse the individual extensions and their READMEs to
+learn more.
diff --git a/extensions/av1/README.md b/extensions/av1/README.md
new file mode 100644
index 0000000000..8e11a5e2e7
--- /dev/null
+++ b/extensions/av1/README.md
@@ -0,0 +1,137 @@
+# ExoPlayer AV1 extension #
+
+The AV1 extension provides `Libgav1VideoRenderer`, which uses libgav1 native
+library to decode AV1 videos.
+
+## License note ##
+
+Please note that whilst the code in this repository is licensed under
+[Apache 2.0][], using this extension also requires building and including one or
+more external libraries as described below. These are licensed separately.
+
+[Apache 2.0]: https://github.com/google/ExoPlayer/blob/release-v2/LICENSE
+
+## Build instructions (Linux, macOS) ##
+
+To use this extension you need to clone the ExoPlayer repository and depend on
+its modules locally. Instructions for doing this can be found in ExoPlayer's
+[top level README][].
+
+In addition, it's necessary to fetch cpu_features library and libgav1 with its
+dependencies as follows:
+
+* Set the following environment variables:
+
+```
+cd ""
+EXOPLAYER_ROOT="$(pwd)"
+AV1_EXT_PATH="${EXOPLAYER_ROOT}/extensions/av1/src/main"
+```
+
+* Fetch cpu_features library:
+
+```
+cd "${AV1_EXT_PATH}/jni" && \
+git clone https://github.com/google/cpu_features
+```
+
+* Fetch libgav1:
+
+```
+cd "${AV1_EXT_PATH}/jni" && \
+git clone https://chromium.googlesource.com/codecs/libgav1
+```
+
+* Fetch Abseil:
+
+```
+cd "${AV1_EXT_PATH}/jni/libgav1" && \
+git clone https://github.com/abseil/abseil-cpp.git third_party/abseil-cpp
+```
+
+* [Install CMake][].
+
+Having followed these steps, gradle will build the extension automatically when
+run on the command line or via Android Studio, using [CMake][] and [Ninja][]
+to configure and build libgav1 and the extension's [JNI wrapper library][].
+
+[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
+[Install CMake]: https://developer.android.com/studio/projects/install-ndk
+[CMake]: https://cmake.org/
+[Ninja]: https://ninja-build.org
+[JNI wrapper library]: https://github.com/google/ExoPlayer/blob/release-v2/extensions/av1/src/main/jni/gav1_jni.cc
+
+## Build instructions (Windows) ##
+
+We do not provide support for building this extension on Windows, however it
+should be possible to follow the Linux instructions in [Windows PowerShell][].
+
+[Windows PowerShell]: https://docs.microsoft.com/en-us/powershell/scripting/getting-started/getting-started-with-windows-powershell
+
+## Using the extension ##
+
+Once you've followed the instructions above to check out, build and depend on
+the extension, the next step is to tell ExoPlayer to use `Libgav1VideoRenderer`.
+How you do this depends on which player API you're using:
+
+* If you're passing a `DefaultRenderersFactory` to `SimpleExoPlayer.Builder`,
+ you can enable using the extension by setting the `extensionRendererMode`
+ parameter of the `DefaultRenderersFactory` constructor to
+ `EXTENSION_RENDERER_MODE_ON`. This will use `Libgav1VideoRenderer` for
+ playback if `MediaCodecVideoRenderer` doesn't support decoding the input AV1
+ stream. Pass `EXTENSION_RENDERER_MODE_PREFER` to give `Libgav1VideoRenderer`
+ priority over `MediaCodecVideoRenderer`.
+* If you've subclassed `DefaultRenderersFactory`, add a `Libvgav1VideoRenderer`
+ to the output list in `buildVideoRenderers`. ExoPlayer will use the first
+ `Renderer` in the list that supports the input media format.
+* If you've implemented your own `RenderersFactory`, return a
+ `Libgav1VideoRenderer` instance from `createRenderers`. ExoPlayer will use the
+ first `Renderer` in the returned array that supports the input media format.
+* If you're using `ExoPlayer.Builder`, pass a `Libgav1VideoRenderer` in the
+ array of `Renderer`s. ExoPlayer will use the first `Renderer` in the list that
+ supports the input media format.
+
+Note: These instructions assume you're using `DefaultTrackSelector`. If you have
+a custom track selector the choice of `Renderer` is up to your implementation.
+You need to make sure you are passing a `Libgav1VideoRenderer` to the player and
+then you need to implement your own logic to use the renderer for a given track.
+
+## Using the extension in the demo application ##
+
+To try out playback using the extension in the [demo application][], see
+[enabling extension decoders][].
+
+[demo application]: https://exoplayer.dev/demo-application.html
+[enabling extension decoders]: https://exoplayer.dev/demo-application.html#enabling-extension-decoders
+
+## Rendering options ##
+
+There are two possibilities for rendering the output `Libgav1VideoRenderer`
+gets from the libgav1 decoder:
+
+* GL rendering using GL shader for color space conversion
+
+ * If you are using `SimpleExoPlayer` with `PlayerView`, enable this option
+ by setting `surface_type` of `PlayerView` to be
+ `video_decoder_gl_surface_view`.
+ * Otherwise, enable this option by sending `Libgav1VideoRenderer` a
+ message of type `Renderer.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER`
+ with an instance of `VideoDecoderOutputBufferRenderer` as its object.
+
+* Native rendering using `ANativeWindow`
+
+ * If you are using `SimpleExoPlayer` with `PlayerView`, this option is
+ enabled by default.
+ * Otherwise, enable this option by sending `Libgav1VideoRenderer` a
+ message of type `Renderer.MSG_SET_SURFACE` with an instance of
+ `SurfaceView` as its object.
+
+Note: Although the default option uses `ANativeWindow`, based on our testing the
+GL rendering mode has better performance, so should be preferred
+
+## Links ##
+
+* [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.av1.*`
+ belong to this module.
+
+[Javadoc]: https://exoplayer.dev/doc/reference/index.html
diff --git a/extensions/av1/build.gradle b/extensions/av1/build.gradle
new file mode 100644
index 0000000000..95a953d145
--- /dev/null
+++ b/extensions/av1/build.gradle
@@ -0,0 +1,54 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle"
+
+android {
+ defaultConfig {
+ externalNativeBuild {
+ cmake {
+ // Debug CMake build type causes video frames to drop,
+ // so native library should always use Release build type.
+ arguments "-DCMAKE_BUILD_TYPE=Release"
+ targets "gav1JNI"
+ }
+ }
+ }
+}
+
+// Configure the native build only if libgav1 is present to avoid gradle sync
+// failures if libgav1 hasn't been built according to the README instructions.
+if (project.file('src/main/jni/libgav1').exists()) {
+ android.externalNativeBuild.cmake {
+ path = 'src/main/jni/CMakeLists.txt'
+ version = '3.7.1+'
+ if (project.hasProperty('externalNativeBuildDir')) {
+ if (!new File(externalNativeBuildDir).isAbsolute()) {
+ ext.externalNativeBuildDir =
+ new File(rootDir, it.externalNativeBuildDir)
+ }
+ buildStagingDirectory = "${externalNativeBuildDir}/${project.name}"
+ }
+ }
+}
+
+dependencies {
+ implementation project(modulePrefix + 'library-core')
+ implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
+ compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion
+}
+
+ext {
+ javadocTitle = 'AV1 extension'
+}
+apply from: '../../javadoc_library.gradle'
diff --git a/extensions/av1/proguard-rules.txt b/extensions/av1/proguard-rules.txt
new file mode 100644
index 0000000000..9d73f7e2b5
--- /dev/null
+++ b/extensions/av1/proguard-rules.txt
@@ -0,0 +1,7 @@
+# Proguard rules specific to the AV1 extension.
+
+# This prevents the names of native methods from being obfuscated.
+-keepclasseswithmembernames class * {
+ native ;
+}
+
diff --git a/extensions/av1/src/main/AndroidManifest.xml b/extensions/av1/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..af85bacdf6
--- /dev/null
+++ b/extensions/av1/src/main/AndroidManifest.xml
@@ -0,0 +1,17 @@
+
+
+
+
diff --git a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java
new file mode 100644
index 0000000000..ad8c8a682c
--- /dev/null
+++ b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java
@@ -0,0 +1,256 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.av1;
+
+import static java.lang.Runtime.getRuntime;
+
+import android.view.Surface;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.decoder.DecoderInputBuffer;
+import com.google.android.exoplayer2.decoder.SimpleDecoder;
+import com.google.android.exoplayer2.util.Util;
+import com.google.android.exoplayer2.video.VideoDecoderInputBuffer;
+import com.google.android.exoplayer2.video.VideoDecoderOutputBuffer;
+import java.nio.ByteBuffer;
+
+/** Gav1 decoder. */
+/* package */ final class Gav1Decoder
+ extends SimpleDecoder {
+
+ // LINT.IfChange
+ private static final int GAV1_ERROR = 0;
+ private static final int GAV1_OK = 1;
+ private static final int GAV1_DECODE_ONLY = 2;
+ // LINT.ThenChange(../../../../../../../jni/gav1_jni.cc)
+
+ private final long gav1DecoderContext;
+
+ @C.VideoOutputMode private volatile int outputMode;
+
+ /**
+ * Creates a Gav1Decoder.
+ *
+ * @param numInputBuffers Number of input buffers.
+ * @param numOutputBuffers Number of output buffers.
+ * @param initialInputBufferSize The initial size of each input buffer, in bytes.
+ * @param threads Number of threads libgav1 will use to decode. If {@link
+ * Libgav1VideoRenderer#THREAD_COUNT_AUTODETECT} is passed, then this class will auto detect
+ * the number of threads to be used.
+ * @throws Gav1DecoderException Thrown if an exception occurs when initializing the decoder.
+ */
+ public Gav1Decoder(
+ int numInputBuffers, int numOutputBuffers, int initialInputBufferSize, int threads)
+ throws Gav1DecoderException {
+ super(
+ new VideoDecoderInputBuffer[numInputBuffers],
+ new VideoDecoderOutputBuffer[numOutputBuffers]);
+ if (!Gav1Library.isAvailable()) {
+ throw new Gav1DecoderException("Failed to load decoder native library.");
+ }
+
+ if (threads == Libgav1VideoRenderer.THREAD_COUNT_AUTODETECT) {
+ // Try to get the optimal number of threads from the AV1 heuristic.
+ threads = gav1GetThreads();
+ if (threads <= 0) {
+ // If that is not available, default to the number of available processors.
+ threads = getRuntime().availableProcessors();
+ }
+ }
+
+ gav1DecoderContext = gav1Init(threads);
+ if (gav1DecoderContext == GAV1_ERROR || gav1CheckError(gav1DecoderContext) == GAV1_ERROR) {
+ throw new Gav1DecoderException(
+ "Failed to initialize decoder. Error: " + gav1GetErrorMessage(gav1DecoderContext));
+ }
+ setInitialInputBufferSize(initialInputBufferSize);
+ }
+
+ @Override
+ public String getName() {
+ return "libgav1";
+ }
+
+ @Override
+ protected VideoDecoderInputBuffer createInputBuffer() {
+ return new VideoDecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT);
+ }
+
+ @Override
+ protected VideoDecoderOutputBuffer createOutputBuffer() {
+ return new VideoDecoderOutputBuffer(this::releaseOutputBuffer);
+ }
+
+ @Override
+ @Nullable
+ protected Gav1DecoderException decode(
+ VideoDecoderInputBuffer inputBuffer, VideoDecoderOutputBuffer outputBuffer, boolean reset) {
+ ByteBuffer inputData = Util.castNonNull(inputBuffer.data);
+ int inputSize = inputData.limit();
+ if (gav1Decode(gav1DecoderContext, inputData, inputSize) == GAV1_ERROR) {
+ return new Gav1DecoderException(
+ "gav1Decode error: " + gav1GetErrorMessage(gav1DecoderContext));
+ }
+
+ boolean decodeOnly = inputBuffer.isDecodeOnly();
+ if (!decodeOnly) {
+ outputBuffer.init(inputBuffer.timeUs, outputMode, /* supplementalData= */ null);
+ }
+ // We need to dequeue the decoded frame from the decoder even when the input data is
+ // decode-only.
+ int getFrameResult = gav1GetFrame(gav1DecoderContext, outputBuffer, decodeOnly);
+ if (getFrameResult == GAV1_ERROR) {
+ return new Gav1DecoderException(
+ "gav1GetFrame error: " + gav1GetErrorMessage(gav1DecoderContext));
+ }
+ if (getFrameResult == GAV1_DECODE_ONLY) {
+ outputBuffer.addFlag(C.BUFFER_FLAG_DECODE_ONLY);
+ }
+ if (!decodeOnly) {
+ outputBuffer.format = inputBuffer.format;
+ }
+
+ return null;
+ }
+
+ @Override
+ protected Gav1DecoderException createUnexpectedDecodeException(Throwable error) {
+ return new Gav1DecoderException("Unexpected decode error", error);
+ }
+
+ @Override
+ public void release() {
+ super.release();
+ gav1Close(gav1DecoderContext);
+ }
+
+ @Override
+ protected void releaseOutputBuffer(VideoDecoderOutputBuffer buffer) {
+ // Decode only frames do not acquire a reference on the internal decoder buffer and thus do not
+ // require a call to gav1ReleaseFrame.
+ if (buffer.mode == C.VIDEO_OUTPUT_MODE_SURFACE_YUV && !buffer.isDecodeOnly()) {
+ gav1ReleaseFrame(gav1DecoderContext, buffer);
+ }
+ super.releaseOutputBuffer(buffer);
+ }
+
+ /**
+ * Sets the output mode for frames rendered by the decoder.
+ *
+ * @param outputMode The output mode.
+ */
+ public void setOutputMode(@C.VideoOutputMode int outputMode) {
+ this.outputMode = outputMode;
+ }
+
+ /**
+ * Renders output buffer to the given surface. Must only be called when in {@link
+ * C#VIDEO_OUTPUT_MODE_SURFACE_YUV} mode.
+ *
+ * @param outputBuffer Output buffer.
+ * @param surface Output surface.
+ * @throws Gav1DecoderException Thrown if called with invalid output mode or frame rendering
+ * fails.
+ */
+ public void renderToSurface(VideoDecoderOutputBuffer outputBuffer, Surface surface)
+ throws Gav1DecoderException {
+ if (outputBuffer.mode != C.VIDEO_OUTPUT_MODE_SURFACE_YUV) {
+ throw new Gav1DecoderException("Invalid output mode.");
+ }
+ if (gav1RenderFrame(gav1DecoderContext, surface, outputBuffer) == GAV1_ERROR) {
+ throw new Gav1DecoderException(
+ "Buffer render error: " + gav1GetErrorMessage(gav1DecoderContext));
+ }
+ }
+
+ /**
+ * Initializes a libgav1 decoder.
+ *
+ * @param threads Number of threads to be used by a libgav1 decoder.
+ * @return The address of the decoder context or {@link #GAV1_ERROR} if there was an error.
+ */
+ private native long gav1Init(int threads);
+
+ /**
+ * Deallocates the decoder context.
+ *
+ * @param context Decoder context.
+ */
+ private native void gav1Close(long context);
+
+ /**
+ * Decodes the encoded data passed.
+ *
+ * @param context Decoder context.
+ * @param encodedData Encoded data.
+ * @param length Length of the data buffer.
+ * @return {@link #GAV1_OK} if successful, {@link #GAV1_ERROR} if an error occurred.
+ */
+ private native int gav1Decode(long context, ByteBuffer encodedData, int length);
+
+ /**
+ * Gets the decoded frame.
+ *
+ * @param context Decoder context.
+ * @param outputBuffer Output buffer for the decoded frame.
+ * @return {@link #GAV1_OK} if successful, {@link #GAV1_DECODE_ONLY} if successful but the frame
+ * is decode-only, {@link #GAV1_ERROR} if an error occurred.
+ */
+ private native int gav1GetFrame(
+ long context, VideoDecoderOutputBuffer outputBuffer, boolean decodeOnly);
+
+ /**
+ * Renders the frame to the surface. Used with {@link C#VIDEO_OUTPUT_MODE_SURFACE_YUV} only.
+ *
+ * @param context Decoder context.
+ * @param surface Output surface.
+ * @param outputBuffer Output buffer with the decoded frame.
+ * @return {@link #GAV1_OK} if successful, {@link #GAV1_ERROR} if an error occurred.
+ */
+ private native int gav1RenderFrame(
+ long context, Surface surface, VideoDecoderOutputBuffer outputBuffer);
+
+ /**
+ * Releases the frame. Used with {@link C#VIDEO_OUTPUT_MODE_SURFACE_YUV} only.
+ *
+ * @param context Decoder context.
+ * @param outputBuffer Output buffer.
+ */
+ private native void gav1ReleaseFrame(long context, VideoDecoderOutputBuffer outputBuffer);
+
+ /**
+ * Returns a human-readable string describing the last error encountered in the given context.
+ *
+ * @param context Decoder context.
+ * @return A string describing the last encountered error.
+ */
+ private native String gav1GetErrorMessage(long context);
+
+ /**
+ * Returns whether an error occurred.
+ *
+ * @param context Decoder context.
+ * @return {@link #GAV1_OK} if there was no error, {@link #GAV1_ERROR} if an error occurred.
+ */
+ private native int gav1CheckError(long context);
+
+ /**
+ * Returns the optimal number of threads to be used for AV1 decoding.
+ *
+ * @return Optimal number of threads if there was no error, 0 if an error occurred.
+ */
+ private native int gav1GetThreads();
+}
diff --git a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1DecoderException.java b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1DecoderException.java
new file mode 100644
index 0000000000..13839f0ceb
--- /dev/null
+++ b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1DecoderException.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.av1;
+
+import com.google.android.exoplayer2.decoder.DecoderException;
+
+/** Thrown when a libgav1 decoder error occurs. */
+public final class Gav1DecoderException extends DecoderException {
+
+ /* package */ Gav1DecoderException(String message) {
+ super(message);
+ }
+
+ /* package */ Gav1DecoderException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Library.java b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Library.java
new file mode 100644
index 0000000000..7907fa4623
--- /dev/null
+++ b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Library.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.av1;
+
+import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
+import com.google.android.exoplayer2.util.LibraryLoader;
+
+/** Configures and queries the underlying native library. */
+public final class Gav1Library {
+
+ static {
+ ExoPlayerLibraryInfo.registerModule("goog.exo.gav1");
+ }
+
+ private static final LibraryLoader LOADER = new LibraryLoader("gav1JNI");
+
+ private Gav1Library() {}
+
+ /** Returns whether the underlying library is available, loading it if necessary. */
+ public static boolean isAvailable() {
+ return LOADER.isAvailable();
+ }
+}
diff --git a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Libgav1VideoRenderer.java b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Libgav1VideoRenderer.java
new file mode 100644
index 0000000000..7c558d24b2
--- /dev/null
+++ b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Libgav1VideoRenderer.java
@@ -0,0 +1,170 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.av1;
+
+import android.os.Handler;
+import android.view.Surface;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.Format;
+import com.google.android.exoplayer2.RendererCapabilities;
+import com.google.android.exoplayer2.drm.ExoMediaCrypto;
+import com.google.android.exoplayer2.util.MimeTypes;
+import com.google.android.exoplayer2.util.TraceUtil;
+import com.google.android.exoplayer2.util.Util;
+import com.google.android.exoplayer2.video.DecoderVideoRenderer;
+import com.google.android.exoplayer2.video.VideoDecoderOutputBuffer;
+import com.google.android.exoplayer2.video.VideoRendererEventListener;
+
+/** Decodes and renders video using libgav1 decoder. */
+public class Libgav1VideoRenderer extends DecoderVideoRenderer {
+
+ /**
+ * Attempts to use as many threads as performance processors available on the device. If the
+ * number of performance processors cannot be detected, the number of available processors is
+ * used.
+ */
+ public static final int THREAD_COUNT_AUTODETECT = 0;
+
+ private static final String TAG = "Libgav1VideoRenderer";
+ private static final int DEFAULT_NUM_OF_INPUT_BUFFERS = 4;
+ private static final int DEFAULT_NUM_OF_OUTPUT_BUFFERS = 4;
+ /* Default size based on 720p resolution video compressed by a factor of two. */
+ private static final int DEFAULT_INPUT_BUFFER_SIZE =
+ Util.ceilDivide(1280, 64) * Util.ceilDivide(720, 64) * (64 * 64 * 3 / 2) / 2;
+
+ /** The number of input buffers. */
+ private final int numInputBuffers;
+ /**
+ * The number of output buffers. The renderer may limit the minimum possible value due to
+ * requiring multiple output buffers to be dequeued at a time for it to make progress.
+ */
+ private final int numOutputBuffers;
+
+ private final int threads;
+
+ @Nullable private Gav1Decoder decoder;
+
+ /**
+ * Creates a new instance.
+ *
+ * @param allowedJoiningTimeMs The maximum duration in milliseconds for which this video renderer
+ * can attempt to seamlessly join an ongoing playback.
+ * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be
+ * null if delivery of events is not required.
+ * @param eventListener A listener of events. May be null if delivery of events is not required.
+ * @param maxDroppedFramesToNotify The maximum number of frames that can be dropped between
+ * invocations of {@link VideoRendererEventListener#onDroppedFrames(int, long)}.
+ */
+ public Libgav1VideoRenderer(
+ long allowedJoiningTimeMs,
+ @Nullable Handler eventHandler,
+ @Nullable VideoRendererEventListener eventListener,
+ int maxDroppedFramesToNotify) {
+ this(
+ allowedJoiningTimeMs,
+ eventHandler,
+ eventListener,
+ maxDroppedFramesToNotify,
+ THREAD_COUNT_AUTODETECT,
+ DEFAULT_NUM_OF_INPUT_BUFFERS,
+ DEFAULT_NUM_OF_OUTPUT_BUFFERS);
+ }
+
+ /**
+ * Creates a new instance.
+ *
+ * @param allowedJoiningTimeMs The maximum duration in milliseconds for which this video renderer
+ * can attempt to seamlessly join an ongoing playback.
+ * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be
+ * null if delivery of events is not required.
+ * @param eventListener A listener of events. May be null if delivery of events is not required.
+ * @param maxDroppedFramesToNotify The maximum number of frames that can be dropped between
+ * invocations of {@link VideoRendererEventListener#onDroppedFrames(int, long)}.
+ * @param threads Number of threads libgav1 will use to decode. If {@link
+ * #THREAD_COUNT_AUTODETECT} is passed, then the number of threads to use is autodetected
+ * based on CPU capabilities.
+ * @param numInputBuffers Number of input buffers.
+ * @param numOutputBuffers Number of output buffers.
+ */
+ public Libgav1VideoRenderer(
+ long allowedJoiningTimeMs,
+ @Nullable Handler eventHandler,
+ @Nullable VideoRendererEventListener eventListener,
+ int maxDroppedFramesToNotify,
+ int threads,
+ int numInputBuffers,
+ int numOutputBuffers) {
+ super(allowedJoiningTimeMs, eventHandler, eventListener, maxDroppedFramesToNotify);
+ this.threads = threads;
+ this.numInputBuffers = numInputBuffers;
+ this.numOutputBuffers = numOutputBuffers;
+ }
+
+ @Override
+ public String getName() {
+ return TAG;
+ }
+
+ @Override
+ @Capabilities
+ public final int supportsFormat(Format format) {
+ if (!MimeTypes.VIDEO_AV1.equalsIgnoreCase(format.sampleMimeType)
+ || !Gav1Library.isAvailable()) {
+ return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE);
+ }
+ if (format.exoMediaCryptoType != null) {
+ return RendererCapabilities.create(FORMAT_UNSUPPORTED_DRM);
+ }
+ return RendererCapabilities.create(FORMAT_HANDLED, ADAPTIVE_SEAMLESS, TUNNELING_NOT_SUPPORTED);
+ }
+
+ @Override
+ protected Gav1Decoder createDecoder(Format format, @Nullable ExoMediaCrypto mediaCrypto)
+ throws Gav1DecoderException {
+ TraceUtil.beginSection("createGav1Decoder");
+ int initialInputBufferSize =
+ format.maxInputSize != Format.NO_VALUE ? format.maxInputSize : DEFAULT_INPUT_BUFFER_SIZE;
+ Gav1Decoder decoder =
+ new Gav1Decoder(numInputBuffers, numOutputBuffers, initialInputBufferSize, threads);
+ this.decoder = decoder;
+ TraceUtil.endSection();
+ return decoder;
+ }
+
+ @Override
+ protected void renderOutputBufferToSurface(VideoDecoderOutputBuffer outputBuffer, Surface surface)
+ throws Gav1DecoderException {
+ if (decoder == null) {
+ throw new Gav1DecoderException(
+ "Failed to render output buffer to surface: decoder is not initialized.");
+ }
+ decoder.renderToSurface(outputBuffer, surface);
+ outputBuffer.release();
+ }
+
+ @Override
+ protected void setDecoderOutputMode(@C.VideoOutputMode int outputMode) {
+ if (decoder != null) {
+ decoder.setOutputMode(outputMode);
+ }
+ }
+
+ @Override
+ protected boolean canKeepCodec(Format oldFormat, Format newFormat) {
+ return true;
+ }
+}
diff --git a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/package-info.java b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/package-info.java
new file mode 100644
index 0000000000..2e289b27fa
--- /dev/null
+++ b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@NonNullApi
+package com.google.android.exoplayer2.ext.av1;
+
+import com.google.android.exoplayer2.util.NonNullApi;
diff --git a/extensions/av1/src/main/jni/CMakeLists.txt b/extensions/av1/src/main/jni/CMakeLists.txt
new file mode 100644
index 0000000000..fe0e8edaeb
--- /dev/null
+++ b/extensions/av1/src/main/jni/CMakeLists.txt
@@ -0,0 +1,46 @@
+cmake_minimum_required(VERSION 3.7.1 FATAL_ERROR)
+set(CMAKE_CXX_STANDARD 11)
+
+project(libgav1JNI C CXX)
+
+# Devices using armeabi-v7a are not required to support
+# Neon which is why Neon is disabled by default for
+# armeabi-v7a build. This flag enables it.
+if(${ANDROID_ABI} MATCHES "armeabi-v7a")
+ add_compile_options("-mfpu=neon")
+ add_compile_options("-marm")
+ add_compile_options("-fPIC")
+endif()
+
+string(TOLOWER "${CMAKE_BUILD_TYPE}" build_type)
+if(build_type MATCHES "^rel")
+ add_compile_options("-O2")
+endif()
+
+set(libgav1_jni_root "${CMAKE_CURRENT_SOURCE_DIR}")
+
+# Build cpu_features library.
+add_subdirectory("${libgav1_jni_root}/cpu_features"
+ EXCLUDE_FROM_ALL)
+
+# Build libgav1.
+add_subdirectory("${libgav1_jni_root}/libgav1"
+ EXCLUDE_FROM_ALL)
+
+# Build libgav1JNI.
+add_library(gav1JNI
+ SHARED
+ gav1_jni.cc
+ cpu_info.cc
+ cpu_info.h)
+
+# Locate NDK log library.
+find_library(android_log_lib log)
+
+# Link libgav1JNI against used libraries.
+target_link_libraries(gav1JNI
+ PRIVATE android
+ PRIVATE cpu_features
+ PRIVATE libgav1_static
+ PRIVATE ${android_log_lib})
+
diff --git a/extensions/av1/src/main/jni/cpu_info.cc b/extensions/av1/src/main/jni/cpu_info.cc
new file mode 100644
index 0000000000..8f4a405f4f
--- /dev/null
+++ b/extensions/av1/src/main/jni/cpu_info.cc
@@ -0,0 +1,153 @@
+#include "cpu_info.h" // NOLINT
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+namespace gav1_jni {
+namespace {
+
+// Note: The code in this file needs to use the 'long' type because it is the
+// return type of the Standard C Library function strtol(). The linter warnings
+// are suppressed with NOLINT comments since they are integers at runtime.
+
+// Returns the number of online processor cores.
+int GetNumberOfProcessorsOnline() {
+ // See https://developer.android.com/ndk/guides/cpu-features.
+ long num_cpus = sysconf(_SC_NPROCESSORS_ONLN); // NOLINT
+ if (num_cpus < 0) {
+ return 0;
+ }
+ // It is safe to cast num_cpus to int. sysconf(_SC_NPROCESSORS_ONLN) returns
+ // the return value of get_nprocs(), which is an int.
+ return static_cast(num_cpus);
+}
+
+} // namespace
+
+// These CPUs support heterogeneous multiprocessing.
+#if defined(__arm__) || defined(__aarch64__)
+
+// A helper function used by GetNumberOfPerformanceCoresOnline().
+//
+// Returns the cpuinfo_max_freq value (in kHz) of the given CPU. Returns 0 on
+// failure.
+long GetCpuinfoMaxFreq(int cpu_index) { // NOLINT
+ char buffer[128];
+ const int rv = snprintf(
+ buffer, sizeof(buffer),
+ "/sys/devices/system/cpu/cpu%d/cpufreq/cpuinfo_max_freq", cpu_index);
+ if (rv < 0 || rv >= sizeof(buffer)) {
+ return 0;
+ }
+ FILE* file = fopen(buffer, "r");
+ if (file == nullptr) {
+ return 0;
+ }
+ char* const str = fgets(buffer, sizeof(buffer), file);
+ fclose(file);
+ if (str == nullptr) {
+ return 0;
+ }
+ const long freq = strtol(str, nullptr, 10); // NOLINT
+ if (freq <= 0 || freq == LONG_MAX) {
+ return 0;
+ }
+ return freq;
+}
+
+// Returns the number of performance CPU cores that are online. The number of
+// efficiency CPU cores is subtracted from the total number of CPU cores. Uses
+// cpuinfo_max_freq to determine whether a CPU is a performance core or an
+// efficiency core.
+//
+// This function is not perfect. For example, the Snapdragon 632 SoC used in
+// Motorola Moto G7 has performance and efficiency cores with the same
+// cpuinfo_max_freq but different cpuinfo_min_freq. This function fails to
+// differentiate the two kinds of cores and reports all the cores as
+// performance cores.
+int GetNumberOfPerformanceCoresOnline() {
+ // Get the online CPU list. Some examples of the online CPU list are:
+ // "0-7"
+ // "0"
+ // "0-1,2,3,4-7"
+ FILE* file = fopen("/sys/devices/system/cpu/online", "r");
+ if (file == nullptr) {
+ return 0;
+ }
+ char online[512];
+ char* const str = fgets(online, sizeof(online), file);
+ fclose(file);
+ file = nullptr;
+ if (str == nullptr) {
+ return 0;
+ }
+
+ // Count the number of the slowest CPUs. Some SoCs such as Snapdragon 855
+ // have performance cores with different max frequencies, so only the slowest
+ // CPUs are efficiency cores. If we count the number of the fastest CPUs, we
+ // will fail to count the second fastest performance cores.
+ long slowest_cpu_freq = LONG_MAX; // NOLINT
+ int num_slowest_cpus = 0;
+ int num_cpus = 0;
+ const char* cp = online;
+ int range_begin = -1;
+ while (true) {
+ char* str_end;
+ const int cpu = static_cast(strtol(cp, &str_end, 10)); // NOLINT
+ if (str_end == cp) {
+ break;
+ }
+ cp = str_end;
+ if (*cp == '-') {
+ range_begin = cpu;
+ } else {
+ if (range_begin == -1) {
+ range_begin = cpu;
+ }
+
+ num_cpus += cpu - range_begin + 1;
+ for (int i = range_begin; i <= cpu; ++i) {
+ const long freq = GetCpuinfoMaxFreq(i); // NOLINT
+ if (freq <= 0) {
+ return 0;
+ }
+ if (freq < slowest_cpu_freq) {
+ slowest_cpu_freq = freq;
+ num_slowest_cpus = 0;
+ }
+ if (freq == slowest_cpu_freq) {
+ ++num_slowest_cpus;
+ }
+ }
+
+ range_begin = -1;
+ }
+ if (*cp == '\0') {
+ break;
+ }
+ ++cp;
+ }
+
+ // If there are faster CPU cores than the slowest CPU cores, exclude the
+ // slowest CPU cores.
+ if (num_slowest_cpus < num_cpus) {
+ num_cpus -= num_slowest_cpus;
+ }
+ return num_cpus;
+}
+
+#else
+
+// Assume symmetric multiprocessing.
+int GetNumberOfPerformanceCoresOnline() {
+ return GetNumberOfProcessorsOnline();
+}
+
+#endif
+
+} // namespace gav1_jni
diff --git a/extensions/av1/src/main/jni/cpu_info.h b/extensions/av1/src/main/jni/cpu_info.h
new file mode 100644
index 0000000000..77f869a93e
--- /dev/null
+++ b/extensions/av1/src/main/jni/cpu_info.h
@@ -0,0 +1,13 @@
+#ifndef EXOPLAYER_V2_EXTENSIONS_AV1_SRC_MAIN_JNI_CPU_INFO_H_
+#define EXOPLAYER_V2_EXTENSIONS_AV1_SRC_MAIN_JNI_CPU_INFO_H_
+
+namespace gav1_jni {
+
+// Returns the number of performance cores that are available for AV1 decoding.
+// This is a heuristic that works on most common android devices. Returns 0 on
+// error or if the number of performance cores cannot be determined.
+int GetNumberOfPerformanceCoresOnline();
+
+} // namespace gav1_jni
+
+#endif // EXOPLAYER_V2_EXTENSIONS_AV1_SRC_MAIN_JNI_CPU_INFO_H_
diff --git a/extensions/av1/src/main/jni/gav1_jni.cc b/extensions/av1/src/main/jni/gav1_jni.cc
new file mode 100644
index 0000000000..6b25798e3f
--- /dev/null
+++ b/extensions/av1/src/main/jni/gav1_jni.cc
@@ -0,0 +1,783 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include
+#include
+#include
+
+#include "cpu_features_macros.h" // NOLINT
+#ifdef CPU_FEATURES_ARCH_ARM
+#include "cpuinfo_arm.h" // NOLINT
+#endif // CPU_FEATURES_ARCH_ARM
+#ifdef CPU_FEATURES_COMPILED_ANY_ARM_NEON
+#include
+#endif // CPU_FEATURES_COMPILED_ANY_ARM_NEON
+#include
+
+#include
+#include
+#include // NOLINT
+#include
+
+#include "cpu_info.h" // NOLINT
+#include "gav1/decoder.h"
+
+#define LOG_TAG "gav1_jni"
+#define LOGE(...) \
+ ((void)__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__))
+
+#define DECODER_FUNC(RETURN_TYPE, NAME, ...) \
+ extern "C" { \
+ JNIEXPORT RETURN_TYPE \
+ Java_com_google_android_exoplayer2_ext_av1_Gav1Decoder_##NAME( \
+ JNIEnv* env, jobject thiz, ##__VA_ARGS__); \
+ } \
+ JNIEXPORT RETURN_TYPE \
+ Java_com_google_android_exoplayer2_ext_av1_Gav1Decoder_##NAME( \
+ JNIEnv* env, jobject thiz, ##__VA_ARGS__)
+
+jint JNI_OnLoad(JavaVM* vm, void* reserved) {
+ JNIEnv* env;
+ if (vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK) {
+ return -1;
+ }
+ return JNI_VERSION_1_6;
+}
+
+namespace {
+
+// YUV plane indices.
+const int kPlaneY = 0;
+const int kPlaneU = 1;
+const int kPlaneV = 2;
+const int kMaxPlanes = 3;
+
+// Android YUV format. See:
+// https://developer.android.com/reference/android/graphics/ImageFormat.html#YV12.
+const int kImageFormatYV12 = 0x32315659;
+
+// LINT.IfChange
+// Output modes.
+const int kOutputModeYuv = 0;
+const int kOutputModeSurfaceYuv = 1;
+// LINT.ThenChange(../../../../../library/common/src/main/java/com/google/android/exoplayer2/C.java)
+
+// LINT.IfChange
+const int kColorSpaceUnknown = 0;
+// LINT.ThenChange(../../../../../library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderOutputBuffer.java)
+
+// LINT.IfChange
+// Return codes for jni methods.
+const int kStatusError = 0;
+const int kStatusOk = 1;
+const int kStatusDecodeOnly = 2;
+// LINT.ThenChange(../java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java)
+
+// Status codes specific to the JNI wrapper code.
+enum JniStatusCode {
+ kJniStatusOk = 0,
+ kJniStatusOutOfMemory = -1,
+ kJniStatusBufferAlreadyReleased = -2,
+ kJniStatusInvalidNumOfPlanes = -3,
+ kJniStatusBitDepth12NotSupportedWithYuv = -4,
+ kJniStatusHighBitDepthNotSupportedWithSurfaceYuv = -5,
+ kJniStatusANativeWindowError = -6,
+ kJniStatusBufferResizeError = -7,
+ kJniStatusNeonNotSupported = -8
+};
+
+const char* GetJniErrorMessage(JniStatusCode error_code) {
+ switch (error_code) {
+ case kJniStatusOutOfMemory:
+ return "Out of memory.";
+ case kJniStatusBufferAlreadyReleased:
+ return "JNI buffer already released.";
+ case kJniStatusBitDepth12NotSupportedWithYuv:
+ return "Bit depth 12 is not supported with YUV.";
+ case kJniStatusHighBitDepthNotSupportedWithSurfaceYuv:
+ return "High bit depth (10 or 12 bits per pixel) output format is not "
+ "supported with YUV surface.";
+ case kJniStatusInvalidNumOfPlanes:
+ return "Libgav1 decoded buffer has invalid number of planes.";
+ case kJniStatusANativeWindowError:
+ return "ANativeWindow error.";
+ case kJniStatusBufferResizeError:
+ return "Buffer resize failed.";
+ case kJniStatusNeonNotSupported:
+ return "Neon is not supported.";
+ default:
+ return "Unrecognized error code.";
+ }
+}
+
+// Manages frame buffer and reference information.
+class JniFrameBuffer {
+ public:
+ explicit JniFrameBuffer(int id) : id_(id), reference_count_(0) {}
+ ~JniFrameBuffer() {
+ for (int plane_index = kPlaneY; plane_index < kMaxPlanes; plane_index++) {
+ delete[] raw_buffer_[plane_index];
+ }
+ }
+
+ // Not copyable or movable.
+ JniFrameBuffer(const JniFrameBuffer&) = delete;
+ JniFrameBuffer(JniFrameBuffer&&) = delete;
+ JniFrameBuffer& operator=(const JniFrameBuffer&) = delete;
+ JniFrameBuffer& operator=(JniFrameBuffer&&) = delete;
+
+ void SetFrameData(const libgav1::DecoderBuffer& decoder_buffer) {
+ for (int plane_index = kPlaneY; plane_index < decoder_buffer.NumPlanes();
+ plane_index++) {
+ stride_[plane_index] = decoder_buffer.stride[plane_index];
+ plane_[plane_index] = decoder_buffer.plane[plane_index];
+ displayed_width_[plane_index] =
+ decoder_buffer.displayed_width[plane_index];
+ displayed_height_[plane_index] =
+ decoder_buffer.displayed_height[plane_index];
+ }
+ }
+
+ int Stride(int plane_index) const { return stride_[plane_index]; }
+ uint8_t* Plane(int plane_index) const { return plane_[plane_index]; }
+ int DisplayedWidth(int plane_index) const {
+ return displayed_width_[plane_index];
+ }
+ int DisplayedHeight(int plane_index) const {
+ return displayed_height_[plane_index];
+ }
+
+ // Methods maintaining reference count are not thread-safe. They must be
+ // called with a lock held.
+ void AddReference() { ++reference_count_; }
+ void RemoveReference() { reference_count_--; }
+ bool InUse() const { return reference_count_ != 0; }
+
+ uint8_t* RawBuffer(int plane_index) const { return raw_buffer_[plane_index]; }
+ void* BufferPrivateData() const { return const_cast(&id_); }
+
+ // Attempts to reallocate data planes if the existing ones don't have enough
+ // capacity. Returns true if the allocation was successful or wasn't needed,
+ // false if the allocation failed.
+ bool MaybeReallocateGav1DataPlanes(int y_plane_min_size,
+ int uv_plane_min_size) {
+ for (int plane_index = kPlaneY; plane_index < kMaxPlanes; plane_index++) {
+ const int min_size =
+ (plane_index == kPlaneY) ? y_plane_min_size : uv_plane_min_size;
+ if (raw_buffer_size_[plane_index] >= min_size) continue;
+ delete[] raw_buffer_[plane_index];
+ raw_buffer_[plane_index] = new (std::nothrow) uint8_t[min_size];
+ if (!raw_buffer_[plane_index]) {
+ raw_buffer_size_[plane_index] = 0;
+ return false;
+ }
+ raw_buffer_size_[plane_index] = min_size;
+ }
+ return true;
+ }
+
+ private:
+ int stride_[kMaxPlanes];
+ uint8_t* plane_[kMaxPlanes];
+ int displayed_width_[kMaxPlanes];
+ int displayed_height_[kMaxPlanes];
+ const int id_;
+ int reference_count_;
+ // Pointers to the raw buffers allocated for the data planes.
+ uint8_t* raw_buffer_[kMaxPlanes] = {};
+ // Sizes of the raw buffers in bytes.
+ size_t raw_buffer_size_[kMaxPlanes] = {};
+};
+
+// Manages frame buffers used by libgav1 decoder and ExoPlayer.
+// Handles synchronization between libgav1 and ExoPlayer threads.
+class JniBufferManager {
+ public:
+ ~JniBufferManager() {
+ // This lock does not do anything since libgav1 has released all the frame
+ // buffers. It exists to merely be consistent with all other usage of
+ // |all_buffers_| and |all_buffer_count_|.
+ std::lock_guard lock(mutex_);
+ while (all_buffer_count_--) {
+ delete all_buffers_[all_buffer_count_];
+ }
+ }
+
+ JniStatusCode GetBuffer(size_t y_plane_min_size, size_t uv_plane_min_size,
+ JniFrameBuffer** jni_buffer) {
+ std::lock_guard lock(mutex_);
+
+ JniFrameBuffer* output_buffer;
+ if (free_buffer_count_) {
+ output_buffer = free_buffers_[--free_buffer_count_];
+ } else if (all_buffer_count_ < kMaxFrames) {
+ output_buffer = new (std::nothrow) JniFrameBuffer(all_buffer_count_);
+ if (output_buffer == nullptr) return kJniStatusOutOfMemory;
+ all_buffers_[all_buffer_count_++] = output_buffer;
+ } else {
+ // Maximum number of buffers is being used.
+ return kJniStatusOutOfMemory;
+ }
+ if (!output_buffer->MaybeReallocateGav1DataPlanes(y_plane_min_size,
+ uv_plane_min_size)) {
+ return kJniStatusOutOfMemory;
+ }
+
+ output_buffer->AddReference();
+ *jni_buffer = output_buffer;
+
+ return kJniStatusOk;
+ }
+
+ JniFrameBuffer* GetBuffer(int id) const { return all_buffers_[id]; }
+
+ void AddBufferReference(int id) {
+ std::lock_guard lock(mutex_);
+ all_buffers_[id]->AddReference();
+ }
+
+ JniStatusCode ReleaseBuffer(int id) {
+ std::lock_guard lock(mutex_);
+ JniFrameBuffer* buffer = all_buffers_[id];
+ if (!buffer->InUse()) {
+ return kJniStatusBufferAlreadyReleased;
+ }
+ buffer->RemoveReference();
+ if (!buffer->InUse()) {
+ free_buffers_[free_buffer_count_++] = buffer;
+ }
+ return kJniStatusOk;
+ }
+
+ private:
+ static const int kMaxFrames = 32;
+
+ JniFrameBuffer* all_buffers_[kMaxFrames];
+ int all_buffer_count_ = 0;
+
+ JniFrameBuffer* free_buffers_[kMaxFrames];
+ int free_buffer_count_ = 0;
+
+ std::mutex mutex_;
+};
+
+struct JniContext {
+ ~JniContext() {
+ if (native_window) {
+ ANativeWindow_release(native_window);
+ }
+ }
+
+ bool MaybeAcquireNativeWindow(JNIEnv* env, jobject new_surface) {
+ if (surface == new_surface) {
+ return true;
+ }
+ if (native_window) {
+ ANativeWindow_release(native_window);
+ }
+ native_window_width = 0;
+ native_window_height = 0;
+ native_window = ANativeWindow_fromSurface(env, new_surface);
+ if (native_window == nullptr) {
+ jni_status_code = kJniStatusANativeWindowError;
+ surface = nullptr;
+ return false;
+ }
+ surface = new_surface;
+ return true;
+ }
+
+ jfieldID decoder_private_field;
+ jfieldID output_mode_field;
+ jfieldID data_field;
+ jmethodID init_for_private_frame_method;
+ jmethodID init_for_yuv_frame_method;
+
+ JniBufferManager buffer_manager;
+ // The libgav1 decoder instance has to be deleted before |buffer_manager| is
+ // destructed. This will make sure that libgav1 releases all the frame
+ // buffers that it might be holding references to. So this has to be declared
+ // after |buffer_manager| since the destruction happens in reverse order of
+ // declaration.
+ libgav1::Decoder decoder;
+
+ ANativeWindow* native_window = nullptr;
+ jobject surface = nullptr;
+ int native_window_width = 0;
+ int native_window_height = 0;
+
+ Libgav1StatusCode libgav1_status_code = kLibgav1StatusOk;
+ JniStatusCode jni_status_code = kJniStatusOk;
+};
+
+Libgav1StatusCode Libgav1GetFrameBuffer(void* callback_private_data,
+ int bitdepth,
+ libgav1::ImageFormat image_format,
+ int width, int height, int left_border,
+ int right_border, int top_border,
+ int bottom_border, int stride_alignment,
+ libgav1::FrameBuffer* frame_buffer) {
+ libgav1::FrameBufferInfo info;
+ Libgav1StatusCode status = libgav1::ComputeFrameBufferInfo(
+ bitdepth, image_format, width, height, left_border, right_border,
+ top_border, bottom_border, stride_alignment, &info);
+ if (status != kLibgav1StatusOk) return status;
+
+ JniContext* const context = static_cast(callback_private_data);
+ JniFrameBuffer* jni_buffer;
+ context->jni_status_code = context->buffer_manager.GetBuffer(
+ info.y_buffer_size, info.uv_buffer_size, &jni_buffer);
+ if (context->jni_status_code != kJniStatusOk) {
+ LOGE("%s", GetJniErrorMessage(context->jni_status_code));
+ return kLibgav1StatusOutOfMemory;
+ }
+
+ uint8_t* const y_buffer = jni_buffer->RawBuffer(0);
+ uint8_t* const u_buffer =
+ (info.uv_buffer_size != 0) ? jni_buffer->RawBuffer(1) : nullptr;
+ uint8_t* const v_buffer =
+ (info.uv_buffer_size != 0) ? jni_buffer->RawBuffer(2) : nullptr;
+
+ return libgav1::SetFrameBuffer(&info, y_buffer, u_buffer, v_buffer,
+ jni_buffer->BufferPrivateData(), frame_buffer);
+}
+
+void Libgav1ReleaseFrameBuffer(void* callback_private_data,
+ void* buffer_private_data) {
+ JniContext* const context = static_cast(callback_private_data);
+ const int buffer_id = *static_cast(buffer_private_data);
+ context->jni_status_code = context->buffer_manager.ReleaseBuffer(buffer_id);
+ if (context->jni_status_code != kJniStatusOk) {
+ LOGE("%s", GetJniErrorMessage(context->jni_status_code));
+ }
+}
+
+constexpr int AlignTo16(int value) { return (value + 15) & (~15); }
+
+void CopyPlane(const uint8_t* source, int source_stride, uint8_t* destination,
+ int destination_stride, int width, int height) {
+ while (height--) {
+ std::memcpy(destination, source, width);
+ source += source_stride;
+ destination += destination_stride;
+ }
+}
+
+void CopyFrameToDataBuffer(const libgav1::DecoderBuffer* decoder_buffer,
+ jbyte* data) {
+ for (int plane_index = kPlaneY; plane_index < decoder_buffer->NumPlanes();
+ plane_index++) {
+ const uint64_t length = decoder_buffer->stride[plane_index] *
+ decoder_buffer->displayed_height[plane_index];
+ memcpy(data, decoder_buffer->plane[plane_index], length);
+ data += length;
+ }
+}
+
+void Convert10BitFrameTo8BitDataBuffer(
+ const libgav1::DecoderBuffer* decoder_buffer, jbyte* data) {
+ for (int plane_index = kPlaneY; plane_index < decoder_buffer->NumPlanes();
+ plane_index++) {
+ int sample = 0;
+ const uint8_t* source = decoder_buffer->plane[plane_index];
+ for (int i = 0; i < decoder_buffer->displayed_height[plane_index]; i++) {
+ const uint16_t* source_16 = reinterpret_cast(source);
+ for (int j = 0; j < decoder_buffer->displayed_width[plane_index]; j++) {
+ // Lightweight dither. Carryover the remainder of each 10->8 bit
+ // conversion to the next pixel.
+ sample += source_16[j];
+ data[j] = sample >> 2;
+ sample &= 3; // Remainder.
+ }
+ source += decoder_buffer->stride[plane_index];
+ data += decoder_buffer->stride[plane_index];
+ }
+ }
+}
+
+#ifdef CPU_FEATURES_COMPILED_ANY_ARM_NEON
+void Convert10BitFrameTo8BitDataBufferNeon(
+ const libgav1::DecoderBuffer* decoder_buffer, jbyte* data) {
+ uint32x2_t lcg_value = vdup_n_u32(random());
+ lcg_value = vset_lane_u32(random(), lcg_value, 1);
+ // LCG values recommended in "Numerical Recipes".
+ const uint32x2_t LCG_MULT = vdup_n_u32(1664525);
+ const uint32x2_t LCG_INCR = vdup_n_u32(1013904223);
+
+ for (int plane_index = kPlaneY; plane_index < kMaxPlanes; plane_index++) {
+ const uint8_t* source = decoder_buffer->plane[plane_index];
+
+ for (int i = 0; i < decoder_buffer->displayed_height[plane_index]; i++) {
+ const uint16_t* source_16 = reinterpret_cast(source);
+ uint8_t* destination = reinterpret_cast(data);
+
+ // Each read consumes 4 2-byte samples, but to reduce branches and
+ // random steps we unroll to 4 rounds, so each loop consumes 16
+ // samples.
+ const int j_max = decoder_buffer->displayed_width[plane_index] & ~15;
+ int j;
+ for (j = 0; j < j_max; j += 16) {
+ // Run a round of the RNG.
+ lcg_value = vmla_u32(LCG_INCR, lcg_value, LCG_MULT);
+
+ // Round 1.
+ // The lower two bits of this LCG parameterization are garbage,
+ // leaving streaks on the image. We access the upper bits of each
+ // 16-bit lane by shifting. (We use this both as an 8- and 16-bit
+ // vector, so the choice of which one to keep it as is arbitrary.)
+ uint8x8_t randvec =
+ vreinterpret_u8_u16(vshr_n_u16(vreinterpret_u16_u32(lcg_value), 8));
+
+ // We retrieve the values and shift them so that the bits we'll
+ // shift out (after biasing) are in the upper 8 bits of each 16-bit
+ // lane.
+ uint16x4_t values = vshl_n_u16(vld1_u16(source_16), 6);
+ // We add the bias bits in the lower 8 to the shifted values to get
+ // the final values in the upper 8 bits.
+ uint16x4_t added_1 = vqadd_u16(values, vreinterpret_u16_u8(randvec));
+ source_16 += 4;
+
+ // Round 2.
+ // Shifting the randvec bits left by 2 bits, as an 8-bit vector,
+ // should leave us with enough bias to get the needed rounding
+ // operation.
+ randvec = vshl_n_u8(randvec, 2);
+
+ // Retrieve and sum the next 4 pixels.
+ values = vshl_n_u16(vld1_u16(source_16), 6);
+ uint16x4_t added_2 = vqadd_u16(values, vreinterpret_u16_u8(randvec));
+ source_16 += 4;
+
+ // Reinterpret the two added vectors as 8x8, zip them together, and
+ // discard the lower portions.
+ uint8x8_t zipped =
+ vuzp_u8(vreinterpret_u8_u16(added_1), vreinterpret_u8_u16(added_2))
+ .val[1];
+ vst1_u8(destination, zipped);
+ destination += 8;
+
+ // Run it again with the next two rounds using the remaining
+ // entropy in randvec.
+
+ // Round 3.
+ randvec = vshl_n_u8(randvec, 2);
+ values = vshl_n_u16(vld1_u16(source_16), 6);
+ added_1 = vqadd_u16(values, vreinterpret_u16_u8(randvec));
+ source_16 += 4;
+
+ // Round 4.
+ randvec = vshl_n_u8(randvec, 2);
+ values = vshl_n_u16(vld1_u16(source_16), 6);
+ added_2 = vqadd_u16(values, vreinterpret_u16_u8(randvec));
+ source_16 += 4;
+
+ zipped =
+ vuzp_u8(vreinterpret_u8_u16(added_1), vreinterpret_u8_u16(added_2))
+ .val[1];
+ vst1_u8(destination, zipped);
+ destination += 8;
+ }
+
+ uint32_t randval = 0;
+ // For the remaining pixels in each row - usually none, as most
+ // standard sizes are divisible by 32 - convert them "by hand".
+ for (; j < decoder_buffer->displayed_width[plane_index]; j++) {
+ if (!randval) randval = random();
+ destination[j] = (source_16[j] + (randval & 3)) >> 2;
+ randval >>= 2;
+ }
+
+ source += decoder_buffer->stride[plane_index];
+ data += decoder_buffer->stride[plane_index];
+ }
+ }
+}
+#endif // CPU_FEATURES_COMPILED_ANY_ARM_NEON
+
+} // namespace
+
+DECODER_FUNC(jlong, gav1Init, jint threads) {
+ JniContext* context = new (std::nothrow) JniContext();
+ if (context == nullptr) {
+ return kStatusError;
+ }
+
+#ifdef CPU_FEATURES_ARCH_ARM
+ // Libgav1 requires NEON with arm ABIs.
+#ifdef CPU_FEATURES_COMPILED_ANY_ARM_NEON
+ const cpu_features::ArmFeatures arm_features =
+ cpu_features::GetArmInfo().features;
+ if (!arm_features.neon) {
+ context->jni_status_code = kJniStatusNeonNotSupported;
+ return reinterpret_cast(context);
+ }
+#else
+ context->jni_status_code = kJniStatusNeonNotSupported;
+ return reinterpret_cast(context);
+#endif // CPU_FEATURES_COMPILED_ANY_ARM_NEON
+#endif // CPU_FEATURES_ARCH_ARM
+
+ libgav1::DecoderSettings settings;
+ settings.threads = threads;
+ settings.get_frame_buffer = Libgav1GetFrameBuffer;
+ settings.release_frame_buffer = Libgav1ReleaseFrameBuffer;
+ settings.callback_private_data = context;
+
+ context->libgav1_status_code = context->decoder.Init(&settings);
+ if (context->libgav1_status_code != kLibgav1StatusOk) {
+ return reinterpret_cast(context);
+ }
+
+ // Populate JNI References.
+ const jclass outputBufferClass = env->FindClass(
+ "com/google/android/exoplayer2/video/VideoDecoderOutputBuffer");
+ context->decoder_private_field =
+ env->GetFieldID(outputBufferClass, "decoderPrivate", "I");
+ context->output_mode_field = env->GetFieldID(outputBufferClass, "mode", "I");
+ context->data_field =
+ env->GetFieldID(outputBufferClass, "data", "Ljava/nio/ByteBuffer;");
+ context->init_for_private_frame_method =
+ env->GetMethodID(outputBufferClass, "initForPrivateFrame", "(II)V");
+ context->init_for_yuv_frame_method =
+ env->GetMethodID(outputBufferClass, "initForYuvFrame", "(IIIII)Z");
+
+ return reinterpret_cast(context);
+}
+
+DECODER_FUNC(void, gav1Close, jlong jContext) {
+ JniContext* const context = reinterpret_cast(jContext);
+ delete context;
+}
+
+DECODER_FUNC(jint, gav1Decode, jlong jContext, jobject encodedData,
+ jint length) {
+ JniContext* const context = reinterpret_cast(jContext);
+ const uint8_t* const buffer = reinterpret_cast(
+ env->GetDirectBufferAddress(encodedData));
+ context->libgav1_status_code =
+ context->decoder.EnqueueFrame(buffer, length, /*user_private_data=*/0,
+ /*buffer_private_data=*/nullptr);
+ if (context->libgav1_status_code != kLibgav1StatusOk) {
+ return kStatusError;
+ }
+ return kStatusOk;
+}
+
+DECODER_FUNC(jint, gav1GetFrame, jlong jContext, jobject jOutputBuffer,
+ jboolean decodeOnly) {
+ JniContext* const context = reinterpret_cast(jContext);
+ const libgav1::DecoderBuffer* decoder_buffer;
+ context->libgav1_status_code = context->decoder.DequeueFrame(&decoder_buffer);
+ if (context->libgav1_status_code != kLibgav1StatusOk) {
+ return kStatusError;
+ }
+
+ if (decodeOnly || decoder_buffer == nullptr) {
+ // This is not an error. The input data was decode-only or no displayable
+ // frames are available.
+ return kStatusDecodeOnly;
+ }
+
+ const int output_mode =
+ env->GetIntField(jOutputBuffer, context->output_mode_field);
+ if (output_mode == kOutputModeYuv) {
+ // Resize the buffer if required. Default color conversion will be used as
+ // libgav1::DecoderBuffer doesn't expose color space info.
+ const jboolean init_result = env->CallBooleanMethod(
+ jOutputBuffer, context->init_for_yuv_frame_method,
+ decoder_buffer->displayed_width[kPlaneY],
+ decoder_buffer->displayed_height[kPlaneY],
+ decoder_buffer->stride[kPlaneY], decoder_buffer->stride[kPlaneU],
+ kColorSpaceUnknown);
+ if (env->ExceptionCheck()) {
+ // Exception is thrown in Java when returning from the native call.
+ return kStatusError;
+ }
+ if (!init_result) {
+ context->jni_status_code = kJniStatusBufferResizeError;
+ return kStatusError;
+ }
+
+ const jobject data_object =
+ env->GetObjectField(jOutputBuffer, context->data_field);
+ jbyte* const data =
+ reinterpret_cast(env->GetDirectBufferAddress(data_object));
+
+ switch (decoder_buffer->bitdepth) {
+ case 8:
+ CopyFrameToDataBuffer(decoder_buffer, data);
+ break;
+ case 10:
+#ifdef CPU_FEATURES_COMPILED_ANY_ARM_NEON
+ Convert10BitFrameTo8BitDataBufferNeon(decoder_buffer, data);
+#else
+ Convert10BitFrameTo8BitDataBuffer(decoder_buffer, data);
+#endif // CPU_FEATURES_COMPILED_ANY_ARM_NEON
+ break;
+ default:
+ context->jni_status_code = kJniStatusBitDepth12NotSupportedWithYuv;
+ return kStatusError;
+ }
+ } else if (output_mode == kOutputModeSurfaceYuv) {
+ if (decoder_buffer->bitdepth != 8) {
+ context->jni_status_code =
+ kJniStatusHighBitDepthNotSupportedWithSurfaceYuv;
+ return kStatusError;
+ }
+
+ if (decoder_buffer->NumPlanes() > kMaxPlanes) {
+ context->jni_status_code = kJniStatusInvalidNumOfPlanes;
+ return kStatusError;
+ }
+
+ const int buffer_id =
+ *static_cast(decoder_buffer->buffer_private_data);
+ context->buffer_manager.AddBufferReference(buffer_id);
+ JniFrameBuffer* const jni_buffer =
+ context->buffer_manager.GetBuffer(buffer_id);
+ jni_buffer->SetFrameData(*decoder_buffer);
+ env->CallVoidMethod(jOutputBuffer, context->init_for_private_frame_method,
+ decoder_buffer->displayed_width[kPlaneY],
+ decoder_buffer->displayed_height[kPlaneY]);
+ if (env->ExceptionCheck()) {
+ // Exception is thrown in Java when returning from the native call.
+ return kStatusError;
+ }
+ env->SetIntField(jOutputBuffer, context->decoder_private_field, buffer_id);
+ }
+
+ return kStatusOk;
+}
+
+DECODER_FUNC(jint, gav1RenderFrame, jlong jContext, jobject jSurface,
+ jobject jOutputBuffer) {
+ JniContext* const context = reinterpret_cast(jContext);
+ const int buffer_id =
+ env->GetIntField(jOutputBuffer, context->decoder_private_field);
+ JniFrameBuffer* const jni_buffer =
+ context->buffer_manager.GetBuffer(buffer_id);
+
+ if (!context->MaybeAcquireNativeWindow(env, jSurface)) {
+ return kStatusError;
+ }
+
+ if (context->native_window_width != jni_buffer->DisplayedWidth(kPlaneY) ||
+ context->native_window_height != jni_buffer->DisplayedHeight(kPlaneY)) {
+ if (ANativeWindow_setBuffersGeometry(
+ context->native_window, jni_buffer->DisplayedWidth(kPlaneY),
+ jni_buffer->DisplayedHeight(kPlaneY), kImageFormatYV12)) {
+ context->jni_status_code = kJniStatusANativeWindowError;
+ return kStatusError;
+ }
+ context->native_window_width = jni_buffer->DisplayedWidth(kPlaneY);
+ context->native_window_height = jni_buffer->DisplayedHeight(kPlaneY);
+ }
+
+ ANativeWindow_Buffer native_window_buffer;
+ if (ANativeWindow_lock(context->native_window, &native_window_buffer,
+ /*inOutDirtyBounds=*/nullptr) ||
+ native_window_buffer.bits == nullptr) {
+ context->jni_status_code = kJniStatusANativeWindowError;
+ return kStatusError;
+ }
+
+ // Y plane
+ CopyPlane(jni_buffer->Plane(kPlaneY), jni_buffer->Stride(kPlaneY),
+ reinterpret_cast(native_window_buffer.bits),
+ native_window_buffer.stride, jni_buffer->DisplayedWidth(kPlaneY),
+ jni_buffer->DisplayedHeight(kPlaneY));
+
+ const int y_plane_size =
+ native_window_buffer.stride * native_window_buffer.height;
+ const int32_t native_window_buffer_uv_height =
+ (native_window_buffer.height + 1) / 2;
+ const int native_window_buffer_uv_stride =
+ AlignTo16(native_window_buffer.stride / 2);
+
+ // TODO(b/140606738): Handle monochrome videos.
+
+ // V plane
+ // Since the format for ANativeWindow is YV12, V plane is being processed
+ // before U plane.
+ const int v_plane_height = std::min(native_window_buffer_uv_height,
+ jni_buffer->DisplayedHeight(kPlaneV));
+ CopyPlane(
+ jni_buffer->Plane(kPlaneV), jni_buffer->Stride(kPlaneV),
+ reinterpret_cast(native_window_buffer.bits) + y_plane_size,
+ native_window_buffer_uv_stride, jni_buffer->DisplayedWidth(kPlaneV),
+ v_plane_height);
+
+ const int v_plane_size = v_plane_height * native_window_buffer_uv_stride;
+
+ // U plane
+ CopyPlane(jni_buffer->Plane(kPlaneU), jni_buffer->Stride(kPlaneU),
+ reinterpret_cast(native_window_buffer.bits) +
+ y_plane_size + v_plane_size,
+ native_window_buffer_uv_stride, jni_buffer->DisplayedWidth(kPlaneU),
+ std::min(native_window_buffer_uv_height,
+ jni_buffer->DisplayedHeight(kPlaneU)));
+
+ if (ANativeWindow_unlockAndPost(context->native_window)) {
+ context->jni_status_code = kJniStatusANativeWindowError;
+ return kStatusError;
+ }
+
+ return kStatusOk;
+}
+
+DECODER_FUNC(void, gav1ReleaseFrame, jlong jContext, jobject jOutputBuffer) {
+ JniContext* const context = reinterpret_cast(jContext);
+ const int buffer_id =
+ env->GetIntField(jOutputBuffer, context->decoder_private_field);
+ env->SetIntField(jOutputBuffer, context->decoder_private_field, -1);
+ context->jni_status_code = context->buffer_manager.ReleaseBuffer(buffer_id);
+ if (context->jni_status_code != kJniStatusOk) {
+ LOGE("%s", GetJniErrorMessage(context->jni_status_code));
+ }
+}
+
+DECODER_FUNC(jstring, gav1GetErrorMessage, jlong jContext) {
+ if (jContext == 0) {
+ return env->NewStringUTF("Failed to initialize JNI context.");
+ }
+
+ JniContext* const context = reinterpret_cast(jContext);
+ if (context->libgav1_status_code != kLibgav1StatusOk) {
+ return env->NewStringUTF(
+ libgav1::GetErrorString(context->libgav1_status_code));
+ }
+ if (context->jni_status_code != kJniStatusOk) {
+ return env->NewStringUTF(GetJniErrorMessage(context->jni_status_code));
+ }
+
+ return env->NewStringUTF("None.");
+}
+
+DECODER_FUNC(jint, gav1CheckError, jlong jContext) {
+ JniContext* const context = reinterpret_cast(jContext);
+ if (context->libgav1_status_code != kLibgav1StatusOk ||
+ context->jni_status_code != kJniStatusOk) {
+ return kStatusError;
+ }
+ return kStatusOk;
+}
+
+DECODER_FUNC(jint, gav1GetThreads) {
+ return gav1_jni::GetNumberOfPerformanceCoresOnline();
+}
+
+// TODO(b/139902005): Add functions for getting libgav1 version and build
+// configuration once libgav1 ABI provides this information.
diff --git a/extensions/cast/README.md b/extensions/cast/README.md
new file mode 100644
index 0000000000..1c0d7ac56f
--- /dev/null
+++ b/extensions/cast/README.md
@@ -0,0 +1,30 @@
+# ExoPlayer Cast extension #
+
+## Description ##
+
+The cast extension is a [Player][] implementation that controls playback on a
+Cast receiver app.
+
+[Player]: https://exoplayer.dev/doc/reference/index.html?com/google/android/exoplayer2/Player.html
+
+## Getting the extension ##
+
+The easiest way to use the extension is to add it as a gradle dependency:
+
+```gradle
+implementation 'com.google.android.exoplayer:extension-cast:2.X.X'
+```
+
+where `2.X.X` is the version, which must match the version of the ExoPlayer
+library being used.
+
+Alternatively, you can clone the ExoPlayer repository and depend on the module
+locally. Instructions for doing this can be found in ExoPlayer's
+[top level README][].
+
+[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
+
+## Using the extension ##
+
+Create a `CastPlayer` and use it to integrate Cast into your app using
+ExoPlayer's common `Player` interface.
diff --git a/extensions/cast/build.gradle b/extensions/cast/build.gradle
new file mode 100644
index 0000000000..4c8f648e34
--- /dev/null
+++ b/extensions/cast/build.gradle
@@ -0,0 +1,37 @@
+// Copyright (C) 2017 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.
+apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle"
+
+dependencies {
+ api 'com.google.android.gms:play-services-cast-framework:18.1.0'
+ implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
+ implementation project(modulePrefix + 'library-core')
+ implementation project(modulePrefix + 'library-ui')
+ compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
+ compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion
+ compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion
+ testImplementation project(modulePrefix + 'testutils')
+ testImplementation 'org.robolectric:robolectric:' + robolectricVersion
+}
+
+ext {
+ javadocTitle = 'Cast extension'
+}
+apply from: '../../javadoc_library.gradle'
+
+ext {
+ releaseArtifact = 'extension-cast'
+ releaseDescription = 'Cast extension for ExoPlayer.'
+}
+apply from: '../../publish.gradle'
diff --git a/extensions/cast/src/main/AndroidManifest.xml b/extensions/cast/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..c12fc1289f
--- /dev/null
+++ b/extensions/cast/src/main/AndroidManifest.xml
@@ -0,0 +1,16 @@
+
+
+
diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java
new file mode 100644
index 0000000000..80d9817a46
--- /dev/null
+++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java
@@ -0,0 +1,1154 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.cast;
+
+import static java.lang.Math.min;
+
+import android.os.Looper;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.BasePlayer;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.ExoPlaybackException;
+import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
+import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.PlaybackParameters;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.Timeline;
+import com.google.android.exoplayer2.source.TrackGroup;
+import com.google.android.exoplayer2.source.TrackGroupArray;
+import com.google.android.exoplayer2.trackselection.FixedTrackSelection;
+import com.google.android.exoplayer2.trackselection.TrackSelection;
+import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
+import com.google.android.exoplayer2.trackselection.TrackSelector;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.Log;
+import com.google.android.exoplayer2.util.MimeTypes;
+import com.google.android.gms.cast.CastStatusCodes;
+import com.google.android.gms.cast.MediaInfo;
+import com.google.android.gms.cast.MediaQueueItem;
+import com.google.android.gms.cast.MediaStatus;
+import com.google.android.gms.cast.MediaTrack;
+import com.google.android.gms.cast.framework.CastContext;
+import com.google.android.gms.cast.framework.CastSession;
+import com.google.android.gms.cast.framework.SessionManager;
+import com.google.android.gms.cast.framework.SessionManagerListener;
+import com.google.android.gms.cast.framework.media.RemoteMediaClient;
+import com.google.android.gms.cast.framework.media.RemoteMediaClient.MediaChannelResult;
+import com.google.android.gms.common.api.PendingResult;
+import com.google.android.gms.common.api.ResultCallback;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import org.checkerframework.checker.nullness.qual.RequiresNonNull;
+
+/**
+ * {@link Player} implementation that communicates with a Cast receiver app.
+ *
+ * The behavior of this class depends on the underlying Cast session, which is obtained from the
+ * injected {@link CastContext}. To keep track of the session, {@link #isCastSessionAvailable()} can
+ * be queried and {@link SessionAvailabilityListener} can be implemented and attached to the player.
+ *
+ *
If no session is available, the player state will remain unchanged and calls to methods that
+ * alter it will be ignored. Querying the player state is possible even when no session is
+ * available, in which case, the last observed receiver app state is reported.
+ *
+ *
Methods should be called on the application's main thread.
+ */
+public final class CastPlayer extends BasePlayer {
+
+ static {
+ ExoPlayerLibraryInfo.registerModule("goog.exo.cast");
+ }
+
+ private static final String TAG = "CastPlayer";
+
+ private static final int RENDERER_COUNT = 3;
+ private static final int RENDERER_INDEX_VIDEO = 0;
+ private static final int RENDERER_INDEX_AUDIO = 1;
+ private static final int RENDERER_INDEX_TEXT = 2;
+ private static final long PROGRESS_REPORT_PERIOD_MS = 1000;
+ private static final TrackSelectionArray EMPTY_TRACK_SELECTION_ARRAY =
+ new TrackSelectionArray(null, null, null);
+ private static final long[] EMPTY_TRACK_ID_ARRAY = new long[0];
+
+ private final CastContext castContext;
+ private final MediaItemConverter mediaItemConverter;
+ // TODO: Allow custom implementations of CastTimelineTracker.
+ private final CastTimelineTracker timelineTracker;
+ private final Timeline.Period period;
+
+ // Result callbacks.
+ private final StatusListener statusListener;
+ private final SeekResultCallback seekResultCallback;
+
+ // Listeners and notification.
+ private final CopyOnWriteArrayList listeners;
+ private final ArrayList notificationsBatch;
+ private final ArrayDeque ongoingNotificationsTasks;
+ @Nullable private SessionAvailabilityListener sessionAvailabilityListener;
+
+ // Internal state.
+ private final StateHolder playWhenReady;
+ private final StateHolder repeatMode;
+ @Nullable private RemoteMediaClient remoteMediaClient;
+ private CastTimeline currentTimeline;
+ private TrackGroupArray currentTrackGroups;
+ private TrackSelectionArray currentTrackSelection;
+ @Player.State private int playbackState;
+ private int currentWindowIndex;
+ private long lastReportedPositionMs;
+ private int pendingSeekCount;
+ private int pendingSeekWindowIndex;
+ private long pendingSeekPositionMs;
+
+ /**
+ * Creates a new cast player that uses a {@link DefaultMediaItemConverter}.
+ *
+ * @param castContext The context from which the cast session is obtained.
+ */
+ public CastPlayer(CastContext castContext) {
+ this(castContext, new DefaultMediaItemConverter());
+ }
+
+ /**
+ * Creates a new cast player.
+ *
+ * @param castContext The context from which the cast session is obtained.
+ * @param mediaItemConverter The {@link MediaItemConverter} to use.
+ */
+ public CastPlayer(CastContext castContext, MediaItemConverter mediaItemConverter) {
+ this.castContext = castContext;
+ this.mediaItemConverter = mediaItemConverter;
+ timelineTracker = new CastTimelineTracker();
+ period = new Timeline.Period();
+ statusListener = new StatusListener();
+ seekResultCallback = new SeekResultCallback();
+ listeners = new CopyOnWriteArrayList<>();
+ notificationsBatch = new ArrayList<>();
+ ongoingNotificationsTasks = new ArrayDeque<>();
+
+ playWhenReady = new StateHolder<>(false);
+ repeatMode = new StateHolder<>(REPEAT_MODE_OFF);
+ playbackState = STATE_IDLE;
+ currentTimeline = CastTimeline.EMPTY_CAST_TIMELINE;
+ currentTrackGroups = TrackGroupArray.EMPTY;
+ currentTrackSelection = EMPTY_TRACK_SELECTION_ARRAY;
+ pendingSeekWindowIndex = C.INDEX_UNSET;
+ pendingSeekPositionMs = C.TIME_UNSET;
+
+ SessionManager sessionManager = castContext.getSessionManager();
+ sessionManager.addSessionManagerListener(statusListener, CastSession.class);
+ CastSession session = sessionManager.getCurrentCastSession();
+ setRemoteMediaClient(session != null ? session.getRemoteMediaClient() : null);
+ updateInternalStateAndNotifyIfChanged();
+ }
+
+ // Media Queue manipulation methods.
+
+ /** @deprecated Use {@link #setMediaItems(List, int, long)} instead. */
+ @Deprecated
+ @Nullable
+ public PendingResult loadItem(MediaQueueItem item, long positionMs) {
+ return setMediaItemsInternal(
+ new MediaQueueItem[] {item}, /* startWindowIndex= */ 0, positionMs, repeatMode.value);
+ }
+
+ /**
+ * @deprecated Use {@link #setMediaItems(List, int, long)} and {@link #setRepeatMode(int)}
+ * instead.
+ */
+ @Deprecated
+ @Nullable
+ public PendingResult loadItems(
+ MediaQueueItem[] items, int startIndex, long positionMs, @RepeatMode int repeatMode) {
+ return setMediaItemsInternal(items, startIndex, positionMs, repeatMode);
+ }
+
+ /** @deprecated Use {@link #addMediaItems(List)} instead. */
+ @Deprecated
+ @Nullable
+ public PendingResult addItems(MediaQueueItem... items) {
+ return addMediaItemsInternal(items, MediaQueueItem.INVALID_ITEM_ID);
+ }
+
+ /** @deprecated Use {@link #addMediaItems(int, List)} instead. */
+ @Deprecated
+ @Nullable
+ public PendingResult addItems(int periodId, MediaQueueItem... items) {
+ if (periodId == MediaQueueItem.INVALID_ITEM_ID
+ || currentTimeline.getIndexOfPeriod(periodId) != C.INDEX_UNSET) {
+ return addMediaItemsInternal(items, periodId);
+ }
+ return null;
+ }
+
+ /** @deprecated Use {@link #removeMediaItem(int)} instead. */
+ @Deprecated
+ @Nullable
+ public PendingResult removeItem(int periodId) {
+ if (currentTimeline.getIndexOfPeriod(periodId) != C.INDEX_UNSET) {
+ return removeMediaItemsInternal(new int[] {periodId});
+ }
+ return null;
+ }
+
+ /** @deprecated Use {@link #moveMediaItem(int, int)} instead. */
+ @Deprecated
+ @Nullable
+ public PendingResult moveItem(int periodId, int newIndex) {
+ Assertions.checkArgument(newIndex >= 0 && newIndex < currentTimeline.getWindowCount());
+ int fromIndex = currentTimeline.getIndexOfPeriod(periodId);
+ if (fromIndex != C.INDEX_UNSET && fromIndex != newIndex) {
+ return moveMediaItemsInternal(new int[] {periodId}, fromIndex, newIndex);
+ }
+ return null;
+ }
+
+ /**
+ * Returns the item that corresponds to the period with the given id, or null if no media queue or
+ * period with id {@code periodId} exist.
+ *
+ * @param periodId The id of the period ({@link #getCurrentTimeline}) that corresponds to the item
+ * to get.
+ * @return The item that corresponds to the period with the given id, or null if no media queue or
+ * period with id {@code periodId} exist.
+ */
+ @Nullable
+ public MediaQueueItem getItem(int periodId) {
+ MediaStatus mediaStatus = getMediaStatus();
+ return mediaStatus != null && currentTimeline.getIndexOfPeriod(periodId) != C.INDEX_UNSET
+ ? mediaStatus.getItemById(periodId) : null;
+ }
+
+ // CastSession methods.
+
+ /**
+ * Returns whether a cast session is available.
+ */
+ public boolean isCastSessionAvailable() {
+ return remoteMediaClient != null;
+ }
+
+ /**
+ * Sets a listener for updates on the cast session availability.
+ *
+ * @param listener The {@link SessionAvailabilityListener}, or null to clear the listener.
+ */
+ public void setSessionAvailabilityListener(@Nullable SessionAvailabilityListener listener) {
+ sessionAvailabilityListener = listener;
+ }
+
+ // Player implementation.
+
+ @Override
+ @Nullable
+ public AudioComponent getAudioComponent() {
+ return null;
+ }
+
+ @Override
+ @Nullable
+ public VideoComponent getVideoComponent() {
+ return null;
+ }
+
+ @Override
+ @Nullable
+ public TextComponent getTextComponent() {
+ return null;
+ }
+
+ @Override
+ @Nullable
+ public MetadataComponent getMetadataComponent() {
+ return null;
+ }
+
+ @Override
+ @Nullable
+ public DeviceComponent getDeviceComponent() {
+ // TODO(b/151792305): Implement the component.
+ return null;
+ }
+
+ @Override
+ public Looper getApplicationLooper() {
+ return Looper.getMainLooper();
+ }
+
+ @Override
+ public void addListener(EventListener listener) {
+ Assertions.checkNotNull(listener);
+ listeners.addIfAbsent(new ListenerHolder(listener));
+ }
+
+ @Override
+ public void removeListener(EventListener listener) {
+ for (ListenerHolder listenerHolder : listeners) {
+ if (listenerHolder.listener.equals(listener)) {
+ listenerHolder.release();
+ listeners.remove(listenerHolder);
+ }
+ }
+ }
+
+ @Override
+ public void setMediaItems(
+ List mediaItems, int startWindowIndex, long startPositionMs) {
+ setMediaItemsInternal(
+ toMediaQueueItems(mediaItems), startWindowIndex, startPositionMs, repeatMode.value);
+ }
+
+ @Override
+ public void addMediaItems(List mediaItems) {
+ addMediaItemsInternal(toMediaQueueItems(mediaItems), MediaQueueItem.INVALID_ITEM_ID);
+ }
+
+ @Override
+ public void addMediaItems(int index, List mediaItems) {
+ Assertions.checkArgument(index >= 0);
+ int uid = MediaQueueItem.INVALID_ITEM_ID;
+ if (index < currentTimeline.getWindowCount()) {
+ uid = (int) currentTimeline.getWindow(/* windowIndex= */ index, window).uid;
+ }
+ addMediaItemsInternal(toMediaQueueItems(mediaItems), uid);
+ }
+
+ @Override
+ public void moveMediaItems(int fromIndex, int toIndex, int newIndex) {
+ Assertions.checkArgument(
+ fromIndex >= 0
+ && fromIndex <= toIndex
+ && toIndex <= currentTimeline.getWindowCount()
+ && newIndex >= 0
+ && newIndex < currentTimeline.getWindowCount());
+ newIndex = min(newIndex, currentTimeline.getWindowCount() - (toIndex - fromIndex));
+ if (fromIndex == toIndex || fromIndex == newIndex) {
+ // Do nothing.
+ return;
+ }
+ int[] uids = new int[toIndex - fromIndex];
+ for (int i = 0; i < uids.length; i++) {
+ uids[i] = (int) currentTimeline.getWindow(/* windowIndex= */ i + fromIndex, window).uid;
+ }
+ moveMediaItemsInternal(uids, fromIndex, newIndex);
+ }
+
+ @Override
+ public void removeMediaItems(int fromIndex, int toIndex) {
+ Assertions.checkArgument(
+ fromIndex >= 0 && toIndex >= fromIndex && toIndex <= currentTimeline.getWindowCount());
+ if (fromIndex == toIndex) {
+ // Do nothing.
+ return;
+ }
+ int[] uids = new int[toIndex - fromIndex];
+ for (int i = 0; i < uids.length; i++) {
+ uids[i] = (int) currentTimeline.getWindow(/* windowIndex= */ i + fromIndex, window).uid;
+ }
+ removeMediaItemsInternal(uids);
+ }
+
+ @Override
+ public void clearMediaItems() {
+ removeMediaItems(/* fromIndex= */ 0, /* toIndex= */ currentTimeline.getWindowCount());
+ }
+
+ @Override
+ public void prepare() {
+ // Do nothing.
+ }
+
+ @Override
+ @Player.State
+ public int getPlaybackState() {
+ return playbackState;
+ }
+
+ @Override
+ @PlaybackSuppressionReason
+ public int getPlaybackSuppressionReason() {
+ return Player.PLAYBACK_SUPPRESSION_REASON_NONE;
+ }
+
+ @Deprecated
+ @Override
+ @Nullable
+ public ExoPlaybackException getPlaybackError() {
+ return getPlayerError();
+ }
+
+ @Override
+ @Nullable
+ public ExoPlaybackException getPlayerError() {
+ return null;
+ }
+
+ @Override
+ public void setPlayWhenReady(boolean playWhenReady) {
+ if (remoteMediaClient == null) {
+ return;
+ }
+ // We update the local state and send the message to the receiver app, which will cause the
+ // operation to be perceived as synchronous by the user. When the operation reports a result,
+ // the local state will be updated to reflect the state reported by the Cast SDK.
+ setPlayerStateAndNotifyIfChanged(
+ playWhenReady, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, playbackState);
+ flushNotifications();
+ PendingResult pendingResult =
+ playWhenReady ? remoteMediaClient.play() : remoteMediaClient.pause();
+ this.playWhenReady.pendingResultCallback =
+ new ResultCallback() {
+ @Override
+ public void onResult(MediaChannelResult mediaChannelResult) {
+ if (remoteMediaClient != null) {
+ updatePlayerStateAndNotifyIfChanged(this);
+ flushNotifications();
+ }
+ }
+ };
+ pendingResult.setResultCallback(this.playWhenReady.pendingResultCallback);
+ }
+
+ @Override
+ public boolean getPlayWhenReady() {
+ return playWhenReady.value;
+ }
+
+ // We still call EventListener#onSeekProcessed() for backwards compatibility with listeners that
+ // don't implement onPositionDiscontinuity().
+ @SuppressWarnings("deprecation")
+ @Override
+ public void seekTo(int windowIndex, long positionMs) {
+ MediaStatus mediaStatus = getMediaStatus();
+ // We assume the default position is 0. There is no support for seeking to the default position
+ // in RemoteMediaClient.
+ positionMs = positionMs != C.TIME_UNSET ? positionMs : 0;
+ if (mediaStatus != null) {
+ if (getCurrentWindowIndex() != windowIndex) {
+ remoteMediaClient.queueJumpToItem((int) currentTimeline.getPeriod(windowIndex, period).uid,
+ positionMs, null).setResultCallback(seekResultCallback);
+ } else {
+ remoteMediaClient.seek(positionMs).setResultCallback(seekResultCallback);
+ }
+ pendingSeekCount++;
+ pendingSeekWindowIndex = windowIndex;
+ pendingSeekPositionMs = positionMs;
+ notificationsBatch.add(
+ new ListenerNotificationTask(
+ listener -> listener.onPositionDiscontinuity(DISCONTINUITY_REASON_SEEK)));
+ } else if (pendingSeekCount == 0) {
+ notificationsBatch.add(new ListenerNotificationTask(EventListener::onSeekProcessed));
+ }
+ flushNotifications();
+ }
+
+ @Override
+ public void setPlaybackParameters(@Nullable PlaybackParameters playbackParameters) {
+ // Unsupported by the RemoteMediaClient API. Do nothing.
+ }
+
+ @Override
+ public PlaybackParameters getPlaybackParameters() {
+ return PlaybackParameters.DEFAULT;
+ }
+
+ @Override
+ public void stop(boolean reset) {
+ playbackState = STATE_IDLE;
+ if (remoteMediaClient != null) {
+ // TODO(b/69792021): Support or emulate stop without position reset.
+ remoteMediaClient.stop();
+ }
+ }
+
+ @Override
+ public void release() {
+ SessionManager sessionManager = castContext.getSessionManager();
+ sessionManager.removeSessionManagerListener(statusListener, CastSession.class);
+ sessionManager.endCurrentSession(false);
+ }
+
+ @Override
+ public int getRendererCount() {
+ // We assume there are three renderers: video, audio, and text.
+ return RENDERER_COUNT;
+ }
+
+ @Override
+ public int getRendererType(int index) {
+ switch (index) {
+ case RENDERER_INDEX_VIDEO:
+ return C.TRACK_TYPE_VIDEO;
+ case RENDERER_INDEX_AUDIO:
+ return C.TRACK_TYPE_AUDIO;
+ case RENDERER_INDEX_TEXT:
+ return C.TRACK_TYPE_TEXT;
+ default:
+ throw new IndexOutOfBoundsException();
+ }
+ }
+
+ @Override
+ @Nullable
+ public TrackSelector getTrackSelector() {
+ return null;
+ }
+
+ @Override
+ public void setRepeatMode(@RepeatMode int repeatMode) {
+ if (remoteMediaClient == null) {
+ return;
+ }
+ // We update the local state and send the message to the receiver app, which will cause the
+ // operation to be perceived as synchronous by the user. When the operation reports a result,
+ // the local state will be updated to reflect the state reported by the Cast SDK.
+ setRepeatModeAndNotifyIfChanged(repeatMode);
+ flushNotifications();
+ PendingResult pendingResult =
+ remoteMediaClient.queueSetRepeatMode(getCastRepeatMode(repeatMode), /* jsonObject= */ null);
+ this.repeatMode.pendingResultCallback =
+ new ResultCallback() {
+ @Override
+ public void onResult(MediaChannelResult mediaChannelResult) {
+ if (remoteMediaClient != null) {
+ updateRepeatModeAndNotifyIfChanged(this);
+ flushNotifications();
+ }
+ }
+ };
+ pendingResult.setResultCallback(this.repeatMode.pendingResultCallback);
+ }
+
+ @Override
+ @RepeatMode public int getRepeatMode() {
+ return repeatMode.value;
+ }
+
+ @Override
+ public void setShuffleModeEnabled(boolean shuffleModeEnabled) {
+ // TODO: Support shuffle mode.
+ }
+
+ @Override
+ public boolean getShuffleModeEnabled() {
+ // TODO: Support shuffle mode.
+ return false;
+ }
+
+ @Override
+ public TrackSelectionArray getCurrentTrackSelections() {
+ return currentTrackSelection;
+ }
+
+ @Override
+ public TrackGroupArray getCurrentTrackGroups() {
+ return currentTrackGroups;
+ }
+
+ @Override
+ public Timeline getCurrentTimeline() {
+ return currentTimeline;
+ }
+
+ @Override
+ public int getCurrentPeriodIndex() {
+ return getCurrentWindowIndex();
+ }
+
+ @Override
+ public int getCurrentWindowIndex() {
+ return pendingSeekWindowIndex != C.INDEX_UNSET ? pendingSeekWindowIndex : currentWindowIndex;
+ }
+
+ // TODO: Fill the cast timeline information with ProgressListener's duration updates.
+ // See [Internal: b/65152553].
+ @Override
+ public long getDuration() {
+ return getContentDuration();
+ }
+
+ @Override
+ public long getCurrentPosition() {
+ return pendingSeekPositionMs != C.TIME_UNSET
+ ? pendingSeekPositionMs
+ : remoteMediaClient != null
+ ? remoteMediaClient.getApproximateStreamPosition()
+ : lastReportedPositionMs;
+ }
+
+ @Override
+ public long getBufferedPosition() {
+ return getCurrentPosition();
+ }
+
+ @Override
+ public long getTotalBufferedDuration() {
+ long bufferedPosition = getBufferedPosition();
+ long currentPosition = getCurrentPosition();
+ return bufferedPosition == C.TIME_UNSET || currentPosition == C.TIME_UNSET
+ ? 0
+ : bufferedPosition - currentPosition;
+ }
+
+ @Override
+ public boolean isPlayingAd() {
+ return false;
+ }
+
+ @Override
+ public int getCurrentAdGroupIndex() {
+ return C.INDEX_UNSET;
+ }
+
+ @Override
+ public int getCurrentAdIndexInAdGroup() {
+ return C.INDEX_UNSET;
+ }
+
+ @Override
+ public boolean isLoading() {
+ return false;
+ }
+
+ @Override
+ public long getContentPosition() {
+ return getCurrentPosition();
+ }
+
+ @Override
+ public long getContentBufferedPosition() {
+ return getBufferedPosition();
+ }
+
+ // Internal methods.
+
+ private void updateInternalStateAndNotifyIfChanged() {
+ if (remoteMediaClient == null) {
+ // There is no session. We leave the state of the player as it is now.
+ return;
+ }
+ boolean wasPlaying = playbackState == Player.STATE_READY && playWhenReady.value;
+ updatePlayerStateAndNotifyIfChanged(/* resultCallback= */ null);
+ boolean isPlaying = playbackState == Player.STATE_READY && playWhenReady.value;
+ if (wasPlaying != isPlaying) {
+ notificationsBatch.add(
+ new ListenerNotificationTask(listener -> listener.onIsPlayingChanged(isPlaying)));
+ }
+ updateRepeatModeAndNotifyIfChanged(/* resultCallback= */ null);
+ updateTimelineAndNotifyIfChanged();
+
+ int currentWindowIndex = C.INDEX_UNSET;
+ MediaQueueItem currentItem = remoteMediaClient.getCurrentItem();
+ if (currentItem != null) {
+ currentWindowIndex = currentTimeline.getIndexOfPeriod(currentItem.getItemId());
+ }
+ if (currentWindowIndex == C.INDEX_UNSET) {
+ // The timeline is empty. Fall back to index 0, which is what ExoPlayer would do.
+ currentWindowIndex = 0;
+ }
+ if (this.currentWindowIndex != currentWindowIndex && pendingSeekCount == 0) {
+ this.currentWindowIndex = currentWindowIndex;
+ notificationsBatch.add(
+ new ListenerNotificationTask(
+ listener ->
+ listener.onPositionDiscontinuity(DISCONTINUITY_REASON_PERIOD_TRANSITION)));
+ }
+ if (updateTracksAndSelectionsAndNotifyIfChanged()) {
+ notificationsBatch.add(
+ new ListenerNotificationTask(
+ listener -> listener.onTracksChanged(currentTrackGroups, currentTrackSelection)));
+ }
+ flushNotifications();
+ }
+
+ /**
+ * Updates {@link #playWhenReady} and {@link #playbackState} to match the Cast {@code
+ * remoteMediaClient} state, and notifies listeners of any state changes.
+ *
+ * This method will only update values whose {@link StateHolder#pendingResultCallback} matches
+ * the given {@code resultCallback}.
+ */
+ @RequiresNonNull("remoteMediaClient")
+ private void updatePlayerStateAndNotifyIfChanged(@Nullable ResultCallback> resultCallback) {
+ boolean newPlayWhenReadyValue = playWhenReady.value;
+ if (playWhenReady.acceptsUpdate(resultCallback)) {
+ newPlayWhenReadyValue = !remoteMediaClient.isPaused();
+ playWhenReady.clearPendingResultCallback();
+ }
+ @PlayWhenReadyChangeReason
+ int playWhenReadyChangeReason =
+ newPlayWhenReadyValue != playWhenReady.value
+ ? PLAY_WHEN_READY_CHANGE_REASON_REMOTE
+ : PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST;
+ // We do not mask the playback state, so try setting it regardless of the playWhenReady masking.
+ setPlayerStateAndNotifyIfChanged(
+ newPlayWhenReadyValue, playWhenReadyChangeReason, fetchPlaybackState(remoteMediaClient));
+ }
+
+ @RequiresNonNull("remoteMediaClient")
+ private void updateRepeatModeAndNotifyIfChanged(@Nullable ResultCallback> resultCallback) {
+ if (repeatMode.acceptsUpdate(resultCallback)) {
+ setRepeatModeAndNotifyIfChanged(fetchRepeatMode(remoteMediaClient));
+ repeatMode.clearPendingResultCallback();
+ }
+ }
+
+ private void updateTimelineAndNotifyIfChanged() {
+ if (updateTimeline()) {
+ // TODO: Differentiate TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED and
+ // TIMELINE_CHANGE_REASON_SOURCE_UPDATE [see internal: b/65152553].
+ notificationsBatch.add(
+ new ListenerNotificationTask(
+ listener ->
+ listener.onTimelineChanged(
+ currentTimeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE)));
+ }
+ }
+
+ /**
+ * Updates the current timeline and returns whether it has changed.
+ */
+ private boolean updateTimeline() {
+ CastTimeline oldTimeline = currentTimeline;
+ MediaStatus status = getMediaStatus();
+ currentTimeline =
+ status != null
+ ? timelineTracker.getCastTimeline(remoteMediaClient)
+ : CastTimeline.EMPTY_CAST_TIMELINE;
+ return !oldTimeline.equals(currentTimeline);
+ }
+
+ /** Updates the internal tracks and selection and returns whether they have changed. */
+ private boolean updateTracksAndSelectionsAndNotifyIfChanged() {
+ if (remoteMediaClient == null) {
+ // There is no session. We leave the state of the player as it is now.
+ return false;
+ }
+
+ MediaStatus mediaStatus = getMediaStatus();
+ MediaInfo mediaInfo = mediaStatus != null ? mediaStatus.getMediaInfo() : null;
+ List castMediaTracks = mediaInfo != null ? mediaInfo.getMediaTracks() : null;
+ if (castMediaTracks == null || castMediaTracks.isEmpty()) {
+ boolean hasChanged = !currentTrackGroups.isEmpty();
+ currentTrackGroups = TrackGroupArray.EMPTY;
+ currentTrackSelection = EMPTY_TRACK_SELECTION_ARRAY;
+ return hasChanged;
+ }
+ long[] activeTrackIds = mediaStatus.getActiveTrackIds();
+ if (activeTrackIds == null) {
+ activeTrackIds = EMPTY_TRACK_ID_ARRAY;
+ }
+
+ TrackGroup[] trackGroups = new TrackGroup[castMediaTracks.size()];
+ TrackSelection[] trackSelections = new TrackSelection[RENDERER_COUNT];
+ for (int i = 0; i < castMediaTracks.size(); i++) {
+ MediaTrack mediaTrack = castMediaTracks.get(i);
+ trackGroups[i] = new TrackGroup(CastUtils.mediaTrackToFormat(mediaTrack));
+
+ long id = mediaTrack.getId();
+ int trackType = MimeTypes.getTrackType(mediaTrack.getContentType());
+ int rendererIndex = getRendererIndexForTrackType(trackType);
+ if (isTrackActive(id, activeTrackIds) && rendererIndex != C.INDEX_UNSET
+ && trackSelections[rendererIndex] == null) {
+ trackSelections[rendererIndex] = new FixedTrackSelection(trackGroups[i], 0);
+ }
+ }
+ TrackGroupArray newTrackGroups = new TrackGroupArray(trackGroups);
+ TrackSelectionArray newTrackSelections = new TrackSelectionArray(trackSelections);
+
+ if (!newTrackGroups.equals(currentTrackGroups)
+ || !newTrackSelections.equals(currentTrackSelection)) {
+ currentTrackSelection = new TrackSelectionArray(trackSelections);
+ currentTrackGroups = new TrackGroupArray(trackGroups);
+ return true;
+ }
+ return false;
+ }
+
+ @Nullable
+ private PendingResult setMediaItemsInternal(
+ MediaQueueItem[] mediaQueueItems,
+ int startWindowIndex,
+ long startPositionMs,
+ @RepeatMode int repeatMode) {
+ if (remoteMediaClient == null || mediaQueueItems.length == 0) {
+ return null;
+ }
+ startPositionMs = startPositionMs == C.TIME_UNSET ? 0 : startPositionMs;
+ if (startWindowIndex == C.INDEX_UNSET) {
+ startWindowIndex = getCurrentWindowIndex();
+ startPositionMs = getCurrentPosition();
+ }
+ return remoteMediaClient.queueLoad(
+ mediaQueueItems,
+ min(startWindowIndex, mediaQueueItems.length - 1),
+ getCastRepeatMode(repeatMode),
+ startPositionMs,
+ /* customData= */ null);
+ }
+
+ @Nullable
+ private PendingResult addMediaItemsInternal(MediaQueueItem[] items, int uid) {
+ if (remoteMediaClient == null || getMediaStatus() == null) {
+ return null;
+ }
+ return remoteMediaClient.queueInsertItems(items, uid, /* customData= */ null);
+ }
+
+ @Nullable
+ private PendingResult moveMediaItemsInternal(
+ int[] uids, int fromIndex, int newIndex) {
+ if (remoteMediaClient == null || getMediaStatus() == null) {
+ return null;
+ }
+ int insertBeforeIndex = fromIndex < newIndex ? newIndex + uids.length : newIndex;
+ int insertBeforeItemId = MediaQueueItem.INVALID_ITEM_ID;
+ if (insertBeforeIndex < currentTimeline.getWindowCount()) {
+ insertBeforeItemId = (int) currentTimeline.getWindow(insertBeforeIndex, window).uid;
+ }
+ return remoteMediaClient.queueReorderItems(uids, insertBeforeItemId, /* customData= */ null);
+ }
+
+ @Nullable
+ private PendingResult removeMediaItemsInternal(int[] uids) {
+ if (remoteMediaClient == null || getMediaStatus() == null) {
+ return null;
+ }
+ return remoteMediaClient.queueRemoveItems(uids, /* customData= */ null);
+ }
+
+ private void setRepeatModeAndNotifyIfChanged(@Player.RepeatMode int repeatMode) {
+ if (this.repeatMode.value != repeatMode) {
+ this.repeatMode.value = repeatMode;
+ notificationsBatch.add(
+ new ListenerNotificationTask(listener -> listener.onRepeatModeChanged(repeatMode)));
+ }
+ }
+
+ @SuppressWarnings("deprecation")
+ private void setPlayerStateAndNotifyIfChanged(
+ boolean playWhenReady,
+ @Player.PlayWhenReadyChangeReason int playWhenReadyChangeReason,
+ @Player.State int playbackState) {
+ boolean playWhenReadyChanged = this.playWhenReady.value != playWhenReady;
+ boolean playbackStateChanged = this.playbackState != playbackState;
+ if (playWhenReadyChanged || playbackStateChanged) {
+ this.playbackState = playbackState;
+ this.playWhenReady.value = playWhenReady;
+ notificationsBatch.add(
+ new ListenerNotificationTask(
+ listener -> {
+ listener.onPlayerStateChanged(playWhenReady, playbackState);
+ if (playbackStateChanged) {
+ listener.onPlaybackStateChanged(playbackState);
+ }
+ if (playWhenReadyChanged) {
+ listener.onPlayWhenReadyChanged(playWhenReady, playWhenReadyChangeReason);
+ }
+ }));
+ }
+ }
+
+ private void setRemoteMediaClient(@Nullable RemoteMediaClient remoteMediaClient) {
+ if (this.remoteMediaClient == remoteMediaClient) {
+ // Do nothing.
+ return;
+ }
+ if (this.remoteMediaClient != null) {
+ this.remoteMediaClient.unregisterCallback(statusListener);
+ this.remoteMediaClient.removeProgressListener(statusListener);
+ }
+ this.remoteMediaClient = remoteMediaClient;
+ if (remoteMediaClient != null) {
+ if (sessionAvailabilityListener != null) {
+ sessionAvailabilityListener.onCastSessionAvailable();
+ }
+ remoteMediaClient.registerCallback(statusListener);
+ remoteMediaClient.addProgressListener(statusListener, PROGRESS_REPORT_PERIOD_MS);
+ updateInternalStateAndNotifyIfChanged();
+ } else {
+ updateTimelineAndNotifyIfChanged();
+ if (sessionAvailabilityListener != null) {
+ sessionAvailabilityListener.onCastSessionUnavailable();
+ }
+ }
+ }
+
+ @Nullable
+ private MediaStatus getMediaStatus() {
+ return remoteMediaClient != null ? remoteMediaClient.getMediaStatus() : null;
+ }
+
+ /**
+ * Retrieves the playback state from {@code remoteMediaClient} and maps it into a {@link Player}
+ * state
+ */
+ private static int fetchPlaybackState(RemoteMediaClient remoteMediaClient) {
+ int receiverAppStatus = remoteMediaClient.getPlayerState();
+ switch (receiverAppStatus) {
+ case MediaStatus.PLAYER_STATE_BUFFERING:
+ return STATE_BUFFERING;
+ case MediaStatus.PLAYER_STATE_PLAYING:
+ case MediaStatus.PLAYER_STATE_PAUSED:
+ return STATE_READY;
+ case MediaStatus.PLAYER_STATE_IDLE:
+ case MediaStatus.PLAYER_STATE_UNKNOWN:
+ default:
+ return STATE_IDLE;
+ }
+ }
+
+ /**
+ * Retrieves the repeat mode from {@code remoteMediaClient} and maps it into a
+ * {@link Player.RepeatMode}.
+ */
+ @RepeatMode
+ private static int fetchRepeatMode(RemoteMediaClient remoteMediaClient) {
+ MediaStatus mediaStatus = remoteMediaClient.getMediaStatus();
+ if (mediaStatus == null) {
+ // No media session active, yet.
+ return REPEAT_MODE_OFF;
+ }
+ int castRepeatMode = mediaStatus.getQueueRepeatMode();
+ switch (castRepeatMode) {
+ case MediaStatus.REPEAT_MODE_REPEAT_SINGLE:
+ return REPEAT_MODE_ONE;
+ case MediaStatus.REPEAT_MODE_REPEAT_ALL:
+ case MediaStatus.REPEAT_MODE_REPEAT_ALL_AND_SHUFFLE:
+ return REPEAT_MODE_ALL;
+ case MediaStatus.REPEAT_MODE_REPEAT_OFF:
+ return REPEAT_MODE_OFF;
+ default:
+ throw new IllegalStateException();
+ }
+ }
+
+ private static boolean isTrackActive(long id, long[] activeTrackIds) {
+ for (long activeTrackId : activeTrackIds) {
+ if (activeTrackId == id) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static int getRendererIndexForTrackType(int trackType) {
+ return trackType == C.TRACK_TYPE_VIDEO
+ ? RENDERER_INDEX_VIDEO
+ : trackType == C.TRACK_TYPE_AUDIO
+ ? RENDERER_INDEX_AUDIO
+ : trackType == C.TRACK_TYPE_TEXT ? RENDERER_INDEX_TEXT : C.INDEX_UNSET;
+ }
+
+ private static int getCastRepeatMode(@RepeatMode int repeatMode) {
+ switch (repeatMode) {
+ case REPEAT_MODE_ONE:
+ return MediaStatus.REPEAT_MODE_REPEAT_SINGLE;
+ case REPEAT_MODE_ALL:
+ return MediaStatus.REPEAT_MODE_REPEAT_ALL;
+ case REPEAT_MODE_OFF:
+ return MediaStatus.REPEAT_MODE_REPEAT_OFF;
+ default:
+ throw new IllegalArgumentException();
+ }
+ }
+
+ private void flushNotifications() {
+ boolean recursiveNotification = !ongoingNotificationsTasks.isEmpty();
+ ongoingNotificationsTasks.addAll(notificationsBatch);
+ notificationsBatch.clear();
+ if (recursiveNotification) {
+ // This will be handled once the current notification task is finished.
+ return;
+ }
+ while (!ongoingNotificationsTasks.isEmpty()) {
+ ongoingNotificationsTasks.peekFirst().execute();
+ ongoingNotificationsTasks.removeFirst();
+ }
+ }
+
+ private MediaQueueItem[] toMediaQueueItems(List mediaItems) {
+ MediaQueueItem[] mediaQueueItems = new MediaQueueItem[mediaItems.size()];
+ for (int i = 0; i < mediaItems.size(); i++) {
+ mediaQueueItems[i] = mediaItemConverter.toMediaQueueItem(mediaItems.get(i));
+ }
+ return mediaQueueItems;
+ }
+
+ // Internal classes.
+
+ private final class StatusListener extends RemoteMediaClient.Callback
+ implements SessionManagerListener, RemoteMediaClient.ProgressListener {
+
+ // RemoteMediaClient.ProgressListener implementation.
+
+ @Override
+ public void onProgressUpdated(long progressMs, long unusedDurationMs) {
+ lastReportedPositionMs = progressMs;
+ }
+
+ // RemoteMediaClient.Callback implementation.
+
+ @Override
+ public void onStatusUpdated() {
+ updateInternalStateAndNotifyIfChanged();
+ }
+
+ @Override
+ public void onMetadataUpdated() {}
+
+ @Override
+ public void onQueueStatusUpdated() {
+ updateTimelineAndNotifyIfChanged();
+ }
+
+ @Override
+ public void onPreloadStatusUpdated() {}
+
+ @Override
+ public void onSendingRemoteMediaRequest() {}
+
+ @Override
+ public void onAdBreakStatusUpdated() {}
+
+ // SessionManagerListener implementation.
+
+ @Override
+ public void onSessionStarted(CastSession castSession, String s) {
+ setRemoteMediaClient(castSession.getRemoteMediaClient());
+ }
+
+ @Override
+ public void onSessionResumed(CastSession castSession, boolean b) {
+ setRemoteMediaClient(castSession.getRemoteMediaClient());
+ }
+
+ @Override
+ public void onSessionEnded(CastSession castSession, int i) {
+ setRemoteMediaClient(null);
+ }
+
+ @Override
+ public void onSessionSuspended(CastSession castSession, int i) {
+ setRemoteMediaClient(null);
+ }
+
+ @Override
+ public void onSessionResumeFailed(CastSession castSession, int statusCode) {
+ Log.e(TAG, "Session resume failed. Error code " + statusCode + ": "
+ + CastUtils.getLogString(statusCode));
+ }
+
+ @Override
+ public void onSessionStarting(CastSession castSession) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onSessionStartFailed(CastSession castSession, int statusCode) {
+ Log.e(TAG, "Session start failed. Error code " + statusCode + ": "
+ + CastUtils.getLogString(statusCode));
+ }
+
+ @Override
+ public void onSessionEnding(CastSession castSession) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onSessionResuming(CastSession castSession, String s) {
+ // Do nothing.
+ }
+
+ }
+
+ private final class SeekResultCallback implements ResultCallback {
+
+ // We still call EventListener#onSeekProcessed() for backwards compatibility with listeners that
+ // don't implement onPositionDiscontinuity().
+ @SuppressWarnings("deprecation")
+ @Override
+ public void onResult(MediaChannelResult result) {
+ int statusCode = result.getStatus().getStatusCode();
+ if (statusCode != CastStatusCodes.SUCCESS && statusCode != CastStatusCodes.REPLACED) {
+ Log.e(TAG, "Seek failed. Error code " + statusCode + ": "
+ + CastUtils.getLogString(statusCode));
+ }
+ if (--pendingSeekCount == 0) {
+ pendingSeekWindowIndex = C.INDEX_UNSET;
+ pendingSeekPositionMs = C.TIME_UNSET;
+ notificationsBatch.add(new ListenerNotificationTask(EventListener::onSeekProcessed));
+ flushNotifications();
+ }
+ }
+ }
+
+ /** Holds the value and the masking status of a specific part of the {@link CastPlayer} state. */
+ private static final class StateHolder {
+
+ /** The user-facing value of a specific part of the {@link CastPlayer} state. */
+ public T value;
+
+ /**
+ * If {@link #value} is being masked, holds the result callback for the operation that triggered
+ * the masking. Or null if {@link #value} is not being masked.
+ */
+ @Nullable public ResultCallback pendingResultCallback;
+
+ public StateHolder(T initialValue) {
+ value = initialValue;
+ }
+
+ public void clearPendingResultCallback() {
+ pendingResultCallback = null;
+ }
+
+ /**
+ * Returns whether this state holder accepts updates coming from the given result callback.
+ *
+ * A null {@code resultCallback} means that the update is a regular receiver state update, in
+ * which case the update will only be accepted if {@link #value} is not being masked. If {@link
+ * #value} is being masked, the update will only be accepted if {@code resultCallback} is the
+ * same as the {@link #pendingResultCallback}.
+ *
+ * @param resultCallback A result callback. May be null if the update comes from a regular
+ * receiver status update.
+ */
+ public boolean acceptsUpdate(@Nullable ResultCallback> resultCallback) {
+ return pendingResultCallback == resultCallback;
+ }
+ }
+
+ private final class ListenerNotificationTask {
+
+ private final Iterator listenersSnapshot;
+ private final ListenerInvocation listenerInvocation;
+
+ private ListenerNotificationTask(ListenerInvocation listenerInvocation) {
+ this.listenersSnapshot = listeners.iterator();
+ this.listenerInvocation = listenerInvocation;
+ }
+
+ public void execute() {
+ while (listenersSnapshot.hasNext()) {
+ listenersSnapshot.next().invoke(listenerInvocation);
+ }
+ }
+ }
+}
diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimeline.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimeline.java
new file mode 100644
index 0000000000..edd2a060d2
--- /dev/null
+++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimeline.java
@@ -0,0 +1,192 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.cast;
+
+import android.net.Uri;
+import android.util.SparseArray;
+import android.util.SparseIntArray;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.Timeline;
+import java.util.Arrays;
+
+/**
+ * A {@link Timeline} for Cast media queues.
+ */
+/* package */ final class CastTimeline extends Timeline {
+
+ /** Holds {@link Timeline} related data for a Cast media item. */
+ public static final class ItemData {
+
+ /** Holds no media information. */
+ public static final ItemData EMPTY = new ItemData();
+
+ /** The duration of the item in microseconds, or {@link C#TIME_UNSET} if unknown. */
+ public final long durationUs;
+ /**
+ * The default start position of the item in microseconds, or {@link C#TIME_UNSET} if unknown.
+ */
+ public final long defaultPositionUs;
+ /** Whether the item is live content, or {@code false} if unknown. */
+ public final boolean isLive;
+
+ private ItemData() {
+ this(
+ /* durationUs= */ C.TIME_UNSET, /* defaultPositionUs */
+ C.TIME_UNSET,
+ /* isLive= */ false);
+ }
+
+ /**
+ * Creates an instance.
+ *
+ * @param durationUs See {@link #durationsUs}.
+ * @param defaultPositionUs See {@link #defaultPositionUs}.
+ * @param isLive See {@link #isLive}.
+ */
+ public ItemData(long durationUs, long defaultPositionUs, boolean isLive) {
+ this.durationUs = durationUs;
+ this.defaultPositionUs = defaultPositionUs;
+ this.isLive = isLive;
+ }
+
+ /**
+ * Returns a copy of this instance with the given values.
+ *
+ * @param durationUs The duration in microseconds, or {@link C#TIME_UNSET} if unknown.
+ * @param defaultPositionUs The default start position in microseconds, or {@link C#TIME_UNSET}
+ * if unknown.
+ * @param isLive Whether the item is live, or {@code false} if unknown.
+ */
+ public ItemData copyWithNewValues(long durationUs, long defaultPositionUs, boolean isLive) {
+ if (durationUs == this.durationUs
+ && defaultPositionUs == this.defaultPositionUs
+ && isLive == this.isLive) {
+ return this;
+ }
+ return new ItemData(durationUs, defaultPositionUs, isLive);
+ }
+ }
+
+ /** {@link Timeline} for a cast queue that has no items. */
+ public static final CastTimeline EMPTY_CAST_TIMELINE =
+ new CastTimeline(new int[0], new SparseArray<>());
+
+ private final SparseIntArray idsToIndex;
+ private final int[] ids;
+ private final long[] durationsUs;
+ private final long[] defaultPositionsUs;
+ private final boolean[] isLive;
+
+ /**
+ * Creates a Cast timeline from the given data.
+ *
+ * @param itemIds The ids of the items in the timeline.
+ * @param itemIdToData Maps item ids to {@link ItemData}.
+ */
+ public CastTimeline(int[] itemIds, SparseArray itemIdToData) {
+ int itemCount = itemIds.length;
+ idsToIndex = new SparseIntArray(itemCount);
+ ids = Arrays.copyOf(itemIds, itemCount);
+ durationsUs = new long[itemCount];
+ defaultPositionsUs = new long[itemCount];
+ isLive = new boolean[itemCount];
+ for (int i = 0; i < ids.length; i++) {
+ int id = ids[i];
+ idsToIndex.put(id, i);
+ ItemData data = itemIdToData.get(id, ItemData.EMPTY);
+ durationsUs[i] = data.durationUs;
+ defaultPositionsUs[i] = data.defaultPositionUs == C.TIME_UNSET ? 0 : data.defaultPositionUs;
+ isLive[i] = data.isLive;
+ }
+ }
+
+ // Timeline implementation.
+
+ @Override
+ public int getWindowCount() {
+ return ids.length;
+ }
+
+ @Override
+ public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) {
+ long durationUs = durationsUs[windowIndex];
+ boolean isDynamic = durationUs == C.TIME_UNSET;
+ return window.set(
+ /* uid= */ ids[windowIndex],
+ /* mediaItem= */ new MediaItem.Builder().setUri(Uri.EMPTY).setTag(ids[windowIndex]).build(),
+ /* manifest= */ null,
+ /* presentationStartTimeMs= */ C.TIME_UNSET,
+ /* windowStartTimeMs= */ C.TIME_UNSET,
+ /* elapsedRealtimeEpochOffsetMs= */ C.TIME_UNSET,
+ /* isSeekable= */ !isDynamic,
+ isDynamic,
+ isLive[windowIndex],
+ defaultPositionsUs[windowIndex],
+ durationUs,
+ /* firstPeriodIndex= */ windowIndex,
+ /* lastPeriodIndex= */ windowIndex,
+ /* positionInFirstPeriodUs= */ 0);
+ }
+
+ @Override
+ public int getPeriodCount() {
+ return ids.length;
+ }
+
+ @Override
+ public Period getPeriod(int periodIndex, Period period, boolean setIds) {
+ int id = ids[periodIndex];
+ return period.set(id, id, periodIndex, durationsUs[periodIndex], 0);
+ }
+
+ @Override
+ public int getIndexOfPeriod(Object uid) {
+ return uid instanceof Integer ? idsToIndex.get((int) uid, C.INDEX_UNSET) : C.INDEX_UNSET;
+ }
+
+ @Override
+ public Integer getUidOfPeriod(int periodIndex) {
+ return ids[periodIndex];
+ }
+
+ // equals and hashCode implementations.
+
+ @Override
+ public boolean equals(@Nullable Object other) {
+ if (this == other) {
+ return true;
+ } else if (!(other instanceof CastTimeline)) {
+ return false;
+ }
+ CastTimeline that = (CastTimeline) other;
+ return Arrays.equals(ids, that.ids)
+ && Arrays.equals(durationsUs, that.durationsUs)
+ && Arrays.equals(defaultPositionsUs, that.defaultPositionsUs)
+ && Arrays.equals(isLive, that.isLive);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = Arrays.hashCode(ids);
+ result = 31 * result + Arrays.hashCode(durationsUs);
+ result = 31 * result + Arrays.hashCode(defaultPositionsUs);
+ result = 31 * result + Arrays.hashCode(isLive);
+ return result;
+ }
+
+}
diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimelineTracker.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimelineTracker.java
new file mode 100644
index 0000000000..3ebd89c8fc
--- /dev/null
+++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimelineTracker.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.cast;
+
+import android.util.SparseArray;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.C;
+import com.google.android.gms.cast.MediaInfo;
+import com.google.android.gms.cast.MediaQueueItem;
+import com.google.android.gms.cast.MediaStatus;
+import com.google.android.gms.cast.framework.media.RemoteMediaClient;
+import java.util.HashSet;
+
+/**
+ * Creates {@link CastTimeline CastTimelines} from cast receiver app status updates.
+ *
+ * This class keeps track of the duration reported by the current item to fill any missing
+ * durations in the media queue items [See internal: b/65152553].
+ */
+/* package */ final class CastTimelineTracker {
+
+ private final SparseArray itemIdToData;
+
+ public CastTimelineTracker() {
+ itemIdToData = new SparseArray<>();
+ }
+
+ /**
+ * Returns a {@link CastTimeline} that represents the state of the given {@code
+ * remoteMediaClient}.
+ *
+ * Returned timelines may contain values obtained from {@code remoteMediaClient} in previous
+ * invocations of this method.
+ *
+ * @param remoteMediaClient The Cast media client.
+ * @return A {@link CastTimeline} that represents the given {@code remoteMediaClient} status.
+ */
+ public CastTimeline getCastTimeline(RemoteMediaClient remoteMediaClient) {
+ int[] itemIds = remoteMediaClient.getMediaQueue().getItemIds();
+ if (itemIds.length > 0) {
+ // Only remove unused items when there is something in the queue to avoid removing all entries
+ // if the remote media client clears the queue temporarily. See [Internal ref: b/128825216].
+ removeUnusedItemDataEntries(itemIds);
+ }
+
+ // TODO: Reset state when the app instance changes [Internal ref: b/129672468].
+ MediaStatus mediaStatus = remoteMediaClient.getMediaStatus();
+ if (mediaStatus == null) {
+ return CastTimeline.EMPTY_CAST_TIMELINE;
+ }
+
+ int currentItemId = mediaStatus.getCurrentItemId();
+ updateItemData(
+ currentItemId, mediaStatus.getMediaInfo(), /* defaultPositionUs= */ C.TIME_UNSET);
+
+ for (MediaQueueItem item : mediaStatus.getQueueItems()) {
+ long defaultPositionUs = (long) (item.getStartTime() * C.MICROS_PER_SECOND);
+ updateItemData(item.getItemId(), item.getMedia(), defaultPositionUs);
+ }
+
+ return new CastTimeline(itemIds, itemIdToData);
+ }
+
+ private void updateItemData(int itemId, @Nullable MediaInfo mediaInfo, long defaultPositionUs) {
+ CastTimeline.ItemData previousData = itemIdToData.get(itemId, CastTimeline.ItemData.EMPTY);
+ long durationUs = CastUtils.getStreamDurationUs(mediaInfo);
+ if (durationUs == C.TIME_UNSET) {
+ durationUs = previousData.durationUs;
+ }
+ boolean isLive =
+ mediaInfo == null
+ ? previousData.isLive
+ : mediaInfo.getStreamType() == MediaInfo.STREAM_TYPE_LIVE;
+ if (defaultPositionUs == C.TIME_UNSET) {
+ defaultPositionUs = previousData.defaultPositionUs;
+ }
+ itemIdToData.put(itemId, previousData.copyWithNewValues(durationUs, defaultPositionUs, isLive));
+ }
+
+ private void removeUnusedItemDataEntries(int[] itemIds) {
+ HashSet scratchItemIds = new HashSet<>(/* initialCapacity= */ itemIds.length * 2);
+ for (int id : itemIds) {
+ scratchItemIds.add(id);
+ }
+
+ int index = 0;
+ while (index < itemIdToData.size()) {
+ if (!scratchItemIds.contains(itemIdToData.keyAt(index))) {
+ itemIdToData.removeAt(index);
+ } else {
+ index++;
+ }
+ }
+ }
+}
diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastUtils.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastUtils.java
new file mode 100644
index 0000000000..182afb0468
--- /dev/null
+++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastUtils.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.cast;
+
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.Format;
+import com.google.android.gms.cast.CastStatusCodes;
+import com.google.android.gms.cast.MediaInfo;
+import com.google.android.gms.cast.MediaTrack;
+
+/**
+ * Utility methods for ExoPlayer/Cast integration.
+ */
+/* package */ final class CastUtils {
+
+ /**
+ * Returns the duration in microseconds advertised by a media info, or {@link C#TIME_UNSET} if
+ * unknown or not applicable.
+ *
+ * @param mediaInfo The media info to get the duration from.
+ * @return The duration in microseconds, or {@link C#TIME_UNSET} if unknown or not applicable.
+ */
+ public static long getStreamDurationUs(@Nullable MediaInfo mediaInfo) {
+ if (mediaInfo == null) {
+ return C.TIME_UNSET;
+ }
+ long durationMs = mediaInfo.getStreamDuration();
+ return durationMs != MediaInfo.UNKNOWN_DURATION ? C.msToUs(durationMs) : C.TIME_UNSET;
+ }
+
+ /**
+ * Returns a descriptive log string for the given {@code statusCode}, or "Unknown." if not one of
+ * {@link CastStatusCodes}.
+ *
+ * @param statusCode A Cast API status code.
+ * @return A descriptive log string for the given {@code statusCode}, or "Unknown." if not one of
+ * {@link CastStatusCodes}.
+ */
+ public static String getLogString(int statusCode) {
+ switch (statusCode) {
+ case CastStatusCodes.APPLICATION_NOT_FOUND:
+ return "A requested application could not be found.";
+ case CastStatusCodes.APPLICATION_NOT_RUNNING:
+ return "A requested application is not currently running.";
+ case CastStatusCodes.AUTHENTICATION_FAILED:
+ return "Authentication failure.";
+ case CastStatusCodes.CANCELED:
+ return "An in-progress request has been canceled, most likely because another action has "
+ + "preempted it.";
+ case CastStatusCodes.ERROR_SERVICE_CREATION_FAILED:
+ return "The Cast Remote Display service could not be created.";
+ case CastStatusCodes.ERROR_SERVICE_DISCONNECTED:
+ return "The Cast Remote Display service was disconnected.";
+ case CastStatusCodes.FAILED:
+ return "The in-progress request failed.";
+ case CastStatusCodes.INTERNAL_ERROR:
+ return "An internal error has occurred.";
+ case CastStatusCodes.INTERRUPTED:
+ return "A blocking call was interrupted while waiting and did not run to completion.";
+ case CastStatusCodes.INVALID_REQUEST:
+ return "An invalid request was made.";
+ case CastStatusCodes.MESSAGE_SEND_BUFFER_TOO_FULL:
+ return "A message could not be sent because there is not enough room in the send buffer at "
+ + "this time.";
+ case CastStatusCodes.MESSAGE_TOO_LARGE:
+ return "A message could not be sent because it is too large.";
+ case CastStatusCodes.NETWORK_ERROR:
+ return "Network I/O error.";
+ case CastStatusCodes.NOT_ALLOWED:
+ return "The request was disallowed and could not be completed.";
+ case CastStatusCodes.REPLACED:
+ return "The request's progress is no longer being tracked because another request of the "
+ + "same type has been made before the first request completed.";
+ case CastStatusCodes.SUCCESS:
+ return "Success.";
+ case CastStatusCodes.TIMEOUT:
+ return "An operation has timed out.";
+ case CastStatusCodes.UNKNOWN_ERROR:
+ return "An unknown, unexpected error has occurred.";
+ default:
+ return CastStatusCodes.getStatusCodeString(statusCode);
+ }
+ }
+
+ /**
+ * Creates a {@link Format} instance containing all information contained in the given
+ * {@link MediaTrack} object.
+ *
+ * @param mediaTrack The {@link MediaTrack}.
+ * @return The equivalent {@link Format}.
+ */
+ public static Format mediaTrackToFormat(MediaTrack mediaTrack) {
+ return new Format.Builder()
+ .setId(mediaTrack.getContentId())
+ .setContainerMimeType(mediaTrack.getContentType())
+ .setLanguage(mediaTrack.getLanguage())
+ .build();
+ }
+
+ private CastUtils() {}
+
+}
diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultCastOptionsProvider.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultCastOptionsProvider.java
new file mode 100644
index 0000000000..ebadb0a08a
--- /dev/null
+++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultCastOptionsProvider.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.cast;
+
+import android.content.Context;
+import com.google.android.gms.cast.CastMediaControlIntent;
+import com.google.android.gms.cast.framework.CastOptions;
+import com.google.android.gms.cast.framework.OptionsProvider;
+import com.google.android.gms.cast.framework.SessionProvider;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A convenience {@link OptionsProvider} to target the default cast receiver app.
+ */
+public final class DefaultCastOptionsProvider implements OptionsProvider {
+
+ /**
+ * App id of the Default Media Receiver app. Apps that do not require DRM support may use this
+ * receiver receiver app ID.
+ *
+ * See https://developers.google.com/cast/docs/caf_receiver/#default_media_receiver.
+ */
+ public static final String APP_ID_DEFAULT_RECEIVER =
+ CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID;
+
+ /**
+ * App id for receiver app with rudimentary support for DRM.
+ *
+ *
This app id is only suitable for ExoPlayer's Cast Demo app, and it is not intended for
+ * production use. In order to use DRM, custom receiver apps should be used. For environments that
+ * do not require DRM, the default receiver app should be used (see {@link
+ * #APP_ID_DEFAULT_RECEIVER}).
+ */
+ // TODO: Add a documentation resource link for DRM support in the receiver app [Internal ref:
+ // b/128603245].
+ public static final String APP_ID_DEFAULT_RECEIVER_WITH_DRM = "A12D4273";
+
+ @Override
+ public CastOptions getCastOptions(Context context) {
+ return new CastOptions.Builder()
+ .setReceiverApplicationId(APP_ID_DEFAULT_RECEIVER_WITH_DRM)
+ .setStopReceiverApplicationWhenEndingSession(true)
+ .build();
+ }
+
+ @Override
+ public List getAdditionalSessionProviders(Context context) {
+ return Collections.emptyList();
+ }
+
+}
diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverter.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverter.java
new file mode 100644
index 0000000000..705f2c2508
--- /dev/null
+++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverter.java
@@ -0,0 +1,178 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.cast;
+
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.gms.cast.MediaInfo;
+import com.google.android.gms.cast.MediaMetadata;
+import com.google.android.gms.cast.MediaQueueItem;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.UUID;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/** Default {@link MediaItemConverter} implementation. */
+public final class DefaultMediaItemConverter implements MediaItemConverter {
+
+ private static final String KEY_MEDIA_ITEM = "mediaItem";
+ private static final String KEY_PLAYER_CONFIG = "exoPlayerConfig";
+ private static final String KEY_URI = "uri";
+ private static final String KEY_TITLE = "title";
+ private static final String KEY_MIME_TYPE = "mimeType";
+ private static final String KEY_DRM_CONFIGURATION = "drmConfiguration";
+ private static final String KEY_UUID = "uuid";
+ private static final String KEY_LICENSE_URI = "licenseUri";
+ private static final String KEY_REQUEST_HEADERS = "requestHeaders";
+
+ @Override
+ public MediaItem toMediaItem(MediaQueueItem item) {
+ // `item` came from `toMediaQueueItem()` so the custom JSON data must be set.
+ return getMediaItem(Assertions.checkNotNull(item.getMedia().getCustomData()));
+ }
+
+ @Override
+ public MediaQueueItem toMediaQueueItem(MediaItem item) {
+ Assertions.checkNotNull(item.playbackProperties);
+ if (item.playbackProperties.mimeType == null) {
+ throw new IllegalArgumentException("The item must specify its mimeType");
+ }
+ MediaMetadata metadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_MOVIE);
+ if (item.mediaMetadata.title != null) {
+ metadata.putString(MediaMetadata.KEY_TITLE, item.mediaMetadata.title);
+ }
+ MediaInfo mediaInfo =
+ new MediaInfo.Builder(item.playbackProperties.uri.toString())
+ .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
+ .setContentType(item.playbackProperties.mimeType)
+ .setMetadata(metadata)
+ .setCustomData(getCustomData(item))
+ .build();
+ return new MediaQueueItem.Builder(mediaInfo).build();
+ }
+
+ // Deserialization.
+
+ private static MediaItem getMediaItem(JSONObject customData) {
+ try {
+ JSONObject mediaItemJson = customData.getJSONObject(KEY_MEDIA_ITEM);
+ MediaItem.Builder builder = new MediaItem.Builder();
+ builder.setUri(Uri.parse(mediaItemJson.getString(KEY_URI)));
+ if (mediaItemJson.has(KEY_TITLE)) {
+ com.google.android.exoplayer2.MediaMetadata mediaMetadata =
+ new com.google.android.exoplayer2.MediaMetadata.Builder()
+ .setTitle(mediaItemJson.getString(KEY_TITLE))
+ .build();
+ builder.setMediaMetadata(mediaMetadata);
+ }
+ if (mediaItemJson.has(KEY_MIME_TYPE)) {
+ builder.setMimeType(mediaItemJson.getString(KEY_MIME_TYPE));
+ }
+ if (mediaItemJson.has(KEY_DRM_CONFIGURATION)) {
+ populateDrmConfiguration(mediaItemJson.getJSONObject(KEY_DRM_CONFIGURATION), builder);
+ }
+ return builder.build();
+ } catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static void populateDrmConfiguration(JSONObject json, MediaItem.Builder builder)
+ throws JSONException {
+ builder.setDrmUuid(UUID.fromString(json.getString(KEY_UUID)));
+ builder.setDrmLicenseUri(json.getString(KEY_LICENSE_URI));
+ JSONObject requestHeadersJson = json.getJSONObject(KEY_REQUEST_HEADERS);
+ HashMap requestHeaders = new HashMap<>();
+ for (Iterator iterator = requestHeadersJson.keys(); iterator.hasNext(); ) {
+ String key = iterator.next();
+ requestHeaders.put(key, requestHeadersJson.getString(key));
+ }
+ builder.setDrmLicenseRequestHeaders(requestHeaders);
+ }
+
+ // Serialization.
+
+ private static JSONObject getCustomData(MediaItem mediaItem) {
+ JSONObject json = new JSONObject();
+ try {
+ json.put(KEY_MEDIA_ITEM, getMediaItemJson(mediaItem));
+ @Nullable JSONObject playerConfigJson = getPlayerConfigJson(mediaItem);
+ if (playerConfigJson != null) {
+ json.put(KEY_PLAYER_CONFIG, playerConfigJson);
+ }
+ } catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ return json;
+ }
+
+ private static JSONObject getMediaItemJson(MediaItem mediaItem) throws JSONException {
+ Assertions.checkNotNull(mediaItem.playbackProperties);
+ JSONObject json = new JSONObject();
+ json.put(KEY_TITLE, mediaItem.mediaMetadata.title);
+ json.put(KEY_URI, mediaItem.playbackProperties.uri.toString());
+ json.put(KEY_MIME_TYPE, mediaItem.playbackProperties.mimeType);
+ if (mediaItem.playbackProperties.drmConfiguration != null) {
+ json.put(
+ KEY_DRM_CONFIGURATION,
+ getDrmConfigurationJson(mediaItem.playbackProperties.drmConfiguration));
+ }
+ return json;
+ }
+
+ private static JSONObject getDrmConfigurationJson(MediaItem.DrmConfiguration drmConfiguration)
+ throws JSONException {
+ JSONObject json = new JSONObject();
+ json.put(KEY_UUID, drmConfiguration.uuid);
+ json.put(KEY_LICENSE_URI, drmConfiguration.licenseUri);
+ json.put(KEY_REQUEST_HEADERS, new JSONObject(drmConfiguration.requestHeaders));
+ return json;
+ }
+
+ @Nullable
+ private static JSONObject getPlayerConfigJson(MediaItem mediaItem) throws JSONException {
+ if (mediaItem.playbackProperties == null
+ || mediaItem.playbackProperties.drmConfiguration == null) {
+ return null;
+ }
+ MediaItem.DrmConfiguration drmConfiguration = mediaItem.playbackProperties.drmConfiguration;
+
+ String drmScheme;
+ if (C.WIDEVINE_UUID.equals(drmConfiguration.uuid)) {
+ drmScheme = "widevine";
+ } else if (C.PLAYREADY_UUID.equals(drmConfiguration.uuid)) {
+ drmScheme = "playready";
+ } else {
+ return null;
+ }
+
+ JSONObject exoPlayerConfigJson = new JSONObject();
+ exoPlayerConfigJson.put("withCredentials", false);
+ exoPlayerConfigJson.put("protectionSystem", drmScheme);
+ if (drmConfiguration.licenseUri != null) {
+ exoPlayerConfigJson.put("licenseUrl", drmConfiguration.licenseUri);
+ }
+ if (!drmConfiguration.requestHeaders.isEmpty()) {
+ exoPlayerConfigJson.put("headers", new JSONObject(drmConfiguration.requestHeaders));
+ }
+
+ return exoPlayerConfigJson;
+ }
+}
diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/MediaItemConverter.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/MediaItemConverter.java
new file mode 100644
index 0000000000..c4a5184632
--- /dev/null
+++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/MediaItemConverter.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.cast;
+
+import com.google.android.exoplayer2.MediaItem;
+import com.google.android.gms.cast.MediaQueueItem;
+
+/** Converts between {@link MediaItem} and the Cast SDK's {@link MediaQueueItem}. */
+public interface MediaItemConverter {
+
+ /**
+ * Converts a {@link MediaItem} to a {@link MediaQueueItem}.
+ *
+ * @param mediaItem The {@link MediaItem}.
+ * @return An equivalent {@link MediaQueueItem}.
+ */
+ MediaQueueItem toMediaQueueItem(MediaItem mediaItem);
+
+ /**
+ * Converts a {@link MediaQueueItem} to a {@link MediaItem}.
+ *
+ * @param mediaQueueItem The {@link MediaQueueItem}.
+ * @return The equivalent {@link MediaItem}.
+ */
+ MediaItem toMediaItem(MediaQueueItem mediaQueueItem);
+}
diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/SessionAvailabilityListener.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/SessionAvailabilityListener.java
new file mode 100644
index 0000000000..c686c496c6
--- /dev/null
+++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/SessionAvailabilityListener.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.cast;
+
+/** Listener of changes in the cast session availability. */
+public interface SessionAvailabilityListener {
+
+ /** Called when a cast session becomes available to the player. */
+ void onCastSessionAvailable();
+
+ /** Called when the cast session becomes unavailable. */
+ void onCastSessionUnavailable();
+}
diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/package-info.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/package-info.java
new file mode 100644
index 0000000000..07055905a6
--- /dev/null
+++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@NonNullApi
+package com.google.android.exoplayer2.ext.cast;
+
+import com.google.android.exoplayer2.util.NonNullApi;
diff --git a/extensions/cast/src/test/AndroidManifest.xml b/extensions/cast/src/test/AndroidManifest.xml
new file mode 100644
index 0000000000..35a5150a47
--- /dev/null
+++ b/extensions/cast/src/test/AndroidManifest.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
diff --git a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastPlayerTest.java b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastPlayerTest.java
new file mode 100644
index 0000000000..049bc89b72
--- /dev/null
+++ b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastPlayerTest.java
@@ -0,0 +1,498 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.cast;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.Timeline;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.MimeTypes;
+import com.google.android.gms.cast.MediaInfo;
+import com.google.android.gms.cast.MediaQueueItem;
+import com.google.android.gms.cast.MediaStatus;
+import com.google.android.gms.cast.framework.CastContext;
+import com.google.android.gms.cast.framework.CastSession;
+import com.google.android.gms.cast.framework.SessionManager;
+import com.google.android.gms.cast.framework.media.MediaQueue;
+import com.google.android.gms.cast.framework.media.RemoteMediaClient;
+import com.google.android.gms.common.api.PendingResult;
+import com.google.android.gms.common.api.ResultCallback;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+
+/** Tests for {@link CastPlayer}. */
+@RunWith(AndroidJUnit4.class)
+public class CastPlayerTest {
+
+ private CastPlayer castPlayer;
+
+ private RemoteMediaClient.Callback remoteMediaClientCallback;
+
+ @Mock private RemoteMediaClient mockRemoteMediaClient;
+ @Mock private MediaStatus mockMediaStatus;
+ @Mock private MediaInfo mockMediaInfo;
+ @Mock private MediaQueue mockMediaQueue;
+ @Mock private CastContext mockCastContext;
+ @Mock private SessionManager mockSessionManager;
+ @Mock private CastSession mockCastSession;
+ @Mock private Player.EventListener mockListener;
+ @Mock private PendingResult mockPendingResult;
+
+ @Captor
+ private ArgumentCaptor>
+ setResultCallbackArgumentCaptor;
+
+ @Captor private ArgumentCaptor callbackArgumentCaptor;
+
+ @Captor private ArgumentCaptor queueItemsArgumentCaptor;
+
+ @SuppressWarnings("deprecation")
+ @Before
+ public void setUp() {
+ initMocks(this);
+ when(mockCastContext.getSessionManager()).thenReturn(mockSessionManager);
+ when(mockSessionManager.getCurrentCastSession()).thenReturn(mockCastSession);
+ when(mockCastSession.getRemoteMediaClient()).thenReturn(mockRemoteMediaClient);
+ when(mockRemoteMediaClient.getMediaStatus()).thenReturn(mockMediaStatus);
+ when(mockRemoteMediaClient.getMediaQueue()).thenReturn(mockMediaQueue);
+ when(mockMediaQueue.getItemIds()).thenReturn(new int[0]);
+ // Make the remote media client present the same default values as ExoPlayer:
+ when(mockRemoteMediaClient.isPaused()).thenReturn(true);
+ when(mockMediaStatus.getQueueRepeatMode()).thenReturn(MediaStatus.REPEAT_MODE_REPEAT_OFF);
+ castPlayer = new CastPlayer(mockCastContext);
+ castPlayer.addListener(mockListener);
+ verify(mockRemoteMediaClient).registerCallback(callbackArgumentCaptor.capture());
+ remoteMediaClientCallback = callbackArgumentCaptor.getValue();
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void setPlayWhenReady_masksRemoteState() {
+ when(mockRemoteMediaClient.play()).thenReturn(mockPendingResult);
+ assertThat(castPlayer.getPlayWhenReady()).isFalse();
+
+ castPlayer.play();
+ verify(mockPendingResult).setResultCallback(setResultCallbackArgumentCaptor.capture());
+ assertThat(castPlayer.getPlayWhenReady()).isTrue();
+ verify(mockListener).onPlayerStateChanged(true, Player.STATE_IDLE);
+ verify(mockListener)
+ .onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST);
+
+ // There is a status update in the middle, which should be hidden by masking.
+ remoteMediaClientCallback.onStatusUpdated();
+ verifyNoMoreInteractions(mockListener);
+
+ // Upon result, the remoteMediaClient has updated its state according to the play() call.
+ when(mockRemoteMediaClient.isPaused()).thenReturn(false);
+ setResultCallbackArgumentCaptor
+ .getValue()
+ .onResult(Mockito.mock(RemoteMediaClient.MediaChannelResult.class));
+ verifyNoMoreInteractions(mockListener);
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void setPlayWhenReadyMasking_updatesUponResultChange() {
+ when(mockRemoteMediaClient.play()).thenReturn(mockPendingResult);
+ assertThat(castPlayer.getPlayWhenReady()).isFalse();
+
+ castPlayer.play();
+ verify(mockPendingResult).setResultCallback(setResultCallbackArgumentCaptor.capture());
+ assertThat(castPlayer.getPlayWhenReady()).isTrue();
+ verify(mockListener).onPlayerStateChanged(true, Player.STATE_IDLE);
+ verify(mockListener)
+ .onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST);
+
+ // Upon result, the remote media client is still paused. The state should reflect that.
+ setResultCallbackArgumentCaptor
+ .getValue()
+ .onResult(Mockito.mock(RemoteMediaClient.MediaChannelResult.class));
+ verify(mockListener).onPlayerStateChanged(false, Player.STATE_IDLE);
+ verify(mockListener).onPlayWhenReadyChanged(false, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE);
+ assertThat(castPlayer.getPlayWhenReady()).isFalse();
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void setPlayWhenReady_correctChangeReasonOnPause() {
+ when(mockRemoteMediaClient.play()).thenReturn(mockPendingResult);
+ when(mockRemoteMediaClient.pause()).thenReturn(mockPendingResult);
+ castPlayer.play();
+ assertThat(castPlayer.getPlayWhenReady()).isTrue();
+ verify(mockListener).onPlayerStateChanged(true, Player.STATE_IDLE);
+ verify(mockListener)
+ .onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST);
+
+ castPlayer.pause();
+ assertThat(castPlayer.getPlayWhenReady()).isFalse();
+ verify(mockListener).onPlayerStateChanged(false, Player.STATE_IDLE);
+ verify(mockListener)
+ .onPlayWhenReadyChanged(false, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST);
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ public void playWhenReady_changesOnStatusUpdates() {
+ assertThat(castPlayer.getPlayWhenReady()).isFalse();
+ when(mockRemoteMediaClient.isPaused()).thenReturn(false);
+ remoteMediaClientCallback.onStatusUpdated();
+ verify(mockListener).onPlayerStateChanged(true, Player.STATE_IDLE);
+ verify(mockListener).onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE);
+ assertThat(castPlayer.getPlayWhenReady()).isTrue();
+ }
+
+ @Test
+ public void setRepeatMode_masksRemoteState() {
+ when(mockRemoteMediaClient.queueSetRepeatMode(anyInt(), any())).thenReturn(mockPendingResult);
+ assertThat(castPlayer.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_OFF);
+
+ castPlayer.setRepeatMode(Player.REPEAT_MODE_ONE);
+ verify(mockPendingResult).setResultCallback(setResultCallbackArgumentCaptor.capture());
+ assertThat(castPlayer.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_ONE);
+ verify(mockListener).onRepeatModeChanged(Player.REPEAT_MODE_ONE);
+
+ // There is a status update in the middle, which should be hidden by masking.
+ when(mockMediaStatus.getQueueRepeatMode()).thenReturn(MediaStatus.REPEAT_MODE_REPEAT_ALL);
+ remoteMediaClientCallback.onStatusUpdated();
+ verifyNoMoreInteractions(mockListener);
+
+ // Upon result, the mediaStatus now exposes the new repeat mode.
+ when(mockMediaStatus.getQueueRepeatMode()).thenReturn(MediaStatus.REPEAT_MODE_REPEAT_SINGLE);
+ setResultCallbackArgumentCaptor
+ .getValue()
+ .onResult(Mockito.mock(RemoteMediaClient.MediaChannelResult.class));
+ verifyNoMoreInteractions(mockListener);
+ }
+
+ @Test
+ public void setRepeatMode_updatesUponResultChange() {
+ when(mockRemoteMediaClient.queueSetRepeatMode(anyInt(), any())).thenReturn(mockPendingResult);
+
+ castPlayer.setRepeatMode(Player.REPEAT_MODE_ONE);
+ verify(mockPendingResult).setResultCallback(setResultCallbackArgumentCaptor.capture());
+ assertThat(castPlayer.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_ONE);
+ verify(mockListener).onRepeatModeChanged(Player.REPEAT_MODE_ONE);
+
+ // There is a status update in the middle, which should be hidden by masking.
+ when(mockMediaStatus.getQueueRepeatMode()).thenReturn(MediaStatus.REPEAT_MODE_REPEAT_ALL);
+ remoteMediaClientCallback.onStatusUpdated();
+ verifyNoMoreInteractions(mockListener);
+
+ // Upon result, the repeat mode is ALL. The state should reflect that.
+ setResultCallbackArgumentCaptor
+ .getValue()
+ .onResult(Mockito.mock(RemoteMediaClient.MediaChannelResult.class));
+ verify(mockListener).onRepeatModeChanged(Player.REPEAT_MODE_ALL);
+ assertThat(castPlayer.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_ALL);
+ }
+
+ @Test
+ public void repeatMode_changesOnStatusUpdates() {
+ assertThat(castPlayer.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_OFF);
+ when(mockMediaStatus.getQueueRepeatMode()).thenReturn(MediaStatus.REPEAT_MODE_REPEAT_SINGLE);
+ remoteMediaClientCallback.onStatusUpdated();
+ verify(mockListener).onRepeatModeChanged(Player.REPEAT_MODE_ONE);
+ assertThat(castPlayer.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_ONE);
+ }
+
+ @Test
+ public void setMediaItems_callsRemoteMediaClient() {
+ List mediaItems = new ArrayList<>();
+ String uri1 = "http://www.google.com/video1";
+ String uri2 = "http://www.google.com/video2";
+ mediaItems.add(
+ new MediaItem.Builder().setUri(uri1).setMimeType(MimeTypes.APPLICATION_MPD).build());
+ mediaItems.add(
+ new MediaItem.Builder().setUri(uri2).setMimeType(MimeTypes.APPLICATION_MP4).build());
+
+ castPlayer.setMediaItems(mediaItems, /* startWindowIndex= */ 1, /* startPositionMs= */ 2000L);
+
+ verify(mockRemoteMediaClient)
+ .queueLoad(queueItemsArgumentCaptor.capture(), eq(1), anyInt(), eq(2000L), any());
+ MediaQueueItem[] mediaQueueItems = queueItemsArgumentCaptor.getValue();
+ assertThat(mediaQueueItems[0].getMedia().getContentId()).isEqualTo(uri1);
+ assertThat(mediaQueueItems[1].getMedia().getContentId()).isEqualTo(uri2);
+ }
+
+ @Test
+ public void setMediaItems_doNotReset_callsRemoteMediaClient() {
+ MediaItem.Builder builder = new MediaItem.Builder();
+ List mediaItems = new ArrayList<>();
+ String uri1 = "http://www.google.com/video1";
+ String uri2 = "http://www.google.com/video2";
+ mediaItems.add(builder.setUri(uri1).setMimeType(MimeTypes.APPLICATION_MPD).build());
+ mediaItems.add(builder.setUri(uri2).setMimeType(MimeTypes.APPLICATION_MP4).build());
+ int startWindowIndex = C.INDEX_UNSET;
+ long startPositionMs = 2000L;
+
+ castPlayer.setMediaItems(mediaItems, startWindowIndex, startPositionMs);
+
+ verify(mockRemoteMediaClient)
+ .queueLoad(queueItemsArgumentCaptor.capture(), eq(0), anyInt(), eq(0L), any());
+
+ MediaQueueItem[] mediaQueueItems = queueItemsArgumentCaptor.getValue();
+ assertThat(mediaQueueItems[0].getMedia().getContentId()).isEqualTo(uri1);
+ assertThat(mediaQueueItems[1].getMedia().getContentId()).isEqualTo(uri2);
+ }
+
+ @Test
+ public void addMediaItems_callsRemoteMediaClient() {
+ MediaItem.Builder builder = new MediaItem.Builder();
+ List mediaItems = new ArrayList<>();
+ String uri1 = "http://www.google.com/video1";
+ String uri2 = "http://www.google.com/video2";
+ mediaItems.add(builder.setUri(uri1).setMimeType(MimeTypes.APPLICATION_MPD).build());
+ mediaItems.add(builder.setUri(uri2).setMimeType(MimeTypes.APPLICATION_MP4).build());
+
+ castPlayer.addMediaItems(mediaItems);
+
+ verify(mockRemoteMediaClient)
+ .queueInsertItems(
+ queueItemsArgumentCaptor.capture(), eq(MediaQueueItem.INVALID_ITEM_ID), any());
+
+ MediaQueueItem[] mediaQueueItems = queueItemsArgumentCaptor.getValue();
+ assertThat(mediaQueueItems[0].getMedia().getContentId()).isEqualTo(uri1);
+ assertThat(mediaQueueItems[1].getMedia().getContentId()).isEqualTo(uri2);
+ }
+
+ @SuppressWarnings("ConstantConditions")
+ @Test
+ public void addMediaItems_insertAtIndex_callsRemoteMediaClient() {
+ int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 2);
+ List mediaItems = createMediaItems(mediaQueueItemIds);
+ fillTimeline(mediaItems, mediaQueueItemIds);
+ String uri = "http://www.google.com/video3";
+ MediaItem anotherMediaItem =
+ new MediaItem.Builder().setUri(uri).setMimeType(MimeTypes.APPLICATION_MPD).build();
+
+ // Add another on position 1
+ int index = 1;
+ castPlayer.addMediaItems(index, Collections.singletonList(anotherMediaItem));
+
+ verify(mockRemoteMediaClient)
+ .queueInsertItems(
+ queueItemsArgumentCaptor.capture(),
+ eq((int) mediaItems.get(index).playbackProperties.tag),
+ any());
+
+ MediaQueueItem[] mediaQueueItems = queueItemsArgumentCaptor.getValue();
+ assertThat(mediaQueueItems[0].getMedia().getContentId()).isEqualTo(uri);
+ }
+
+ @Test
+ public void moveMediaItem_callsRemoteMediaClient() {
+ int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5);
+ List mediaItems = createMediaItems(mediaQueueItemIds);
+ fillTimeline(mediaItems, mediaQueueItemIds);
+
+ castPlayer.moveMediaItem(/* currentIndex= */ 1, /* newIndex= */ 2);
+
+ verify(mockRemoteMediaClient)
+ .queueReorderItems(new int[] {2}, /* insertBeforeItemId= */ 4, /* customData= */ null);
+ }
+
+ @Test
+ public void moveMediaItem_toBegin_callsRemoteMediaClient() {
+ int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5);
+ List mediaItems = createMediaItems(mediaQueueItemIds);
+ fillTimeline(mediaItems, mediaQueueItemIds);
+
+ castPlayer.moveMediaItem(/* currentIndex= */ 1, /* newIndex= */ 0);
+
+ verify(mockRemoteMediaClient)
+ .queueReorderItems(new int[] {2}, /* insertBeforeItemId= */ 1, /* customData= */ null);
+ }
+
+ @Test
+ public void moveMediaItem_toEnd_callsRemoteMediaClient() {
+ int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5);
+ List mediaItems = createMediaItems(mediaQueueItemIds);
+ fillTimeline(mediaItems, mediaQueueItemIds);
+
+ castPlayer.moveMediaItem(/* currentIndex= */ 1, /* newIndex= */ 4);
+
+ verify(mockRemoteMediaClient)
+ .queueReorderItems(
+ new int[] {2},
+ /* insertBeforeItemId= */ MediaQueueItem.INVALID_ITEM_ID,
+ /* customData= */ null);
+ }
+
+ @Test
+ public void moveMediaItems_callsRemoteMediaClient() {
+ int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5);
+ List mediaItems = createMediaItems(mediaQueueItemIds);
+ fillTimeline(mediaItems, mediaQueueItemIds);
+
+ castPlayer.moveMediaItems(/* fromIndex= */ 0, /* toIndex= */ 3, /* newIndex= */ 1);
+
+ verify(mockRemoteMediaClient)
+ .queueReorderItems(
+ new int[] {1, 2, 3}, /* insertBeforeItemId= */ 5, /* customData= */ null);
+ }
+
+ @Test
+ public void moveMediaItems_toBeginning_callsRemoteMediaClient() {
+ int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5);
+ List mediaItems = createMediaItems(mediaQueueItemIds);
+ fillTimeline(mediaItems, mediaQueueItemIds);
+
+ castPlayer.moveMediaItems(/* fromIndex= */ 1, /* toIndex= */ 4, /* newIndex= */ 0);
+
+ verify(mockRemoteMediaClient)
+ .queueReorderItems(
+ new int[] {2, 3, 4}, /* insertBeforeItemId= */ 1, /* customData= */ null);
+ }
+
+ @Test
+ public void moveMediaItems_toEnd_callsRemoteMediaClient() {
+ int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5);
+ List mediaItems = createMediaItems(mediaQueueItemIds);
+ fillTimeline(mediaItems, mediaQueueItemIds);
+
+ castPlayer.moveMediaItems(/* fromIndex= */ 0, /* toIndex= */ 2, /* newIndex= */ 3);
+
+ verify(mockRemoteMediaClient)
+ .queueReorderItems(
+ new int[] {1, 2},
+ /* insertBeforeItemId= */ MediaQueueItem.INVALID_ITEM_ID,
+ /* customData= */ null);
+ }
+
+ @Test
+ public void moveMediaItems_noItems_doesNotCallRemoteMediaClient() {
+ int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5);
+ List mediaItems = createMediaItems(mediaQueueItemIds);
+ fillTimeline(mediaItems, mediaQueueItemIds);
+
+ castPlayer.moveMediaItems(/* fromIndex= */ 1, /* toIndex= */ 1, /* newIndex= */ 0);
+
+ verify(mockRemoteMediaClient, never()).queueReorderItems(any(), anyInt(), any());
+ }
+
+ @Test
+ public void moveMediaItems_noMove_doesNotCallRemoteMediaClient() {
+ int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5);
+ List mediaItems = createMediaItems(mediaQueueItemIds);
+ fillTimeline(mediaItems, mediaQueueItemIds);
+
+ castPlayer.moveMediaItems(/* fromIndex= */ 1, /* toIndex= */ 3, /* newIndex= */ 1);
+
+ verify(mockRemoteMediaClient, never()).queueReorderItems(any(), anyInt(), any());
+ }
+
+ @Test
+ public void removeMediaItems_callsRemoteMediaClient() {
+ int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5);
+ List mediaItems = createMediaItems(mediaQueueItemIds);
+ fillTimeline(mediaItems, mediaQueueItemIds);
+
+ castPlayer.removeMediaItems(/* fromIndex= */ 1, /* toIndex= */ 4);
+
+ verify(mockRemoteMediaClient).queueRemoveItems(new int[] {2, 3, 4}, /* customData= */ null);
+ }
+
+ @Test
+ public void clearMediaItems_callsRemoteMediaClient() {
+ int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5);
+ List mediaItems = createMediaItems(mediaQueueItemIds);
+ fillTimeline(mediaItems, mediaQueueItemIds);
+
+ castPlayer.clearMediaItems();
+
+ verify(mockRemoteMediaClient)
+ .queueRemoveItems(new int[] {1, 2, 3, 4, 5}, /* customData= */ null);
+ }
+
+ @SuppressWarnings("ConstantConditions")
+ @Test
+ public void addMediaItems_fillsTimeline() {
+ Timeline.Window window = new Timeline.Window();
+ int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5);
+ List mediaItems = createMediaItems(mediaQueueItemIds);
+
+ fillTimeline(mediaItems, mediaQueueItemIds);
+
+ Timeline currentTimeline = castPlayer.getCurrentTimeline();
+ for (int i = 0; i < mediaItems.size(); i++) {
+ assertThat(currentTimeline.getWindow(/* windowIndex= */ i, window).uid)
+ .isEqualTo(mediaItems.get(i).playbackProperties.tag);
+ }
+ }
+
+ private int[] createMediaQueueItemIds(int numberOfIds) {
+ int[] mediaQueueItemIds = new int[numberOfIds];
+ for (int i = 0; i < numberOfIds; i++) {
+ mediaQueueItemIds[i] = i + 1;
+ }
+ return mediaQueueItemIds;
+ }
+
+ private List createMediaItems(int[] mediaQueueItemIds) {
+ MediaItem.Builder builder = new MediaItem.Builder();
+ List mediaItems = new ArrayList<>();
+ for (int mediaQueueItemId : mediaQueueItemIds) {
+ MediaItem mediaItem =
+ builder
+ .setUri("http://www.google.com/video" + mediaQueueItemId)
+ .setMimeType(MimeTypes.APPLICATION_MPD)
+ .setTag(mediaQueueItemId)
+ .build();
+ mediaItems.add(mediaItem);
+ }
+ return mediaItems;
+ }
+
+ private void fillTimeline(List mediaItems, int[] mediaQueueItemIds) {
+ Assertions.checkState(mediaItems.size() == mediaQueueItemIds.length);
+ List queueItems = new ArrayList<>();
+ DefaultMediaItemConverter converter = new DefaultMediaItemConverter();
+ for (MediaItem mediaItem : mediaItems) {
+ queueItems.add(converter.toMediaQueueItem(mediaItem));
+ }
+
+ // Set up mocks to allow the player to update the timeline.
+ when(mockMediaQueue.getItemIds()).thenReturn(mediaQueueItemIds);
+ when(mockMediaStatus.getCurrentItemId()).thenReturn(1);
+ when(mockMediaStatus.getMediaInfo()).thenReturn(mockMediaInfo);
+ when(mockMediaInfo.getStreamType()).thenReturn(MediaInfo.STREAM_TYPE_NONE);
+ when(mockMediaStatus.getQueueItems()).thenReturn(queueItems);
+
+ castPlayer.addMediaItems(mediaItems);
+ // Call listener to update the timeline of the player.
+ remoteMediaClientCallback.onQueueStatusUpdated();
+ }
+}
diff --git a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastTimelineTrackerTest.java b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastTimelineTrackerTest.java
new file mode 100644
index 0000000000..cae117ea00
--- /dev/null
+++ b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastTimelineTrackerTest.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.cast;
+
+import static org.mockito.Mockito.when;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.testutil.TimelineAsserts;
+import com.google.android.exoplayer2.util.MimeTypes;
+import com.google.android.gms.cast.MediaInfo;
+import com.google.android.gms.cast.MediaStatus;
+import com.google.android.gms.cast.framework.media.MediaQueue;
+import com.google.android.gms.cast.framework.media.RemoteMediaClient;
+import java.util.Collections;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+
+/** Tests for {@link CastTimelineTracker}. */
+@RunWith(AndroidJUnit4.class)
+public class CastTimelineTrackerTest {
+
+ private static final long DURATION_2_MS = 2000;
+ private static final long DURATION_3_MS = 3000;
+ private static final long DURATION_4_MS = 4000;
+ private static final long DURATION_5_MS = 5000;
+
+ /** Tests that duration of the current media info is correctly propagated to the timeline. */
+ @Test
+ public void getCastTimelinePersistsDuration() {
+ CastTimelineTracker tracker = new CastTimelineTracker();
+
+ RemoteMediaClient remoteMediaClient =
+ mockRemoteMediaClient(
+ /* itemIds= */ new int[] {1, 2, 3, 4, 5},
+ /* currentItemId= */ 2,
+ /* currentDurationMs= */ DURATION_2_MS);
+ TimelineAsserts.assertPeriodDurations(
+ tracker.getCastTimeline(remoteMediaClient),
+ C.TIME_UNSET,
+ C.msToUs(DURATION_2_MS),
+ C.TIME_UNSET,
+ C.TIME_UNSET,
+ C.TIME_UNSET);
+
+ remoteMediaClient =
+ mockRemoteMediaClient(
+ /* itemIds= */ new int[] {1, 2, 3},
+ /* currentItemId= */ 3,
+ /* currentDurationMs= */ DURATION_3_MS);
+ TimelineAsserts.assertPeriodDurations(
+ tracker.getCastTimeline(remoteMediaClient),
+ C.TIME_UNSET,
+ C.msToUs(DURATION_2_MS),
+ C.msToUs(DURATION_3_MS));
+
+ remoteMediaClient =
+ mockRemoteMediaClient(
+ /* itemIds= */ new int[] {1, 3},
+ /* currentItemId= */ 3,
+ /* currentDurationMs= */ DURATION_3_MS);
+ TimelineAsserts.assertPeriodDurations(
+ tracker.getCastTimeline(remoteMediaClient), C.TIME_UNSET, C.msToUs(DURATION_3_MS));
+
+ remoteMediaClient =
+ mockRemoteMediaClient(
+ /* itemIds= */ new int[] {1, 2, 3, 4, 5},
+ /* currentItemId= */ 4,
+ /* currentDurationMs= */ DURATION_4_MS);
+ TimelineAsserts.assertPeriodDurations(
+ tracker.getCastTimeline(remoteMediaClient),
+ C.TIME_UNSET,
+ C.TIME_UNSET,
+ C.msToUs(DURATION_3_MS),
+ C.msToUs(DURATION_4_MS),
+ C.TIME_UNSET);
+
+ remoteMediaClient =
+ mockRemoteMediaClient(
+ /* itemIds= */ new int[] {1, 2, 3, 4, 5},
+ /* currentItemId= */ 5,
+ /* currentDurationMs= */ DURATION_5_MS);
+ TimelineAsserts.assertPeriodDurations(
+ tracker.getCastTimeline(remoteMediaClient),
+ C.TIME_UNSET,
+ C.TIME_UNSET,
+ C.msToUs(DURATION_3_MS),
+ C.msToUs(DURATION_4_MS),
+ C.msToUs(DURATION_5_MS));
+ }
+
+ private static RemoteMediaClient mockRemoteMediaClient(
+ int[] itemIds, int currentItemId, long currentDurationMs) {
+ RemoteMediaClient remoteMediaClient = Mockito.mock(RemoteMediaClient.class);
+ MediaStatus status = Mockito.mock(MediaStatus.class);
+ when(status.getQueueItems()).thenReturn(Collections.emptyList());
+ when(remoteMediaClient.getMediaStatus()).thenReturn(status);
+ when(status.getMediaInfo()).thenReturn(getMediaInfo(currentDurationMs));
+ when(status.getCurrentItemId()).thenReturn(currentItemId);
+ MediaQueue mediaQueue = mockMediaQueue(itemIds);
+ when(remoteMediaClient.getMediaQueue()).thenReturn(mediaQueue);
+ return remoteMediaClient;
+ }
+
+ private static MediaQueue mockMediaQueue(int[] itemIds) {
+ MediaQueue mediaQueue = Mockito.mock(MediaQueue.class);
+ when(mediaQueue.getItemIds()).thenReturn(itemIds);
+ return mediaQueue;
+ }
+
+ private static MediaInfo getMediaInfo(long durationMs) {
+ return new MediaInfo.Builder(/*contentId= */ "")
+ .setStreamDuration(durationMs)
+ .setContentType(MimeTypes.APPLICATION_MP4)
+ .setStreamType(MediaInfo.STREAM_TYPE_NONE)
+ .build();
+ }
+}
diff --git a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverterTest.java b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverterTest.java
new file mode 100644
index 0000000000..9d65bada16
--- /dev/null
+++ b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverterTest.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.cast;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.Uri;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.MediaMetadata;
+import com.google.android.exoplayer2.util.MimeTypes;
+import com.google.android.gms.cast.MediaQueueItem;
+import java.util.Collections;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Test for {@link DefaultMediaItemConverter}. */
+@RunWith(AndroidJUnit4.class)
+public class DefaultMediaItemConverterTest {
+
+ @Test
+ public void serialize_deserialize_minimal() {
+ MediaItem.Builder builder = new MediaItem.Builder();
+ MediaItem item =
+ builder.setUri("http://example.com").setMimeType(MimeTypes.APPLICATION_MPD).build();
+
+ DefaultMediaItemConverter converter = new DefaultMediaItemConverter();
+ MediaQueueItem queueItem = converter.toMediaQueueItem(item);
+ MediaItem reconstructedItem = converter.toMediaItem(queueItem);
+
+ assertThat(reconstructedItem).isEqualTo(item);
+ }
+
+ @Test
+ public void serialize_deserialize_complete() {
+ MediaItem.Builder builder = new MediaItem.Builder();
+ MediaItem item =
+ builder
+ .setUri(Uri.parse("http://example.com"))
+ .setMediaMetadata(new MediaMetadata.Builder().build())
+ .setMimeType(MimeTypes.APPLICATION_MPD)
+ .setDrmUuid(C.WIDEVINE_UUID)
+ .setDrmLicenseUri("http://license.com")
+ .setDrmLicenseRequestHeaders(Collections.singletonMap("key", "value"))
+ .build();
+
+ DefaultMediaItemConverter converter = new DefaultMediaItemConverter();
+ MediaQueueItem queueItem = converter.toMediaQueueItem(item);
+ MediaItem reconstructedItem = converter.toMediaItem(queueItem);
+
+ assertThat(reconstructedItem).isEqualTo(item);
+ }
+}
diff --git a/extensions/cronet/README.md b/extensions/cronet/README.md
index a570385a52..112ad26bba 100644
--- a/extensions/cronet/README.md
+++ b/extensions/cronet/README.md
@@ -1,32 +1,99 @@
-# ExoPlayer Cronet Extension #
+# ExoPlayer Cronet extension #
-## Description ##
+The Cronet extension is an [HttpDataSource][] implementation using [Cronet][].
-[Cronet][] is Chromium's Networking stack packaged as a library.
-
-The Cronet Extension is an [HttpDataSource][] implementation using [Cronet][].
-
-[HttpDataSource]: https://google.github.io/ExoPlayer/doc/reference/com/google/android/exoplayer/upstream/HttpDataSource.html
+[HttpDataSource]: https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/upstream/HttpDataSource.html
[Cronet]: https://chromium.googlesource.com/chromium/src/+/master/components/cronet?autodive=0%2F%2F
-## Build Instructions ##
+## Getting the extension ##
-* Checkout ExoPlayer along with Extensions:
+The easiest way to use the extension is to add it as a gradle dependency:
-```
-git clone https://github.com/google/ExoPlayer.git
+```gradle
+implementation 'com.google.android.exoplayer:extension-cronet:2.X.X'
```
-* Get the Cronet libraries:
+where `2.X.X` is the version, which must match the version of the ExoPlayer
+library being used.
-1. Find the latest Cronet release [here][] and navigate to its `Release/cronet`
- directory
-1. Download `cronet_api.jar`, `cronet_impl_common_java.jar`,
- `cronet_impl_native_java.jar` and the `libs` directory
-1. Copy the three jar files into the `libs` directory of this extension
-1. Copy the content of the downloaded `libs` directory into the `jniLibs`
- directory of this extension
+Alternatively, you can clone the ExoPlayer repository and depend on the module
+locally. Instructions for doing this can be found in ExoPlayer's
+[top level README][].
-* In ExoPlayer's `settings.gradle` file, uncomment the Cronet extension
+Note that by default, the extension will use the Cronet implementation in
+Google Play Services. If you prefer, it's also possible to embed the Cronet
+implementation directly into your application. See below for more details.
-[here]: https://console.cloud.google.com/storage/browser/chromium-cronet/android
+[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
+
+## Using the extension ##
+
+ExoPlayer requests data through `DataSource` instances. These instances are
+either instantiated and injected from application code, or obtained from
+instances of `DataSource.Factory` that are instantiated and injected from
+application code.
+
+If your application only needs to play http(s) content, using the Cronet
+extension is as simple as updating any `DataSource`s and `DataSource.Factory`
+instantiations in your application code to use `CronetDataSource` and
+`CronetDataSourceFactory` respectively. If your application also needs to play
+non-http(s) content such as local files, use
+```
+new DefaultDataSource(
+ ...
+ new CronetDataSource(...) /* baseDataSource argument */);
+```
+and
+```
+new DefaultDataSourceFactory(
+ ...
+ new CronetDataSourceFactory(...) /* baseDataSourceFactory argument */);
+```
+respectively.
+
+## Choosing between Google Play Services Cronet and Cronet Embedded ##
+
+The underlying Cronet implementation is available both via a [Google Play
+Services](https://developers.google.com/android/guides/overview) API, and as a
+library that can be embedded directly into your application. When you depend on
+`com.google.android.exoplayer:extension-cronet:2.X.X`, the library will _not_ be
+embedded into your application by default. The extension will attempt to use the
+Cronet implementation in Google Play Services. The benefits of this approach
+are:
+
+* A negligible increase in the size of your application.
+* The Cronet implementation is updated automatically by Google Play Services.
+
+If Google Play Services is not available on a device, `CronetDataSourceFactory`
+will fall back to creating `DefaultHttpDataSource` instances, or
+`HttpDataSource` instances created by a `fallbackFactory` that you can specify.
+
+It's also possible to embed the Cronet implementation directly into your
+application. To do this, add an additional gradle dependency to the Cronet
+Embedded library:
+
+```gradle
+implementation 'com.google.android.exoplayer:extension-cronet:2.X.X'
+implementation 'org.chromium.net:cronet-embedded:XX.XXXX.XXX'
+```
+
+where `XX.XXXX.XXX` is the version of the library that you wish to use. The
+extension will automatically detect and use the library. Embedding will add
+approximately 8MB to your application, however it may be suitable if:
+
+* Your application is likely to be used in markets where Google Play Services is
+ not widely available.
+* You want to control the exact version of the Cronet implementation being used.
+
+If you do embed the library, you can specify which implementation should
+be preferred if the Google Play Services implementation is also available. This
+is controlled by a `preferGMSCoreCronet` parameter, which can be passed to the
+`CronetEngineWrapper` constructor (GMS Core is another name for Google Play
+Services).
+
+## Links ##
+
+* [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.cronet.*`
+ belong to this module.
+
+[Javadoc]: https://exoplayer.dev/doc/reference/index.html
diff --git a/extensions/cronet/build.gradle b/extensions/cronet/build.gradle
index 5611817b2e..0dd1d42d72 100644
--- a/extensions/cronet/build.gradle
+++ b/extensions/cronet/build.gradle
@@ -11,36 +11,33 @@
// 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.
-apply plugin: 'com.android.library'
-
-android {
- compileSdkVersion project.ext.compileSdkVersion
- buildToolsVersion project.ext.buildToolsVersion
-
- defaultConfig {
- minSdkVersion project.ext.minSdkVersion
- targetSdkVersion project.ext.targetSdkVersion
- testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
- }
-
- sourceSets.main {
- jniLibs.srcDirs = ['jniLibs']
- }
-}
+apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle"
dependencies {
- compile project(':library-core')
- compile files('libs/cronet_api.jar')
- compile files('libs/cronet_impl_common_java.jar')
- compile files('libs/cronet_impl_native_java.jar')
- androidTestCompile project(':library')
- androidTestCompile 'com.google.dexmaker:dexmaker:' + dexmakerVersion
- androidTestCompile 'com.google.dexmaker:dexmaker-mockito:' + dexmakerVersion
- androidTestCompile 'org.mockito:mockito-core:' + mockitoVersion
- androidTestCompile 'com.android.support.test:runner:' + testSupportLibraryVersion
+ api "com.google.android.gms:play-services-cronet:17.0.0"
+ implementation project(modulePrefix + 'library-core')
+ implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
+ implementation ('com.google.guava:guava:' + guavaVersion) {
+ exclude group: 'com.google.code.findbugs', module: 'jsr305'
+ exclude group: 'org.checkerframework', module: 'checker-compat-qual'
+ exclude group: 'com.google.errorprone', module: 'error_prone_annotations'
+ exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
+ exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations'
+ }
+ compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
+ compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion
+ testImplementation project(modulePrefix + 'library')
+ testImplementation project(modulePrefix + 'testutils')
+ testImplementation 'org.robolectric:robolectric:' + robolectricVersion
}
ext {
javadocTitle = 'Cronet extension'
}
apply from: '../../javadoc_library.gradle'
+
+ext {
+ releaseArtifact = 'extension-cronet'
+ releaseDescription = 'Cronet extension for ExoPlayer.'
+}
+apply from: '../../publish.gradle'
diff --git a/extensions/cronet/jniLibs/README.md b/extensions/cronet/jniLibs/README.md
deleted file mode 100644
index e9f0717ae6..0000000000
--- a/extensions/cronet/jniLibs/README.md
+++ /dev/null
@@ -1 +0,0 @@
-Copy folders containing architecture specific .so files here.
diff --git a/extensions/cronet/libs/README.md b/extensions/cronet/libs/README.md
deleted file mode 100644
index 641a80db18..0000000000
--- a/extensions/cronet/libs/README.md
+++ /dev/null
@@ -1 +0,0 @@
-Copy cronet.jar and cronet_api.jar here.
diff --git a/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java b/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java
deleted file mode 100644
index 246e23e172..0000000000
--- a/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java
+++ /dev/null
@@ -1,809 +0,0 @@
-/*
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.google.android.exoplayer2.ext.cronet;
-
-import static org.junit.Assert.assertArrayEquals;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
-import static org.mockito.Matchers.any;
-import static org.mockito.Matchers.anyString;
-import static org.mockito.Matchers.eq;
-import static org.mockito.Mockito.doAnswer;
-import static org.mockito.Mockito.doThrow;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.spy;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-import static org.mockito.MockitoAnnotations.initMocks;
-
-import android.net.Uri;
-import android.os.ConditionVariable;
-import android.support.test.InstrumentationRegistry;
-import android.support.test.runner.AndroidJUnit4;
-import com.google.android.exoplayer2.C;
-import com.google.android.exoplayer2.upstream.DataSpec;
-import com.google.android.exoplayer2.upstream.HttpDataSource;
-import com.google.android.exoplayer2.upstream.HttpDataSource.HttpDataSourceException;
-import com.google.android.exoplayer2.upstream.TransferListener;
-import com.google.android.exoplayer2.util.Clock;
-import com.google.android.exoplayer2.util.Predicate;
-import java.io.IOException;
-import java.net.SocketTimeoutException;
-import java.net.UnknownHostException;
-import java.nio.ByteBuffer;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.concurrent.Executor;
-import java.util.concurrent.atomic.AtomicInteger;
-import org.chromium.net.CronetEngine;
-import org.chromium.net.NetworkException;
-import org.chromium.net.UrlRequest;
-import org.chromium.net.UrlResponseInfo;
-import org.chromium.net.impl.UrlResponseInfoImpl;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.Mock;
-import org.mockito.invocation.InvocationOnMock;
-import org.mockito.stubbing.Answer;
-
-/**
- * Tests for {@link CronetDataSource}.
- */
-@RunWith(AndroidJUnit4.class)
-public final class CronetDataSourceTest {
-
- private static final int TEST_CONNECT_TIMEOUT_MS = 100;
- private static final int TEST_READ_TIMEOUT_MS = 50;
- private static final String TEST_URL = "http://google.com";
- private static final String TEST_CONTENT_TYPE = "test/test";
- private static final byte[] TEST_POST_BODY = "test post body".getBytes();
- private static final long TEST_CONTENT_LENGTH = 16000L;
- private static final int TEST_CONNECTION_STATUS = 5;
-
- private DataSpec testDataSpec;
- private DataSpec testPostDataSpec;
- private Map testResponseHeader;
- private UrlResponseInfo testUrlResponseInfo;
-
- @Mock private UrlRequest.Builder mockUrlRequestBuilder;
- @Mock
- private UrlRequest mockUrlRequest;
- @Mock
- private Predicate mockContentTypePredicate;
- @Mock
- private TransferListener mockTransferListener;
- @Mock
- private Clock mockClock;
- @Mock
- private Executor mockExecutor;
- @Mock
- private NetworkException mockNetworkException;
- @Mock private CronetEngine mockCronetEngine;
-
- private CronetDataSource dataSourceUnderTest;
-
- @Before
- public void setUp() throws Exception {
- System.setProperty("dexmaker.dexcache",
- InstrumentationRegistry.getTargetContext().getCacheDir().getPath());
- initMocks(this);
- dataSourceUnderTest = spy(
- new CronetDataSource(
- mockCronetEngine,
- mockExecutor,
- mockContentTypePredicate,
- mockTransferListener,
- TEST_CONNECT_TIMEOUT_MS,
- TEST_READ_TIMEOUT_MS,
- true, // resetTimeoutOnRedirects
- mockClock,
- null));
- when(mockContentTypePredicate.evaluate(anyString())).thenReturn(true);
- when(mockCronetEngine.newUrlRequestBuilder(
- anyString(), any(UrlRequest.Callback.class), any(Executor.class)))
- .thenReturn(mockUrlRequestBuilder);
- when(mockUrlRequestBuilder.build()).thenReturn(mockUrlRequest);
- mockStatusResponse();
-
- testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, C.LENGTH_UNSET, null);
- testPostDataSpec = new DataSpec(
- Uri.parse(TEST_URL), TEST_POST_BODY, 0, 0, C.LENGTH_UNSET, null, 0);
- testResponseHeader = new HashMap<>();
- testResponseHeader.put("Content-Type", TEST_CONTENT_TYPE);
- // This value can be anything since the DataSpec is unset.
- testResponseHeader.put("Content-Length", Long.toString(TEST_CONTENT_LENGTH));
- testUrlResponseInfo = createUrlResponseInfo(200); // statusCode
- }
-
- private UrlResponseInfo createUrlResponseInfo(int statusCode) {
- ArrayList> responseHeaderList = new ArrayList<>();
- responseHeaderList.addAll(testResponseHeader.entrySet());
- return new UrlResponseInfoImpl(
- Collections.singletonList(TEST_URL),
- statusCode,
- null, // httpStatusText
- responseHeaderList,
- false, // wasCached
- null, // negotiatedProtocol
- null); // proxyServer
- }
-
- @Test(expected = IllegalStateException.class)
- public void testOpeningTwiceThrows() throws HttpDataSourceException {
- mockResponseStartSuccess();
- dataSourceUnderTest.open(testDataSpec);
- dataSourceUnderTest.open(testDataSpec);
- }
-
- @Test
- public void testCallbackFromPreviousRequest() throws HttpDataSourceException {
- mockResponseStartSuccess();
-
- dataSourceUnderTest.open(testDataSpec);
- dataSourceUnderTest.close();
- // Prepare a mock UrlRequest to be used in the second open() call.
- final UrlRequest mockUrlRequest2 = mock(UrlRequest.class);
- when(mockUrlRequestBuilder.build()).thenReturn(mockUrlRequest2);
- doAnswer(new Answer() {
- @Override
- public Object answer(InvocationOnMock invocation) throws Throwable {
- // Invoke the callback for the previous request.
- dataSourceUnderTest.onFailed(
- mockUrlRequest,
- testUrlResponseInfo,
- mockNetworkException);
- dataSourceUnderTest.onResponseStarted(
- mockUrlRequest2,
- testUrlResponseInfo);
- return null;
- }
- }).when(mockUrlRequest2).start();
- dataSourceUnderTest.open(testDataSpec);
- }
-
- @Test
- public void testRequestStartCalled() throws HttpDataSourceException {
- mockResponseStartSuccess();
-
- dataSourceUnderTest.open(testDataSpec);
- verify(mockCronetEngine)
- .newUrlRequestBuilder(eq(TEST_URL), any(UrlRequest.Callback.class), any(Executor.class));
- verify(mockUrlRequest).start();
- }
-
- @Test
- public void testRequestHeadersSet() throws HttpDataSourceException {
- testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000, null);
- mockResponseStartSuccess();
-
- dataSourceUnderTest.setRequestProperty("firstHeader", "firstValue");
- dataSourceUnderTest.setRequestProperty("secondHeader", "secondValue");
-
- dataSourceUnderTest.open(testDataSpec);
- // The header value to add is current position to current position + length - 1.
- verify(mockUrlRequestBuilder).addHeader("Range", "bytes=1000-5999");
- verify(mockUrlRequestBuilder).addHeader("firstHeader", "firstValue");
- verify(mockUrlRequestBuilder).addHeader("secondHeader", "secondValue");
- verify(mockUrlRequest).start();
- }
-
- @Test
- public void testRequestOpen() throws HttpDataSourceException {
- mockResponseStartSuccess();
- assertEquals(TEST_CONTENT_LENGTH, dataSourceUnderTest.open(testDataSpec));
- verify(mockTransferListener).onTransferStart(dataSourceUnderTest, testDataSpec);
- }
-
- @Test
- public void testRequestOpenGzippedCompressedReturnsDataSpecLength()
- throws HttpDataSourceException {
- testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, 5000, null);
- testResponseHeader.put("Content-Encoding", "gzip");
- testResponseHeader.put("Content-Length", Long.toString(50L));
- mockResponseStartSuccess();
-
- assertEquals(5000 /* contentLength */, dataSourceUnderTest.open(testDataSpec));
- verify(mockTransferListener).onTransferStart(dataSourceUnderTest, testDataSpec);
- }
-
- @Test
- public void testRequestOpenFail() {
- mockResponseStartFailure();
-
- try {
- dataSourceUnderTest.open(testDataSpec);
- fail("HttpDataSource.HttpDataSourceException expected");
- } catch (HttpDataSourceException e) {
- // Check for connection not automatically closed.
- assertFalse(e.getCause() instanceof UnknownHostException);
- verify(mockUrlRequest, never()).cancel();
- verify(mockTransferListener, never()).onTransferStart(dataSourceUnderTest, testDataSpec);
- }
- }
-
- @Test
- public void testRequestOpenFailDueToDnsFailure() {
- mockResponseStartFailure();
- when(mockNetworkException.getErrorCode()).thenReturn(
- NetworkException.ERROR_HOSTNAME_NOT_RESOLVED);
-
- try {
- dataSourceUnderTest.open(testDataSpec);
- fail("HttpDataSource.HttpDataSourceException expected");
- } catch (HttpDataSourceException e) {
- // Check for connection not automatically closed.
- assertTrue(e.getCause() instanceof UnknownHostException);
- verify(mockUrlRequest, never()).cancel();
- verify(mockTransferListener, never()).onTransferStart(dataSourceUnderTest, testDataSpec);
- }
- }
-
- @Test
- public void testRequestOpenValidatesStatusCode() {
- mockResponseStartSuccess();
- testUrlResponseInfo = createUrlResponseInfo(500); // statusCode
-
- try {
- dataSourceUnderTest.open(testDataSpec);
- fail("HttpDataSource.HttpDataSourceException expected");
- } catch (HttpDataSourceException e) {
- assertTrue(e instanceof HttpDataSource.InvalidResponseCodeException);
- // Check for connection not automatically closed.
- verify(mockUrlRequest, never()).cancel();
- verify(mockTransferListener, never()).onTransferStart(dataSourceUnderTest, testDataSpec);
- }
- }
-
- @Test
- public void testRequestOpenValidatesContentTypePredicate() {
- mockResponseStartSuccess();
- when(mockContentTypePredicate.evaluate(anyString())).thenReturn(false);
-
- try {
- dataSourceUnderTest.open(testDataSpec);
- fail("HttpDataSource.HttpDataSourceException expected");
- } catch (HttpDataSourceException e) {
- assertTrue(e instanceof HttpDataSource.InvalidContentTypeException);
- // Check for connection not automatically closed.
- verify(mockUrlRequest, never()).cancel();
- verify(mockContentTypePredicate).evaluate(TEST_CONTENT_TYPE);
- }
- }
-
- @Test
- public void testPostRequestOpen() throws HttpDataSourceException {
- mockResponseStartSuccess();
-
- dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE);
- assertEquals(TEST_CONTENT_LENGTH, dataSourceUnderTest.open(testPostDataSpec));
- verify(mockTransferListener).onTransferStart(dataSourceUnderTest, testPostDataSpec);
- }
-
- @Test
- public void testPostRequestOpenValidatesContentType() {
- mockResponseStartSuccess();
-
- try {
- dataSourceUnderTest.open(testPostDataSpec);
- fail("HttpDataSource.HttpDataSourceException expected");
- } catch (HttpDataSourceException e) {
- verify(mockUrlRequest, never()).start();
- }
- }
-
- @Test
- public void testPostRequestOpenRejects307Redirects() {
- mockResponseStartSuccess();
- mockResponseStartRedirect();
-
- try {
- dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE);
- dataSourceUnderTest.open(testPostDataSpec);
- fail("HttpDataSource.HttpDataSourceException expected");
- } catch (HttpDataSourceException e) {
- verify(mockUrlRequest, never()).followRedirect();
- }
- }
-
- @Test
- public void testRequestReadTwice() throws HttpDataSourceException {
- mockResponseStartSuccess();
- mockReadSuccess(0, 16);
-
- dataSourceUnderTest.open(testDataSpec);
-
- byte[] returnedBuffer = new byte[8];
- int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8);
- assertArrayEquals(buildTestDataArray(0, 8), returnedBuffer);
- assertEquals(8, bytesRead);
-
- returnedBuffer = new byte[8];
- bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8);
- assertArrayEquals(buildTestDataArray(8, 8), returnedBuffer);
- assertEquals(8, bytesRead);
-
- // Should have only called read on cronet once.
- verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class));
- verify(mockTransferListener, times(2)).onBytesTransferred(dataSourceUnderTest, 8);
- }
-
- @Test
- public void testSecondRequestNoContentLength() throws HttpDataSourceException {
- mockResponseStartSuccess();
- testResponseHeader.put("Content-Length", Long.toString(1L));
- mockReadSuccess(0, 16);
-
- // First request.
- dataSourceUnderTest.open(testDataSpec);
- byte[] returnedBuffer = new byte[8];
- dataSourceUnderTest.read(returnedBuffer, 0, 1);
- dataSourceUnderTest.close();
-
- testResponseHeader.remove("Content-Length");
- mockReadSuccess(0, 16);
-
- // Second request.
- dataSourceUnderTest.open(testDataSpec);
- returnedBuffer = new byte[16];
- int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10);
- assertEquals(10, bytesRead);
- bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10);
- assertEquals(6, bytesRead);
- bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10);
- assertEquals(C.RESULT_END_OF_INPUT, bytesRead);
- }
-
- @Test
- public void testReadWithOffset() throws HttpDataSourceException {
- mockResponseStartSuccess();
- mockReadSuccess(0, 16);
-
- dataSourceUnderTest.open(testDataSpec);
-
- byte[] returnedBuffer = new byte[16];
- int bytesRead = dataSourceUnderTest.read(returnedBuffer, 8, 8);
- assertEquals(8, bytesRead);
- assertArrayEquals(prefixZeros(buildTestDataArray(0, 8), 16), returnedBuffer);
- verify(mockTransferListener).onBytesTransferred(dataSourceUnderTest, 8);
- }
-
- @Test
- public void testRangeRequestWith206Response() throws HttpDataSourceException {
- mockResponseStartSuccess();
- mockReadSuccess(1000, 5000);
- testUrlResponseInfo = createUrlResponseInfo(206); // Server supports range requests.
- testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000, null);
-
- dataSourceUnderTest.open(testDataSpec);
-
- byte[] returnedBuffer = new byte[16];
- int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 16);
- assertEquals(16, bytesRead);
- assertArrayEquals(buildTestDataArray(1000, 16), returnedBuffer);
- verify(mockTransferListener).onBytesTransferred(dataSourceUnderTest, 16);
- }
-
- @Test
- public void testRangeRequestWith200Response() throws HttpDataSourceException {
- mockResponseStartSuccess();
- mockReadSuccess(0, 7000);
- testUrlResponseInfo = createUrlResponseInfo(200); // Server does not support range requests.
- testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000, null);
-
- dataSourceUnderTest.open(testDataSpec);
-
- byte[] returnedBuffer = new byte[16];
- int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 16);
- assertEquals(16, bytesRead);
- assertArrayEquals(buildTestDataArray(1000, 16), returnedBuffer);
- verify(mockTransferListener).onBytesTransferred(dataSourceUnderTest, 16);
- }
-
- @Test
- public void testReadWithUnsetLength() throws HttpDataSourceException {
- testResponseHeader.remove("Content-Length");
- mockResponseStartSuccess();
- mockReadSuccess(0, 16);
-
- dataSourceUnderTest.open(testDataSpec);
-
- byte[] returnedBuffer = new byte[16];
- int bytesRead = dataSourceUnderTest.read(returnedBuffer, 8, 8);
- assertArrayEquals(prefixZeros(buildTestDataArray(0, 8), 16), returnedBuffer);
- assertEquals(8, bytesRead);
- verify(mockTransferListener).onBytesTransferred(dataSourceUnderTest, 8);
- }
-
- @Test
- public void testReadReturnsWhatItCan() throws HttpDataSourceException {
- mockResponseStartSuccess();
- mockReadSuccess(0, 16);
-
- dataSourceUnderTest.open(testDataSpec);
-
- byte[] returnedBuffer = new byte[24];
- int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 24);
- assertArrayEquals(suffixZeros(buildTestDataArray(0, 16), 24), returnedBuffer);
- assertEquals(16, bytesRead);
- verify(mockTransferListener).onBytesTransferred(dataSourceUnderTest, 16);
- }
-
- @Test
- public void testClosedMeansClosed() throws HttpDataSourceException {
- mockResponseStartSuccess();
- mockReadSuccess(0, 16);
-
- int bytesRead = 0;
- dataSourceUnderTest.open(testDataSpec);
-
- byte[] returnedBuffer = new byte[8];
- bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8);
- assertArrayEquals(buildTestDataArray(0, 8), returnedBuffer);
- assertEquals(8, bytesRead);
-
- dataSourceUnderTest.close();
- verify(mockTransferListener).onTransferEnd(dataSourceUnderTest);
-
- try {
- bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8);
- fail();
- } catch (IllegalStateException e) {
- // Expected.
- }
-
- // 16 bytes were attempted but only 8 should have been successfully read.
- assertEquals(8, bytesRead);
- }
-
- @Test
- public void testOverread() throws HttpDataSourceException {
- testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, 16, null);
- testResponseHeader.put("Content-Length", Long.toString(16L));
- mockResponseStartSuccess();
- mockReadSuccess(0, 16);
-
- dataSourceUnderTest.open(testDataSpec);
-
- byte[] returnedBuffer = new byte[8];
- int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8);
- assertEquals(8, bytesRead);
- assertArrayEquals(buildTestDataArray(0, 8), returnedBuffer);
-
- // The current buffer is kept if not completely consumed by DataSource reader.
- returnedBuffer = new byte[8];
- bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 6);
- assertEquals(14, bytesRead);
- assertArrayEquals(suffixZeros(buildTestDataArray(8, 6), 8), returnedBuffer);
-
- // 2 bytes left at this point.
- returnedBuffer = new byte[8];
- bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8);
- assertEquals(16, bytesRead);
- assertArrayEquals(suffixZeros(buildTestDataArray(14, 2), 8), returnedBuffer);
-
- // Should have only called read on cronet once.
- verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class));
- verify(mockTransferListener, times(1)).onBytesTransferred(dataSourceUnderTest, 8);
- verify(mockTransferListener, times(1)).onBytesTransferred(dataSourceUnderTest, 6);
- verify(mockTransferListener, times(1)).onBytesTransferred(dataSourceUnderTest, 2);
-
- // Now we already returned the 16 bytes initially asked.
- // Try to read again even though all requested 16 bytes are already returned.
- // Return C.RESULT_END_OF_INPUT
- returnedBuffer = new byte[16];
- int bytesOverRead = dataSourceUnderTest.read(returnedBuffer, 0, 16);
- assertEquals(C.RESULT_END_OF_INPUT, bytesOverRead);
- assertArrayEquals(new byte[16], returnedBuffer);
- // C.RESULT_END_OF_INPUT should not be reported though the TransferListener.
- verify(mockTransferListener, never()).onBytesTransferred(dataSourceUnderTest,
- C.RESULT_END_OF_INPUT);
- // There should still be only one call to read on cronet.
- verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class));
- // Check for connection not automatically closed.
- verify(mockUrlRequest, never()).cancel();
- assertEquals(16, bytesRead);
- }
-
- @Test
- public void testConnectTimeout() {
- when(mockClock.elapsedRealtime()).thenReturn(0L);
- final ConditionVariable startCondition = buildUrlRequestStartedCondition();
- final ConditionVariable timedOutCondition = new ConditionVariable();
-
- new Thread() {
- @Override
- public void run() {
- try {
- dataSourceUnderTest.open(testDataSpec);
- fail();
- } catch (HttpDataSourceException e) {
- // Expected.
- assertTrue(e instanceof CronetDataSource.OpenException);
- assertTrue(e.getCause() instanceof SocketTimeoutException);
- assertEquals(
- TEST_CONNECTION_STATUS,
- ((CronetDataSource.OpenException) e).cronetConnectionStatus);
- timedOutCondition.open();
- }
- }
- }.start();
- startCondition.block();
-
- // We should still be trying to open.
- assertFalse(timedOutCondition.block(50));
- // We should still be trying to open as we approach the timeout.
- when(mockClock.elapsedRealtime()).thenReturn((long) TEST_CONNECT_TIMEOUT_MS - 1);
- assertFalse(timedOutCondition.block(50));
- // Now we timeout.
- when(mockClock.elapsedRealtime()).thenReturn((long) TEST_CONNECT_TIMEOUT_MS);
- timedOutCondition.block();
-
- verify(mockTransferListener, never()).onTransferStart(dataSourceUnderTest, testDataSpec);
- }
-
- @Test
- public void testConnectResponseBeforeTimeout() {
- when(mockClock.elapsedRealtime()).thenReturn(0L);
- final ConditionVariable startCondition = buildUrlRequestStartedCondition();
- final ConditionVariable openCondition = new ConditionVariable();
-
- new Thread() {
- @Override
- public void run() {
- try {
- dataSourceUnderTest.open(testDataSpec);
- openCondition.open();
- } catch (HttpDataSourceException e) {
- fail();
- }
- }
- }.start();
- startCondition.block();
-
- // We should still be trying to open.
- assertFalse(openCondition.block(50));
- // We should still be trying to open as we approach the timeout.
- when(mockClock.elapsedRealtime()).thenReturn((long) TEST_CONNECT_TIMEOUT_MS - 1);
- assertFalse(openCondition.block(50));
- // The response arrives just in time.
- dataSourceUnderTest.onResponseStarted(mockUrlRequest, testUrlResponseInfo);
- openCondition.block();
- }
-
- @Test
- public void testRedirectIncreasesConnectionTimeout() throws InterruptedException {
- when(mockClock.elapsedRealtime()).thenReturn(0L);
- final ConditionVariable startCondition = buildUrlRequestStartedCondition();
- final ConditionVariable timedOutCondition = new ConditionVariable();
- final AtomicInteger openExceptions = new AtomicInteger(0);
-
- new Thread() {
- @Override
- public void run() {
- try {
- dataSourceUnderTest.open(testDataSpec);
- fail();
- } catch (HttpDataSourceException e) {
- // Expected.
- assertTrue(e instanceof CronetDataSource.OpenException);
- assertTrue(e.getCause() instanceof SocketTimeoutException);
- openExceptions.getAndIncrement();
- timedOutCondition.open();
- }
- }
- }.start();
- startCondition.block();
-
- // We should still be trying to open.
- assertFalse(timedOutCondition.block(50));
- // We should still be trying to open as we approach the timeout.
- when(mockClock.elapsedRealtime()).thenReturn((long) TEST_CONNECT_TIMEOUT_MS - 1);
- assertFalse(timedOutCondition.block(50));
- // A redirect arrives just in time.
- dataSourceUnderTest.onRedirectReceived(mockUrlRequest, testUrlResponseInfo,
- "RandomRedirectedUrl1");
-
- long newTimeoutMs = 2 * TEST_CONNECT_TIMEOUT_MS - 1;
- when(mockClock.elapsedRealtime()).thenReturn(newTimeoutMs - 1);
- // Give the thread some time to run.
- assertFalse(timedOutCondition.block(newTimeoutMs));
- // We should still be trying to open as we approach the new timeout.
- assertFalse(timedOutCondition.block(50));
- // A redirect arrives just in time.
- dataSourceUnderTest.onRedirectReceived(mockUrlRequest, testUrlResponseInfo,
- "RandomRedirectedUrl2");
-
- newTimeoutMs = 3 * TEST_CONNECT_TIMEOUT_MS - 2;
- when(mockClock.elapsedRealtime()).thenReturn(newTimeoutMs - 1);
- // Give the thread some time to run.
- assertFalse(timedOutCondition.block(newTimeoutMs));
- // We should still be trying to open as we approach the new timeout.
- assertFalse(timedOutCondition.block(50));
- // Now we timeout.
- when(mockClock.elapsedRealtime()).thenReturn(newTimeoutMs);
- timedOutCondition.block();
-
- verify(mockTransferListener, never()).onTransferStart(dataSourceUnderTest, testDataSpec);
- assertEquals(1, openExceptions.get());
- }
-
- @Test
- public void testExceptionFromTransferListener() throws HttpDataSourceException {
- mockResponseStartSuccess();
-
- // Make mockTransferListener throw an exception in CronetDataSource.close(). Ensure that
- // the subsequent open() call succeeds.
- doThrow(new NullPointerException()).when(mockTransferListener).onTransferEnd(
- dataSourceUnderTest);
- dataSourceUnderTest.open(testDataSpec);
- try {
- dataSourceUnderTest.close();
- fail("NullPointerException expected");
- } catch (NullPointerException e) {
- // Expected.
- }
- // Open should return successfully.
- dataSourceUnderTest.open(testDataSpec);
- }
-
- @Test
- public void testReadFailure() throws HttpDataSourceException {
- mockResponseStartSuccess();
- mockReadFailure();
-
- dataSourceUnderTest.open(testDataSpec);
- byte[] returnedBuffer = new byte[8];
- try {
- dataSourceUnderTest.read(returnedBuffer, 0, 8);
- fail("dataSourceUnderTest.read() returned, but IOException expected");
- } catch (IOException e) {
- // Expected.
- }
- }
-
- // Helper methods.
-
- private void mockStatusResponse() {
- doAnswer(new Answer() {
- @Override
- public Object answer(InvocationOnMock invocation) throws Throwable {
- UrlRequest.StatusListener statusListener =
- (UrlRequest.StatusListener) invocation.getArguments()[0];
- statusListener.onStatus(TEST_CONNECTION_STATUS);
- return null;
- }
- }).when(mockUrlRequest).getStatus(any(UrlRequest.StatusListener.class));
- }
-
- private void mockResponseStartSuccess() {
- doAnswer(new Answer() {
- @Override
- public Object answer(InvocationOnMock invocation) throws Throwable {
- dataSourceUnderTest.onResponseStarted(
- mockUrlRequest,
- testUrlResponseInfo);
- return null;
- }
- }).when(mockUrlRequest).start();
- }
-
- private void mockResponseStartRedirect() {
- doAnswer(new Answer() {
- @Override
- public Object answer(InvocationOnMock invocation) throws Throwable {
- dataSourceUnderTest.onRedirectReceived(
- mockUrlRequest,
- createUrlResponseInfo(307), // statusCode
- "http://redirect.location.com");
- return null;
- }
- }).when(mockUrlRequest).start();
- }
-
- private void mockResponseStartFailure() {
- doAnswer(new Answer() {
- @Override
- public Object answer(InvocationOnMock invocation) throws Throwable {
- dataSourceUnderTest.onFailed(
- mockUrlRequest,
- createUrlResponseInfo(500), // statusCode
- mockNetworkException);
- return null;
- }
- }).when(mockUrlRequest).start();
- }
-
- private void mockReadSuccess(int position, int length) {
- final int[] positionAndRemaining = new int[] {position, length};
- doAnswer(new Answer() {
- @Override
- public Void answer(InvocationOnMock invocation) throws Throwable {
- if (positionAndRemaining[1] == 0) {
- dataSourceUnderTest.onSucceeded(mockUrlRequest, testUrlResponseInfo);
- } else {
- ByteBuffer inputBuffer = (ByteBuffer) invocation.getArguments()[0];
- int readLength = Math.min(positionAndRemaining[1], inputBuffer.remaining());
- inputBuffer.put(buildTestDataBuffer(positionAndRemaining[0], readLength));
- positionAndRemaining[0] += readLength;
- positionAndRemaining[1] -= readLength;
- dataSourceUnderTest.onReadCompleted(
- mockUrlRequest,
- testUrlResponseInfo,
- inputBuffer);
- }
- return null;
- }
- }).when(mockUrlRequest).read(any(ByteBuffer.class));
- }
-
- private void mockReadFailure() {
- doAnswer(new Answer() {
- @Override
- public Object answer(InvocationOnMock invocation) throws Throwable {
- dataSourceUnderTest.onFailed(
- mockUrlRequest,
- createUrlResponseInfo(500), // statusCode
- mockNetworkException);
- return null;
- }
- }).when(mockUrlRequest).read(any(ByteBuffer.class));
- }
-
- private ConditionVariable buildUrlRequestStartedCondition() {
- final ConditionVariable startedCondition = new ConditionVariable();
- doAnswer(new Answer() {
- @Override
- public Object answer(InvocationOnMock invocation) throws Throwable {
- startedCondition.open();
- return null;
- }
- }).when(mockUrlRequest).start();
- return startedCondition;
- }
-
- private static byte[] buildTestDataArray(int position, int length) {
- return buildTestDataBuffer(position, length).array();
- }
-
- public static byte[] prefixZeros(byte[] data, int requiredLength) {
- byte[] prefixedData = new byte[requiredLength];
- System.arraycopy(data, 0, prefixedData, requiredLength - data.length, data.length);
- return prefixedData;
- }
-
- public static byte[] suffixZeros(byte[] data, int requiredLength) {
- return Arrays.copyOf(data, requiredLength);
- }
-
- private static ByteBuffer buildTestDataBuffer(int position, int length) {
- ByteBuffer testBuffer = ByteBuffer.allocate(length);
- for (int i = 0; i < length; i++) {
- testBuffer.put((byte) (position + i));
- }
- testBuffer.flip();
- return testBuffer;
- }
-
-}
diff --git a/extensions/cronet/src/main/AndroidManifest.xml b/extensions/cronet/src/main/AndroidManifest.xml
index c81d95f104..5ba54999f4 100644
--- a/extensions/cronet/src/main/AndroidManifest.xml
+++ b/extensions/cronet/src/main/AndroidManifest.xml
@@ -14,7 +14,7 @@
-->
+ package="com.google.android.exoplayer2.ext.cronet">
diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProvider.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProvider.java
index 314e06900e..e70538d7be 100644
--- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProvider.java
+++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProvider.java
@@ -15,6 +15,8 @@
*/
package com.google.android.exoplayer2.ext.cronet;
+import static java.lang.Math.min;
+
import java.io.IOException;
import java.nio.ByteBuffer;
import org.chromium.net.UploadDataProvider;
@@ -40,7 +42,7 @@ import org.chromium.net.UploadDataSink;
@Override
public void read(UploadDataSink uploadDataSink, ByteBuffer byteBuffer) throws IOException {
- int readLength = Math.min(byteBuffer.remaining(), data.length - position);
+ int readLength = min(byteBuffer.remaining(), data.length - position);
byteBuffer.put(data, position, readLength);
position += readLength;
uploadDataSink.onReadSucceeded(false);
diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java
index 4f15a6eabc..26a60d3332 100644
--- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java
+++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java
@@ -15,29 +15,40 @@
*/
package com.google.android.exoplayer2.ext.cronet;
+import static com.google.android.exoplayer2.util.Util.castNonNull;
+import static java.lang.Math.max;
+import static java.lang.Math.min;
+
import android.net.Uri;
-import android.os.ConditionVariable;
import android.text.TextUtils;
-import android.util.Log;
+import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
+import com.google.android.exoplayer2.upstream.BaseDataSource;
import com.google.android.exoplayer2.upstream.DataSourceException;
import com.google.android.exoplayer2.upstream.DataSpec;
import com.google.android.exoplayer2.upstream.HttpDataSource;
-import com.google.android.exoplayer2.upstream.TransferListener;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Clock;
-import com.google.android.exoplayer2.util.Predicate;
-import com.google.android.exoplayer2.util.SystemClock;
+import com.google.android.exoplayer2.util.ConditionVariable;
+import com.google.android.exoplayer2.util.Log;
+import com.google.android.exoplayer2.util.Util;
+import com.google.common.base.Predicate;
import java.io.IOException;
+import java.io.InterruptedIOException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.Executor;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf;
import org.chromium.net.CronetEngine;
import org.chromium.net.CronetException;
import org.chromium.net.NetworkException;
@@ -47,9 +58,12 @@ import org.chromium.net.UrlResponseInfo;
/**
* DataSource without intermediate buffer based on Cronet API set using UrlRequest.
- * This class's methods are organized in the sequence of expected calls.
+ *
+ *
Note: HTTP request headers will be set using all parameters passed via (in order of decreasing
+ * priority) the {@code dataSpec}, {@link #setRequestProperty} and the default parameters used to
+ * construct the instance.
*/
-public class CronetDataSource extends UrlRequest.Callback implements HttpDataSource {
+public class CronetDataSource extends BaseDataSource implements HttpDataSource {
/**
* Thrown when an error is encountered when trying to open a {@link CronetDataSource}.
@@ -74,6 +88,10 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
}
+ static {
+ ExoPlayerLibraryInfo.registerModule("goog.exo.cronet");
+ }
+
/**
* The default connection timeout, in milliseconds.
*/
@@ -83,8 +101,13 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
*/
public static final int DEFAULT_READ_TIMEOUT_MILLIS = 8 * 1000;
+ /* package */ final UrlRequest.Callback urlRequestCallback;
+
private static final String TAG = "CronetDataSource";
private static final String CONTENT_TYPE = "Content-Type";
+ private static final String SET_COOKIE = "Set-Cookie";
+ private static final String COOKIE = "Cookie";
+
private static final Pattern CONTENT_RANGE_HEADER_PATTERN =
Pattern.compile("^bytes (\\d+)-(\\d+)/(\\d+)$");
// The size of read buffer passed to cronet UrlRequest.read().
@@ -92,16 +115,17 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
private final CronetEngine cronetEngine;
private final Executor executor;
- private final Predicate contentTypePredicate;
- private final TransferListener super CronetDataSource> listener;
private final int connectTimeoutMs;
private final int readTimeoutMs;
private final boolean resetTimeoutOnRedirects;
- private final RequestProperties defaultRequestProperties;
+ private final boolean handleSetCookieRequests;
+ @Nullable private final RequestProperties defaultRequestProperties;
private final RequestProperties requestProperties;
private final ConditionVariable operation;
private final Clock clock;
+ @Nullable private Predicate contentTypePredicate;
+
// Accessed by the calling thread only.
private boolean opened;
private long bytesToSkip;
@@ -109,73 +133,261 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
// Written from the calling thread only. currentUrlRequest.start() calls ensure writes are visible
// to reads made by the Cronet thread.
- private UrlRequest currentUrlRequest;
- private DataSpec currentDataSpec;
+ @Nullable private UrlRequest currentUrlRequest;
+ @Nullable private DataSpec currentDataSpec;
// Reference written and read by calling thread only. Passed to Cronet thread as a local variable.
// operation.open() calls ensure writes into the buffer are visible to reads made by the calling
// thread.
- private ByteBuffer readBuffer;
+ @Nullable private ByteBuffer readBuffer;
// Written from the Cronet thread only. operation.open() calls ensure writes are visible to reads
// made by the calling thread.
- private UrlResponseInfo responseInfo;
- private IOException exception;
+ @Nullable private UrlResponseInfo responseInfo;
+ @Nullable private IOException exception;
private boolean finished;
private volatile long currentConnectTimeoutMs;
/**
+ * Creates an instance.
+ *
* @param cronetEngine A CronetEngine.
- * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
- * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
- * predicate then an {@link InvalidContentTypeException} is thrown from
- * {@link #open(DataSpec)}.
- * @param listener An optional listener.
+ * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may
+ * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread
+ * hop from Cronet's internal network thread to the response handling thread. However, to
+ * avoid slowing down overall network performance, care must be taken to make sure response
+ * handling is a fast operation when using a direct executor.
*/
- public CronetDataSource(CronetEngine cronetEngine, Executor executor,
- Predicate contentTypePredicate, TransferListener super CronetDataSource> listener) {
- this(cronetEngine, executor, contentTypePredicate, listener, DEFAULT_CONNECT_TIMEOUT_MILLIS,
- DEFAULT_READ_TIMEOUT_MILLIS, false, null);
+ public CronetDataSource(CronetEngine cronetEngine, Executor executor) {
+ this(
+ cronetEngine,
+ executor,
+ DEFAULT_CONNECT_TIMEOUT_MILLIS,
+ DEFAULT_READ_TIMEOUT_MILLIS,
+ /* resetTimeoutOnRedirects= */ false,
+ /* defaultRequestProperties= */ null);
}
/**
+ * Creates an instance.
+ *
* @param cronetEngine A CronetEngine.
- * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
- * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
- * predicate then an {@link InvalidContentTypeException} is thrown from
- * {@link #open(DataSpec)}.
- * @param listener An optional listener.
+ * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may
+ * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread
+ * hop from Cronet's internal network thread to the response handling thread. However, to
+ * avoid slowing down overall network performance, care must be taken to make sure response
+ * handling is a fast operation when using a direct executor.
* @param connectTimeoutMs The connection timeout, in milliseconds.
* @param readTimeoutMs The read timeout, in milliseconds.
* @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs.
- * @param defaultRequestProperties The default request properties to be used.
+ * @param defaultRequestProperties Optional default {@link RequestProperties} to be sent to the
+ * server as HTTP headers on every request.
*/
- public CronetDataSource(CronetEngine cronetEngine, Executor executor,
- Predicate contentTypePredicate, TransferListener super CronetDataSource> listener,
- int connectTimeoutMs, int readTimeoutMs, boolean resetTimeoutOnRedirects,
- RequestProperties defaultRequestProperties) {
- this(cronetEngine, executor, contentTypePredicate, listener, connectTimeoutMs,
- readTimeoutMs, resetTimeoutOnRedirects, new SystemClock(), defaultRequestProperties);
+ public CronetDataSource(
+ CronetEngine cronetEngine,
+ Executor executor,
+ int connectTimeoutMs,
+ int readTimeoutMs,
+ boolean resetTimeoutOnRedirects,
+ @Nullable RequestProperties defaultRequestProperties) {
+ this(
+ cronetEngine,
+ executor,
+ connectTimeoutMs,
+ readTimeoutMs,
+ resetTimeoutOnRedirects,
+ Clock.DEFAULT,
+ defaultRequestProperties,
+ /* handleSetCookieRequests= */ false);
}
- /* package */ CronetDataSource(CronetEngine cronetEngine, Executor executor,
- Predicate contentTypePredicate, TransferListener super CronetDataSource> listener,
- int connectTimeoutMs, int readTimeoutMs, boolean resetTimeoutOnRedirects, Clock clock,
- RequestProperties defaultRequestProperties) {
+ /**
+ * Creates an instance.
+ *
+ * @param cronetEngine A CronetEngine.
+ * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may
+ * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread
+ * hop from Cronet's internal network thread to the response handling thread. However, to
+ * avoid slowing down overall network performance, care must be taken to make sure response
+ * handling is a fast operation when using a direct executor.
+ * @param connectTimeoutMs The connection timeout, in milliseconds.
+ * @param readTimeoutMs The read timeout, in milliseconds.
+ * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs.
+ * @param defaultRequestProperties Optional default {@link RequestProperties} to be sent to the
+ * server as HTTP headers on every request.
+ * @param handleSetCookieRequests Whether "Set-Cookie" requests on redirect should be forwarded to
+ * the redirect url in the "Cookie" header.
+ */
+ public CronetDataSource(
+ CronetEngine cronetEngine,
+ Executor executor,
+ int connectTimeoutMs,
+ int readTimeoutMs,
+ boolean resetTimeoutOnRedirects,
+ @Nullable RequestProperties defaultRequestProperties,
+ boolean handleSetCookieRequests) {
+ this(
+ cronetEngine,
+ executor,
+ connectTimeoutMs,
+ readTimeoutMs,
+ resetTimeoutOnRedirects,
+ Clock.DEFAULT,
+ defaultRequestProperties,
+ handleSetCookieRequests);
+ }
+
+ /**
+ * Creates an instance.
+ *
+ * @param cronetEngine A CronetEngine.
+ * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may
+ * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread
+ * hop from Cronet's internal network thread to the response handling thread. However, to
+ * avoid slowing down overall network performance, care must be taken to make sure response
+ * handling is a fast operation when using a direct executor.
+ * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
+ * predicate then an {@link InvalidContentTypeException} is thrown from {@link
+ * #open(DataSpec)}.
+ * @deprecated Use {@link #CronetDataSource(CronetEngine, Executor)} and {@link
+ * #setContentTypePredicate(Predicate)}.
+ */
+ @SuppressWarnings("deprecation")
+ @Deprecated
+ public CronetDataSource(
+ CronetEngine cronetEngine,
+ Executor executor,
+ @Nullable Predicate contentTypePredicate) {
+ this(
+ cronetEngine,
+ executor,
+ contentTypePredicate,
+ DEFAULT_CONNECT_TIMEOUT_MILLIS,
+ DEFAULT_READ_TIMEOUT_MILLIS,
+ /* resetTimeoutOnRedirects= */ false,
+ /* defaultRequestProperties= */ null);
+ }
+
+ /**
+ * Creates an instance.
+ *
+ * @param cronetEngine A CronetEngine.
+ * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may
+ * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread
+ * hop from Cronet's internal network thread to the response handling thread. However, to
+ * avoid slowing down overall network performance, care must be taken to make sure response
+ * handling is a fast operation when using a direct executor.
+ * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
+ * predicate then an {@link InvalidContentTypeException} is thrown from {@link
+ * #open(DataSpec)}.
+ * @param connectTimeoutMs The connection timeout, in milliseconds.
+ * @param readTimeoutMs The read timeout, in milliseconds.
+ * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs.
+ * @param defaultRequestProperties Optional default {@link RequestProperties} to be sent to the
+ * server as HTTP headers on every request.
+ * @deprecated Use {@link #CronetDataSource(CronetEngine, Executor, int, int, boolean,
+ * RequestProperties)} and {@link #setContentTypePredicate(Predicate)}.
+ */
+ @SuppressWarnings("deprecation")
+ @Deprecated
+ public CronetDataSource(
+ CronetEngine cronetEngine,
+ Executor executor,
+ @Nullable Predicate contentTypePredicate,
+ int connectTimeoutMs,
+ int readTimeoutMs,
+ boolean resetTimeoutOnRedirects,
+ @Nullable RequestProperties defaultRequestProperties) {
+ this(
+ cronetEngine,
+ executor,
+ contentTypePredicate,
+ connectTimeoutMs,
+ readTimeoutMs,
+ resetTimeoutOnRedirects,
+ defaultRequestProperties,
+ /* handleSetCookieRequests= */ false);
+ }
+
+ /**
+ * Creates an instance.
+ *
+ * @param cronetEngine A CronetEngine.
+ * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may
+ * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread
+ * hop from Cronet's internal network thread to the response handling thread. However, to
+ * avoid slowing down overall network performance, care must be taken to make sure response
+ * handling is a fast operation when using a direct executor.
+ * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
+ * predicate then an {@link InvalidContentTypeException} is thrown from {@link
+ * #open(DataSpec)}.
+ * @param connectTimeoutMs The connection timeout, in milliseconds.
+ * @param readTimeoutMs The read timeout, in milliseconds.
+ * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs.
+ * @param defaultRequestProperties Optional default {@link RequestProperties} to be sent to the
+ * server as HTTP headers on every request.
+ * @param handleSetCookieRequests Whether "Set-Cookie" requests on redirect should be forwarded to
+ * the redirect url in the "Cookie" header.
+ * @deprecated Use {@link #CronetDataSource(CronetEngine, Executor, int, int, boolean,
+ * RequestProperties, boolean)} and {@link #setContentTypePredicate(Predicate)}.
+ */
+ @Deprecated
+ public CronetDataSource(
+ CronetEngine cronetEngine,
+ Executor executor,
+ @Nullable Predicate contentTypePredicate,
+ int connectTimeoutMs,
+ int readTimeoutMs,
+ boolean resetTimeoutOnRedirects,
+ @Nullable RequestProperties defaultRequestProperties,
+ boolean handleSetCookieRequests) {
+ this(
+ cronetEngine,
+ executor,
+ connectTimeoutMs,
+ readTimeoutMs,
+ resetTimeoutOnRedirects,
+ Clock.DEFAULT,
+ defaultRequestProperties,
+ handleSetCookieRequests);
+ this.contentTypePredicate = contentTypePredicate;
+ }
+
+ /* package */ CronetDataSource(
+ CronetEngine cronetEngine,
+ Executor executor,
+ int connectTimeoutMs,
+ int readTimeoutMs,
+ boolean resetTimeoutOnRedirects,
+ Clock clock,
+ @Nullable RequestProperties defaultRequestProperties,
+ boolean handleSetCookieRequests) {
+ super(/* isNetwork= */ true);
+ this.urlRequestCallback = new UrlRequestCallback();
this.cronetEngine = Assertions.checkNotNull(cronetEngine);
this.executor = Assertions.checkNotNull(executor);
- this.contentTypePredicate = contentTypePredicate;
- this.listener = listener;
this.connectTimeoutMs = connectTimeoutMs;
this.readTimeoutMs = readTimeoutMs;
this.resetTimeoutOnRedirects = resetTimeoutOnRedirects;
this.clock = Assertions.checkNotNull(clock);
this.defaultRequestProperties = defaultRequestProperties;
+ this.handleSetCookieRequests = handleSetCookieRequests;
requestProperties = new RequestProperties();
operation = new ConditionVariable();
}
+ /**
+ * Sets a content type {@link Predicate}. If a content type is rejected by the predicate then a
+ * {@link HttpDataSource.InvalidContentTypeException} is thrown from {@link #open(DataSpec)}.
+ *
+ * @param contentTypePredicate The content type {@link Predicate}, or {@code null} to clear a
+ * predicate that was previously set.
+ */
+ public void setContentTypePredicate(@Nullable Predicate contentTypePredicate) {
+ this.contentTypePredicate = contentTypePredicate;
+ }
+
// HttpDataSource implementation.
@Override
@@ -194,11 +406,19 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
}
@Override
- public Map> getResponseHeaders() {
- return responseInfo == null ? null : responseInfo.getAllHeaders();
+ public int getResponseCode() {
+ return responseInfo == null || responseInfo.getHttpStatusCode() <= 0
+ ? -1
+ : responseInfo.getHttpStatusCode();
}
@Override
+ public Map> getResponseHeaders() {
+ return responseInfo == null ? Collections.emptyMap() : responseInfo.getAllHeaders();
+ }
+
+ @Override
+ @Nullable
public Uri getUri() {
return responseInfo == null ? null : Uri.parse(responseInfo.getUrl());
}
@@ -211,22 +431,55 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
operation.close();
resetConnectTimeout();
currentDataSpec = dataSpec;
- currentUrlRequest = buildRequest(dataSpec);
- currentUrlRequest.start();
- boolean requestStarted = blockUntilConnectTimeout();
+ UrlRequest urlRequest;
+ try {
+ urlRequest = buildRequestBuilder(dataSpec).build();
+ currentUrlRequest = urlRequest;
+ } catch (IOException e) {
+ throw new OpenException(e, dataSpec, Status.IDLE);
+ }
+ urlRequest.start();
- if (exception != null) {
- throw new OpenException(exception, currentDataSpec, getStatus(currentUrlRequest));
- } else if (!requestStarted) {
- // The timeout was reached before the connection was opened.
- throw new OpenException(new SocketTimeoutException(), dataSpec, getStatus(currentUrlRequest));
+ transferInitializing(dataSpec);
+ try {
+ boolean connectionOpened = blockUntilConnectTimeout();
+ if (exception != null) {
+ throw new OpenException(exception, dataSpec, getStatus(urlRequest));
+ } else if (!connectionOpened) {
+ // The timeout was reached before the connection was opened.
+ throw new OpenException(new SocketTimeoutException(), dataSpec, getStatus(urlRequest));
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new OpenException(new InterruptedIOException(), dataSpec, Status.INVALID);
}
// Check for a valid response code.
+ UrlResponseInfo responseInfo = Assertions.checkNotNull(this.responseInfo);
int responseCode = responseInfo.getHttpStatusCode();
if (responseCode < 200 || responseCode > 299) {
- InvalidResponseCodeException exception = new InvalidResponseCodeException(responseCode,
- responseInfo.getAllHeaders(), currentDataSpec);
+ byte[] responseBody = Util.EMPTY_BYTE_ARRAY;
+ ByteBuffer readBuffer = getOrCreateReadBuffer();
+ while (!readBuffer.hasRemaining()) {
+ operation.close();
+ readBuffer.clear();
+ readInternal(readBuffer);
+ if (finished) {
+ break;
+ }
+ readBuffer.flip();
+ int existingResponseBodyEnd = responseBody.length;
+ responseBody = Arrays.copyOf(responseBody, responseBody.length + readBuffer.remaining());
+ readBuffer.get(responseBody, existingResponseBodyEnd, readBuffer.remaining());
+ }
+
+ InvalidResponseCodeException exception =
+ new InvalidResponseCodeException(
+ responseCode,
+ responseInfo.getHttpStatusText(),
+ responseInfo.getAllHeaders(),
+ dataSpec,
+ responseBody);
if (responseCode == 416) {
exception.initCause(new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE));
}
@@ -234,11 +487,12 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
}
// Check for a valid content type.
+ Predicate contentTypePredicate = this.contentTypePredicate;
if (contentTypePredicate != null) {
List contentTypeHeaders = responseInfo.getAllHeaders().get(CONTENT_TYPE);
String contentType = isEmpty(contentTypeHeaders) ? null : contentTypeHeaders.get(0);
- if (!contentTypePredicate.evaluate(contentType)) {
- throw new InvalidContentTypeException(contentType, currentDataSpec);
+ if (contentType != null && !contentTypePredicate.apply(contentType)) {
+ throw new InvalidContentTypeException(contentType, dataSpec);
}
}
@@ -248,7 +502,7 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0;
// Calculate the content length.
- if (!getIsCompressed(responseInfo)) {
+ if (!isCompressed(responseInfo)) {
if (dataSpec.length != C.LENGTH_UNSET) {
bytesRemaining = dataSpec.length;
} else {
@@ -257,13 +511,11 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
} else {
// If the response is compressed then the content length will be that of the compressed data
// which isn't what we want. Always use the dataSpec length in this case.
- bytesRemaining = currentDataSpec.length;
+ bytesRemaining = dataSpec.length;
}
opened = true;
- if (listener != null) {
- listener.onTransferStart(this, dataSpec);
- }
+ transferStarted(dataSpec);
return bytesRemaining;
}
@@ -278,48 +530,141 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
return C.RESULT_END_OF_INPUT;
}
- if (readBuffer == null) {
- readBuffer = ByteBuffer.allocateDirect(READ_BUFFER_SIZE_BYTES);
- readBuffer.limit(0);
- }
+ ByteBuffer readBuffer = getOrCreateReadBuffer();
while (!readBuffer.hasRemaining()) {
// Fill readBuffer with more data from Cronet.
operation.close();
readBuffer.clear();
- currentUrlRequest.read(readBuffer);
- if (!operation.block(readTimeoutMs)) {
- // We're timing out, but since the operation is still ongoing we'll need to replace
- // readBuffer to avoid the possibility of it being written to by this operation during a
- // subsequent request.
- readBuffer = null;
- throw new HttpDataSourceException(
- new SocketTimeoutException(), currentDataSpec, HttpDataSourceException.TYPE_READ);
- } else if (exception != null) {
- throw new HttpDataSourceException(exception, currentDataSpec,
- HttpDataSourceException.TYPE_READ);
- } else if (finished) {
+ readInternal(readBuffer);
+
+ if (finished) {
+ bytesRemaining = 0;
return C.RESULT_END_OF_INPUT;
} else {
// The operation didn't time out, fail or finish, and therefore data must have been read.
readBuffer.flip();
Assertions.checkState(readBuffer.hasRemaining());
if (bytesToSkip > 0) {
- int bytesSkipped = (int) Math.min(readBuffer.remaining(), bytesToSkip);
+ int bytesSkipped = (int) min(readBuffer.remaining(), bytesToSkip);
readBuffer.position(readBuffer.position() + bytesSkipped);
bytesToSkip -= bytesSkipped;
}
}
}
- int bytesRead = Math.min(readBuffer.remaining(), readLength);
+ int bytesRead = min(readBuffer.remaining(), readLength);
readBuffer.get(buffer, offset, bytesRead);
if (bytesRemaining != C.LENGTH_UNSET) {
bytesRemaining -= bytesRead;
}
- if (listener != null) {
- listener.onBytesTransferred(this, bytesRead);
+ bytesTransferred(bytesRead);
+ return bytesRead;
+ }
+
+ /**
+ * Reads up to {@code buffer.remaining()} bytes of data and stores them into {@code buffer},
+ * starting at {@code buffer.position()}. Advances the position of the buffer by the number of
+ * bytes read and returns this length.
+ *
+ * If there is an error, a {@link HttpDataSourceException} is thrown and the contents of {@code
+ * buffer} should be ignored. If the exception has error code {@code
+ * HttpDataSourceException.TYPE_READ}, note that Cronet may continue writing into {@code buffer}
+ * after the method has returned. Thus the caller should not attempt to reuse the buffer.
+ *
+ *
If {@code buffer.remaining()} is zero then 0 is returned. Otherwise, if no data is available
+ * because the end of the opened range has been reached, then {@link C#RESULT_END_OF_INPUT} is
+ * returned. Otherwise, the call will block until at least one byte of data has been read and the
+ * number of bytes read is returned.
+ *
+ *
Passed buffer must be direct ByteBuffer. If you have a non-direct ByteBuffer, consider the
+ * alternative read method with its backed array.
+ *
+ * @param buffer The ByteBuffer into which the read data should be stored. Must be a direct
+ * ByteBuffer.
+ * @return The number of bytes read, or {@link C#RESULT_END_OF_INPUT} if no data is available
+ * because the end of the opened range has been reached.
+ * @throws HttpDataSourceException If an error occurs reading from the source.
+ * @throws IllegalArgumentException If {@code buffer} is not a direct ByteBuffer.
+ */
+ public int read(ByteBuffer buffer) throws HttpDataSourceException {
+ Assertions.checkState(opened);
+
+ if (!buffer.isDirect()) {
+ throw new IllegalArgumentException("Passed buffer is not a direct ByteBuffer");
}
+ if (!buffer.hasRemaining()) {
+ return 0;
+ } else if (bytesRemaining == 0) {
+ return C.RESULT_END_OF_INPUT;
+ }
+ int readLength = buffer.remaining();
+
+ if (readBuffer != null) {
+ // Skip all the bytes we can from readBuffer if there are still bytes to skip.
+ if (bytesToSkip != 0) {
+ if (bytesToSkip >= readBuffer.remaining()) {
+ bytesToSkip -= readBuffer.remaining();
+ readBuffer.position(readBuffer.limit());
+ } else {
+ readBuffer.position(readBuffer.position() + (int) bytesToSkip);
+ bytesToSkip = 0;
+ }
+ }
+
+ // If there is existing data in the readBuffer, read as much as possible. Return if any read.
+ int copyBytes = copyByteBuffer(/* src= */ readBuffer, /* dst= */ buffer);
+ if (copyBytes != 0) {
+ if (bytesRemaining != C.LENGTH_UNSET) {
+ bytesRemaining -= copyBytes;
+ }
+ bytesTransferred(copyBytes);
+ return copyBytes;
+ }
+ }
+
+ boolean readMore = true;
+ while (readMore) {
+ // If bytesToSkip > 0, read into intermediate buffer that we can discard instead of caller's
+ // buffer. If we do not need to skip bytes, we may write to buffer directly.
+ final boolean useCallerBuffer = bytesToSkip == 0;
+
+ operation.close();
+
+ if (!useCallerBuffer) {
+ ByteBuffer readBuffer = getOrCreateReadBuffer();
+ readBuffer.clear();
+ if (bytesToSkip < READ_BUFFER_SIZE_BYTES) {
+ readBuffer.limit((int) bytesToSkip);
+ }
+ }
+
+ // Fill buffer with more data from Cronet.
+ readInternal(useCallerBuffer ? buffer : castNonNull(readBuffer));
+
+ if (finished) {
+ bytesRemaining = 0;
+ return C.RESULT_END_OF_INPUT;
+ } else {
+ // The operation didn't time out, fail or finish, and therefore data must have been read.
+ Assertions.checkState(
+ useCallerBuffer
+ ? readLength > buffer.remaining()
+ : castNonNull(readBuffer).position() > 0);
+ // If we meant to skip bytes, subtract what was left and repeat, otherwise, continue.
+ if (useCallerBuffer) {
+ readMore = false;
+ } else {
+ bytesToSkip -= castNonNull(readBuffer).position();
+ }
+ }
+ }
+
+ final int bytesRead = readLength - buffer.remaining();
+ if (bytesRemaining != C.LENGTH_UNSET) {
+ bytesRemaining -= bytesRead;
+ }
+ bytesTransferred(bytesRead);
return bytesRead;
}
@@ -338,128 +683,75 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
finished = false;
if (opened) {
opened = false;
- if (listener != null) {
- listener.onTransferEnd(this);
- }
+ transferEnded();
}
}
- // UrlRequest.Callback implementation
-
- @Override
- public synchronized void onRedirectReceived(UrlRequest request, UrlResponseInfo info,
- String newLocationUrl) {
- if (request != currentUrlRequest) {
- return;
- }
- if (currentDataSpec.postBody != null) {
- int responseCode = info.getHttpStatusCode();
- // The industry standard is to disregard POST redirects when the status code is 307 or 308.
- // For other redirect response codes the POST request is converted to a GET request and the
- // redirect is followed.
- if (responseCode == 307 || responseCode == 308) {
- exception = new InvalidResponseCodeException(responseCode, info.getAllHeaders(),
- currentDataSpec);
- operation.open();
- return;
- }
- }
- if (resetTimeoutOnRedirects) {
- resetConnectTimeout();
- }
- request.followRedirect();
+ /** Returns current {@link UrlRequest}. May be null if the data source is not opened. */
+ @Nullable
+ protected UrlRequest getCurrentUrlRequest() {
+ return currentUrlRequest;
}
- @Override
- public synchronized void onResponseStarted(UrlRequest request, UrlResponseInfo info) {
- if (request != currentUrlRequest) {
- return;
- }
- responseInfo = info;
- operation.open();
- }
-
- @Override
- public synchronized void onReadCompleted(UrlRequest request, UrlResponseInfo info,
- ByteBuffer buffer) {
- if (request != currentUrlRequest) {
- return;
- }
- operation.open();
- }
-
- @Override
- public synchronized void onSucceeded(UrlRequest request, UrlResponseInfo info) {
- if (request != currentUrlRequest) {
- return;
- }
- finished = true;
- operation.open();
- }
-
- @Override
- public synchronized void onFailed(UrlRequest request, UrlResponseInfo info,
- CronetException error) {
- if (request != currentUrlRequest) {
- return;
- }
- if (error instanceof NetworkException
- && ((NetworkException) error).getErrorCode()
- == NetworkException.ERROR_HOSTNAME_NOT_RESOLVED) {
- exception = new UnknownHostException();
- } else {
- exception = error;
- }
- operation.open();
+ /** Returns current {@link UrlResponseInfo}. May be null if the data source is not opened. */
+ @Nullable
+ protected UrlResponseInfo getCurrentUrlResponseInfo() {
+ return responseInfo;
}
// Internal methods.
- private UrlRequest buildRequest(DataSpec dataSpec) throws OpenException {
- UrlRequest.Builder requestBuilder = cronetEngine.newUrlRequestBuilder(dataSpec.uri.toString(),
- this, executor);
+ private UrlRequest.Builder buildRequestBuilder(DataSpec dataSpec) throws IOException {
+ UrlRequest.Builder requestBuilder =
+ cronetEngine
+ .newUrlRequestBuilder(dataSpec.uri.toString(), urlRequestCallback, executor)
+ .allowDirectExecutor();
+
// Set the headers.
- boolean isContentTypeHeaderSet = false;
+ Map requestHeaders = new HashMap<>();
if (defaultRequestProperties != null) {
- for (Entry headerEntry : defaultRequestProperties.getSnapshot().entrySet()) {
- String key = headerEntry.getKey();
- isContentTypeHeaderSet = isContentTypeHeaderSet || CONTENT_TYPE.equals(key);
- requestBuilder.addHeader(key, headerEntry.getValue());
- }
+ requestHeaders.putAll(defaultRequestProperties.getSnapshot());
}
- Map requestPropertiesSnapshot = requestProperties.getSnapshot();
- for (Entry headerEntry : requestPropertiesSnapshot.entrySet()) {
+ requestHeaders.putAll(requestProperties.getSnapshot());
+ requestHeaders.putAll(dataSpec.httpRequestHeaders);
+
+ for (Entry headerEntry : requestHeaders.entrySet()) {
String key = headerEntry.getKey();
- isContentTypeHeaderSet = isContentTypeHeaderSet || CONTENT_TYPE.equals(key);
- requestBuilder.addHeader(key, headerEntry.getValue());
+ String value = headerEntry.getValue();
+ requestBuilder.addHeader(key, value);
}
- if (dataSpec.postBody != null && dataSpec.postBody.length != 0 && !isContentTypeHeaderSet) {
- throw new OpenException("POST request with non-empty body must set Content-Type", dataSpec,
- Status.IDLE);
+
+ if (dataSpec.httpBody != null && !requestHeaders.containsKey(CONTENT_TYPE)) {
+ throw new IOException("HTTP request with non-empty body must set Content-Type");
}
+
// Set the Range header.
- if (currentDataSpec.position != 0 || currentDataSpec.length != C.LENGTH_UNSET) {
+ if (dataSpec.position != 0 || dataSpec.length != C.LENGTH_UNSET) {
StringBuilder rangeValue = new StringBuilder();
rangeValue.append("bytes=");
- rangeValue.append(currentDataSpec.position);
+ rangeValue.append(dataSpec.position);
rangeValue.append("-");
- if (currentDataSpec.length != C.LENGTH_UNSET) {
- rangeValue.append(currentDataSpec.position + currentDataSpec.length - 1);
+ if (dataSpec.length != C.LENGTH_UNSET) {
+ rangeValue.append(dataSpec.position + dataSpec.length - 1);
}
requestBuilder.addHeader("Range", rangeValue.toString());
}
+ // TODO: Uncomment when https://bugs.chromium.org/p/chromium/issues/detail?id=711810 is fixed
+ // (adjusting the code as necessary).
+ // Force identity encoding unless gzip is allowed.
+ // if (!dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_GZIP)) {
+ // requestBuilder.addHeader("Accept-Encoding", "identity");
+ // }
// Set the method and (if non-empty) the body.
- if (dataSpec.postBody != null) {
- requestBuilder.setHttpMethod("POST");
- if (dataSpec.postBody.length != 0) {
- requestBuilder.setUploadDataProvider(new ByteArrayUploadDataProvider(dataSpec.postBody),
- executor);
- }
+ requestBuilder.setHttpMethod(dataSpec.getHttpMethodString());
+ if (dataSpec.httpBody != null) {
+ requestBuilder.setUploadDataProvider(
+ new ByteArrayUploadDataProvider(dataSpec.httpBody), executor);
}
- return requestBuilder.build();
+ return requestBuilder;
}
- private boolean blockUntilConnectTimeout() {
+ private boolean blockUntilConnectTimeout() throws InterruptedException {
long now = clock.elapsedRealtime();
boolean opened = false;
while (!opened && now < currentConnectTimeoutMs) {
@@ -473,7 +765,57 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
currentConnectTimeoutMs = clock.elapsedRealtime() + connectTimeoutMs;
}
- private static boolean getIsCompressed(UrlResponseInfo info) {
+ /**
+ * Reads up to {@code buffer.remaining()} bytes of data from {@code currentUrlRequest} and stores
+ * them into {@code buffer}. If there is an error and {@code buffer == readBuffer}, then it resets
+ * the current {@code readBuffer} object so that it is not reused in the future.
+ *
+ * @param buffer The ByteBuffer into which the read data is stored. Must be a direct ByteBuffer.
+ * @throws HttpDataSourceException If an error occurs reading from the source.
+ */
+ @SuppressWarnings("ReferenceEquality")
+ private void readInternal(ByteBuffer buffer) throws HttpDataSourceException {
+ castNonNull(currentUrlRequest).read(buffer);
+ try {
+ if (!operation.block(readTimeoutMs)) {
+ throw new SocketTimeoutException();
+ }
+ } catch (InterruptedException e) {
+ // The operation is ongoing so replace buffer to avoid it being written to by this
+ // operation during a subsequent request.
+ if (buffer == readBuffer) {
+ readBuffer = null;
+ }
+ Thread.currentThread().interrupt();
+ throw new HttpDataSourceException(
+ new InterruptedIOException(),
+ castNonNull(currentDataSpec),
+ HttpDataSourceException.TYPE_READ);
+ } catch (SocketTimeoutException e) {
+ // The operation is ongoing so replace buffer to avoid it being written to by this
+ // operation during a subsequent request.
+ if (buffer == readBuffer) {
+ readBuffer = null;
+ }
+ throw new HttpDataSourceException(
+ e, castNonNull(currentDataSpec), HttpDataSourceException.TYPE_READ);
+ }
+
+ if (exception != null) {
+ throw new HttpDataSourceException(
+ exception, castNonNull(currentDataSpec), HttpDataSourceException.TYPE_READ);
+ }
+ }
+
+ private ByteBuffer getOrCreateReadBuffer() {
+ if (readBuffer == null) {
+ readBuffer = ByteBuffer.allocateDirect(READ_BUFFER_SIZE_BYTES);
+ readBuffer.limit(0);
+ }
+ return readBuffer;
+ }
+
+ private static boolean isCompressed(UrlResponseInfo info) {
for (Map.Entry entry : info.getAllHeadersAsList()) {
if (entry.getKey().equalsIgnoreCase("Content-Encoding")) {
return !entry.getValue().equalsIgnoreCase("identity");
@@ -504,7 +846,9 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
if (matcher.find()) {
try {
long contentLengthFromRange =
- Long.parseLong(matcher.group(2)) - Long.parseLong(matcher.group(1)) + 1;
+ Long.parseLong(Assertions.checkNotNull(matcher.group(2)))
+ - Long.parseLong(Assertions.checkNotNull(matcher.group(1)))
+ + 1;
if (contentLength < 0) {
// Some proxy servers strip the Content-Length header. Fall back to the length
// calculated here in this case.
@@ -516,7 +860,7 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
// would increase it.
Log.w(TAG, "Inconsistent headers [" + contentLengthHeader + "] [" + contentRangeHeader
+ "]");
- contentLength = Math.max(contentLength, contentLengthFromRange);
+ contentLength = max(contentLength, contentLengthFromRange);
}
} catch (NumberFormatException e) {
Log.e(TAG, "Unexpected Content-Range [" + contentRangeHeader + "]");
@@ -526,7 +870,18 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
return contentLength;
}
- private static int getStatus(UrlRequest request) {
+ private static String parseCookies(List setCookieHeaders) {
+ return TextUtils.join(";", setCookieHeaders);
+ }
+
+ private static void attachCookies(UrlRequest.Builder requestBuilder, String cookies) {
+ if (TextUtils.isEmpty(cookies)) {
+ return;
+ }
+ requestBuilder.addHeader(COOKIE, cookies);
+ }
+
+ private static int getStatus(UrlRequest request) throws InterruptedException {
final ConditionVariable conditionVariable = new ConditionVariable();
final int[] statusHolder = new int[1];
request.getStatus(new UrlRequest.StatusListener() {
@@ -540,8 +895,131 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
return statusHolder[0];
}
- private static boolean isEmpty(List> list) {
+ @EnsuresNonNullIf(result = false, expression = "#1")
+ private static boolean isEmpty(@Nullable List> list) {
return list == null || list.isEmpty();
}
+ // Copy as much as possible from the src buffer into dst buffer.
+ // Returns the number of bytes copied.
+ private static int copyByteBuffer(ByteBuffer src, ByteBuffer dst) {
+ int remaining = min(src.remaining(), dst.remaining());
+ int limit = src.limit();
+ src.limit(src.position() + remaining);
+ dst.put(src);
+ src.limit(limit);
+ return remaining;
+ }
+
+ private final class UrlRequestCallback extends UrlRequest.Callback {
+
+ @Override
+ public synchronized void onRedirectReceived(
+ UrlRequest request, UrlResponseInfo info, String newLocationUrl) {
+ if (request != currentUrlRequest) {
+ return;
+ }
+ UrlRequest urlRequest = Assertions.checkNotNull(currentUrlRequest);
+ DataSpec dataSpec = Assertions.checkNotNull(currentDataSpec);
+ if (dataSpec.httpMethod == DataSpec.HTTP_METHOD_POST) {
+ int responseCode = info.getHttpStatusCode();
+ // The industry standard is to disregard POST redirects when the status code is 307 or 308.
+ if (responseCode == 307 || responseCode == 308) {
+ exception =
+ new InvalidResponseCodeException(
+ responseCode,
+ info.getHttpStatusText(),
+ info.getAllHeaders(),
+ dataSpec,
+ /* responseBody= */ Util.EMPTY_BYTE_ARRAY);
+ operation.open();
+ return;
+ }
+ }
+ if (resetTimeoutOnRedirects) {
+ resetConnectTimeout();
+ }
+
+ if (!handleSetCookieRequests) {
+ request.followRedirect();
+ return;
+ }
+
+ List setCookieHeaders = info.getAllHeaders().get(SET_COOKIE);
+ if (isEmpty(setCookieHeaders)) {
+ request.followRedirect();
+ return;
+ }
+
+ urlRequest.cancel();
+ DataSpec redirectUrlDataSpec;
+ if (dataSpec.httpMethod == DataSpec.HTTP_METHOD_POST) {
+ // For POST redirects that aren't 307 or 308, the redirect is followed but request is
+ // transformed into a GET.
+ redirectUrlDataSpec =
+ dataSpec
+ .buildUpon()
+ .setUri(newLocationUrl)
+ .setHttpMethod(DataSpec.HTTP_METHOD_GET)
+ .setHttpBody(null)
+ .build();
+ } else {
+ redirectUrlDataSpec = dataSpec.withUri(Uri.parse(newLocationUrl));
+ }
+ UrlRequest.Builder requestBuilder;
+ try {
+ requestBuilder = buildRequestBuilder(redirectUrlDataSpec);
+ } catch (IOException e) {
+ exception = e;
+ return;
+ }
+ String cookieHeadersValue = parseCookies(setCookieHeaders);
+ attachCookies(requestBuilder, cookieHeadersValue);
+ currentUrlRequest = requestBuilder.build();
+ currentUrlRequest.start();
+ }
+
+ @Override
+ public synchronized void onResponseStarted(UrlRequest request, UrlResponseInfo info) {
+ if (request != currentUrlRequest) {
+ return;
+ }
+ responseInfo = info;
+ operation.open();
+ }
+
+ @Override
+ public synchronized void onReadCompleted(
+ UrlRequest request, UrlResponseInfo info, ByteBuffer buffer) {
+ if (request != currentUrlRequest) {
+ return;
+ }
+ operation.open();
+ }
+
+ @Override
+ public synchronized void onSucceeded(UrlRequest request, UrlResponseInfo info) {
+ if (request != currentUrlRequest) {
+ return;
+ }
+ finished = true;
+ operation.open();
+ }
+
+ @Override
+ public synchronized void onFailed(
+ UrlRequest request, UrlResponseInfo info, CronetException error) {
+ if (request != currentUrlRequest) {
+ return;
+ }
+ if (error instanceof NetworkException
+ && ((NetworkException) error).getErrorCode()
+ == NetworkException.ERROR_HOSTNAME_NOT_RESOLVED) {
+ exception = new UnknownHostException();
+ } else {
+ exception = error;
+ }
+ operation.open();
+ }
+ }
}
diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java
index 2ad6da6a54..85c9d09a79 100644
--- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java
+++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java
@@ -15,12 +15,14 @@
*/
package com.google.android.exoplayer2.ext.cronet;
-import com.google.android.exoplayer2.upstream.DataSource;
+import static com.google.android.exoplayer2.ExoPlayerLibraryInfo.DEFAULT_USER_AGENT;
+
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory;
import com.google.android.exoplayer2.upstream.HttpDataSource;
import com.google.android.exoplayer2.upstream.HttpDataSource.BaseFactory;
import com.google.android.exoplayer2.upstream.HttpDataSource.Factory;
import com.google.android.exoplayer2.upstream.TransferListener;
-import com.google.android.exoplayer2.util.Predicate;
import java.util.concurrent.Executor;
import org.chromium.net.CronetEngine;
@@ -34,45 +36,329 @@ public final class CronetDataSourceFactory extends BaseFactory {
*/
public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS =
CronetDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS;
+
/**
* The default read timeout, in milliseconds.
*/
public static final int DEFAULT_READ_TIMEOUT_MILLIS =
CronetDataSource.DEFAULT_READ_TIMEOUT_MILLIS;
- private final CronetEngine cronetEngine;
+ private final CronetEngineWrapper cronetEngineWrapper;
private final Executor executor;
- private final Predicate contentTypePredicate;
- private final TransferListener super DataSource> transferListener;
+ @Nullable private final TransferListener transferListener;
private final int connectTimeoutMs;
private final int readTimeoutMs;
private final boolean resetTimeoutOnRedirects;
+ private final HttpDataSource.Factory fallbackFactory;
- public CronetDataSourceFactory(CronetEngine cronetEngine,
- Executor executor, Predicate contentTypePredicate,
- TransferListener super DataSource> transferListener) {
- this(cronetEngine, executor, contentTypePredicate, transferListener,
- DEFAULT_CONNECT_TIMEOUT_MILLIS, DEFAULT_READ_TIMEOUT_MILLIS, false);
+ /**
+ * Creates an instance.
+ *
+ * If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided
+ * fallback {@link HttpDataSource.Factory} will be used instead.
+ *
+ *
Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout,
+ * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout.
+ *
+ * @param cronetEngineWrapper A {@link CronetEngineWrapper}.
+ * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
+ * @param fallbackFactory A {@link HttpDataSource.Factory} which is used as a fallback in case no
+ * suitable CronetEngine can be build.
+ */
+ public CronetDataSourceFactory(
+ CronetEngineWrapper cronetEngineWrapper,
+ Executor executor,
+ HttpDataSource.Factory fallbackFactory) {
+ this(
+ cronetEngineWrapper,
+ executor,
+ /* transferListener= */ null,
+ DEFAULT_CONNECT_TIMEOUT_MILLIS,
+ DEFAULT_READ_TIMEOUT_MILLIS,
+ false,
+ fallbackFactory);
}
- public CronetDataSourceFactory(CronetEngine cronetEngine,
- Executor executor, Predicate contentTypePredicate,
- TransferListener super DataSource> transferListener, int connectTimeoutMs,
- int readTimeoutMs, boolean resetTimeoutOnRedirects) {
- this.cronetEngine = cronetEngine;
+ /**
+ * Creates an instance.
+ *
+ * If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link
+ * DefaultHttpDataSourceFactory} will be used instead.
+ *
+ *
Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout,
+ * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout.
+ *
+ * @param cronetEngineWrapper A {@link CronetEngineWrapper}.
+ * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
+ */
+ public CronetDataSourceFactory(CronetEngineWrapper cronetEngineWrapper, Executor executor) {
+ this(cronetEngineWrapper, executor, DEFAULT_USER_AGENT);
+ }
+
+ /**
+ * Creates an instance.
+ *
+ *
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link
+ * DefaultHttpDataSourceFactory} will be used instead.
+ *
+ *
Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout,
+ * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout.
+ *
+ * @param cronetEngineWrapper A {@link CronetEngineWrapper}.
+ * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
+ * @param userAgent A user agent used to create a fallback HttpDataSource if needed.
+ */
+ public CronetDataSourceFactory(
+ CronetEngineWrapper cronetEngineWrapper, Executor executor, String userAgent) {
+ this(
+ cronetEngineWrapper,
+ executor,
+ /* transferListener= */ null,
+ DEFAULT_CONNECT_TIMEOUT_MILLIS,
+ DEFAULT_READ_TIMEOUT_MILLIS,
+ false,
+ new DefaultHttpDataSourceFactory(
+ userAgent,
+ /* listener= */ null,
+ DEFAULT_CONNECT_TIMEOUT_MILLIS,
+ DEFAULT_READ_TIMEOUT_MILLIS,
+ false));
+ }
+
+ /**
+ * Creates an instance.
+ *
+ *
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link
+ * DefaultHttpDataSourceFactory} will be used instead.
+ *
+ * @param cronetEngineWrapper A {@link CronetEngineWrapper}.
+ * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
+ * @param connectTimeoutMs The connection timeout, in milliseconds.
+ * @param readTimeoutMs The read timeout, in milliseconds.
+ * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs.
+ * @param userAgent A user agent used to create a fallback HttpDataSource if needed.
+ */
+ public CronetDataSourceFactory(
+ CronetEngineWrapper cronetEngineWrapper,
+ Executor executor,
+ int connectTimeoutMs,
+ int readTimeoutMs,
+ boolean resetTimeoutOnRedirects,
+ String userAgent) {
+ this(
+ cronetEngineWrapper,
+ executor,
+ /* transferListener= */ null,
+ DEFAULT_CONNECT_TIMEOUT_MILLIS,
+ DEFAULT_READ_TIMEOUT_MILLIS,
+ resetTimeoutOnRedirects,
+ new DefaultHttpDataSourceFactory(
+ userAgent,
+ /* listener= */ null,
+ connectTimeoutMs,
+ readTimeoutMs,
+ resetTimeoutOnRedirects));
+ }
+
+ /**
+ * Creates an instance.
+ *
+ *
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided
+ * fallback {@link HttpDataSource.Factory} will be used instead.
+ *
+ * @param cronetEngineWrapper A {@link CronetEngineWrapper}.
+ * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
+ * @param connectTimeoutMs The connection timeout, in milliseconds.
+ * @param readTimeoutMs The read timeout, in milliseconds.
+ * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs.
+ * @param fallbackFactory A {@link HttpDataSource.Factory} which is used as a fallback in case no
+ * suitable CronetEngine can be build.
+ */
+ public CronetDataSourceFactory(
+ CronetEngineWrapper cronetEngineWrapper,
+ Executor executor,
+ int connectTimeoutMs,
+ int readTimeoutMs,
+ boolean resetTimeoutOnRedirects,
+ HttpDataSource.Factory fallbackFactory) {
+ this(
+ cronetEngineWrapper,
+ executor,
+ /* transferListener= */ null,
+ connectTimeoutMs,
+ readTimeoutMs,
+ resetTimeoutOnRedirects,
+ fallbackFactory);
+ }
+
+ /**
+ * Creates an instance.
+ *
+ *
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided
+ * fallback {@link HttpDataSource.Factory} will be used instead.
+ *
+ *
Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout,
+ * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout.
+ *
+ * @param cronetEngineWrapper A {@link CronetEngineWrapper}.
+ * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
+ * @param transferListener An optional listener.
+ * @param fallbackFactory A {@link HttpDataSource.Factory} which is used as a fallback in case no
+ * suitable CronetEngine can be build.
+ */
+ public CronetDataSourceFactory(
+ CronetEngineWrapper cronetEngineWrapper,
+ Executor executor,
+ @Nullable TransferListener transferListener,
+ HttpDataSource.Factory fallbackFactory) {
+ this(
+ cronetEngineWrapper,
+ executor,
+ transferListener,
+ DEFAULT_CONNECT_TIMEOUT_MILLIS,
+ DEFAULT_READ_TIMEOUT_MILLIS,
+ false,
+ fallbackFactory);
+ }
+
+ /**
+ * Creates an instance.
+ *
+ *
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link
+ * DefaultHttpDataSourceFactory} will be used instead.
+ *
+ *
Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout,
+ * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout.
+ *
+ * @param cronetEngineWrapper A {@link CronetEngineWrapper}.
+ * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
+ * @param transferListener An optional listener.
+ */
+ public CronetDataSourceFactory(
+ CronetEngineWrapper cronetEngineWrapper,
+ Executor executor,
+ @Nullable TransferListener transferListener) {
+ this(cronetEngineWrapper, executor, transferListener, DEFAULT_USER_AGENT);
+ }
+
+ /**
+ * Creates an instance.
+ *
+ *
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link
+ * DefaultHttpDataSourceFactory} will be used instead.
+ *
+ *
Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout,
+ * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout.
+ *
+ * @param cronetEngineWrapper A {@link CronetEngineWrapper}.
+ * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
+ * @param transferListener An optional listener.
+ * @param userAgent A user agent used to create a fallback HttpDataSource if needed.
+ */
+ public CronetDataSourceFactory(
+ CronetEngineWrapper cronetEngineWrapper,
+ Executor executor,
+ @Nullable TransferListener transferListener,
+ String userAgent) {
+ this(
+ cronetEngineWrapper,
+ executor,
+ transferListener,
+ DEFAULT_CONNECT_TIMEOUT_MILLIS,
+ DEFAULT_READ_TIMEOUT_MILLIS,
+ false,
+ new DefaultHttpDataSourceFactory(
+ userAgent,
+ transferListener,
+ DEFAULT_CONNECT_TIMEOUT_MILLIS,
+ DEFAULT_READ_TIMEOUT_MILLIS,
+ false));
+ }
+
+ /**
+ * Creates an instance.
+ *
+ *
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link
+ * DefaultHttpDataSourceFactory} will be used instead.
+ *
+ * @param cronetEngineWrapper A {@link CronetEngineWrapper}.
+ * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
+ * @param transferListener An optional listener.
+ * @param connectTimeoutMs The connection timeout, in milliseconds.
+ * @param readTimeoutMs The read timeout, in milliseconds.
+ * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs.
+ * @param userAgent A user agent used to create a fallback HttpDataSource if needed.
+ */
+ public CronetDataSourceFactory(
+ CronetEngineWrapper cronetEngineWrapper,
+ Executor executor,
+ @Nullable TransferListener transferListener,
+ int connectTimeoutMs,
+ int readTimeoutMs,
+ boolean resetTimeoutOnRedirects,
+ String userAgent) {
+ this(
+ cronetEngineWrapper,
+ executor,
+ transferListener,
+ DEFAULT_CONNECT_TIMEOUT_MILLIS,
+ DEFAULT_READ_TIMEOUT_MILLIS,
+ resetTimeoutOnRedirects,
+ new DefaultHttpDataSourceFactory(
+ userAgent, transferListener, connectTimeoutMs, readTimeoutMs, resetTimeoutOnRedirects));
+ }
+
+ /**
+ * Creates an instance.
+ *
+ *
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided
+ * fallback {@link HttpDataSource.Factory} will be used instead.
+ *
+ * @param cronetEngineWrapper A {@link CronetEngineWrapper}.
+ * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
+ * @param transferListener An optional listener.
+ * @param connectTimeoutMs The connection timeout, in milliseconds.
+ * @param readTimeoutMs The read timeout, in milliseconds.
+ * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs.
+ * @param fallbackFactory A {@link HttpDataSource.Factory} which is used as a fallback in case no
+ * suitable CronetEngine can be build.
+ */
+ public CronetDataSourceFactory(
+ CronetEngineWrapper cronetEngineWrapper,
+ Executor executor,
+ @Nullable TransferListener transferListener,
+ int connectTimeoutMs,
+ int readTimeoutMs,
+ boolean resetTimeoutOnRedirects,
+ HttpDataSource.Factory fallbackFactory) {
+ this.cronetEngineWrapper = cronetEngineWrapper;
this.executor = executor;
- this.contentTypePredicate = contentTypePredicate;
this.transferListener = transferListener;
this.connectTimeoutMs = connectTimeoutMs;
this.readTimeoutMs = readTimeoutMs;
this.resetTimeoutOnRedirects = resetTimeoutOnRedirects;
+ this.fallbackFactory = fallbackFactory;
}
@Override
- protected CronetDataSource createDataSourceInternal(HttpDataSource.RequestProperties
+ protected HttpDataSource createDataSourceInternal(HttpDataSource.RequestProperties
defaultRequestProperties) {
- return new CronetDataSource(cronetEngine, executor, contentTypePredicate, transferListener,
- connectTimeoutMs, readTimeoutMs, resetTimeoutOnRedirects, defaultRequestProperties);
+ CronetEngine cronetEngine = cronetEngineWrapper.getCronetEngine();
+ if (cronetEngine == null) {
+ return fallbackFactory.createDataSource();
+ }
+ CronetDataSource dataSource =
+ new CronetDataSource(
+ cronetEngine,
+ executor,
+ connectTimeoutMs,
+ readTimeoutMs,
+ resetTimeoutOnRedirects,
+ defaultRequestProperties);
+ if (transferListener != null) {
+ dataSource.addTransferListener(transferListener);
+ }
+ return dataSource;
}
}
diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java
new file mode 100644
index 0000000000..9f709b14d0
--- /dev/null
+++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java
@@ -0,0 +1,251 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.cronet;
+
+import static java.lang.Math.min;
+
+import android.content.Context;
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.util.Log;
+import com.google.android.exoplayer2.util.Util;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import org.chromium.net.CronetEngine;
+import org.chromium.net.CronetProvider;
+
+/**
+ * A wrapper class for a {@link CronetEngine}.
+ */
+public final class CronetEngineWrapper {
+
+ private static final String TAG = "CronetEngineWrapper";
+
+ @Nullable private final CronetEngine cronetEngine;
+ @CronetEngineSource private final int cronetEngineSource;
+
+ /**
+ * Source of {@link CronetEngine}. One of {@link #SOURCE_NATIVE}, {@link #SOURCE_GMS}, {@link
+ * #SOURCE_UNKNOWN}, {@link #SOURCE_USER_PROVIDED} or {@link #SOURCE_UNAVAILABLE}.
+ */
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({SOURCE_NATIVE, SOURCE_GMS, SOURCE_UNKNOWN, SOURCE_USER_PROVIDED, SOURCE_UNAVAILABLE})
+ public @interface CronetEngineSource {}
+ /**
+ * Natively bundled Cronet implementation.
+ */
+ public static final int SOURCE_NATIVE = 0;
+ /**
+ * Cronet implementation from GMSCore.
+ */
+ public static final int SOURCE_GMS = 1;
+ /**
+ * Other (unknown) Cronet implementation.
+ */
+ public static final int SOURCE_UNKNOWN = 2;
+ /**
+ * User-provided Cronet engine.
+ */
+ public static final int SOURCE_USER_PROVIDED = 3;
+ /**
+ * No Cronet implementation available. Fallback Http provider is used if possible.
+ */
+ public static final int SOURCE_UNAVAILABLE = 4;
+
+ /**
+ * Creates a wrapper for a {@link CronetEngine} which automatically selects the most suitable
+ * {@link CronetProvider}. Sets wrapper to prefer natively bundled Cronet over GMSCore Cronet
+ * if both are available.
+ *
+ * @param context A context.
+ */
+ public CronetEngineWrapper(Context context) {
+ this(context, false);
+ }
+
+ /**
+ * Creates a wrapper for a {@link CronetEngine} which automatically selects the most suitable
+ * {@link CronetProvider} based on user preference.
+ *
+ * @param context A context.
+ * @param preferGMSCoreCronet Whether Cronet from GMSCore should be preferred over natively
+ * bundled Cronet if both are available.
+ */
+ public CronetEngineWrapper(Context context, boolean preferGMSCoreCronet) {
+ CronetEngine cronetEngine = null;
+ @CronetEngineSource int cronetEngineSource = SOURCE_UNAVAILABLE;
+ List cronetProviders = new ArrayList<>(CronetProvider.getAllProviders(context));
+ // Remove disabled and fallback Cronet providers from list
+ for (int i = cronetProviders.size() - 1; i >= 0; i--) {
+ if (!cronetProviders.get(i).isEnabled()
+ || CronetProvider.PROVIDER_NAME_FALLBACK.equals(cronetProviders.get(i).getName())) {
+ cronetProviders.remove(i);
+ }
+ }
+ // Sort remaining providers by type and version.
+ CronetProviderComparator providerComparator = new CronetProviderComparator(preferGMSCoreCronet);
+ Collections.sort(cronetProviders, providerComparator);
+ for (int i = 0; i < cronetProviders.size() && cronetEngine == null; i++) {
+ String providerName = cronetProviders.get(i).getName();
+ try {
+ cronetEngine = cronetProviders.get(i).createBuilder().build();
+ if (providerComparator.isNativeProvider(providerName)) {
+ cronetEngineSource = SOURCE_NATIVE;
+ } else if (providerComparator.isGMSCoreProvider(providerName)) {
+ cronetEngineSource = SOURCE_GMS;
+ } else {
+ cronetEngineSource = SOURCE_UNKNOWN;
+ }
+ Log.d(TAG, "CronetEngine built using " + providerName);
+ } catch (SecurityException e) {
+ Log.w(TAG, "Failed to build CronetEngine. Please check if current process has "
+ + "android.permission.ACCESS_NETWORK_STATE.");
+ } catch (UnsatisfiedLinkError e) {
+ Log.w(TAG, "Failed to link Cronet binaries. Please check if native Cronet binaries are "
+ + "bundled into your app.");
+ }
+ }
+ if (cronetEngine == null) {
+ Log.w(TAG, "Cronet not available. Using fallback provider.");
+ }
+ this.cronetEngine = cronetEngine;
+ this.cronetEngineSource = cronetEngineSource;
+ }
+
+ /**
+ * Creates a wrapper for an existing CronetEngine.
+ *
+ * @param cronetEngine An existing CronetEngine.
+ */
+ public CronetEngineWrapper(CronetEngine cronetEngine) {
+ this.cronetEngine = cronetEngine;
+ this.cronetEngineSource = SOURCE_USER_PROVIDED;
+ }
+
+ /**
+ * Returns the source of the wrapped {@link CronetEngine}.
+ *
+ * @return A {@link CronetEngineSource} value.
+ */
+ @CronetEngineSource
+ public int getCronetEngineSource() {
+ return cronetEngineSource;
+ }
+
+ /**
+ * Returns the wrapped {@link CronetEngine}.
+ *
+ * @return The CronetEngine, or null if no CronetEngine is available.
+ */
+ @Nullable
+ /* package */ CronetEngine getCronetEngine() {
+ return cronetEngine;
+ }
+
+ private static class CronetProviderComparator implements Comparator {
+
+ @Nullable private final String gmsCoreCronetName;
+ private final boolean preferGMSCoreCronet;
+
+ // Multi-catch can only be used for API 19+ in this case.
+ // Field#get(null) is blocked by the null-checker, but is safe because the field is static.
+ @SuppressWarnings({"UseMultiCatch", "nullness:argument.type.incompatible"})
+ public CronetProviderComparator(boolean preferGMSCoreCronet) {
+ // GMSCore CronetProvider classes are only available in some configurations.
+ // Thus, we use reflection to copy static name.
+ String gmsCoreVersionString = null;
+ try {
+ Class> cronetProviderInstallerClass =
+ Class.forName("com.google.android.gms.net.CronetProviderInstaller");
+ Field providerNameField = cronetProviderInstallerClass.getDeclaredField("PROVIDER_NAME");
+ gmsCoreVersionString = (String) providerNameField.get(null);
+ } catch (ClassNotFoundException e) {
+ // GMSCore CronetProvider not available.
+ } catch (NoSuchFieldException e) {
+ // GMSCore CronetProvider not available.
+ } catch (IllegalAccessException e) {
+ // GMSCore CronetProvider not available.
+ }
+ gmsCoreCronetName = gmsCoreVersionString;
+ this.preferGMSCoreCronet = preferGMSCoreCronet;
+ }
+
+ @Override
+ public int compare(CronetProvider providerLeft, CronetProvider providerRight) {
+ int typePreferenceLeft = evaluateCronetProviderType(providerLeft.getName());
+ int typePreferenceRight = evaluateCronetProviderType(providerRight.getName());
+ if (typePreferenceLeft != typePreferenceRight) {
+ return typePreferenceLeft - typePreferenceRight;
+ }
+ return -compareVersionStrings(providerLeft.getVersion(), providerRight.getVersion());
+ }
+
+ public boolean isNativeProvider(String providerName) {
+ return CronetProvider.PROVIDER_NAME_APP_PACKAGED.equals(providerName);
+ }
+
+ public boolean isGMSCoreProvider(String providerName) {
+ return gmsCoreCronetName != null && gmsCoreCronetName.equals(providerName);
+ }
+
+ /**
+ * Convert Cronet provider name into a sortable preference value.
+ * Smaller values are preferred.
+ */
+ private int evaluateCronetProviderType(String providerName) {
+ if (isNativeProvider(providerName)) {
+ return 1;
+ }
+ if (isGMSCoreProvider(providerName)) {
+ return preferGMSCoreCronet ? 0 : 2;
+ }
+ // Unknown provider type.
+ return -1;
+ }
+
+ /**
+ * Compares version strings of format "12.123.35.23".
+ */
+ private static int compareVersionStrings(String versionLeft, String versionRight) {
+ if (versionLeft == null || versionRight == null) {
+ return 0;
+ }
+ String[] versionStringsLeft = Util.split(versionLeft, "\\.");
+ String[] versionStringsRight = Util.split(versionRight, "\\.");
+ int minLength = min(versionStringsLeft.length, versionStringsRight.length);
+ for (int i = 0; i < minLength; i++) {
+ if (!versionStringsLeft[i].equals(versionStringsRight[i])) {
+ try {
+ int versionIntLeft = Integer.parseInt(versionStringsLeft[i]);
+ int versionIntRight = Integer.parseInt(versionStringsRight[i]);
+ return versionIntLeft - versionIntRight;
+ } catch (NumberFormatException e) {
+ return 0;
+ }
+ }
+ }
+ return 0;
+ }
+ }
+
+}
diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/package-info.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/package-info.java
new file mode 100644
index 0000000000..ec0cf8df05
--- /dev/null
+++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@NonNullApi
+package com.google.android.exoplayer2.ext.cronet;
+
+import com.google.android.exoplayer2.util.NonNullApi;
diff --git a/library/ui/src/main/res/values-v11/styles.xml b/extensions/cronet/src/test/AndroidManifest.xml
similarity index 68%
rename from library/ui/src/main/res/values-v11/styles.xml
rename to extensions/cronet/src/test/AndroidManifest.xml
index 6f77440287..d6e09107a7 100644
--- a/library/ui/src/main/res/values-v11/styles.xml
+++ b/extensions/cronet/src/test/AndroidManifest.xml
@@ -13,12 +13,7 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
-
-
-
-
+
+
+
diff --git a/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProviderTest.java b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProviderTest.java
similarity index 66%
rename from extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProviderTest.java
rename to extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProviderTest.java
index 4282244a7a..355b4ed2a9 100644
--- a/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProviderTest.java
+++ b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProviderTest.java
@@ -13,20 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
package com.google.android.exoplayer2.ext.cronet;
-import static org.junit.Assert.assertArrayEquals;
-import static org.junit.Assert.assertEquals;
+import static com.google.common.truth.Truth.assertThat;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
-import static org.mockito.MockitoAnnotations.initMocks;
-
-import android.annotation.TargetApi;
-import android.os.Build.VERSION_CODES;
-import android.support.test.InstrumentationRegistry;
-import android.support.test.runner.AndroidJUnit4;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Arrays;
@@ -35,10 +28,9 @@ import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
-/**
- * Tests for {@link ByteArrayUploadDataProvider}.
- */
+/** Tests for {@link ByteArrayUploadDataProvider}. */
@RunWith(AndroidJUnit4.class)
public final class ByteArrayUploadDataProviderTest {
@@ -50,53 +42,49 @@ public final class ByteArrayUploadDataProviderTest {
@Before
public void setUp() {
- System.setProperty("dexmaker.dexcache",
- InstrumentationRegistry.getTargetContext().getCacheDir().getPath());
- initMocks(this);
+ MockitoAnnotations.initMocks(this);
byteBuffer = ByteBuffer.allocate(TEST_DATA.length);
byteArrayUploadDataProvider = new ByteArrayUploadDataProvider(TEST_DATA);
}
@Test
- public void testGetLength() {
- assertEquals(TEST_DATA.length, byteArrayUploadDataProvider.getLength());
+ public void getLength() {
+ assertThat(byteArrayUploadDataProvider.getLength()).isEqualTo(TEST_DATA.length);
}
@Test
- public void testReadFullBuffer() throws IOException {
+ public void readFullBuffer() throws IOException {
byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer);
- assertArrayEquals(TEST_DATA, byteBuffer.array());
+ assertThat(byteBuffer.array()).isEqualTo(TEST_DATA);
}
- @TargetApi(VERSION_CODES.GINGERBREAD)
@Test
- public void testReadPartialBuffer() throws IOException {
- byte[] firstHalf = Arrays.copyOfRange(TEST_DATA, 0, TEST_DATA.length / 2);
+ public void readPartialBuffer() throws IOException {
+ byte[] firstHalf = Arrays.copyOf(TEST_DATA, TEST_DATA.length / 2);
byte[] secondHalf = Arrays.copyOfRange(TEST_DATA, TEST_DATA.length / 2, TEST_DATA.length);
byteBuffer = ByteBuffer.allocate(TEST_DATA.length / 2);
// Read half of the data.
byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer);
- assertArrayEquals(firstHalf, byteBuffer.array());
+ assertThat(byteBuffer.array()).isEqualTo(firstHalf);
// Read the second half of the data.
byteBuffer.rewind();
byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer);
- assertArrayEquals(secondHalf, byteBuffer.array());
+ assertThat(byteBuffer.array()).isEqualTo(secondHalf);
verify(mockUploadDataSink, times(2)).onReadSucceeded(false);
}
@Test
- public void testRewind() throws IOException {
+ public void rewind() throws IOException {
// Read all the data.
byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer);
- assertArrayEquals(TEST_DATA, byteBuffer.array());
+ assertThat(byteBuffer.array()).isEqualTo(TEST_DATA);
// Rewind and make sure it can be read again.
byteBuffer.clear();
byteArrayUploadDataProvider.rewind(mockUploadDataSink);
byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer);
- assertArrayEquals(TEST_DATA, byteBuffer.array());
+ assertThat(byteBuffer.array()).isEqualTo(TEST_DATA);
verify(mockUploadDataSink).onRewindSucceeded();
}
-
}
diff --git a/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java
new file mode 100644
index 0000000000..ac19c8548d
--- /dev/null
+++ b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java
@@ -0,0 +1,1524 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.cronet;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.lang.Math.min;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+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 android.net.Uri;
+import android.os.ConditionVariable;
+import android.os.SystemClock;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.upstream.DataSpec;
+import com.google.android.exoplayer2.upstream.HttpDataSource;
+import com.google.android.exoplayer2.upstream.HttpDataSource.HttpDataSourceException;
+import com.google.android.exoplayer2.upstream.TransferListener;
+import com.google.android.exoplayer2.util.Clock;
+import com.google.android.exoplayer2.util.Util;
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.net.SocketTimeoutException;
+import java.net.UnknownHostException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import org.chromium.net.CronetEngine;
+import org.chromium.net.NetworkException;
+import org.chromium.net.UrlRequest;
+import org.chromium.net.UrlResponseInfo;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentMatchers;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.shadows.ShadowLooper;
+
+/** Tests for {@link CronetDataSource}. */
+@RunWith(AndroidJUnit4.class)
+public final class CronetDataSourceTest {
+
+ private static final int TEST_CONNECT_TIMEOUT_MS = 100;
+ private static final int TEST_READ_TIMEOUT_MS = 100;
+ private static final String TEST_URL = "http://google.com";
+ private static final String TEST_CONTENT_TYPE = "test/test";
+ private static final byte[] TEST_POST_BODY = Util.getUtf8Bytes("test post body");
+ private static final long TEST_CONTENT_LENGTH = 16000L;
+ private static final int TEST_CONNECTION_STATUS = 5;
+ private static final int TEST_INVALID_CONNECTION_STATUS = -1;
+
+ private DataSpec testDataSpec;
+ private DataSpec testPostDataSpec;
+ private DataSpec testHeadDataSpec;
+ private Map testResponseHeader;
+ private UrlResponseInfo testUrlResponseInfo;
+
+ @Mock private UrlRequest.Builder mockUrlRequestBuilder;
+ @Mock private UrlRequest mockUrlRequest;
+ @Mock private TransferListener mockTransferListener;
+ @Mock private Executor mockExecutor;
+ @Mock private NetworkException mockNetworkException;
+ @Mock private CronetEngine mockCronetEngine;
+
+ private CronetDataSource dataSourceUnderTest;
+ private boolean redirectCalled;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+
+ HttpDataSource.RequestProperties defaultRequestProperties =
+ new HttpDataSource.RequestProperties();
+ defaultRequestProperties.set("defaultHeader1", "defaultValue1");
+ defaultRequestProperties.set("defaultHeader2", "defaultValue2");
+
+ dataSourceUnderTest =
+ new CronetDataSource(
+ mockCronetEngine,
+ mockExecutor,
+ TEST_CONNECT_TIMEOUT_MS,
+ TEST_READ_TIMEOUT_MS,
+ /* resetTimeoutOnRedirects= */ true,
+ Clock.DEFAULT,
+ defaultRequestProperties,
+ /* handleSetCookieRequests= */ false);
+ dataSourceUnderTest.addTransferListener(mockTransferListener);
+ when(mockCronetEngine.newUrlRequestBuilder(
+ anyString(), any(UrlRequest.Callback.class), any(Executor.class)))
+ .thenReturn(mockUrlRequestBuilder);
+ when(mockUrlRequestBuilder.allowDirectExecutor()).thenReturn(mockUrlRequestBuilder);
+ when(mockUrlRequestBuilder.build()).thenReturn(mockUrlRequest);
+ mockStatusResponse();
+
+ testDataSpec = new DataSpec(Uri.parse(TEST_URL));
+ testPostDataSpec =
+ new DataSpec.Builder()
+ .setUri(TEST_URL)
+ .setHttpMethod(DataSpec.HTTP_METHOD_POST)
+ .setHttpBody(TEST_POST_BODY)
+ .build();
+ testHeadDataSpec =
+ new DataSpec.Builder().setUri(TEST_URL).setHttpMethod(DataSpec.HTTP_METHOD_HEAD).build();
+ testResponseHeader = new HashMap<>();
+ testResponseHeader.put("Content-Type", TEST_CONTENT_TYPE);
+ // This value can be anything since the DataSpec is unset.
+ testResponseHeader.put("Content-Length", Long.toString(TEST_CONTENT_LENGTH));
+ testUrlResponseInfo = createUrlResponseInfo(200); // statusCode
+ }
+
+ private UrlResponseInfo createUrlResponseInfo(int statusCode) {
+ return createUrlResponseInfoWithUrl(TEST_URL, statusCode);
+ }
+
+ private UrlResponseInfo createUrlResponseInfoWithUrl(String url, int statusCode) {
+ ArrayList> responseHeaderList = new ArrayList<>();
+ Map> responseHeaderMap = new HashMap<>();
+ for (Map.Entry entry : testResponseHeader.entrySet()) {
+ responseHeaderList.add(entry);
+ responseHeaderMap.put(entry.getKey(), Collections.singletonList(entry.getValue()));
+ }
+ return new UrlResponseInfo() {
+ @Override
+ public String getUrl() {
+ return url;
+ }
+
+ @Override
+ public List getUrlChain() {
+ return Collections.singletonList(url);
+ }
+
+ @Override
+ public int getHttpStatusCode() {
+ return statusCode;
+ }
+
+ @Override
+ public String getHttpStatusText() {
+ return null;
+ }
+
+ @Override
+ public List> getAllHeadersAsList() {
+ return responseHeaderList;
+ }
+
+ @Override
+ public Map> getAllHeaders() {
+ return responseHeaderMap;
+ }
+
+ @Override
+ public boolean wasCached() {
+ return false;
+ }
+
+ @Override
+ public String getNegotiatedProtocol() {
+ return null;
+ }
+
+ @Override
+ public String getProxyServer() {
+ return null;
+ }
+
+ @Override
+ public long getReceivedByteCount() {
+ return 0;
+ }
+ };
+ }
+
+ @Test
+ public void openingTwiceThrows() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ dataSourceUnderTest.open(testDataSpec);
+ try {
+ dataSourceUnderTest.open(testDataSpec);
+ fail("Expected IllegalStateException.");
+ } catch (IllegalStateException e) {
+ // Expected.
+ }
+ }
+
+ @Test
+ public void callbackFromPreviousRequest() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+
+ dataSourceUnderTest.open(testDataSpec);
+ dataSourceUnderTest.close();
+ // Prepare a mock UrlRequest to be used in the second open() call.
+ final UrlRequest mockUrlRequest2 = mock(UrlRequest.class);
+ when(mockUrlRequestBuilder.build()).thenReturn(mockUrlRequest2);
+ doAnswer(
+ invocation -> {
+ // Invoke the callback for the previous request.
+ dataSourceUnderTest.urlRequestCallback.onFailed(
+ mockUrlRequest, testUrlResponseInfo, mockNetworkException);
+ dataSourceUnderTest.urlRequestCallback.onResponseStarted(
+ mockUrlRequest2, testUrlResponseInfo);
+ return null;
+ })
+ .when(mockUrlRequest2)
+ .start();
+ dataSourceUnderTest.open(testDataSpec);
+ }
+
+ @Test
+ public void requestStartCalled() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+
+ dataSourceUnderTest.open(testDataSpec);
+ verify(mockCronetEngine)
+ .newUrlRequestBuilder(eq(TEST_URL), any(UrlRequest.Callback.class), any(Executor.class));
+ verify(mockUrlRequest).start();
+ }
+
+ @Test
+ public void requestSetsRangeHeader() throws HttpDataSourceException {
+ testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000);
+ mockResponseStartSuccess();
+
+ dataSourceUnderTest.open(testDataSpec);
+ // The header value to add is current position to current position + length - 1.
+ verify(mockUrlRequestBuilder).addHeader("Range", "bytes=1000-5999");
+ }
+
+ @Test
+ public void requestHeadersSet() throws HttpDataSourceException {
+ Map headersSet = new HashMap<>();
+ doAnswer(
+ (invocation) -> {
+ String key = invocation.getArgument(0);
+ String value = invocation.getArgument(1);
+ headersSet.put(key, value);
+ return null;
+ })
+ .when(mockUrlRequestBuilder)
+ .addHeader(ArgumentMatchers.anyString(), ArgumentMatchers.anyString());
+
+ dataSourceUnderTest.setRequestProperty("defaultHeader2", "dataSourceOverridesDefault");
+ dataSourceUnderTest.setRequestProperty("dataSourceHeader1", "dataSourceValue1");
+ dataSourceUnderTest.setRequestProperty("dataSourceHeader2", "dataSourceValue2");
+
+ Map dataSpecRequestProperties = new HashMap<>();
+ dataSpecRequestProperties.put("defaultHeader3", "dataSpecOverridesAll");
+ dataSpecRequestProperties.put("dataSourceHeader2", "dataSpecOverridesDataSource");
+ dataSpecRequestProperties.put("dataSpecHeader1", "dataSpecValue1");
+
+ testDataSpec =
+ new DataSpec.Builder()
+ .setUri(TEST_URL)
+ .setPosition(1000)
+ .setLength(5000)
+ .setHttpRequestHeaders(dataSpecRequestProperties)
+ .build();
+ mockResponseStartSuccess();
+
+ dataSourceUnderTest.open(testDataSpec);
+
+ assertThat(headersSet.get("defaultHeader1")).isEqualTo("defaultValue1");
+ assertThat(headersSet.get("defaultHeader2")).isEqualTo("dataSourceOverridesDefault");
+ assertThat(headersSet.get("defaultHeader3")).isEqualTo("dataSpecOverridesAll");
+ assertThat(headersSet.get("dataSourceHeader1")).isEqualTo("dataSourceValue1");
+ assertThat(headersSet.get("dataSourceHeader2")).isEqualTo("dataSpecOverridesDataSource");
+ assertThat(headersSet.get("dataSpecHeader1")).isEqualTo("dataSpecValue1");
+
+ verify(mockUrlRequest).start();
+ }
+
+ @Test
+ public void requestOpen() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ assertThat(dataSourceUnderTest.open(testDataSpec)).isEqualTo(TEST_CONTENT_LENGTH);
+ verify(mockTransferListener)
+ .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
+ }
+
+ @Test
+ public void requestOpenGzippedCompressedReturnsDataSpecLength() throws HttpDataSourceException {
+ testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, 5000);
+ testResponseHeader.put("Content-Encoding", "gzip");
+ testResponseHeader.put("Content-Length", Long.toString(50L));
+ mockResponseStartSuccess();
+
+ assertThat(dataSourceUnderTest.open(testDataSpec)).isEqualTo(5000 /* contentLength */);
+ verify(mockTransferListener)
+ .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
+ }
+
+ @Test
+ public void requestOpenFail() {
+ mockResponseStartFailure();
+
+ try {
+ dataSourceUnderTest.open(testDataSpec);
+ fail("HttpDataSource.HttpDataSourceException expected");
+ } catch (HttpDataSourceException e) {
+ // Check for connection not automatically closed.
+ assertThat(e).hasCauseThat().isNotInstanceOf(UnknownHostException.class);
+ verify(mockUrlRequest, never()).cancel();
+ verify(mockTransferListener, never())
+ .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
+ }
+ }
+
+ @Test
+ public void open_ifBodyIsSetWithoutContentTypeHeader_fails() {
+ testDataSpec =
+ new DataSpec.Builder()
+ .setUri(TEST_URL)
+ .setHttpMethod(DataSpec.HTTP_METHOD_POST)
+ .setHttpBody(new byte[1024])
+ .setPosition(200)
+ .setLength(1024)
+ .setKey("key")
+ .build();
+
+ try {
+ dataSourceUnderTest.open(testDataSpec);
+ fail();
+ } catch (IOException expected) {
+ // Expected
+ }
+ }
+
+ @Test
+ public void requestOpenFailDueToDnsFailure() {
+ mockResponseStartFailure();
+ when(mockNetworkException.getErrorCode())
+ .thenReturn(NetworkException.ERROR_HOSTNAME_NOT_RESOLVED);
+
+ try {
+ dataSourceUnderTest.open(testDataSpec);
+ fail("HttpDataSource.HttpDataSourceException expected");
+ } catch (HttpDataSourceException e) {
+ // Check for connection not automatically closed.
+ assertThat(e).hasCauseThat().isInstanceOf(UnknownHostException.class);
+ verify(mockUrlRequest, never()).cancel();
+ verify(mockTransferListener, never())
+ .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
+ }
+ }
+
+ @Test
+ public void requestOpenPropagatesFailureResponseBody() throws Exception {
+ mockResponseStartSuccess();
+ // Use a size larger than CronetDataSource.READ_BUFFER_SIZE_BYTES
+ int responseLength = 40 * 1024;
+ mockReadSuccess(/* position= */ 0, /* length= */ responseLength);
+ testUrlResponseInfo = createUrlResponseInfo(/* statusCode= */ 500);
+
+ try {
+ dataSourceUnderTest.open(testDataSpec);
+ fail("HttpDataSource.InvalidResponseCodeException expected");
+ } catch (HttpDataSource.InvalidResponseCodeException e) {
+ assertThat(e.responseBody).isEqualTo(buildTestDataArray(0, responseLength));
+ // Check for connection not automatically closed.
+ verify(mockUrlRequest, never()).cancel();
+ verify(mockTransferListener, never())
+ .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
+ }
+ }
+
+ @Test
+ public void requestOpenValidatesContentTypePredicate() {
+ mockResponseStartSuccess();
+
+ ArrayList testedContentTypes = new ArrayList<>();
+ dataSourceUnderTest.setContentTypePredicate(
+ (String input) -> {
+ testedContentTypes.add(input);
+ return false;
+ });
+
+ try {
+ dataSourceUnderTest.open(testDataSpec);
+ fail("HttpDataSource.HttpDataSourceException expected");
+ } catch (HttpDataSourceException e) {
+ assertThat(e).isInstanceOf(HttpDataSource.InvalidContentTypeException.class);
+ // Check for connection not automatically closed.
+ verify(mockUrlRequest, never()).cancel();
+ assertThat(testedContentTypes).hasSize(1);
+ assertThat(testedContentTypes.get(0)).isEqualTo(TEST_CONTENT_TYPE);
+ }
+ }
+
+ @Test
+ public void postRequestOpen() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+
+ dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE);
+ assertThat(dataSourceUnderTest.open(testPostDataSpec)).isEqualTo(TEST_CONTENT_LENGTH);
+ verify(mockTransferListener)
+ .onTransferStart(dataSourceUnderTest, testPostDataSpec, /* isNetwork= */ true);
+ }
+
+ @Test
+ public void postRequestOpenValidatesContentType() {
+ mockResponseStartSuccess();
+
+ try {
+ dataSourceUnderTest.open(testPostDataSpec);
+ fail("HttpDataSource.HttpDataSourceException expected");
+ } catch (HttpDataSourceException e) {
+ verify(mockUrlRequest, never()).start();
+ }
+ }
+
+ @Test
+ public void postRequestOpenRejects307Redirects() {
+ mockResponseStartSuccess();
+ mockResponseStartRedirect();
+
+ try {
+ dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE);
+ dataSourceUnderTest.open(testPostDataSpec);
+ fail("HttpDataSource.HttpDataSourceException expected");
+ } catch (HttpDataSourceException e) {
+ verify(mockUrlRequest, never()).followRedirect();
+ }
+ }
+
+ @Test
+ public void headRequestOpen() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ dataSourceUnderTest.open(testHeadDataSpec);
+ verify(mockTransferListener)
+ .onTransferStart(dataSourceUnderTest, testHeadDataSpec, /* isNetwork= */ true);
+ dataSourceUnderTest.close();
+ }
+
+ @Test
+ public void requestReadTwice() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ mockReadSuccess(0, 16);
+
+ dataSourceUnderTest.open(testDataSpec);
+
+ byte[] returnedBuffer = new byte[8];
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8);
+ assertThat(returnedBuffer).isEqualTo(buildTestDataArray(0, 8));
+ assertThat(bytesRead).isEqualTo(8);
+
+ returnedBuffer = new byte[8];
+ bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8);
+ assertThat(returnedBuffer).isEqualTo(buildTestDataArray(8, 8));
+ assertThat(bytesRead).isEqualTo(8);
+
+ // Should have only called read on cronet once.
+ verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class));
+ verify(mockTransferListener, times(2))
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8);
+ }
+
+ @Test
+ public void secondRequestNoContentLength() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ testResponseHeader.put("Content-Length", Long.toString(1L));
+ mockReadSuccess(0, 16);
+
+ // First request.
+ dataSourceUnderTest.open(testDataSpec);
+ byte[] returnedBuffer = new byte[8];
+ dataSourceUnderTest.read(returnedBuffer, 0, 1);
+ dataSourceUnderTest.close();
+
+ testResponseHeader.remove("Content-Length");
+ mockReadSuccess(0, 16);
+
+ // Second request.
+ dataSourceUnderTest.open(testDataSpec);
+ returnedBuffer = new byte[16];
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10);
+ assertThat(bytesRead).isEqualTo(10);
+ bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10);
+ assertThat(bytesRead).isEqualTo(6);
+ bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10);
+ assertThat(bytesRead).isEqualTo(C.RESULT_END_OF_INPUT);
+ }
+
+ @Test
+ public void readWithOffset() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ mockReadSuccess(0, 16);
+
+ dataSourceUnderTest.open(testDataSpec);
+
+ byte[] returnedBuffer = new byte[16];
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer, 8, 8);
+ assertThat(bytesRead).isEqualTo(8);
+ assertThat(returnedBuffer).isEqualTo(prefixZeros(buildTestDataArray(0, 8), 16));
+ verify(mockTransferListener)
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8);
+ }
+
+ @Test
+ public void rangeRequestWith206Response() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ mockReadSuccess(1000, 5000);
+ testUrlResponseInfo = createUrlResponseInfo(206); // Server supports range requests.
+ testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000);
+
+ dataSourceUnderTest.open(testDataSpec);
+
+ byte[] returnedBuffer = new byte[16];
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 16);
+ assertThat(bytesRead).isEqualTo(16);
+ assertThat(returnedBuffer).isEqualTo(buildTestDataArray(1000, 16));
+ verify(mockTransferListener)
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16);
+ }
+
+ @Test
+ public void rangeRequestWith200Response() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ mockReadSuccess(0, 7000);
+ testUrlResponseInfo = createUrlResponseInfo(200); // Server does not support range requests.
+ testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000);
+
+ dataSourceUnderTest.open(testDataSpec);
+
+ byte[] returnedBuffer = new byte[16];
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 16);
+ assertThat(bytesRead).isEqualTo(16);
+ assertThat(returnedBuffer).isEqualTo(buildTestDataArray(1000, 16));
+ verify(mockTransferListener)
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16);
+ }
+
+ @Test
+ public void readWithUnsetLength() throws HttpDataSourceException {
+ testResponseHeader.remove("Content-Length");
+ mockResponseStartSuccess();
+ mockReadSuccess(0, 16);
+
+ dataSourceUnderTest.open(testDataSpec);
+
+ byte[] returnedBuffer = new byte[16];
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer, 8, 8);
+ assertThat(returnedBuffer).isEqualTo(prefixZeros(buildTestDataArray(0, 8), 16));
+ assertThat(bytesRead).isEqualTo(8);
+ verify(mockTransferListener)
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8);
+ }
+
+ @Test
+ public void readReturnsWhatItCan() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ mockReadSuccess(0, 16);
+
+ dataSourceUnderTest.open(testDataSpec);
+
+ byte[] returnedBuffer = new byte[24];
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 24);
+ assertThat(returnedBuffer).isEqualTo(suffixZeros(buildTestDataArray(0, 16), 24));
+ assertThat(bytesRead).isEqualTo(16);
+ verify(mockTransferListener)
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16);
+ }
+
+ @Test
+ public void closedMeansClosed() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ mockReadSuccess(0, 16);
+
+ int bytesRead = 0;
+ dataSourceUnderTest.open(testDataSpec);
+
+ byte[] returnedBuffer = new byte[8];
+ bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8);
+ assertThat(returnedBuffer).isEqualTo(buildTestDataArray(0, 8));
+ assertThat(bytesRead).isEqualTo(8);
+
+ dataSourceUnderTest.close();
+ verify(mockTransferListener)
+ .onTransferEnd(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
+
+ try {
+ bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8);
+ fail();
+ } catch (IllegalStateException e) {
+ // Expected.
+ }
+
+ // 16 bytes were attempted but only 8 should have been successfully read.
+ assertThat(bytesRead).isEqualTo(8);
+ }
+
+ @Test
+ public void overread() throws HttpDataSourceException {
+ testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, 16);
+ testResponseHeader.put("Content-Length", Long.toString(16L));
+ mockResponseStartSuccess();
+ mockReadSuccess(0, 16);
+
+ dataSourceUnderTest.open(testDataSpec);
+
+ byte[] returnedBuffer = new byte[8];
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8);
+ assertThat(bytesRead).isEqualTo(8);
+ assertThat(returnedBuffer).isEqualTo(buildTestDataArray(0, 8));
+
+ // The current buffer is kept if not completely consumed by DataSource reader.
+ returnedBuffer = new byte[8];
+ bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 6);
+ assertThat(bytesRead).isEqualTo(14);
+ assertThat(returnedBuffer).isEqualTo(suffixZeros(buildTestDataArray(8, 6), 8));
+
+ // 2 bytes left at this point.
+ returnedBuffer = new byte[8];
+ bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8);
+ assertThat(bytesRead).isEqualTo(16);
+ assertThat(returnedBuffer).isEqualTo(suffixZeros(buildTestDataArray(14, 2), 8));
+
+ // Should have only called read on cronet once.
+ verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class));
+ verify(mockTransferListener, times(1))
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8);
+ verify(mockTransferListener, times(1))
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 6);
+ verify(mockTransferListener, times(1))
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 2);
+
+ // Now we already returned the 16 bytes initially asked.
+ // Try to read again even though all requested 16 bytes are already returned.
+ // Return C.RESULT_END_OF_INPUT
+ returnedBuffer = new byte[16];
+ int bytesOverRead = dataSourceUnderTest.read(returnedBuffer, 0, 16);
+ assertThat(bytesOverRead).isEqualTo(C.RESULT_END_OF_INPUT);
+ assertThat(returnedBuffer).isEqualTo(new byte[16]);
+ // C.RESULT_END_OF_INPUT should not be reported though the TransferListener.
+ verify(mockTransferListener, never())
+ .onBytesTransferred(
+ dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, C.RESULT_END_OF_INPUT);
+ // There should still be only one call to read on cronet.
+ verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class));
+ // Check for connection not automatically closed.
+ verify(mockUrlRequest, never()).cancel();
+ assertThat(bytesRead).isEqualTo(16);
+ }
+
+ @Test
+ public void requestReadByteBufferTwice() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ mockReadSuccess(0, 16);
+
+ dataSourceUnderTest.open(testDataSpec);
+
+ ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(8);
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer);
+ assertThat(bytesRead).isEqualTo(8);
+ returnedBuffer.flip();
+ assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(0, 8));
+
+ // Use a wrapped ByteBuffer instead of direct for coverage.
+ returnedBuffer.rewind();
+ bytesRead = dataSourceUnderTest.read(returnedBuffer);
+ returnedBuffer.flip();
+ assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(8, 8));
+ assertThat(bytesRead).isEqualTo(8);
+
+ // Separate cronet calls for each read.
+ verify(mockUrlRequest, times(2)).read(any(ByteBuffer.class));
+ verify(mockTransferListener, times(2))
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8);
+ }
+
+ @Test
+ public void requestIntermixRead() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ // Chunking reads into parts 6, 7, 8, 9.
+ mockReadSuccess(0, 30);
+
+ dataSourceUnderTest.open(testDataSpec);
+
+ ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(6);
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer);
+ returnedBuffer.flip();
+ assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(0, 6));
+ assertThat(bytesRead).isEqualTo(6);
+
+ byte[] returnedBytes = new byte[7];
+ bytesRead += dataSourceUnderTest.read(returnedBytes, 0, 7);
+ assertThat(returnedBytes).isEqualTo(buildTestDataArray(6, 7));
+ assertThat(bytesRead).isEqualTo(6 + 7);
+
+ returnedBuffer = ByteBuffer.allocateDirect(8);
+ bytesRead += dataSourceUnderTest.read(returnedBuffer);
+ returnedBuffer.flip();
+ assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(13, 8));
+ assertThat(bytesRead).isEqualTo(6 + 7 + 8);
+
+ returnedBytes = new byte[9];
+ bytesRead += dataSourceUnderTest.read(returnedBytes, 0, 9);
+ assertThat(returnedBytes).isEqualTo(buildTestDataArray(21, 9));
+ assertThat(bytesRead).isEqualTo(6 + 7 + 8 + 9);
+
+ // First ByteBuffer call. The first byte[] call populates enough bytes for the rest.
+ verify(mockUrlRequest, times(2)).read(any(ByteBuffer.class));
+ verify(mockTransferListener, times(1))
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 6);
+ verify(mockTransferListener, times(1))
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 7);
+ verify(mockTransferListener, times(1))
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8);
+ verify(mockTransferListener, times(1))
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 9);
+ }
+
+ @Test
+ public void secondRequestNoContentLengthReadByteBuffer() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ testResponseHeader.put("Content-Length", Long.toString(1L));
+ mockReadSuccess(0, 16);
+
+ // First request.
+ dataSourceUnderTest.open(testDataSpec);
+ ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(8);
+ dataSourceUnderTest.read(returnedBuffer);
+ dataSourceUnderTest.close();
+
+ testResponseHeader.remove("Content-Length");
+ mockReadSuccess(0, 16);
+
+ // Second request.
+ dataSourceUnderTest.open(testDataSpec);
+ returnedBuffer = ByteBuffer.allocateDirect(16);
+ returnedBuffer.limit(10);
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer);
+ assertThat(bytesRead).isEqualTo(10);
+ returnedBuffer.limit(returnedBuffer.capacity());
+ bytesRead = dataSourceUnderTest.read(returnedBuffer);
+ assertThat(bytesRead).isEqualTo(6);
+ returnedBuffer.rewind();
+ bytesRead = dataSourceUnderTest.read(returnedBuffer);
+ assertThat(bytesRead).isEqualTo(C.RESULT_END_OF_INPUT);
+ }
+
+ @Test
+ public void rangeRequestWith206ResponseReadByteBuffer() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ mockReadSuccess(1000, 5000);
+ testUrlResponseInfo = createUrlResponseInfo(206); // Server supports range requests.
+ testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000);
+
+ dataSourceUnderTest.open(testDataSpec);
+
+ ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(16);
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer);
+ assertThat(bytesRead).isEqualTo(16);
+ returnedBuffer.flip();
+ assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(1000, 16));
+ verify(mockTransferListener)
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16);
+ }
+
+ @Test
+ public void rangeRequestWith200ResponseReadByteBuffer() throws HttpDataSourceException {
+ // Tests for skipping bytes.
+ mockResponseStartSuccess();
+ mockReadSuccess(0, 7000);
+ testUrlResponseInfo = createUrlResponseInfo(200); // Server does not support range requests.
+ testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000);
+
+ dataSourceUnderTest.open(testDataSpec);
+
+ ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(16);
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer);
+ assertThat(bytesRead).isEqualTo(16);
+ returnedBuffer.flip();
+ assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(1000, 16));
+ verify(mockTransferListener)
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16);
+ }
+
+ @Test
+ public void readByteBufferWithUnsetLength() throws HttpDataSourceException {
+ testResponseHeader.remove("Content-Length");
+ mockResponseStartSuccess();
+ mockReadSuccess(0, 16);
+
+ dataSourceUnderTest.open(testDataSpec);
+
+ ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(16);
+ returnedBuffer.limit(8);
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer);
+ returnedBuffer.flip();
+ assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(0, 8));
+ assertThat(bytesRead).isEqualTo(8);
+ verify(mockTransferListener)
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8);
+ }
+
+ @Test
+ public void readByteBufferReturnsWhatItCan() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ mockReadSuccess(0, 16);
+
+ dataSourceUnderTest.open(testDataSpec);
+
+ ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(24);
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer);
+ returnedBuffer.flip();
+ assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(0, 16));
+ assertThat(bytesRead).isEqualTo(16);
+ verify(mockTransferListener)
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16);
+ }
+
+ @Test
+ public void overreadByteBuffer() throws HttpDataSourceException {
+ testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, 16);
+ testResponseHeader.put("Content-Length", Long.toString(16L));
+ mockResponseStartSuccess();
+ mockReadSuccess(0, 16);
+
+ dataSourceUnderTest.open(testDataSpec);
+
+ ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(8);
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer);
+ assertThat(bytesRead).isEqualTo(8);
+ returnedBuffer.flip();
+ assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(0, 8));
+
+ // The current buffer is kept if not completely consumed by DataSource reader.
+ returnedBuffer = ByteBuffer.allocateDirect(6);
+ bytesRead += dataSourceUnderTest.read(returnedBuffer);
+ assertThat(bytesRead).isEqualTo(14);
+ returnedBuffer.flip();
+ assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(8, 6));
+
+ // 2 bytes left at this point.
+ returnedBuffer = ByteBuffer.allocateDirect(8);
+ bytesRead += dataSourceUnderTest.read(returnedBuffer);
+ assertThat(bytesRead).isEqualTo(16);
+ returnedBuffer.flip();
+ assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(14, 2));
+
+ // Called on each.
+ verify(mockUrlRequest, times(3)).read(any(ByteBuffer.class));
+ verify(mockTransferListener, times(1))
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8);
+ verify(mockTransferListener, times(1))
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 6);
+ verify(mockTransferListener, times(1))
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 2);
+
+ // Now we already returned the 16 bytes initially asked.
+ // Try to read again even though all requested 16 bytes are already returned.
+ // Return C.RESULT_END_OF_INPUT
+ returnedBuffer = ByteBuffer.allocateDirect(16);
+ int bytesOverRead = dataSourceUnderTest.read(returnedBuffer);
+ assertThat(bytesOverRead).isEqualTo(C.RESULT_END_OF_INPUT);
+ assertThat(returnedBuffer.position()).isEqualTo(0);
+ // C.RESULT_END_OF_INPUT should not be reported though the TransferListener.
+ verify(mockTransferListener, never())
+ .onBytesTransferred(
+ dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, C.RESULT_END_OF_INPUT);
+ // Number of calls to cronet should not have increased.
+ verify(mockUrlRequest, times(3)).read(any(ByteBuffer.class));
+ // Check for connection not automatically closed.
+ verify(mockUrlRequest, never()).cancel();
+ assertThat(bytesRead).isEqualTo(16);
+ }
+
+ @Test
+ public void closedMeansClosedReadByteBuffer() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ mockReadSuccess(0, 16);
+
+ int bytesRead = 0;
+ dataSourceUnderTest.open(testDataSpec);
+
+ ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(16);
+ returnedBuffer.limit(8);
+ bytesRead += dataSourceUnderTest.read(returnedBuffer);
+ returnedBuffer.flip();
+ assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(0, 8));
+ assertThat(bytesRead).isEqualTo(8);
+
+ dataSourceUnderTest.close();
+ verify(mockTransferListener)
+ .onTransferEnd(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
+
+ try {
+ bytesRead += dataSourceUnderTest.read(returnedBuffer);
+ fail();
+ } catch (IllegalStateException e) {
+ // Expected.
+ }
+
+ // 16 bytes were attempted but only 8 should have been successfully read.
+ assertThat(bytesRead).isEqualTo(8);
+ }
+
+ @Test
+ public void connectTimeout() throws InterruptedException {
+ long startTimeMs = SystemClock.elapsedRealtime();
+ final ConditionVariable startCondition = buildUrlRequestStartedCondition();
+ final CountDownLatch timedOutLatch = new CountDownLatch(1);
+
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ dataSourceUnderTest.open(testDataSpec);
+ fail();
+ } catch (HttpDataSourceException e) {
+ // Expected.
+ assertThat(e).isInstanceOf(CronetDataSource.OpenException.class);
+ assertThat(e).hasCauseThat().isInstanceOf(SocketTimeoutException.class);
+ assertThat(((CronetDataSource.OpenException) e).cronetConnectionStatus)
+ .isEqualTo(TEST_CONNECTION_STATUS);
+ timedOutLatch.countDown();
+ }
+ }
+ }.start();
+ startCondition.block();
+
+ // We should still be trying to open.
+ assertNotCountedDown(timedOutLatch);
+ // We should still be trying to open as we approach the timeout.
+ setSystemClockInMsAndTriggerPendingMessages(
+ /* nowMs= */ startTimeMs + TEST_CONNECT_TIMEOUT_MS - 1);
+ assertNotCountedDown(timedOutLatch);
+ // Now we timeout.
+ setSystemClockInMsAndTriggerPendingMessages(
+ /* nowMs= */ startTimeMs + TEST_CONNECT_TIMEOUT_MS + 10);
+ timedOutLatch.await();
+
+ verify(mockTransferListener, never())
+ .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
+ }
+
+ @Test
+ public void connectInterrupted() throws InterruptedException {
+ long startTimeMs = SystemClock.elapsedRealtime();
+ final ConditionVariable startCondition = buildUrlRequestStartedCondition();
+ final CountDownLatch timedOutLatch = new CountDownLatch(1);
+
+ Thread thread =
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ dataSourceUnderTest.open(testDataSpec);
+ fail();
+ } catch (HttpDataSourceException e) {
+ // Expected.
+ assertThat(e).isInstanceOf(CronetDataSource.OpenException.class);
+ assertThat(e).hasCauseThat().isInstanceOf(InterruptedIOException.class);
+ assertThat(((CronetDataSource.OpenException) e).cronetConnectionStatus)
+ .isEqualTo(TEST_INVALID_CONNECTION_STATUS);
+ timedOutLatch.countDown();
+ }
+ }
+ };
+ thread.start();
+ startCondition.block();
+
+ // We should still be trying to open.
+ assertNotCountedDown(timedOutLatch);
+ // We should still be trying to open as we approach the timeout.
+ setSystemClockInMsAndTriggerPendingMessages(
+ /* nowMs= */ startTimeMs + TEST_CONNECT_TIMEOUT_MS - 1);
+ assertNotCountedDown(timedOutLatch);
+ // Now we interrupt.
+ thread.interrupt();
+ timedOutLatch.await();
+
+ verify(mockTransferListener, never())
+ .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
+ }
+
+ @Test
+ public void connectResponseBeforeTimeout() throws Exception {
+ long startTimeMs = SystemClock.elapsedRealtime();
+ final ConditionVariable startCondition = buildUrlRequestStartedCondition();
+ final CountDownLatch openLatch = new CountDownLatch(1);
+
+ AtomicReference exceptionOnTestThread = new AtomicReference<>();
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ dataSourceUnderTest.open(testDataSpec);
+ } catch (HttpDataSourceException e) {
+ exceptionOnTestThread.set(e);
+ } finally {
+ openLatch.countDown();
+ }
+ }
+ }.start();
+ startCondition.block();
+
+ // We should still be trying to open.
+ assertNotCountedDown(openLatch);
+ // We should still be trying to open as we approach the timeout.
+ setSystemClockInMsAndTriggerPendingMessages(
+ /* nowMs= */ startTimeMs + TEST_CONNECT_TIMEOUT_MS - 1);
+ assertNotCountedDown(openLatch);
+ // The response arrives just in time.
+ dataSourceUnderTest.urlRequestCallback.onResponseStarted(mockUrlRequest, testUrlResponseInfo);
+ openLatch.await();
+ assertThat(exceptionOnTestThread.get()).isNull();
+ }
+
+ @Test
+ public void redirectIncreasesConnectionTimeout() throws Exception {
+ long startTimeMs = SystemClock.elapsedRealtime();
+ final ConditionVariable startCondition = buildUrlRequestStartedCondition();
+ final CountDownLatch timedOutLatch = new CountDownLatch(1);
+ final AtomicInteger openExceptions = new AtomicInteger(0);
+
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ dataSourceUnderTest.open(testDataSpec);
+ fail();
+ } catch (HttpDataSourceException e) {
+ // Expected.
+ assertThat(e).isInstanceOf(CronetDataSource.OpenException.class);
+ assertThat(e).hasCauseThat().isInstanceOf(SocketTimeoutException.class);
+ openExceptions.getAndIncrement();
+ timedOutLatch.countDown();
+ }
+ }
+ }.start();
+ startCondition.block();
+
+ // We should still be trying to open.
+ assertNotCountedDown(timedOutLatch);
+ // We should still be trying to open as we approach the timeout.
+ setSystemClockInMsAndTriggerPendingMessages(
+ /* nowMs= */ startTimeMs + TEST_CONNECT_TIMEOUT_MS - 1);
+ assertNotCountedDown(timedOutLatch);
+ // A redirect arrives just in time.
+ dataSourceUnderTest.urlRequestCallback.onRedirectReceived(
+ mockUrlRequest, testUrlResponseInfo, "RandomRedirectedUrl1");
+
+ long newTimeoutMs = 2 * TEST_CONNECT_TIMEOUT_MS - 1;
+ setSystemClockInMsAndTriggerPendingMessages(/* nowMs= */ startTimeMs + newTimeoutMs - 1);
+ // We should still be trying to open as we approach the new timeout.
+ assertNotCountedDown(timedOutLatch);
+ // A redirect arrives just in time.
+ dataSourceUnderTest.urlRequestCallback.onRedirectReceived(
+ mockUrlRequest, testUrlResponseInfo, "RandomRedirectedUrl2");
+
+ newTimeoutMs = 3 * TEST_CONNECT_TIMEOUT_MS - 2;
+ setSystemClockInMsAndTriggerPendingMessages(/* nowMs= */ startTimeMs + newTimeoutMs - 1);
+ // We should still be trying to open as we approach the new timeout.
+ assertNotCountedDown(timedOutLatch);
+ // Now we timeout.
+ setSystemClockInMsAndTriggerPendingMessages(/* nowMs= */ startTimeMs + newTimeoutMs + 10);
+ timedOutLatch.await();
+
+ verify(mockTransferListener, never())
+ .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
+ assertThat(openExceptions.get()).isEqualTo(1);
+ }
+
+ @Test
+ public void redirectParseAndAttachCookie_dataSourceDoesNotHandleSetCookie_followsRedirect()
+ throws HttpDataSourceException {
+ mockSingleRedirectSuccess();
+ mockFollowRedirectSuccess();
+
+ testResponseHeader.put("Set-Cookie", "testcookie=testcookie; Path=/video");
+
+ dataSourceUnderTest.open(testDataSpec);
+ verify(mockUrlRequestBuilder, never()).addHeader(eq("Cookie"), any(String.class));
+ verify(mockUrlRequest).followRedirect();
+ }
+
+ @Test
+ public void
+ testRedirectParseAndAttachCookie_dataSourceHandlesSetCookie_andPreservesOriginalRequestHeaders()
+ throws HttpDataSourceException {
+ dataSourceUnderTest =
+ new CronetDataSource(
+ mockCronetEngine,
+ mockExecutor,
+ TEST_CONNECT_TIMEOUT_MS,
+ TEST_READ_TIMEOUT_MS,
+ true, // resetTimeoutOnRedirects
+ Clock.DEFAULT,
+ null,
+ true);
+ dataSourceUnderTest.addTransferListener(mockTransferListener);
+ dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE);
+
+ mockSingleRedirectSuccess();
+
+ testResponseHeader.put("Set-Cookie", "testcookie=testcookie; Path=/video");
+
+ dataSourceUnderTest.open(testDataSpec);
+ verify(mockUrlRequestBuilder).addHeader(eq("Cookie"), any(String.class));
+ verify(mockUrlRequestBuilder, never()).addHeader(eq("Range"), any(String.class));
+ verify(mockUrlRequestBuilder, times(2)).addHeader("Content-Type", TEST_CONTENT_TYPE);
+ verify(mockUrlRequest, never()).followRedirect();
+ verify(mockUrlRequest, times(2)).start();
+ }
+
+ @Test
+ public void
+ testRedirectParseAndAttachCookie_dataSourceHandlesSetCookie_andPreservesOriginalRequestHeadersIncludingByteRangeHeader()
+ throws HttpDataSourceException {
+ testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000);
+ dataSourceUnderTest =
+ new CronetDataSource(
+ mockCronetEngine,
+ mockExecutor,
+ TEST_CONNECT_TIMEOUT_MS,
+ TEST_READ_TIMEOUT_MS,
+ /* resetTimeoutOnRedirects= */ true,
+ Clock.DEFAULT,
+ /* defaultRequestProperties= */ null,
+ /* handleSetCookieRequests= */ true);
+ dataSourceUnderTest.addTransferListener(mockTransferListener);
+ dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE);
+
+ mockSingleRedirectSuccess();
+
+ testResponseHeader.put("Set-Cookie", "testcookie=testcookie; Path=/video");
+
+ dataSourceUnderTest.open(testDataSpec);
+ verify(mockUrlRequestBuilder).addHeader(eq("Cookie"), any(String.class));
+ verify(mockUrlRequestBuilder, times(2)).addHeader("Range", "bytes=1000-5999");
+ verify(mockUrlRequestBuilder, times(2)).addHeader("Content-Type", TEST_CONTENT_TYPE);
+ verify(mockUrlRequest, never()).followRedirect();
+ verify(mockUrlRequest, times(2)).start();
+ }
+
+ @Test
+ public void redirectNoSetCookieFollowsRedirect() throws HttpDataSourceException {
+ mockSingleRedirectSuccess();
+ mockFollowRedirectSuccess();
+
+ dataSourceUnderTest.open(testDataSpec);
+ verify(mockUrlRequestBuilder, never()).addHeader(eq("Cookie"), any(String.class));
+ verify(mockUrlRequest).followRedirect();
+ }
+
+ @Test
+ public void redirectNoSetCookieFollowsRedirect_dataSourceHandlesSetCookie()
+ throws HttpDataSourceException {
+ dataSourceUnderTest =
+ new CronetDataSource(
+ mockCronetEngine,
+ mockExecutor,
+ TEST_CONNECT_TIMEOUT_MS,
+ TEST_READ_TIMEOUT_MS,
+ /* resetTimeoutOnRedirects= */ true,
+ Clock.DEFAULT,
+ /* defaultRequestProperties= */ null,
+ /* handleSetCookieRequests= */ true);
+ dataSourceUnderTest.addTransferListener(mockTransferListener);
+ mockSingleRedirectSuccess();
+ mockFollowRedirectSuccess();
+
+ dataSourceUnderTest.open(testDataSpec);
+ verify(mockUrlRequestBuilder, never()).addHeader(eq("Cookie"), any(String.class));
+ verify(mockUrlRequest).followRedirect();
+ }
+
+ @Test
+ public void exceptionFromTransferListener() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+
+ // Make mockTransferListener throw an exception in CronetDataSource.close(). Ensure that
+ // the subsequent open() call succeeds.
+ doThrow(new NullPointerException())
+ .when(mockTransferListener)
+ .onTransferEnd(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
+ dataSourceUnderTest.open(testDataSpec);
+ try {
+ dataSourceUnderTest.close();
+ fail("NullPointerException expected");
+ } catch (NullPointerException e) {
+ // Expected.
+ }
+ // Open should return successfully.
+ dataSourceUnderTest.open(testDataSpec);
+ }
+
+ @Test
+ public void readFailure() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ mockReadFailure();
+
+ dataSourceUnderTest.open(testDataSpec);
+ byte[] returnedBuffer = new byte[8];
+ try {
+ dataSourceUnderTest.read(returnedBuffer, 0, 8);
+ fail("dataSourceUnderTest.read() returned, but IOException expected");
+ } catch (IOException e) {
+ // Expected.
+ }
+ }
+
+ @Test
+ public void readByteBufferFailure() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ mockReadFailure();
+
+ dataSourceUnderTest.open(testDataSpec);
+ ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(8);
+ try {
+ dataSourceUnderTest.read(returnedBuffer);
+ fail("dataSourceUnderTest.read() returned, but IOException expected");
+ } catch (IOException e) {
+ // Expected.
+ }
+ }
+
+ @Test
+ public void readNonDirectedByteBufferFailure() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ mockReadFailure();
+
+ dataSourceUnderTest.open(testDataSpec);
+ byte[] returnedBuffer = new byte[8];
+ try {
+ dataSourceUnderTest.read(ByteBuffer.wrap(returnedBuffer));
+ fail("dataSourceUnderTest.read() returned, but IllegalArgumentException expected");
+ } catch (IllegalArgumentException e) {
+ // Expected.
+ }
+ }
+
+ @Test
+ public void readInterrupted() throws HttpDataSourceException, InterruptedException {
+ mockResponseStartSuccess();
+ dataSourceUnderTest.open(testDataSpec);
+
+ final ConditionVariable startCondition = buildReadStartedCondition();
+ final CountDownLatch timedOutLatch = new CountDownLatch(1);
+ byte[] returnedBuffer = new byte[8];
+ Thread thread =
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ dataSourceUnderTest.read(returnedBuffer, 0, 8);
+ fail();
+ } catch (HttpDataSourceException e) {
+ // Expected.
+ assertThat(e).hasCauseThat().isInstanceOf(InterruptedIOException.class);
+ timedOutLatch.countDown();
+ }
+ }
+ };
+ thread.start();
+ startCondition.block();
+
+ assertNotCountedDown(timedOutLatch);
+ // Now we interrupt.
+ thread.interrupt();
+ timedOutLatch.await();
+ }
+
+ @Test
+ public void readByteBufferInterrupted() throws HttpDataSourceException, InterruptedException {
+ mockResponseStartSuccess();
+ dataSourceUnderTest.open(testDataSpec);
+
+ final ConditionVariable startCondition = buildReadStartedCondition();
+ final CountDownLatch timedOutLatch = new CountDownLatch(1);
+ ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(8);
+ Thread thread =
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ dataSourceUnderTest.read(returnedBuffer);
+ fail();
+ } catch (HttpDataSourceException e) {
+ // Expected.
+ assertThat(e).hasCauseThat().isInstanceOf(InterruptedIOException.class);
+ timedOutLatch.countDown();
+ }
+ }
+ };
+ thread.start();
+ startCondition.block();
+
+ assertNotCountedDown(timedOutLatch);
+ // Now we interrupt.
+ thread.interrupt();
+ timedOutLatch.await();
+ }
+
+ @Test
+ public void allowDirectExecutor() throws HttpDataSourceException {
+ testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000);
+ mockResponseStartSuccess();
+
+ dataSourceUnderTest.open(testDataSpec);
+ verify(mockUrlRequestBuilder).allowDirectExecutor();
+ }
+
+ // Helper methods.
+
+ private void mockStatusResponse() {
+ doAnswer(
+ invocation -> {
+ UrlRequest.StatusListener statusListener =
+ (UrlRequest.StatusListener) invocation.getArguments()[0];
+ statusListener.onStatus(TEST_CONNECTION_STATUS);
+ return null;
+ })
+ .when(mockUrlRequest)
+ .getStatus(any(UrlRequest.StatusListener.class));
+ }
+
+ private void mockResponseStartSuccess() {
+ doAnswer(
+ invocation -> {
+ dataSourceUnderTest.urlRequestCallback.onResponseStarted(
+ mockUrlRequest, testUrlResponseInfo);
+ return null;
+ })
+ .when(mockUrlRequest)
+ .start();
+ }
+
+ private void mockResponseStartRedirect() {
+ doAnswer(
+ invocation -> {
+ dataSourceUnderTest.urlRequestCallback.onRedirectReceived(
+ mockUrlRequest,
+ createUrlResponseInfo(307), // statusCode
+ "http://redirect.location.com");
+ return null;
+ })
+ .when(mockUrlRequest)
+ .start();
+ }
+
+ private void mockSingleRedirectSuccess() {
+ doAnswer(
+ invocation -> {
+ if (!redirectCalled) {
+ redirectCalled = true;
+ dataSourceUnderTest.urlRequestCallback.onRedirectReceived(
+ mockUrlRequest,
+ createUrlResponseInfoWithUrl("http://example.com/video", 300),
+ "http://example.com/video/redirect");
+ } else {
+ dataSourceUnderTest.urlRequestCallback.onResponseStarted(
+ mockUrlRequest, testUrlResponseInfo);
+ }
+ return null;
+ })
+ .when(mockUrlRequest)
+ .start();
+ }
+
+ private void mockFollowRedirectSuccess() {
+ doAnswer(
+ invocation -> {
+ dataSourceUnderTest.urlRequestCallback.onResponseStarted(
+ mockUrlRequest, testUrlResponseInfo);
+ return null;
+ })
+ .when(mockUrlRequest)
+ .followRedirect();
+ }
+
+ private void mockResponseStartFailure() {
+ doAnswer(
+ invocation -> {
+ dataSourceUnderTest.urlRequestCallback.onFailed(
+ mockUrlRequest,
+ createUrlResponseInfo(500), // statusCode
+ mockNetworkException);
+ return null;
+ })
+ .when(mockUrlRequest)
+ .start();
+ }
+
+ private void mockReadSuccess(int position, int length) {
+ final int[] positionAndRemaining = new int[] {position, length};
+ doAnswer(
+ invocation -> {
+ if (positionAndRemaining[1] == 0) {
+ dataSourceUnderTest.urlRequestCallback.onSucceeded(
+ mockUrlRequest, testUrlResponseInfo);
+ } else {
+ ByteBuffer inputBuffer = (ByteBuffer) invocation.getArguments()[0];
+ int readLength = min(positionAndRemaining[1], inputBuffer.remaining());
+ inputBuffer.put(buildTestDataBuffer(positionAndRemaining[0], readLength));
+ positionAndRemaining[0] += readLength;
+ positionAndRemaining[1] -= readLength;
+ dataSourceUnderTest.urlRequestCallback.onReadCompleted(
+ mockUrlRequest, testUrlResponseInfo, inputBuffer);
+ }
+ return null;
+ })
+ .when(mockUrlRequest)
+ .read(any(ByteBuffer.class));
+ }
+
+ private void mockReadFailure() {
+ doAnswer(
+ invocation -> {
+ dataSourceUnderTest.urlRequestCallback.onFailed(
+ mockUrlRequest,
+ createUrlResponseInfo(500), // statusCode
+ mockNetworkException);
+ return null;
+ })
+ .when(mockUrlRequest)
+ .read(any(ByteBuffer.class));
+ }
+
+ private ConditionVariable buildReadStartedCondition() {
+ final ConditionVariable startedCondition = new ConditionVariable();
+ doAnswer(
+ invocation -> {
+ startedCondition.open();
+ return null;
+ })
+ .when(mockUrlRequest)
+ .read(any(ByteBuffer.class));
+ return startedCondition;
+ }
+
+ private ConditionVariable buildUrlRequestStartedCondition() {
+ final ConditionVariable startedCondition = new ConditionVariable();
+ doAnswer(
+ invocation -> {
+ startedCondition.open();
+ return null;
+ })
+ .when(mockUrlRequest)
+ .start();
+ return startedCondition;
+ }
+
+ private void assertNotCountedDown(CountDownLatch countDownLatch) throws InterruptedException {
+ // We are asserting that another thread does not count down the latch. We therefore sleep some
+ // time to give the other thread the chance to fail this test.
+ Thread.sleep(50);
+ assertThat(countDownLatch.getCount()).isGreaterThan(0L);
+ }
+
+ private static byte[] buildTestDataArray(int position, int length) {
+ return buildTestDataBuffer(position, length).array();
+ }
+
+ public static byte[] prefixZeros(byte[] data, int requiredLength) {
+ byte[] prefixedData = new byte[requiredLength];
+ System.arraycopy(data, 0, prefixedData, requiredLength - data.length, data.length);
+ return prefixedData;
+ }
+
+ public static byte[] suffixZeros(byte[] data, int requiredLength) {
+ return Arrays.copyOf(data, requiredLength);
+ }
+
+ private static ByteBuffer buildTestDataBuffer(int position, int length) {
+ ByteBuffer testBuffer = ByteBuffer.allocate(length);
+ for (int i = 0; i < length; i++) {
+ testBuffer.put((byte) (position + i));
+ }
+ testBuffer.flip();
+ return testBuffer;
+ }
+
+ // Returns a copy of what is remaining in the src buffer from the current position to capacity.
+ private static byte[] copyByteBufferToArray(ByteBuffer src) {
+ if (src == null) {
+ return null;
+ }
+ byte[] copy = new byte[src.remaining()];
+ int index = 0;
+ while (src.hasRemaining()) {
+ copy[index++] = src.get();
+ }
+ return copy;
+ }
+
+ private static void setSystemClockInMsAndTriggerPendingMessages(long nowMs) {
+ SystemClock.setCurrentTimeMillis(nowMs);
+ ShadowLooper.idleMainLooper();
+ }
+}
diff --git a/extensions/ffmpeg/README.md b/extensions/ffmpeg/README.md
index 4ce9173ec9..639d1f6d6c 100644
--- a/extensions/ffmpeg/README.md
+++ b/extensions/ffmpeg/README.md
@@ -1,21 +1,27 @@
-# FfmpegAudioRenderer #
+# ExoPlayer FFmpeg extension #
-## Description ##
+The FFmpeg extension provides `FfmpegAudioRenderer`, which uses FFmpeg for
+decoding and can render audio encoded in a variety of formats.
-The FFmpeg extension is a [Renderer][] implementation that uses FFmpeg to decode
-audio.
+## License note ##
-[Renderer]: https://google.github.io/ExoPlayer/doc/reference/com/google/android/exoplayer2/Renderer.html
+Please note that whilst the code in this repository is licensed under
+[Apache 2.0][], using this extension also requires building and including one or
+more external libraries as described below. These are licensed separately.
-## Build instructions ##
+[Apache 2.0]: https://github.com/google/ExoPlayer/blob/release-v2/LICENSE
-* Checkout ExoPlayer along with Extensions
+## Build instructions (Linux, macOS) ##
-```
-git clone https://github.com/google/ExoPlayer.git
-```
+To use this extension you need to clone the ExoPlayer repository and depend on
+its modules locally. Instructions for doing this can be found in ExoPlayer's
+[top level README][]. The extension is not provided via JCenter (see [#2781][]
+for more information).
-* Set the following environment variables:
+In addition, it's necessary to manually build the FFmpeg library, so that gradle
+can bundle the FFmpeg binaries in the APK:
+
+* Set the following shell variable:
```
cd ""
@@ -23,103 +29,107 @@ EXOPLAYER_ROOT="$(pwd)"
FFMPEG_EXT_PATH="${EXOPLAYER_ROOT}/extensions/ffmpeg/src/main"
```
-* Download the [Android NDK][] and set its location in an environment variable:
-
-[Android NDK]: https://developer.android.com/tools/sdk/ndk/index.html
+* Download the [Android NDK][] and set its location in a shell variable.
+ This build configuration has been tested on NDK r20.
```
NDK_PATH=""
```
-* Set up host platform ("darwin-x86_64" for Mac OS X):
+* Set the host platform (use "darwin-x86_64" for Mac OS X):
```
HOST_PLATFORM="linux-x86_64"
```
-* Fetch and build FFmpeg. For example, to fetch and build for armeabi-v7a,
- arm64-v8a and x86 on Linux x86_64:
+* Fetch FFmpeg and checkout an appropriate branch. We cannot guarantee
+ compatibility with all versions of FFmpeg. We currently recommend version 4.2:
+
+```
+cd "" && \
+git clone git://source.ffmpeg.org/ffmpeg && \
+cd ffmpeg && \
+git checkout release/4.2 && \
+FFMPEG_PATH="$(pwd)"
+```
+
+* Configure the decoders to include. See the [Supported formats][] page for
+ details of the available decoders, and which formats they support.
+
+```
+ENABLED_DECODERS=(vorbis opus flac)
+```
+
+* Add a link to the FFmpeg source code in the FFmpeg extension `jni` directory.
```
-COMMON_OPTIONS="\
- --target-os=android \
- --disable-static \
- --enable-shared \
- --disable-doc \
- --disable-programs \
- --disable-everything \
- --disable-avdevice \
- --disable-avformat \
- --disable-swscale \
- --disable-postproc \
- --disable-avfilter \
- --disable-symver \
- --disable-swresample \
- --enable-avresample \
- --enable-decoder=vorbis \
- --enable-decoder=opus \
- --enable-decoder=flac \
- " && \
cd "${FFMPEG_EXT_PATH}/jni" && \
-git clone git://source.ffmpeg.org/ffmpeg ffmpeg && cd ffmpeg && \
-./configure \
- --libdir=android-libs/armeabi-v7a \
- --arch=arm \
- --cpu=armv7-a \
- --cross-prefix="${NDK_PATH}/toolchains/arm-linux-androideabi-4.9/prebuilt/${HOST_PLATFORM}/bin/arm-linux-androideabi-" \
- --sysroot="${NDK_PATH}/platforms/android-9/arch-arm/" \
- --extra-cflags="-march=armv7-a -mfloat-abi=softfp" \
- --extra-ldflags="-Wl,--fix-cortex-a8" \
- --extra-ldexeflags=-pie \
- ${COMMON_OPTIONS} \
- && \
-make -j4 && make install-libs && \
-make clean && ./configure \
- --libdir=android-libs/arm64-v8a \
- --arch=aarch64 \
- --cpu=armv8-a \
- --cross-prefix="${NDK_PATH}/toolchains/aarch64-linux-android-4.9/prebuilt/${HOST_PLATFORM}/bin/aarch64-linux-android-" \
- --sysroot="${NDK_PATH}/platforms/android-21/arch-arm64/" \
- --extra-ldexeflags=-pie \
- ${COMMON_OPTIONS} \
- && \
-make -j4 && make install-libs && \
-make clean && ./configure \
- --libdir=android-libs/x86 \
- --arch=x86 \
- --cpu=i686 \
- --cross-prefix="${NDK_PATH}/toolchains/x86-4.9/prebuilt/${HOST_PLATFORM}/bin/i686-linux-android-" \
- --sysroot="${NDK_PATH}/platforms/android-9/arch-x86/" \
- --extra-ldexeflags=-pie \
- --disable-asm \
- ${COMMON_OPTIONS} \
- && \
-make -j4 && make install-libs && \
-make clean
+ln -s "$FFMPEG_PATH" ffmpeg
```
-* Build the JNI native libraries, setting `APP_ABI` to include the architectures
- built in the previous step. For example:
+* Execute `build_ffmpeg.sh` to build FFmpeg for `armeabi-v7a`, `arm64-v8a`,
+ `x86` and `x86_64`. The script can be edited if you need to build for
+ different architectures:
```
-cd "${FFMPEG_EXT_PATH}"/jni && \
-${NDK_PATH}/ndk-build APP_ABI="armeabi-v7a arm64-v8a x86" -j4
+cd "${FFMPEG_EXT_PATH}/jni" && \
+./build_ffmpeg.sh \
+ "${FFMPEG_EXT_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" "${ENABLED_DECODERS[@]}"
```
-* In your project, you can add a dependency on the extension by using a rule
- like this:
+## Build instructions (Windows) ##
-```
-// in settings.gradle
-include ':..:ExoPlayer:library'
-include ':..:ExoPlayer:extension-ffmpeg'
+We do not provide support for building this extension on Windows, however it
+should be possible to follow the Linux instructions in [Windows PowerShell][].
-// in build.gradle
-dependencies {
- compile project(':..:ExoPlayer:library')
- compile project(':..:ExoPlayer:extension-ffmpeg')
-}
-```
+[Windows PowerShell]: https://docs.microsoft.com/en-us/powershell/scripting/getting-started/getting-started-with-windows-powershell
-* Now, when you build your app, the extension will be built and the native
- libraries will be packaged along with the APK.
+## Using the extension ##
+
+Once you've followed the instructions above to check out, build and depend on
+the extension, the next step is to tell ExoPlayer to use `FfmpegAudioRenderer`.
+How you do this depends on which player API you're using:
+
+* If you're passing a `DefaultRenderersFactory` to `SimpleExoPlayer.Builder`,
+ you can enable using the extension by setting the `extensionRendererMode`
+ parameter of the `DefaultRenderersFactory` constructor to
+ `EXTENSION_RENDERER_MODE_ON`. This will use `FfmpegAudioRenderer` for playback
+ if `MediaCodecAudioRenderer` doesn't support the input format. Pass
+ `EXTENSION_RENDERER_MODE_PREFER` to give `FfmpegAudioRenderer` priority over
+ `MediaCodecAudioRenderer`.
+* If you've subclassed `DefaultRenderersFactory`, add an `FfmpegAudioRenderer`
+ to the output list in `buildAudioRenderers`. ExoPlayer will use the first
+ `Renderer` in the list that supports the input media format.
+* If you've implemented your own `RenderersFactory`, return an
+ `FfmpegAudioRenderer` instance from `createRenderers`. ExoPlayer will use the
+ first `Renderer` in the returned array that supports the input media format.
+* If you're using `ExoPlayer.Builder`, pass an `FfmpegAudioRenderer` in the
+ array of `Renderer`s. ExoPlayer will use the first `Renderer` in the list that
+ supports the input media format.
+
+Note: These instructions assume you're using `DefaultTrackSelector`. If you have
+a custom track selector the choice of `Renderer` is up to your implementation,
+so you need to make sure you are passing an `FfmpegAudioRenderer` to the player,
+then implement your own logic to use the renderer for a given track.
+
+[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
+[Android NDK]: https://developer.android.com/tools/sdk/ndk/index.html
+[#2781]: https://github.com/google/ExoPlayer/issues/2781
+[Supported formats]: https://exoplayer.dev/supported-formats.html#ffmpeg-extension
+
+## Using the extension in the demo application ##
+
+To try out playback using the extension in the [demo application][], see
+[enabling extension decoders][].
+
+[demo application]: https://exoplayer.dev/demo-application.html
+[enabling extension decoders]: https://exoplayer.dev/demo-application.html#enabling-extension-decoders
+
+## Links ##
+
+* [Troubleshooting using extensions][]
+* [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.ffmpeg.*`
+ belong to this module.
+
+[Troubleshooting using extensions]: https://exoplayer.dev/troubleshooting.html#how-can-i-get-a-decoding-extension-to-load-and-be-used-for-playback
+[Javadoc]: https://exoplayer.dev/doc/reference/index.html
diff --git a/extensions/ffmpeg/build.gradle b/extensions/ffmpeg/build.gradle
index 0eddd017a4..a9edeaff6b 100644
--- a/extensions/ffmpeg/build.gradle
+++ b/extensions/ffmpeg/build.gradle
@@ -11,26 +11,22 @@
// 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.
-apply plugin: 'com.android.library'
+apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle"
-android {
- compileSdkVersion project.ext.compileSdkVersion
- buildToolsVersion project.ext.buildToolsVersion
-
- defaultConfig {
- minSdkVersion project.ext.minSdkVersion
- targetSdkVersion project.ext.targetSdkVersion
- consumerProguardFiles 'proguard-rules.txt'
- }
-
- sourceSets.main {
- jniLibs.srcDir 'src/main/libs'
- jni.srcDirs = [] // Disable the automatic ndk-build call by Android Studio.
- }
+// Configure the native build only if ffmpeg is present to avoid gradle sync
+// failures if ffmpeg hasn't been built according to the README instructions.
+if (project.file('src/main/jni/ffmpeg').exists()) {
+ android.externalNativeBuild.cmake.path = 'src/main/jni/CMakeLists.txt'
+ android.externalNativeBuild.cmake.version = '3.7.1+'
}
dependencies {
- compile project(':library-core')
+ implementation project(modulePrefix + 'library-core')
+ implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
+ compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
+ compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion
+ testImplementation project(modulePrefix + 'testutils')
+ testImplementation 'org.robolectric:robolectric:' + robolectricVersion
}
ext {
diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioDecoder.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioDecoder.java
new file mode 100644
index 0000000000..d6980f2801
--- /dev/null
+++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioDecoder.java
@@ -0,0 +1,229 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.ffmpeg;
+
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.Format;
+import com.google.android.exoplayer2.decoder.DecoderInputBuffer;
+import com.google.android.exoplayer2.decoder.SimpleDecoder;
+import com.google.android.exoplayer2.decoder.SimpleOutputBuffer;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.MimeTypes;
+import com.google.android.exoplayer2.util.ParsableByteArray;
+import com.google.android.exoplayer2.util.Util;
+import java.nio.ByteBuffer;
+import java.util.List;
+
+/** FFmpeg audio decoder. */
+/* package */ final class FfmpegAudioDecoder
+ extends SimpleDecoder {
+
+ // Output buffer sizes when decoding PCM mu-law streams, which is the maximum FFmpeg outputs.
+ private static final int OUTPUT_BUFFER_SIZE_16BIT = 65536;
+ private static final int OUTPUT_BUFFER_SIZE_32BIT = OUTPUT_BUFFER_SIZE_16BIT * 2;
+
+ // LINT.IfChange
+ private static final int AUDIO_DECODER_ERROR_INVALID_DATA = -1;
+ private static final int AUDIO_DECODER_ERROR_OTHER = -2;
+ // LINT.ThenChange(../../../../../../../jni/ffmpeg_jni.cc)
+
+ private final String codecName;
+ @Nullable private final byte[] extraData;
+ private final @C.Encoding int encoding;
+ private final int outputBufferSize;
+
+ private long nativeContext; // May be reassigned on resetting the codec.
+ private boolean hasOutputFormat;
+ private volatile int channelCount;
+ private volatile int sampleRate;
+
+ public FfmpegAudioDecoder(
+ Format format,
+ int numInputBuffers,
+ int numOutputBuffers,
+ int initialInputBufferSize,
+ boolean outputFloat)
+ throws FfmpegDecoderException {
+ super(new DecoderInputBuffer[numInputBuffers], new SimpleOutputBuffer[numOutputBuffers]);
+ if (!FfmpegLibrary.isAvailable()) {
+ throw new FfmpegDecoderException("Failed to load decoder native libraries.");
+ }
+ Assertions.checkNotNull(format.sampleMimeType);
+ codecName = Assertions.checkNotNull(FfmpegLibrary.getCodecName(format.sampleMimeType));
+ extraData = getExtraData(format.sampleMimeType, format.initializationData);
+ encoding = outputFloat ? C.ENCODING_PCM_FLOAT : C.ENCODING_PCM_16BIT;
+ outputBufferSize = outputFloat ? OUTPUT_BUFFER_SIZE_32BIT : OUTPUT_BUFFER_SIZE_16BIT;
+ nativeContext =
+ ffmpegInitialize(codecName, extraData, outputFloat, format.sampleRate, format.channelCount);
+ if (nativeContext == 0) {
+ throw new FfmpegDecoderException("Initialization failed.");
+ }
+ setInitialInputBufferSize(initialInputBufferSize);
+ }
+
+ @Override
+ public String getName() {
+ return "ffmpeg" + FfmpegLibrary.getVersion() + "-" + codecName;
+ }
+
+ @Override
+ protected DecoderInputBuffer createInputBuffer() {
+ return new DecoderInputBuffer(
+ DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT,
+ FfmpegLibrary.getInputBufferPaddingSize());
+ }
+
+ @Override
+ protected SimpleOutputBuffer createOutputBuffer() {
+ return new SimpleOutputBuffer(this::releaseOutputBuffer);
+ }
+
+ @Override
+ protected FfmpegDecoderException createUnexpectedDecodeException(Throwable error) {
+ return new FfmpegDecoderException("Unexpected decode error", error);
+ }
+
+ @Override
+ protected @Nullable FfmpegDecoderException decode(
+ DecoderInputBuffer inputBuffer, SimpleOutputBuffer outputBuffer, boolean reset) {
+ if (reset) {
+ nativeContext = ffmpegReset(nativeContext, extraData);
+ if (nativeContext == 0) {
+ return new FfmpegDecoderException("Error resetting (see logcat).");
+ }
+ }
+ ByteBuffer inputData = Util.castNonNull(inputBuffer.data);
+ int inputSize = inputData.limit();
+ ByteBuffer outputData = outputBuffer.init(inputBuffer.timeUs, outputBufferSize);
+ int result = ffmpegDecode(nativeContext, inputData, inputSize, outputData, outputBufferSize);
+ if (result == AUDIO_DECODER_ERROR_INVALID_DATA) {
+ // Treat invalid data errors as non-fatal to match the behavior of MediaCodec. No output will
+ // be produced for this buffer, so mark it as decode-only to ensure that the audio sink's
+ // position is reset when more audio is produced.
+ outputBuffer.setFlags(C.BUFFER_FLAG_DECODE_ONLY);
+ return null;
+ } else if (result == AUDIO_DECODER_ERROR_OTHER) {
+ return new FfmpegDecoderException("Error decoding (see logcat).");
+ }
+ if (!hasOutputFormat) {
+ channelCount = ffmpegGetChannelCount(nativeContext);
+ sampleRate = ffmpegGetSampleRate(nativeContext);
+ if (sampleRate == 0 && "alac".equals(codecName)) {
+ Assertions.checkNotNull(extraData);
+ // ALAC decoder did not set the sample rate in earlier versions of FFmpeg. See
+ // https://trac.ffmpeg.org/ticket/6096.
+ ParsableByteArray parsableExtraData = new ParsableByteArray(extraData);
+ parsableExtraData.setPosition(extraData.length - 4);
+ sampleRate = parsableExtraData.readUnsignedIntToInt();
+ }
+ hasOutputFormat = true;
+ }
+ outputData.position(0);
+ outputData.limit(result);
+ return null;
+ }
+
+ @Override
+ public void release() {
+ super.release();
+ ffmpegRelease(nativeContext);
+ nativeContext = 0;
+ }
+
+ /** Returns the channel count of output audio. */
+ public int getChannelCount() {
+ return channelCount;
+ }
+
+ /** Returns the sample rate of output audio. */
+ public int getSampleRate() {
+ return sampleRate;
+ }
+
+ /** Returns the encoding of output audio. */
+ public @C.Encoding int getEncoding() {
+ return encoding;
+ }
+
+ /**
+ * Returns FFmpeg-compatible codec-specific initialization data ("extra data"), or {@code null} if
+ * not required.
+ */
+ private static @Nullable byte[] getExtraData(String mimeType, List initializationData) {
+ switch (mimeType) {
+ case MimeTypes.AUDIO_AAC:
+ case MimeTypes.AUDIO_OPUS:
+ return initializationData.get(0);
+ case MimeTypes.AUDIO_ALAC:
+ return getAlacExtraData(initializationData);
+ case MimeTypes.AUDIO_VORBIS:
+ return getVorbisExtraData(initializationData);
+ default:
+ // Other codecs do not require extra data.
+ return null;
+ }
+ }
+
+ private static byte[] getAlacExtraData(List initializationData) {
+ // FFmpeg's ALAC decoder expects an ALAC atom, which contains the ALAC "magic cookie", as extra
+ // data. initializationData[0] contains only the magic cookie, and so we need to package it into
+ // an ALAC atom. See:
+ // https://ffmpeg.org/doxygen/0.6/alac_8c.html
+ // https://github.com/macosforge/alac/blob/master/ALACMagicCookieDescription.txt
+ byte[] magicCookie = initializationData.get(0);
+ int alacAtomLength = 12 + magicCookie.length;
+ ByteBuffer alacAtom = ByteBuffer.allocate(alacAtomLength);
+ alacAtom.putInt(alacAtomLength);
+ alacAtom.putInt(0x616c6163); // type=alac
+ alacAtom.putInt(0); // version=0, flags=0
+ alacAtom.put(magicCookie, /* offset= */ 0, magicCookie.length);
+ return alacAtom.array();
+ }
+
+ private static byte[] getVorbisExtraData(List initializationData) {
+ byte[] header0 = initializationData.get(0);
+ byte[] header1 = initializationData.get(1);
+ byte[] extraData = new byte[header0.length + header1.length + 6];
+ extraData[0] = (byte) (header0.length >> 8);
+ extraData[1] = (byte) (header0.length & 0xFF);
+ System.arraycopy(header0, 0, extraData, 2, header0.length);
+ extraData[header0.length + 2] = 0;
+ extraData[header0.length + 3] = 0;
+ extraData[header0.length + 4] = (byte) (header1.length >> 8);
+ extraData[header0.length + 5] = (byte) (header1.length & 0xFF);
+ System.arraycopy(header1, 0, extraData, header0.length + 6, header1.length);
+ return extraData;
+ }
+
+ private native long ffmpegInitialize(
+ String codecName,
+ @Nullable byte[] extraData,
+ boolean outputFloat,
+ int rawSampleRate,
+ int rawChannelCount);
+
+ private native int ffmpegDecode(
+ long context, ByteBuffer inputData, int inputSize, ByteBuffer outputData, int outputSize);
+
+ private native int ffmpegGetChannelCount(long context);
+
+ private native int ffmpegGetSampleRate(long context);
+
+ private native long ffmpegReset(long context, @Nullable byte[] extraData);
+
+ private native void ffmpegRelease(long context);
+}
diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java
index 8d75ca3dbb..0718dc2c5c 100644
--- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java
+++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java
@@ -15,70 +15,158 @@
*/
package com.google.android.exoplayer2.ext.ffmpeg;
+import static com.google.android.exoplayer2.audio.AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY;
+import static com.google.android.exoplayer2.audio.AudioSink.SINK_FORMAT_SUPPORTED_WITH_TRANSCODING;
+import static com.google.android.exoplayer2.audio.AudioSink.SINK_FORMAT_UNSUPPORTED;
+
import android.os.Handler;
+import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
-import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.Format;
import com.google.android.exoplayer2.audio.AudioProcessor;
import com.google.android.exoplayer2.audio.AudioRendererEventListener;
-import com.google.android.exoplayer2.audio.SimpleDecoderAudioRenderer;
+import com.google.android.exoplayer2.audio.AudioSink;
+import com.google.android.exoplayer2.audio.AudioSink.SinkFormatSupport;
+import com.google.android.exoplayer2.audio.DecoderAudioRenderer;
+import com.google.android.exoplayer2.audio.DefaultAudioSink;
import com.google.android.exoplayer2.drm.ExoMediaCrypto;
+import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.MimeTypes;
+import com.google.android.exoplayer2.util.TraceUtil;
+import com.google.android.exoplayer2.util.Util;
-/**
- * Decodes and renders audio using FFmpeg.
- */
-public final class FfmpegAudioRenderer extends SimpleDecoderAudioRenderer {
+/** Decodes and renders audio using FFmpeg. */
+public final class FfmpegAudioRenderer extends DecoderAudioRenderer {
+ private static final String TAG = "FfmpegAudioRenderer";
+
+ /** The number of input and output buffers. */
private static final int NUM_BUFFERS = 16;
- private static final int INITIAL_INPUT_BUFFER_SIZE = 960 * 6;
-
- private FfmpegDecoder decoder;
+ /** The default input buffer size. */
+ private static final int DEFAULT_INPUT_BUFFER_SIZE = 960 * 6;
public FfmpegAudioRenderer() {
- this(null, null);
+ this(/* eventHandler= */ null, /* eventListener= */ null);
}
/**
+ * Creates a new instance.
+ *
* @param eventHandler A handler to use when delivering events to {@code eventListener}. May be
* null if delivery of events is not required.
* @param eventListener A listener of events. May be null if delivery of events is not required.
* @param audioProcessors Optional {@link AudioProcessor}s that will process audio before output.
*/
- public FfmpegAudioRenderer(Handler eventHandler, AudioRendererEventListener eventListener,
+ public FfmpegAudioRenderer(
+ @Nullable Handler eventHandler,
+ @Nullable AudioRendererEventListener eventListener,
AudioProcessor... audioProcessors) {
- super(eventHandler, eventListener, audioProcessors);
+ this(
+ eventHandler,
+ eventListener,
+ new DefaultAudioSink(/* audioCapabilities= */ null, audioProcessors));
+ }
+
+ /**
+ * Creates a new instance.
+ *
+ * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be
+ * null if delivery of events is not required.
+ * @param eventListener A listener of events. May be null if delivery of events is not required.
+ * @param audioSink The sink to which audio will be output.
+ */
+ public FfmpegAudioRenderer(
+ @Nullable Handler eventHandler,
+ @Nullable AudioRendererEventListener eventListener,
+ AudioSink audioSink) {
+ super(
+ eventHandler,
+ eventListener,
+ audioSink);
}
@Override
+ public String getName() {
+ return TAG;
+ }
+
+ @Override
+ @FormatSupport
protected int supportsFormatInternal(Format format) {
- if (!FfmpegLibrary.isAvailable()) {
+ String mimeType = Assertions.checkNotNull(format.sampleMimeType);
+ if (!FfmpegLibrary.isAvailable() || !MimeTypes.isAudio(mimeType)) {
return FORMAT_UNSUPPORTED_TYPE;
+ } else if (!FfmpegLibrary.supportsFormat(mimeType)
+ || (!sinkSupportsFormat(format, C.ENCODING_PCM_16BIT)
+ && !sinkSupportsFormat(format, C.ENCODING_PCM_FLOAT))) {
+ return FORMAT_UNSUPPORTED_SUBTYPE;
+ } else if (format.exoMediaCryptoType != null) {
+ return FORMAT_UNSUPPORTED_DRM;
+ } else {
+ return FORMAT_HANDLED;
}
- String mimeType = format.sampleMimeType;
- return FfmpegLibrary.supportsFormat(mimeType) ? FORMAT_HANDLED
- : MimeTypes.isAudio(mimeType) ? FORMAT_UNSUPPORTED_SUBTYPE : FORMAT_UNSUPPORTED_TYPE;
}
@Override
- public final int supportsMixedMimeTypeAdaptation() throws ExoPlaybackException {
+ @AdaptiveSupport
+ public final int supportsMixedMimeTypeAdaptation() {
return ADAPTIVE_NOT_SEAMLESS;
}
@Override
- protected FfmpegDecoder createDecoder(Format format, ExoMediaCrypto mediaCrypto)
+ protected FfmpegAudioDecoder createDecoder(Format format, @Nullable ExoMediaCrypto mediaCrypto)
throws FfmpegDecoderException {
- decoder = new FfmpegDecoder(NUM_BUFFERS, NUM_BUFFERS, INITIAL_INPUT_BUFFER_SIZE,
- format.sampleMimeType, format.initializationData);
+ TraceUtil.beginSection("createFfmpegAudioDecoder");
+ int initialInputBufferSize =
+ format.maxInputSize != Format.NO_VALUE ? format.maxInputSize : DEFAULT_INPUT_BUFFER_SIZE;
+ FfmpegAudioDecoder decoder =
+ new FfmpegAudioDecoder(
+ format, NUM_BUFFERS, NUM_BUFFERS, initialInputBufferSize, shouldOutputFloat(format));
+ TraceUtil.endSection();
return decoder;
}
@Override
- public Format getOutputFormat() {
- int channelCount = decoder.getChannelCount();
- int sampleRate = decoder.getSampleRate();
- return Format.createAudioSampleFormat(null, MimeTypes.AUDIO_RAW, null, Format.NO_VALUE,
- Format.NO_VALUE, channelCount, sampleRate, C.ENCODING_PCM_16BIT, null, null, 0, null);
+ public Format getOutputFormat(FfmpegAudioDecoder decoder) {
+ Assertions.checkNotNull(decoder);
+ return new Format.Builder()
+ .setSampleMimeType(MimeTypes.AUDIO_RAW)
+ .setChannelCount(decoder.getChannelCount())
+ .setSampleRate(decoder.getSampleRate())
+ .setPcmEncoding(decoder.getEncoding())
+ .build();
}
+ /**
+ * Returns whether the renderer's {@link AudioSink} supports the PCM format that will be output
+ * from the decoder for the given input format and requested output encoding.
+ */
+ private boolean sinkSupportsFormat(Format inputFormat, @C.PcmEncoding int pcmEncoding) {
+ return sinkSupportsFormat(
+ Util.getPcmFormat(pcmEncoding, inputFormat.channelCount, inputFormat.sampleRate));
+ }
+
+ private boolean shouldOutputFloat(Format inputFormat) {
+ if (!sinkSupportsFormat(inputFormat, C.ENCODING_PCM_16BIT)) {
+ // We have no choice because the sink doesn't support 16-bit integer PCM.
+ return true;
+ }
+
+ @SinkFormatSupport
+ int formatSupport =
+ getSinkFormatSupport(
+ Util.getPcmFormat(
+ C.ENCODING_PCM_FLOAT, inputFormat.channelCount, inputFormat.sampleRate));
+ switch (formatSupport) {
+ case SINK_FORMAT_SUPPORTED_DIRECTLY:
+ // AC-3 is always 16-bit, so there's no point using floating point. Assume that it's worth
+ // using for all other formats.
+ return !MimeTypes.AUDIO_AC3.equals(inputFormat.sampleMimeType);
+ case SINK_FORMAT_UNSUPPORTED:
+ case SINK_FORMAT_SUPPORTED_WITH_TRANSCODING:
+ default:
+ // Always prefer 16-bit PCM if the sink does not provide direct support for floating point.
+ return false;
+ }
+ }
}
diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoder.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoder.java
deleted file mode 100644
index 2af2101ee7..0000000000
--- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoder.java
+++ /dev/null
@@ -1,164 +0,0 @@
-/*
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.google.android.exoplayer2.ext.ffmpeg;
-
-import com.google.android.exoplayer2.decoder.DecoderInputBuffer;
-import com.google.android.exoplayer2.decoder.SimpleDecoder;
-import com.google.android.exoplayer2.decoder.SimpleOutputBuffer;
-import com.google.android.exoplayer2.util.MimeTypes;
-import com.google.android.exoplayer2.util.ParsableByteArray;
-import java.nio.ByteBuffer;
-import java.util.List;
-
-/**
- * FFmpeg audio decoder.
- */
-/* package */ final class FfmpegDecoder extends
- SimpleDecoder {
-
- // Space for 64 ms of 6 channel 48 kHz 16-bit PCM audio.
- private static final int OUTPUT_BUFFER_SIZE = 1536 * 6 * 2 * 2;
-
- private final String codecName;
- private final byte[] extraData;
-
- private long nativeContext; // May be reassigned on resetting the codec.
- private boolean hasOutputFormat;
- private volatile int channelCount;
- private volatile int sampleRate;
-
- public FfmpegDecoder(int numInputBuffers, int numOutputBuffers, int initialInputBufferSize,
- String mimeType, List initializationData) throws FfmpegDecoderException {
- super(new DecoderInputBuffer[numInputBuffers], new SimpleOutputBuffer[numOutputBuffers]);
- if (!FfmpegLibrary.isAvailable()) {
- throw new FfmpegDecoderException("Failed to load decoder native libraries.");
- }
- codecName = FfmpegLibrary.getCodecName(mimeType);
- extraData = getExtraData(mimeType, initializationData);
- nativeContext = ffmpegInitialize(codecName, extraData);
- if (nativeContext == 0) {
- throw new FfmpegDecoderException("Initialization failed.");
- }
- setInitialInputBufferSize(initialInputBufferSize);
- }
-
- @Override
- public String getName() {
- return "ffmpeg" + FfmpegLibrary.getVersion() + "-" + codecName;
- }
-
- @Override
- public DecoderInputBuffer createInputBuffer() {
- return new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT);
- }
-
- @Override
- public SimpleOutputBuffer createOutputBuffer() {
- return new SimpleOutputBuffer(this);
- }
-
- @Override
- public FfmpegDecoderException decode(DecoderInputBuffer inputBuffer,
- SimpleOutputBuffer outputBuffer, boolean reset) {
- if (reset) {
- nativeContext = ffmpegReset(nativeContext, extraData);
- if (nativeContext == 0) {
- return new FfmpegDecoderException("Error resetting (see logcat).");
- }
- }
- ByteBuffer inputData = inputBuffer.data;
- int inputSize = inputData.limit();
- ByteBuffer outputData = outputBuffer.init(inputBuffer.timeUs, OUTPUT_BUFFER_SIZE);
- int result = ffmpegDecode(nativeContext, inputData, inputSize, outputData, OUTPUT_BUFFER_SIZE);
- if (result < 0) {
- return new FfmpegDecoderException("Error decoding (see logcat). Code: " + result);
- }
- if (!hasOutputFormat) {
- channelCount = ffmpegGetChannelCount(nativeContext);
- sampleRate = ffmpegGetSampleRate(nativeContext);
- if (sampleRate == 0 && "alac".equals(codecName)) {
- // ALAC decoder did not set the sample rate in earlier versions of FFMPEG.
- // See https://trac.ffmpeg.org/ticket/6096
- ParsableByteArray parsableExtraData = new ParsableByteArray(extraData);
- parsableExtraData.setPosition(extraData.length - 4);
- sampleRate = parsableExtraData.readUnsignedIntToInt();
- }
- hasOutputFormat = true;
- }
- outputBuffer.data.position(0);
- outputBuffer.data.limit(result);
- return null;
- }
-
- @Override
- public void release() {
- super.release();
- ffmpegRelease(nativeContext);
- nativeContext = 0;
- }
-
- /**
- * Returns the channel count of output audio. May only be called after {@link #decode}.
- */
- public int getChannelCount() {
- return channelCount;
- }
-
- /**
- * Returns the sample rate of output audio. May only be called after {@link #decode}.
- */
- public int getSampleRate() {
- return sampleRate;
- }
-
- /**
- * Returns FFmpeg-compatible codec-specific initialization data ("extra data"), or {@code null} if
- * not required.
- */
- private static byte[] getExtraData(String mimeType, List initializationData) {
- switch (mimeType) {
- case MimeTypes.AUDIO_AAC:
- case MimeTypes.AUDIO_ALAC:
- case MimeTypes.AUDIO_OPUS:
- return initializationData.get(0);
- case MimeTypes.AUDIO_VORBIS:
- byte[] header0 = initializationData.get(0);
- byte[] header1 = initializationData.get(1);
- byte[] extraData = new byte[header0.length + header1.length + 6];
- extraData[0] = (byte) (header0.length >> 8);
- extraData[1] = (byte) (header0.length & 0xFF);
- System.arraycopy(header0, 0, extraData, 2, header0.length);
- extraData[header0.length + 2] = 0;
- extraData[header0.length + 3] = 0;
- extraData[header0.length + 4] = (byte) (header1.length >> 8);
- extraData[header0.length + 5] = (byte) (header1.length & 0xFF);
- System.arraycopy(header1, 0, extraData, header0.length + 6, header1.length);
- return extraData;
- default:
- // Other codecs do not require extra data.
- return null;
- }
- }
-
- private native long ffmpegInitialize(String codecName, byte[] extraData);
- private native int ffmpegDecode(long context, ByteBuffer inputData, int inputSize,
- ByteBuffer outputData, int outputSize);
- private native int ffmpegGetChannelCount(long context);
- private native int ffmpegGetSampleRate(long context);
- private native long ffmpegReset(long context, byte[] extraData);
- private native void ffmpegRelease(long context);
-
-}
diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoderException.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoderException.java
index b4cf327198..47d5017350 100644
--- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoderException.java
+++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoderException.java
@@ -15,15 +15,16 @@
*/
package com.google.android.exoplayer2.ext.ffmpeg;
-import com.google.android.exoplayer2.audio.AudioDecoderException;
+import com.google.android.exoplayer2.decoder.DecoderException;
-/**
- * Thrown when an FFmpeg decoder error occurs.
- */
-public final class FfmpegDecoderException extends AudioDecoderException {
+/** Thrown when an FFmpeg decoder error occurs. */
+public final class FfmpegDecoderException extends DecoderException {
/* package */ FfmpegDecoderException(String message) {
super(message);
}
+ /* package */ FfmpegDecoderException(String message, Throwable cause) {
+ super(message, cause);
+ }
}
diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java
index 4992bcbb3e..71912aea2f 100644
--- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java
+++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java
@@ -15,23 +15,39 @@
*/
package com.google.android.exoplayer2.ext.ffmpeg;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
import com.google.android.exoplayer2.util.LibraryLoader;
+import com.google.android.exoplayer2.util.Log;
import com.google.android.exoplayer2.util.MimeTypes;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
/**
* Configures and queries the underlying native library.
*/
public final class FfmpegLibrary {
+ static {
+ ExoPlayerLibraryInfo.registerModule("goog.exo.ffmpeg");
+ }
+
+ private static final String TAG = "FfmpegLibrary";
+
private static final LibraryLoader LOADER =
- new LibraryLoader("avutil", "avresample", "avcodec", "ffmpeg");
+ new LibraryLoader("avutil", "swresample", "avcodec", "ffmpeg_jni");
+
+ private static @MonotonicNonNull String version;
+ private static int inputBufferPaddingSize = C.LENGTH_UNSET;
private FfmpegLibrary() {}
/**
* Override the names of the FFmpeg native libraries. If an application wishes to call this
* method, it must do so before calling any other method defined by this class, and before
- * instantiating a {@link FfmpegAudioRenderer} instance.
+ * instantiating a {@link FfmpegAudioRenderer} or {@link FfmpegVideoRenderer} instance.
+ *
+ * @param libraries The names of the FFmpeg native libraries.
*/
public static void setLibraries(String... libraries) {
LOADER.setLibraries(libraries);
@@ -44,27 +60,57 @@ public final class FfmpegLibrary {
return LOADER.isAvailable();
}
- /**
- * Returns the version of the underlying library if available, or null otherwise.
- */
+ /** Returns the version of the underlying library if available, or null otherwise. */
+ @Nullable
public static String getVersion() {
- return isAvailable() ? ffmpegGetVersion() : null;
+ if (!isAvailable()) {
+ return null;
+ }
+ if (version == null) {
+ version = ffmpegGetVersion();
+ }
+ return version;
+ }
+
+ /**
+ * Returns the required amount of padding for input buffers in bytes, or {@link C#LENGTH_UNSET} if
+ * the underlying library is not available.
+ */
+ public static int getInputBufferPaddingSize() {
+ if (!isAvailable()) {
+ return C.LENGTH_UNSET;
+ }
+ if (inputBufferPaddingSize == C.LENGTH_UNSET) {
+ inputBufferPaddingSize = ffmpegGetInputBufferPaddingSize();
+ }
+ return inputBufferPaddingSize;
}
/**
* Returns whether the underlying library supports the specified MIME type.
+ *
+ * @param mimeType The MIME type to check.
*/
public static boolean supportsFormat(String mimeType) {
if (!isAvailable()) {
return false;
}
- String codecName = getCodecName(mimeType);
- return codecName != null && ffmpegHasDecoder(codecName);
+ @Nullable String codecName = getCodecName(mimeType);
+ if (codecName == null) {
+ return false;
+ }
+ if (!ffmpegHasDecoder(codecName)) {
+ Log.w(TAG, "No " + codecName + " decoder available. Check the FFmpeg build configuration.");
+ return false;
+ }
+ return true;
}
/**
- * Returns the name of the FFmpeg decoder that could be used to decode {@code mimeType}.
+ * Returns the name of the FFmpeg decoder that could be used to decode the format, or {@code null}
+ * if it's unsupported.
*/
+ @Nullable
/* package */ static String getCodecName(String mimeType) {
switch (mimeType) {
case MimeTypes.AUDIO_AAC:
@@ -76,6 +122,7 @@ public final class FfmpegLibrary {
case MimeTypes.AUDIO_AC3:
return "ac3";
case MimeTypes.AUDIO_E_AC3:
+ case MimeTypes.AUDIO_E_AC3_JOC:
return "eac3";
case MimeTypes.AUDIO_TRUEHD:
return "truehd";
@@ -94,12 +141,22 @@ public final class FfmpegLibrary {
return "flac";
case MimeTypes.AUDIO_ALAC:
return "alac";
+ case MimeTypes.AUDIO_MLAW:
+ return "pcm_mulaw";
+ case MimeTypes.AUDIO_ALAW:
+ return "pcm_alaw";
+ case MimeTypes.VIDEO_H264:
+ return "h264";
+ case MimeTypes.VIDEO_H265:
+ return "hevc";
default:
return null;
}
}
private static native String ffmpegGetVersion();
- private static native boolean ffmpegHasDecoder(String codecName);
+ private static native int ffmpegGetInputBufferPaddingSize();
+
+ private static native boolean ffmpegHasDecoder(String codecName);
}
diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegVideoRenderer.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegVideoRenderer.java
new file mode 100644
index 0000000000..d2f2fce639
--- /dev/null
+++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegVideoRenderer.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.ffmpeg;
+
+import android.os.Handler;
+import android.view.Surface;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.Format;
+import com.google.android.exoplayer2.RendererCapabilities;
+import com.google.android.exoplayer2.decoder.Decoder;
+import com.google.android.exoplayer2.drm.ExoMediaCrypto;
+import com.google.android.exoplayer2.util.TraceUtil;
+import com.google.android.exoplayer2.util.Util;
+import com.google.android.exoplayer2.video.DecoderVideoRenderer;
+import com.google.android.exoplayer2.video.VideoDecoderInputBuffer;
+import com.google.android.exoplayer2.video.VideoDecoderOutputBuffer;
+import com.google.android.exoplayer2.video.VideoRendererEventListener;
+
+// TODO: Remove the NOTE below.
+/**
+ *