diff --git a/.gitignore b/.gitignore index db5a8c4305..4731d5ba99 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,12 @@ local.properties proguard.cfg proguard-project.txt +# Bazel +bazel-bin +bazel-genfiles +bazel-out +bazel-testlogs + # Other .DS_Store cmake-build-debug @@ -66,3 +72,6 @@ extensions/cronet/jniLibs/* extensions/cronet/libs/* !extensions/cronet/libs/README.md +# Cast receiver +cast_receiver_app/external-js +cast_receiver_app/bazel-cast_receiver_app diff --git a/.hgignore b/.hgignore index f7c3656f65..36d3268005 100644 --- a/.hgignore +++ b/.hgignore @@ -44,6 +44,12 @@ local.properties proguard.cfg proguard-project.txt +# Bazel +bazel-bin +bazel-genfiles +bazel-out +bazel-testlogs + # Other .DS_Store cmake-build-debug @@ -69,3 +75,7 @@ extensions/cronet/jniLibs/* !extensions/cronet/jniLibs/README.md extensions/cronet/libs/* !extensions/cronet/libs/README.md + +# Cast receiver +cast_receiver_app/external-js +cast_receiver_app/bazel-cast_receiver_app diff --git a/README.md b/README.md index 37967dd527..03f16bd655 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ repository and depend on the modules locally. ### From JCenter ### +#### 1. Add repositories #### + The easiest way to get started using ExoPlayer is to add it as a gradle dependency. You need to make sure you have the Google and JCenter repositories included in the `build.gradle` file in the root of your project: @@ -38,6 +40,8 @@ repositories { } ``` +#### 2. Add ExoPlayer module dependencies #### + Next add a dependency in the `build.gradle` file of your app module. The following will add a dependency to the full library: @@ -45,15 +49,7 @@ following will add a dependency to the full library: implementation 'com.google.android.exoplayer:exoplayer:2.X.X' ``` -where `2.X.X` is your preferred version. If not enabled already, you also need -to turn on Java 8 support in all `build.gradle` files depending on ExoPlayer, by -adding the following to the `android` section: - -```gradle -compileOptions { - targetCompatibility JavaVersion.VERSION_1_8 -} -``` +where `2.X.X` is your preferred version. As an alternative to the full library, you can depend on only the library modules that you actually need. For example the following will add dependencies @@ -87,6 +83,32 @@ JCenter can be found on [Bintray][]. [extensions directory]: https://github.com/google/ExoPlayer/tree/release-v2/extensions/ [Bintray]: https://bintray.com/google/exoplayer +#### 3. Turn on Java 8 support #### + +If not enabled already, you also need to turn on Java 8 support in all +`build.gradle` files depending on ExoPlayer, by adding the following to the +`android` section: + +```gradle +compileOptions { + targetCompatibility JavaVersion.VERSION_1_8 +} +``` + +Note that if you want to use Java 8 features in your own code, the following +additional options need to be set: + +```gradle +// For Java compilers: +compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 +} +// For Kotlin compilers: +kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 +} +``` + ### Locally ### Cloning the repository and depending on the modules locally is required when diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 1634b8de74..1278d36600 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -2,6 +2,7 @@ ### dev-v2 (not yet released) ### +* `ExtractorMediaSource` renamed to `ProgressiveMediaSource`. * Support for playing spherical videos on Daydream. * Improve decoder re-use between playbacks. TODO: Write and link a blog post here ([#2826](https://github.com/google/ExoPlayer/issues/2826)). @@ -17,7 +18,8 @@ * Add `setStreamKeys` method to factories of DASH, SmoothStreaming and HLS media sources to simplify filtering by downloaded streams. * Caching: - * Improve performance of `SimpleCache`. + * Improve performance of `SimpleCache` + ([#4253](https://github.com/google/ExoPlayer/issues/4253)). * Cache data with unknown length by default. The previous flag to opt in to this behavior (`DataSpec.FLAG_ALLOW_CACHING_UNKNOWN_LENGTH`) has been replaced with an opt out flag @@ -27,20 +29,70 @@ * Rename TaskState to DownloadState. * Add new states to DownloadState. * Replace DownloadState.action with DownloadAction fields. +* DRM: Fix black flicker when keys rotate in DRM protected content + ([#3561](https://github.com/google/ExoPlayer/issues/3561)). * Add support for SHOUTcast ICY metadata ([#3735](https://github.com/google/ExoPlayer/issues/3735)). -* IMA extension: - * Clear ads loader listeners on release - ([#4114](https://github.com/google/ExoPlayer/issues/4114)). - * Require setting the `Player` on `AdsLoader` instances before playback. +* CEA-608: Improved conformance to the specification + ([#3860](https://github.com/google/ExoPlayer/issues/3860)). +* IMA extension: Require setting the `Player` on `AdsLoader` instances before + playback. +* Remove `player` and `isTopLevelSource` parameters from `MediaSource.prepare`. +* VP9 extension: Remove RGB output mode and libyuv dependency, and switch to + surface YUV output as the default. Remove constructor parameters `scaleToFit` + and `useSurfaceYuvOutput`. +* Change signature of `PlayerNotificationManager.NotificationListener` to better + fit service requirements. Remove ability to set a custom stop action. +* Fix issues with flickering notifications on KitKat. + `PlayerNotificationManager` has been fixed. Apps using + `DownloadNotificationUtil` should switch to using + `DownloadNotificationHelper`. + +### 2.9.5 ### + +* HLS: Parse `CHANNELS` attribute from `EXT-X-MEDIA` tag. +* ConcatenatingMediaSource: + * Add `Handler` parameter to methods that take a callback `Runnable`. + * Fix issue with dropped messages when releasing the source + ([#5464](https://github.com/google/ExoPlayer/issues/5464)). +* ExtractorMediaSource: Fix issue that could cause the player to get stuck + buffering at the end of the media. +* PlayerView: Fix issue preventing `OnClickListener` from receiving events + ([#5433](https://github.com/google/ExoPlayer/issues/5433)). +* IMA extension: Upgrade IMA dependency to 3.10.6. +* Cronet extension: Upgrade Cronet dependency to 71.3578.98. +* OkHttp extension: Upgrade OkHttp dependency to 3.12.1. +* MP3: Wider fix for issue where streams would play twice on some Samsung + devices ([#4519](https://github.com/google/ExoPlayer/issues/4519)). + +### 2.9.4 ### + +* IMA extension: Clear ads loader listeners on release + ([#4114](https://github.com/google/ExoPlayer/issues/4114)). +* SmoothStreaming: Fix support for subtitles in DRM protected streams + ([#5378](https://github.com/google/ExoPlayer/issues/5378)). * FFmpeg extension: Treat invalid data errors as non-fatal to match the behavior of MediaCodec ([#5293](https://github.com/google/ExoPlayer/issues/5293)). +* GVR extension: upgrade GVR SDK dependency to 1.190.0. +* Associate fatal player errors of type SOURCE with the loading source in + `AnalyticsListener.EventTime` + ([#5407](https://github.com/google/ExoPlayer/issues/5407)). +* Add `startPositionUs` to `MediaSource.createPeriod`. This fixes an issue where + using lazy preparation in `ConcatenatingMediaSource` with an + `ExtractorMediaSource` overrides initial seek positions + ([#5350](https://github.com/google/ExoPlayer/issues/5350)). +* Add subtext to the `MediaDescriptionAdapter` of the + `PlayerNotificationManager`. +* Add workaround for video quality problems with Amlogic decoders + ([#5003](https://github.com/google/ExoPlayer/issues/5003)). * Fix issue where sending callbacks for playlist changes may cause problems because of parallel player access ([#5240](https://github.com/google/ExoPlayer/issues/5240)). -* Add `Handler` parameter to `ConcatenatingMediaSource` methods which take a - callback `Runnable`. -* Remove `player` and `isTopLevelSource` parameters from `MediaSource.prepare`. +* Fix issue with reusing a `ClippingMediaSource` with an inner + `ExtractorMediaSource` and a non-zero start position + ([#5351](https://github.com/google/ExoPlayer/issues/5351)). +* Fix issue where uneven track durations in MP4 streams can cause OOM problems + ([#3670](https://github.com/google/ExoPlayer/issues/3670)). ### 2.9.3 ### @@ -1173,7 +1225,7 @@ [here](https://medium.com/google-exoplayer/customizing-exoplayers-ui-components-728cf55ee07a#.9ewjg7avi). * Robustness improvements when handling MediaSource timeline changes and MediaPeriod transitions. -* EIA608: Support for caption styling and positioning. +* CEA-608: Support for caption styling and positioning. * MPEG-TS: Improved support: * Support injection of custom TS payload readers. * Support injection of custom section payload readers. @@ -1417,8 +1469,8 @@ V2 release. (#801). * MP3: Fix playback of some streams when stream length is unknown. * ID3: Support multiple frames of the same type in a single tag. -* EIA608: Correctly handle repeated control characters, fixing an issue in which - captions would immediately disappear. +* CEA-608: Correctly handle repeated control characters, fixing an issue in + which captions would immediately disappear. * AVC3: Fix decoder failures on some MediaTek devices in the case where the first buffer fed to the decoder does not start with SPS/PPS NAL units. * Misc bug fixes. diff --git a/constants.gradle b/constants.gradle index ac801d2d3b..a932ef218f 100644 --- a/constants.gradle +++ b/constants.gradle @@ -13,13 +13,9 @@ // limitations under the License. project.ext { // ExoPlayer version and version code. - releaseVersion = '2.9.3' - releaseVersionCode = 2009003 - // Important: ExoPlayer specifies a minSdkVersion of 14 because various - // components provided by the library may be of use on older devices. - // However, please note that the core media playback functionality provided - // by the library requires API level 16 or greater. - minSdkVersion = 14 + releaseVersion = '2.9.5' + releaseVersionCode = 2009005 + minSdkVersion = 16 targetSdkVersion = 28 compileSdkVersion = 28 buildToolsVersion = '28.0.2' diff --git a/demos/cast/build.gradle b/demos/cast/build.gradle index 8af52a787e..e056530d45 100644 --- a/demos/cast/build.gradle +++ b/demos/cast/build.gradle @@ -26,7 +26,7 @@ android { defaultConfig { versionName project.ext.releaseVersion versionCode project.ext.releaseVersionCode - minSdkVersion 16 + minSdkVersion project.ext.minSdkVersion targetSdkVersion project.ext.targetSdkVersion } diff --git a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DefaultReceiverPlayerManager.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DefaultReceiverPlayerManager.java index f4678fc541..1db68ca08d 100644 --- a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DefaultReceiverPlayerManager.java +++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DefaultReceiverPlayerManager.java @@ -35,8 +35,8 @@ import com.google.android.exoplayer2.ext.cast.CastPlayer; import com.google.android.exoplayer2.ext.cast.MediaItem; import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener; import com.google.android.exoplayer2.source.ConcatenatingMediaSource; -import com.google.android.exoplayer2.source.ExtractorMediaSource; 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.source.hls.HlsMediaSource; import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; @@ -63,7 +63,7 @@ import java.util.ArrayList; private final SimpleExoPlayer exoPlayer; private final CastPlayer castPlayer; private final ArrayList mediaQueue; - private final QueuePositionListener queuePositionListener; + private final QueueChangesListener queueChangesListener; private final ConcatenatingMediaSource concatenatingMediaSource; private boolean castMediaQueueCreationPending; @@ -71,32 +71,21 @@ import java.util.ArrayList; private Player currentPlayer; /** - * @param queuePositionListener A {@link QueuePositionListener} for queue position changes. + * Creates a new manager for {@link SimpleExoPlayer} and {@link CastPlayer}. + * + * @param queueChangesListener A {@link QueueChangesListener} 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 static DefaultReceiverPlayerManager createPlayerManager( - QueuePositionListener queuePositionListener, + public DefaultReceiverPlayerManager( + QueueChangesListener queueChangesListener, PlayerView localPlayerView, PlayerControlView castControlView, Context context, CastContext castContext) { - DefaultReceiverPlayerManager defaultReceiverPlayerManager = - new DefaultReceiverPlayerManager( - queuePositionListener, localPlayerView, castControlView, context, castContext); - defaultReceiverPlayerManager.init(); - return defaultReceiverPlayerManager; - } - - private DefaultReceiverPlayerManager( - QueuePositionListener queuePositionListener, - PlayerView localPlayerView, - PlayerControlView castControlView, - Context context, - CastContext castContext) { - this.queuePositionListener = queuePositionListener; + this.queueChangesListener = queueChangesListener; this.localPlayerView = localPlayerView; this.castControlView = castControlView; mediaQueue = new ArrayList<>(); @@ -113,6 +102,8 @@ import java.util.ArrayList; castPlayer.addListener(this); castPlayer.setSessionAvailabilityListener(this); castControlView.setPlayer(castPlayer); + + setCurrentPlayer(castPlayer.isCastSessionAvailable() ? castPlayer : exoPlayer); } // Queue manipulation methods. @@ -287,10 +278,6 @@ import java.util.ArrayList; // Internal methods. - private void init() { - setCurrentPlayer(castPlayer.isCastSessionAvailable() ? castPlayer : exoPlayer); - } - private void updateCurrentItemIndex() { int playbackState = currentPlayer.getPlaybackState(); maybeSetCurrentItemAndNotify( @@ -372,7 +359,7 @@ import java.util.ArrayList; if (this.currentItemIndex != currentItemIndex) { int oldIndex = this.currentItemIndex; this.currentItemIndex = currentItemIndex; - queuePositionListener.onQueuePositionChanged(oldIndex, currentItemIndex); + queueChangesListener.onQueuePositionChanged(oldIndex, currentItemIndex); } } @@ -386,7 +373,7 @@ import java.util.ArrayList; case DemoUtil.MIME_TYPE_HLS: return new HlsMediaSource.Factory(DATA_SOURCE_FACTORY).createMediaSource(uri); case DemoUtil.MIME_TYPE_VIDEO_MP4: - return new ExtractorMediaSource.Factory(DATA_SOURCE_FACTORY).createMediaSource(uri); + return new ProgressiveMediaSource.Factory(DATA_SOURCE_FACTORY).createMediaSource(uri); default: { throw new IllegalStateException("Unsupported type: " + item.mimeType); } diff --git a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DemoUtil.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DemoUtil.java index 776ed1a3bd..735084495f 100644 --- a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DemoUtil.java +++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DemoUtil.java @@ -15,10 +15,14 @@ */ package com.google.android.exoplayer2.castdemo; +import android.net.Uri; +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.MimeTypes; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.UUID; /** Utility methods and constants for the Cast demo application. */ /* package */ final class DemoUtil { @@ -32,6 +36,16 @@ import java.util.List; public final String name; /** The mime type of the sample media content. */ public final String mimeType; + /** + * The {@link UUID} of the DRM scheme that protects the content, or null if the content is not + * DRM-protected. + */ + @Nullable public final UUID drmSchemeUuid; + /** + * The url from which players should obtain DRM licenses, or null if the content is not + * DRM-protected. + */ + @Nullable public final Uri licenseServerUri; /** * @param uri See {@link #uri}. @@ -39,9 +53,21 @@ import java.util.List; * @param mimeType See {@link #mimeType}. */ public Sample(String uri, String name, String mimeType) { + this(uri, name, mimeType, /* drmSchemeUuid= */ null, /* licenseServerUriString= */ null); + } + + public Sample( + String uri, + String name, + String mimeType, + @Nullable UUID drmSchemeUuid, + @Nullable String licenseServerUriString) { this.uri = uri; this.name = name; this.mimeType = mimeType; + this.drmSchemeUuid = drmSchemeUuid; + this.licenseServerUri = + licenseServerUriString != null ? Uri.parse(licenseServerUriString) : null; } @Override @@ -62,25 +88,15 @@ import java.util.List; // App samples. ArrayList samples = new ArrayList<>(); + // Clear content. samples.add( new Sample( "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd", - "DASH (clear,MP4,H264)", + "Clear DASH: Tears", MIME_TYPE_DASH)); samples.add( new Sample( - "https://commondatastorage.googleapis.com/gtv-videos-bucket/CastVideos/" - + "hls/TearsOfSteel.m3u8", - "Tears of Steel (HLS)", - MIME_TYPE_HLS)); - samples.add( - new Sample( - "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3" - + "/bipbop_4x3_variant.m3u8", - "HLS Basic (TS)", - MIME_TYPE_HLS)); - samples.add( - new Sample("https://html5demos.com/assets/dizzy.mp4", "Dizzy (MP4)", MIME_TYPE_VIDEO_MP4)); + "https://html5demos.com/assets/dizzy.mp4", "Clear MP4: Dizzy", MIME_TYPE_VIDEO_MP4)); SAMPLES = Collections.unmodifiableList(samples); } diff --git a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/MainActivity.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/MainActivity.java index 9d4c3ec87f..48934abd2c 100644 --- a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/MainActivity.java +++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/MainActivity.java @@ -42,13 +42,14 @@ import com.google.android.gms.cast.CastMediaControlIntent; import com.google.android.gms.cast.framework.CastButtonFactory; import com.google.android.gms.cast.framework.CastContext; import com.google.android.gms.dynamite.DynamiteModule; +import java.util.Collections; /** * An activity that plays video using {@link SimpleExoPlayer} and supports casting using ExoPlayer's * Cast extension. */ public class MainActivity extends AppCompatActivity - implements OnClickListener, PlayerManager.QueuePositionListener { + implements OnClickListener, PlayerManager.QueueChangesListener { private final MediaItem.Builder mediaItemBuilder; @@ -120,8 +121,8 @@ public class MainActivity extends AppCompatActivity switch (applicationId) { case CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID: playerManager = - DefaultReceiverPlayerManager.createPlayerManager( - /* queuePositionListener= */ this, + new DefaultReceiverPlayerManager( + /* queueChangesListener= */ this, localPlayerView, castControlView, /* context= */ this, @@ -161,7 +162,7 @@ public class MainActivity extends AppCompatActivity .show(); } - // PlayerManager.QueuePositionListener implementation. + // PlayerManager.QueueChangesListener implementation. @Override public void onQueuePositionChanged(int previousIndex, int newIndex) { @@ -173,6 +174,11 @@ public class MainActivity extends AppCompatActivity } } + @Override + public void onQueueContentsExternallyChanged() { + mediaQueueListAdapter.notifyDataSetChanged(); + } + // Internal methods. private View buildSampleListView() { @@ -182,13 +188,18 @@ public class MainActivity extends AppCompatActivity sampleList.setOnItemClickListener( (parent, view, position, id) -> { DemoUtil.Sample sample = DemoUtil.SAMPLES.get(position); - playerManager.addItem( - mediaItemBuilder - .clear() - .setMedia(sample.uri) - .setTitle(sample.name) - .setMimeType(sample.mimeType) - .build()); + mediaItemBuilder + .clear() + .setMedia(sample.uri) + .setTitle(sample.name) + .setMimeType(sample.mimeType); + if (sample.drmSchemeUuid != null) { + mediaItemBuilder.setDrmSchemes( + Collections.singletonList( + new MediaItem.DrmScheme( + sample.drmSchemeUuid, new MediaItem.UriBundle(sample.licenseServerUri)))); + } + playerManager.addItem(mediaItemBuilder.build()); mediaQueueListAdapter.notifyItemInserted(playerManager.getMediaQueueSize() - 1); }); return dialogList; @@ -268,6 +279,8 @@ public class MainActivity extends AppCompatActivity int position = viewHolder.getAdapterPosition(); if (playerManager.removeItem(position)) { mediaQueueListAdapter.notifyItemRemoved(position); + // Update whichever item took its place, in case it became the new selected item. + mediaQueueListAdapter.notifyItemChanged(position); } } diff --git a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java index c56f0eb855..184dfe29b3 100644 --- a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java +++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java @@ -22,14 +22,14 @@ import com.google.android.exoplayer2.ext.cast.MediaItem; /** Manages the players in the Cast demo app. */ interface PlayerManager { - /** Listener for changes in the media queue playback position. */ - interface QueuePositionListener { + /** Listener for changes in the media queue. */ + interface QueueChangesListener { - /** - * Called when the currently played item of the media queue changes. - */ + /** Called when the currently played item of the media queue changes. */ void onQueuePositionChanged(int previousIndex, int newIndex); + /** Called when the media queue changes due to modifications not caused by this manager. */ + void onQueueContentsExternallyChanged(); } /** Redirects the given {@code keyEvent} to the active player. */ diff --git a/demos/ima/build.gradle b/demos/ima/build.gradle index 33cca6ef46..0530c42eb7 100644 --- a/demos/ima/build.gradle +++ b/demos/ima/build.gradle @@ -26,7 +26,7 @@ android { defaultConfig { versionName project.ext.releaseVersion versionCode project.ext.releaseVersionCode - minSdkVersion 16 + minSdkVersion project.ext.minSdkVersion targetSdkVersion project.ext.targetSdkVersion } diff --git a/demos/ima/src/main/java/com/google/android/exoplayer2/imademo/PlayerManager.java b/demos/ima/src/main/java/com/google/android/exoplayer2/imademo/PlayerManager.java index d67c4549d8..97c3299a4a 100644 --- a/demos/ima/src/main/java/com/google/android/exoplayer2/imademo/PlayerManager.java +++ b/demos/ima/src/main/java/com/google/android/exoplayer2/imademo/PlayerManager.java @@ -23,16 +23,12 @@ import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.ExoPlayerFactory; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.ext.ima.ImaAdsLoader; -import com.google.android.exoplayer2.source.ExtractorMediaSource; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.source.ads.AdsMediaSource; import com.google.android.exoplayer2.source.dash.DashMediaSource; import com.google.android.exoplayer2.source.hls.HlsMediaSource; import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; -import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; -import com.google.android.exoplayer2.trackselection.TrackSelection; -import com.google.android.exoplayer2.trackselection.TrackSelector; import com.google.android.exoplayer2.ui.PlayerView; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; @@ -56,14 +52,9 @@ import com.google.android.exoplayer2.util.Util; } public void init(Context context, PlayerView playerView) { - // Create a default track selector. - TrackSelection.Factory videoTrackSelectionFactory = new AdaptiveTrackSelection.Factory(); - TrackSelector trackSelector = new DefaultTrackSelector(videoTrackSelectionFactory); - // Create a player instance. - player = ExoPlayerFactory.newSimpleInstance(context, trackSelector); - - // Bind the player to the view. + player = ExoPlayerFactory.newSimpleInstance(context); + adsLoader.setPlayer(player); playerView.setPlayer(player); // This is the MediaSource representing the content media (i.e. not the ad). @@ -89,6 +80,7 @@ import com.google.android.exoplayer2.util.Util; contentPosition = player.getContentPosition(); player.release(); player = null; + adsLoader.setPlayer(null); } } @@ -125,7 +117,7 @@ import com.google.android.exoplayer2.util.Util; case C.TYPE_HLS: return new HlsMediaSource.Factory(dataSourceFactory).createMediaSource(uri); case C.TYPE_OTHER: - return new ExtractorMediaSource.Factory(dataSourceFactory).createMediaSource(uri); + return new ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(uri); default: throw new IllegalStateException("Unsupported type: " + type); } diff --git a/demos/main/build.gradle b/demos/main/build.gradle index c516ba297f..e3e382f6d0 100644 --- a/demos/main/build.gradle +++ b/demos/main/build.gradle @@ -26,7 +26,7 @@ android { defaultConfig { versionName project.ext.releaseVersion versionCode project.ext.releaseVersionCode - minSdkVersion 16 + minSdkVersion project.ext.minSdkVersion targetSdkVersion project.ext.targetSdkVersion } diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java index 9b72df8d98..27033bb03e 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java @@ -18,7 +18,11 @@ package com.google.android.exoplayer2.demo; import android.app.Application; import com.google.android.exoplayer2.DefaultRenderersFactory; import com.google.android.exoplayer2.RenderersFactory; +import com.google.android.exoplayer2.database.ExoDatabaseProvider; +import com.google.android.exoplayer2.offline.ActionFile; +import com.google.android.exoplayer2.offline.DefaultDownloadIndex; import com.google.android.exoplayer2.offline.DefaultDownloaderFactory; +import com.google.android.exoplayer2.offline.DownloadIndexUtil; import com.google.android.exoplayer2.offline.DownloadManager; import com.google.android.exoplayer2.offline.DownloaderConstructorHelper; import com.google.android.exoplayer2.upstream.DataSource; @@ -31,14 +35,17 @@ import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory; import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor; import com.google.android.exoplayer2.upstream.cache.SimpleCache; +import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; import java.io.File; +import java.io.IOException; /** * Placeholder application to facilitate overriding Application methods for debugging and testing. */ public class DemoApplication extends Application { + private static final String TAG = "DemoApplication"; 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"; @@ -97,19 +104,28 @@ public class DemoApplication extends Application { private synchronized void initDownloadManager() { if (downloadManager == null) { + DefaultDownloadIndex downloadIndex = new DefaultDownloadIndex(new ExoDatabaseProvider(this)); + File actionFile = new File(getDownloadDirectory(), DOWNLOAD_TRACKER_ACTION_FILE); + if (actionFile.exists()) { + try { + DownloadIndexUtil.upgradeActionFile(new ActionFile(actionFile), downloadIndex, null); + } catch (IOException e) { + Log.e(TAG, "Upgrading action file failed", e); + } + actionFile.delete(); + } DownloaderConstructorHelper downloaderConstructorHelper = new DownloaderConstructorHelper(getDownloadCache(), buildHttpDataSourceFactory()); downloadManager = new DownloadManager( + this, new File(getDownloadDirectory(), DOWNLOAD_ACTION_FILE), new DefaultDownloaderFactory(downloaderConstructorHelper), MAX_SIMULTANEOUS_DOWNLOADS, - DownloadManager.DEFAULT_MIN_RETRY_COUNT); + DownloadManager.DEFAULT_MIN_RETRY_COUNT, + DownloadManager.DEFAULT_REQUIREMENTS); downloadTracker = - new DownloadTracker( - /* context= */ this, - buildDataSourceFactory(), - new File(getDownloadDirectory(), DOWNLOAD_TRACKER_ACTION_FILE)); + new DownloadTracker(/* context= */ this, buildDataSourceFactory(), downloadIndex); downloadManager.addListener(downloadTracker); } } 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 index 70cbe43dd8..91e2aa5bcc 100644 --- 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 @@ -20,7 +20,7 @@ import com.google.android.exoplayer2.offline.DownloadManager; import com.google.android.exoplayer2.offline.DownloadService; import com.google.android.exoplayer2.offline.DownloadState; import com.google.android.exoplayer2.scheduler.PlatformScheduler; -import com.google.android.exoplayer2.ui.DownloadNotificationUtil; +import com.google.android.exoplayer2.ui.DownloadNotificationHelper; import com.google.android.exoplayer2.util.NotificationUtil; import com.google.android.exoplayer2.util.Util; @@ -33,6 +33,8 @@ public class DemoDownloadService extends DownloadService { private static int nextNotificationId = FOREGROUND_NOTIFICATION_ID + 1; + private DownloadNotificationHelper notificationHelper; + public DemoDownloadService() { super( FOREGROUND_NOTIFICATION_ID, @@ -42,6 +44,12 @@ public class DemoDownloadService extends DownloadService { nextNotificationId = FOREGROUND_NOTIFICATION_ID + 1; } + @Override + public void onCreate() { + super.onCreate(); + notificationHelper = new DownloadNotificationHelper(this, CHANNEL_ID); + } + @Override protected DownloadManager getDownloadManager() { return ((DemoApplication) getApplication()).getDownloadManager(); @@ -54,32 +62,23 @@ public class DemoDownloadService extends DownloadService { @Override protected Notification getForegroundNotification(DownloadState[] downloadStates) { - return DownloadNotificationUtil.buildProgressNotification( - /* context= */ this, - R.drawable.ic_download, - CHANNEL_ID, - /* contentIntent= */ null, - /* message= */ null, - downloadStates); + return notificationHelper.buildProgressNotification( + R.drawable.ic_download, /* contentIntent= */ null, /* message= */ null, downloadStates); } @Override protected void onDownloadStateChanged(DownloadState downloadState) { - Notification notification = null; + Notification notification; if (downloadState.state == DownloadState.STATE_COMPLETED) { notification = - DownloadNotificationUtil.buildDownloadCompletedNotification( - /* context= */ this, + notificationHelper.buildDownloadCompletedNotification( R.drawable.ic_download_done, - CHANNEL_ID, /* contentIntent= */ null, Util.fromUtf8Bytes(downloadState.customMetadata)); } else if (downloadState.state == DownloadState.STATE_FAILED) { notification = - DownloadNotificationUtil.buildDownloadFailedNotification( - /* context= */ this, + notificationHelper.buildDownloadFailedNotification( R.drawable.ic_download_done, - CHANNEL_ID, /* contentIntent= */ null, Util.fromUtf8Bytes(downloadState.customMetadata)); } else { 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 index 08ecdf3be7..689e7241f7 100644 --- 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 @@ -34,17 +34,16 @@ import android.widget.Toast; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.offline.ActionFile; +import com.google.android.exoplayer2.offline.DefaultDownloadIndex; import com.google.android.exoplayer2.offline.DownloadAction; import com.google.android.exoplayer2.offline.DownloadHelper; import com.google.android.exoplayer2.offline.DownloadManager; import com.google.android.exoplayer2.offline.DownloadService; import com.google.android.exoplayer2.offline.DownloadState; -import com.google.android.exoplayer2.offline.ProgressiveDownloadHelper; +import com.google.android.exoplayer2.offline.DownloadStateCursor; import com.google.android.exoplayer2.offline.StreamKey; +import com.google.android.exoplayer2.scheduler.Requirements; import com.google.android.exoplayer2.source.TrackGroupArray; -import com.google.android.exoplayer2.source.dash.offline.DashDownloadHelper; -import com.google.android.exoplayer2.source.hls.offline.HlsDownloadHelper; -import com.google.android.exoplayer2.source.smoothstreaming.offline.SsDownloadHelper; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; import com.google.android.exoplayer2.trackselection.TrackSelection; @@ -54,8 +53,8 @@ import com.google.android.exoplayer2.ui.TrackSelectionView; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; -import java.io.File; import java.io.IOException; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -83,20 +82,21 @@ public class DownloadTracker implements DownloadManager.Listener { private final DataSource.Factory dataSourceFactory; private final TrackNameProvider trackNameProvider; private final CopyOnWriteArraySet listeners; - private final HashMap trackedDownloadStates; - private final ActionFile actionFile; - private final Handler actionFileWriteHandler; + private final HashMap trackedDownloadStates; + private final DefaultDownloadIndex downloadIndex; + private final Handler actionFileIOHandler; - public DownloadTracker(Context context, DataSource.Factory dataSourceFactory, File actionFile) { + public DownloadTracker( + Context context, DataSource.Factory dataSourceFactory, DefaultDownloadIndex downloadIndex) { this.context = context.getApplicationContext(); this.dataSourceFactory = dataSourceFactory; - this.actionFile = new ActionFile(actionFile); + this.downloadIndex = downloadIndex; trackNameProvider = new DefaultTrackNameProvider(context.getResources()); listeners = new CopyOnWriteArraySet<>(); trackedDownloadStates = new HashMap<>(); HandlerThread actionFileWriteThread = new HandlerThread("DownloadTracker"); actionFileWriteThread.start(); - actionFileWriteHandler = new Handler(actionFileWriteThread.getLooper()); + actionFileIOHandler = new Handler(actionFileWriteThread.getLooper()); loadTrackedActions(); } @@ -117,7 +117,7 @@ public class DownloadTracker implements DownloadManager.Listener { if (!trackedDownloadStates.containsKey(uri)) { return Collections.emptyList(); } - return trackedDownloadStates.get(uri).getKeys(); + return Arrays.asList(trackedDownloadStates.get(uri).streamKeys); } public void toggleDownload( @@ -149,7 +149,7 @@ public class DownloadTracker implements DownloadManager.Listener { || downloadState.state == DownloadState.STATE_FAILED) { // A download has been removed, or has failed. Stop tracking it. if (trackedDownloadStates.remove(downloadState.uri) != null) { - handleTrackedDownloadStatesChanged(); + handleTrackedDownloadStateChanged(downloadState); } } } @@ -159,30 +159,35 @@ public class DownloadTracker implements DownloadManager.Listener { // Do nothing. } + @Override + public void onRequirementsStateChanged( + DownloadManager downloadManager, + Requirements requirements, + @Requirements.RequirementFlags int notMetRequirements) { + // Do nothing. + } + // Internal methods private void loadTrackedActions() { - try { - DownloadAction[] allActions = actionFile.load(); - for (DownloadAction action : allActions) { - trackedDownloadStates.put(action.uri, action); - } - } catch (IOException e) { - Log.e(TAG, "Failed to load tracked actions", e); + DownloadStateCursor downloadStates = downloadIndex.getDownloadStates(); + while (downloadStates.moveToNext()) { + DownloadState downloadState = downloadStates.getDownloadState(); + trackedDownloadStates.put(downloadState.uri, downloadState); } + downloadStates.close(); } - private void handleTrackedDownloadStatesChanged() { + private void handleTrackedDownloadStateChanged(DownloadState downloadState) { for (Listener listener : listeners) { listener.onDownloadsChanged(); } - final DownloadAction[] actions = trackedDownloadStates.values().toArray(new DownloadAction[0]); - actionFileWriteHandler.post( + actionFileIOHandler.post( () -> { - try { - actionFile.store(actions); - } catch (IOException e) { - Log.e(TAG, "Failed to store tracked actions", e); + if (downloadState.state == DownloadState.STATE_REMOVED) { + downloadIndex.removeDownloadState(downloadState.id); + } else { + downloadIndex.putDownloadState(downloadState); } }); } @@ -192,8 +197,9 @@ public class DownloadTracker implements DownloadManager.Listener { // This content is already being downloaded. Do nothing. return; } - trackedDownloadStates.put(action.uri, action); - handleTrackedDownloadStatesChanged(); + DownloadState downloadState = new DownloadState(action); + trackedDownloadStates.put(downloadState.uri, downloadState); + handleTrackedDownloadStateChanged(downloadState); startServiceWithAction(action); } @@ -201,18 +207,18 @@ public class DownloadTracker implements DownloadManager.Listener { DownloadService.startWithAction(context, DemoDownloadService.class, action, false); } - private DownloadHelper getDownloadHelper( + private DownloadHelper getDownloadHelper( Uri uri, String extension, RenderersFactory renderersFactory) { int type = Util.inferContentType(uri, extension); switch (type) { case C.TYPE_DASH: - return new DashDownloadHelper(uri, dataSourceFactory, renderersFactory); + return DownloadHelper.forDash(uri, dataSourceFactory, renderersFactory); case C.TYPE_SS: - return new SsDownloadHelper(uri, dataSourceFactory, renderersFactory); + return DownloadHelper.forSmoothStreaming(uri, dataSourceFactory, renderersFactory); case C.TYPE_HLS: - return new HlsDownloadHelper(uri, dataSourceFactory, renderersFactory); + return DownloadHelper.forHls(uri, dataSourceFactory, renderersFactory); case C.TYPE_OTHER: - return new ProgressiveDownloadHelper(uri); + return DownloadHelper.forProgressive(uri); default: throw new IllegalStateException("Unsupported type: " + type); } @@ -222,10 +228,11 @@ public class DownloadTracker implements DownloadManager.Listener { private final class StartDownloadDialogHelper implements DownloadHelper.Callback, DialogInterface.OnClickListener, + DialogInterface.OnDismissListener, View.OnClickListener, TrackSelectionView.DialogCallback { - private final DownloadHelper downloadHelper; + private final DownloadHelper downloadHelper; private final String name; private final LayoutInflater dialogInflater; private final AlertDialog dialog; @@ -235,20 +242,21 @@ public class DownloadTracker implements DownloadManager.Listener { private DefaultTrackSelector.Parameters parameters; private StartDownloadDialogHelper( - Activity activity, DownloadHelper downloadHelper, String name) { + Activity activity, DownloadHelper downloadHelper, String name) { this.downloadHelper = downloadHelper; this.name = name; AlertDialog.Builder builder = new AlertDialog.Builder(activity) .setTitle(R.string.download_preparing) - .setPositiveButton(android.R.string.ok, this) - .setNegativeButton(android.R.string.cancel, null); + .setPositiveButton(android.R.string.ok, /* listener= */ this) + .setNegativeButton(android.R.string.cancel, /* listener= */ null); // Inflate with the builder's context to ensure the correct style is used. dialogInflater = LayoutInflater.from(builder.getContext()); selectionList = (LinearLayout) dialogInflater.inflate(R.layout.start_download_dialog, null); builder.setView(selectionList); dialog = builder.create(); + dialog.setOnDismissListener(/* listener= */ this); dialog.show(); dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false); @@ -259,19 +267,17 @@ public class DownloadTracker implements DownloadManager.Listener { // DownloadHelper.Callback implementation. @Override - public void onPrepared(DownloadHelper helper) { - if (helper.getPeriodCount() < 1) { - onPrepareError(downloadHelper, new IOException("Content is empty.")); - return; + public void onPrepared(DownloadHelper helper) { + if (helper.getPeriodCount() > 0) { + mappedTrackInfo = downloadHelper.getMappedTrackInfo(/* periodIndex= */ 0); + updateSelectionList(); } - mappedTrackInfo = downloadHelper.getMappedTrackInfo(/* periodIndex= */ 0); - updateSelectionList(); dialog.setTitle(R.string.exo_download_description); dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(true); } @Override - public void onPrepareError(DownloadHelper helper, IOException e) { + public void onPrepareError(DownloadHelper helper, IOException e) { Toast.makeText( context.getApplicationContext(), R.string.download_start_error, Toast.LENGTH_LONG) .show(); @@ -317,6 +323,13 @@ public class DownloadTracker implements DownloadManager.Listener { startDownload(downloadAction); } + // DialogInterface.OnDismissListener implementation. + + @Override + public void onDismiss(DialogInterface dialog) { + downloadHelper.release(); + } + // Internal methods. private void updateSelectionList() { 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 index 582638b460..2d4efd7f3d 100644 --- 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 @@ -51,8 +51,8 @@ import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryExcep import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.source.BehindLiveWindowException; import com.google.android.exoplayer2.source.ConcatenatingMediaSource; -import com.google.android.exoplayer2.source.ExtractorMediaSource; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.source.ads.AdsMediaSource; @@ -483,7 +483,7 @@ public class PlayerActivity extends Activity .setStreamKeys(offlineStreamKeys) .createMediaSource(uri); case C.TYPE_OTHER: - return new ExtractorMediaSource.Factory(dataSourceFactory).createMediaSource(uri); + return new ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(uri); default: { throw new IllegalStateException("Unsupported type: " + type); } diff --git a/extensions/cast/build.gradle b/extensions/cast/build.gradle index 0baa074d4a..fea8363960 100644 --- a/extensions/cast/build.gradle +++ b/extensions/cast/build.gradle @@ -24,7 +24,7 @@ android { } defaultConfig { - minSdkVersion 14 + minSdkVersion project.ext.minSdkVersion targetSdkVersion project.ext.targetSdkVersion consumerProguardFiles 'proguard-rules.txt' } diff --git a/extensions/cronet/build.gradle b/extensions/cronet/build.gradle index 7d8c217b58..1c1c099e7b 100644 --- a/extensions/cronet/build.gradle +++ b/extensions/cronet/build.gradle @@ -19,7 +19,7 @@ android { buildToolsVersion project.ext.buildToolsVersion defaultConfig { - minSdkVersion 16 + minSdkVersion project.ext.minSdkVersion targetSdkVersion project.ext.targetSdkVersion } @@ -30,7 +30,7 @@ android { } dependencies { - api 'org.chromium.net:cronet-embedded:66.3359.158' + api 'org.chromium.net:cronet-embedded:71.3578.98' implementation project(modulePrefix + 'library-core') implementation 'com.android.support:support-annotations:' + supportLibraryVersion testImplementation project(modulePrefix + 'library') diff --git a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java index 2efdde4e58..5448919626 100644 --- a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java +++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java @@ -28,8 +28,8 @@ import com.google.android.exoplayer2.ExoPlayerFactory; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; -import com.google.android.exoplayer2.source.ExtractorMediaSource; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import org.junit.Before; @@ -86,7 +86,7 @@ public class FlacPlaybackTest { player = ExoPlayerFactory.newInstance(context, new Renderer[] {audioRenderer}, trackSelector); player.addListener(this); MediaSource mediaSource = - new ExtractorMediaSource.Factory( + new ProgressiveMediaSource.Factory( new DefaultDataSourceFactory(context, "ExoPlayerExtFlacTest")) .setExtractorsFactory(MatroskaExtractor.FACTORY) .createMediaSource(uri); diff --git a/extensions/gvr/build.gradle b/extensions/gvr/build.gradle index c845cb3423..6c0ec05bfb 100644 --- a/extensions/gvr/build.gradle +++ b/extensions/gvr/build.gradle @@ -33,9 +33,7 @@ dependencies { implementation project(modulePrefix + 'library-core') implementation project(modulePrefix + 'library-ui') implementation 'com.android.support:support-annotations:' + supportLibraryVersion - implementation 'com.google.vr:sdk-audio:1.80.0' - implementation 'com.google.vr:sdk-controller:1.80.0' - api 'com.google.vr:sdk-base:1.80.0' + api 'com.google.vr:sdk-base:1.190.0' compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion } diff --git a/extensions/ima/build.gradle b/extensions/ima/build.gradle index 22196ff3ab..4d6302c898 100644 --- a/extensions/ima/build.gradle +++ b/extensions/ima/build.gradle @@ -31,13 +31,13 @@ android { } dependencies { - api 'com.google.ads.interactivemedia.v3:interactivemedia:3.10.2' + api 'com.google.ads.interactivemedia.v3:interactivemedia:3.10.6' implementation project(modulePrefix + 'library-core') - implementation 'com.google.android.gms:play-services-ads:17.1.1' + implementation 'com.google.android.gms:play-services-ads:17.1.2' // These dependencies are necessary to force the supportLibraryVersion of // com.android.support:support-v4 and com.android.support:customtabs to be // used. Else older versions are used, for example via: - // com.google.android.gms:play-services-ads:17.1.1 + // com.google.android.gms:play-services-ads:17.1.2 // |-- com.android.support:customtabs:26.1.0 implementation 'com.android.support:support-v4:' + supportLibraryVersion implementation 'com.android.support:customtabs:' + supportLibraryVersion diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 311752c7ab..4bdec23804 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -466,11 +466,11 @@ public final class ImaAdsLoader } imaSdkSettings.setPlayerType(IMA_SDK_SETTINGS_PLAYER_TYPE); imaSdkSettings.setPlayerVersion(IMA_SDK_SETTINGS_PLAYER_VERSION); - adsLoader = imaFactory.createAdsLoader(context, imaSdkSettings); period = new Timeline.Period(); adCallbacks = new ArrayList<>(/* initialCapacity= */ 1); adDisplayContainer = imaFactory.createAdDisplayContainer(); adDisplayContainer.setPlayer(/* videoAdPlayer= */ this); + adsLoader = imaFactory.createAdsLoader(context, imaSdkSettings, adDisplayContainer); adsLoader.addAdErrorListener(/* adErrorListener= */ this); adsLoader.addAdsLoadedListener(/* adsLoadedListener= */ this); fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; @@ -524,7 +524,6 @@ public final class ImaAdsLoader if (vastLoadTimeoutMs != TIMEOUT_UNSET) { request.setVastLoadTimeout(vastLoadTimeoutMs); } - request.setAdDisplayContainer(adDisplayContainer); request.setContentProgressProvider(this); request.setUserRequestContext(pendingAdRequestContext); adsLoader.requestAds(request); @@ -1374,9 +1373,9 @@ public final class ImaAdsLoader AdDisplayContainer createAdDisplayContainer(); /** @see com.google.ads.interactivemedia.v3.api.ImaSdkFactory#createAdsRequest() */ AdsRequest createAdsRequest(); - /** @see ImaSdkFactory#createAdsLoader(Context, ImaSdkSettings) */ + /** @see ImaSdkFactory#createAdsLoader(Context, ImaSdkSettings, AdDisplayContainer) */ com.google.ads.interactivemedia.v3.api.AdsLoader createAdsLoader( - Context context, ImaSdkSettings imaSdkSettings); + Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer); } /** Default {@link ImaFactory} for non-test usage, which delegates to {@link ImaSdkFactory}. */ @@ -1403,8 +1402,9 @@ public final class ImaAdsLoader @Override public com.google.ads.interactivemedia.v3.api.AdsLoader createAdsLoader( - Context context, ImaSdkSettings imaSdkSettings) { - return ImaSdkFactory.getInstance().createAdsLoader(context, imaSdkSettings); + Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer) { + return ImaSdkFactory.getInstance() + .createAdsLoader(context, imaSdkSettings, adDisplayContainer); } } } diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsMediaSource.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsMediaSource.java index 574bac5d35..bcccd6cec7 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsMediaSource.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsMediaSource.java @@ -93,8 +93,8 @@ public final class ImaAdsMediaSource extends BaseMediaSource implements SourceIn } @Override - public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { - return adsMediaSource.createPeriod(id, allocator); + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + return adsMediaSource.createPeriod(id, allocator, startPositionUs); } @Override diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/SingletonImaFactory.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/SingletonImaFactory.java index dd46d8a68b..4efd8cf38c 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/SingletonImaFactory.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/SingletonImaFactory.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.ext.ima; import android.content.Context; import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; +import com.google.ads.interactivemedia.v3.api.AdsLoader; import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings; import com.google.ads.interactivemedia.v3.api.AdsRequest; import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; @@ -64,8 +65,8 @@ final class SingletonImaFactory implements ImaAdsLoader.ImaFactory { } @Override - public com.google.ads.interactivemedia.v3.api.AdsLoader createAdsLoader( - Context context, ImaSdkSettings imaSdkSettings) { + public AdsLoader createAdsLoader( + Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer) { return adsLoader; } } diff --git a/extensions/okhttp/build.gradle b/extensions/okhttp/build.gradle index 4e6b11c495..78825a6277 100644 --- a/extensions/okhttp/build.gradle +++ b/extensions/okhttp/build.gradle @@ -34,7 +34,7 @@ dependencies { implementation project(modulePrefix + 'library-core') implementation 'com.android.support:support-annotations:' + supportLibraryVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion - api 'com.squareup.okhttp3:okhttp:3.11.0' + api 'com.squareup.okhttp3:okhttp:3.12.1' } ext { diff --git a/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java b/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java index 5ad864c597..6a254c8230 100644 --- a/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java +++ b/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java @@ -28,8 +28,8 @@ import com.google.android.exoplayer2.ExoPlayerFactory; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; -import com.google.android.exoplayer2.source.ExtractorMediaSource; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import org.junit.Before; @@ -86,7 +86,7 @@ public class OpusPlaybackTest { player = ExoPlayerFactory.newInstance(context, new Renderer[] {audioRenderer}, trackSelector); player.addListener(this); MediaSource mediaSource = - new ExtractorMediaSource.Factory( + new ProgressiveMediaSource.Factory( new DefaultDataSourceFactory(context, "ExoPlayerExtOpusTest")) .setExtractorsFactory(MatroskaExtractor.FACTORY) .createMediaSource(uri); diff --git a/extensions/rtmp/build.gradle b/extensions/rtmp/build.gradle index af02ee2eaa..4869df7a1a 100644 --- a/extensions/rtmp/build.gradle +++ b/extensions/rtmp/build.gradle @@ -24,7 +24,7 @@ android { } defaultConfig { - minSdkVersion 15 + minSdkVersion project.ext.minSdkVersion targetSdkVersion project.ext.targetSdkVersion } } diff --git a/extensions/vp9/README.md b/extensions/vp9/README.md index 306f04d0e2..6f46a4e6ad 100644 --- a/extensions/vp9/README.md +++ b/extensions/vp9/README.md @@ -34,26 +34,6 @@ VP9_EXT_PATH="${EXOPLAYER_ROOT}/extensions/vp9/src/main" NDK_PATH="" ``` -* Fetch libvpx and libyuv: - -``` -cd "${VP9_EXT_PATH}/jni" && \ -git clone https://chromium.googlesource.com/webm/libvpx libvpx && \ -git clone https://chromium.googlesource.com/libyuv/libyuv libyuv -``` - -* Checkout the appropriate branches of libvpx and libyuv (the scripts and - makefiles bundled in this repo are known to work only at these versions of the - libraries - we will update this periodically as newer versions of - libvpx/libyuv are released): - -``` -cd "${VP9_EXT_PATH}/jni/libvpx" && \ -git checkout tags/v1.7.0 -b v1.7.0 && \ -cd "${VP9_EXT_PATH}/jni/libyuv" && \ -git checkout 996a2bbd -``` - * Run a script that generates necessary configuration files for libvpx: ``` @@ -78,10 +58,6 @@ ${NDK_PATH}/ndk-build APP_ABI=all -j4 * Android config scripts should be re-generated by running `generate_libvpx_android_configs.sh` * Clean and re-build the project. -* If you want to use your own version of libvpx or libyuv, place it in - `${VP9_EXT_PATH}/jni/libvpx` or `${VP9_EXT_PATH}/jni/libyuv` respectively. But - please note that `generate_libvpx_android_configs.sh` and the makefiles need - to be modified to work with arbitrary versions of libvpx and libyuv. ## Using the extension ## diff --git a/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java b/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java index c6d1e667e0..a36b578588 100644 --- a/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java +++ b/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java @@ -29,8 +29,8 @@ import com.google.android.exoplayer2.ExoPlayerFactory; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; -import com.google.android.exoplayer2.source.ExtractorMediaSource; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import com.google.android.exoplayer2.util.Log; @@ -114,12 +114,12 @@ public class VpxPlaybackTest { @Override public void run() { Looper.prepare(); - LibvpxVideoRenderer videoRenderer = new LibvpxVideoRenderer(true, 0); + LibvpxVideoRenderer videoRenderer = new LibvpxVideoRenderer(0); DefaultTrackSelector trackSelector = new DefaultTrackSelector(); player = ExoPlayerFactory.newInstance(context, new Renderer[] {videoRenderer}, trackSelector); player.addListener(this); MediaSource mediaSource = - new ExtractorMediaSource.Factory( + new ProgressiveMediaSource.Factory( new DefaultDataSourceFactory(context, "ExoPlayerExtVp9Test")) .setExtractorsFactory(MatroskaExtractor.FACTORY) .createMediaSource(uri); diff --git a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java index e3081cd2d2..54ccbb40ad 100644 --- a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java +++ b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java @@ -15,8 +15,6 @@ */ package com.google.android.exoplayer2.ext.vp9; -import android.graphics.Bitmap; -import android.graphics.Canvas; import android.os.Handler; import android.os.Looper; import android.os.SystemClock; @@ -109,7 +107,6 @@ public class LibvpxVideoRenderer extends BaseRenderer { /** The default input buffer size. */ private static final int DEFAULT_INPUT_BUFFER_SIZE = 768 * 1024; // Value based on cs/SoftVpx.cpp. - private final boolean scaleToFit; private final boolean disableLoopFilter; private final long allowedJoiningTimeMs; private final int maxDroppedFramesToNotify; @@ -119,7 +116,6 @@ public class LibvpxVideoRenderer extends BaseRenderer { private final TimedValueQueue formatQueue; private final DecoderInputBuffer flagsOnlyBuffer; private final DrmSessionManager drmSessionManager; - private final boolean useSurfaceYuvOutput; private Format format; private Format pendingFormat; @@ -127,13 +123,12 @@ public class LibvpxVideoRenderer extends BaseRenderer { private VpxDecoder decoder; private VpxInputBuffer inputBuffer; private VpxOutputBuffer outputBuffer; - private DrmSession drmSession; - private DrmSession pendingDrmSession; + @Nullable private DrmSession decoderDrmSession; + @Nullable private DrmSession sourceDrmSession; private @ReinitializationState int decoderReinitializationState; private boolean decoderReceivedBuffers; - private Bitmap bitmap; private boolean renderedFirstFrame; private long initialPositionUs; private long joiningDeadlineMs; @@ -158,16 +153,14 @@ public class LibvpxVideoRenderer extends BaseRenderer { protected DecoderCounters decoderCounters; /** - * @param scaleToFit Whether video frames should be scaled to fit when rendering. * @param allowedJoiningTimeMs The maximum duration in milliseconds for which this video renderer * can attempt to seamlessly join an ongoing playback. */ - public LibvpxVideoRenderer(boolean scaleToFit, long allowedJoiningTimeMs) { - this(scaleToFit, allowedJoiningTimeMs, null, null, 0); + public LibvpxVideoRenderer(long allowedJoiningTimeMs) { + this(allowedJoiningTimeMs, null, null, 0); } /** - * @param scaleToFit Whether video frames should be scaled to fit when rendering. * @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 @@ -176,23 +169,22 @@ public class LibvpxVideoRenderer extends BaseRenderer { * @param maxDroppedFramesToNotify The maximum number of frames that can be dropped between * invocations of {@link VideoRendererEventListener#onDroppedFrames(int, long)}. */ - public LibvpxVideoRenderer(boolean scaleToFit, long allowedJoiningTimeMs, - Handler eventHandler, VideoRendererEventListener eventListener, + public LibvpxVideoRenderer( + long allowedJoiningTimeMs, + Handler eventHandler, + VideoRendererEventListener eventListener, int maxDroppedFramesToNotify) { this( - scaleToFit, allowedJoiningTimeMs, eventHandler, eventListener, maxDroppedFramesToNotify, /* drmSessionManager= */ null, /* playClearSamplesWithoutKeys= */ false, - /* disableLoopFilter= */ false, - /* useSurfaceYuvOutput= */ false); + /* disableLoopFilter= */ false); } /** - * @param scaleToFit Whether video frames should be scaled to fit when rendering. * @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 @@ -208,26 +200,21 @@ public class LibvpxVideoRenderer extends BaseRenderer { * permitted to play clear regions of encrypted media files before {@code drmSessionManager} * has obtained the keys necessary to decrypt encrypted regions of the media. * @param disableLoopFilter Disable the libvpx in-loop smoothing filter. - * @param useSurfaceYuvOutput Directly output YUV to the Surface via ANativeWindow. */ public LibvpxVideoRenderer( - boolean scaleToFit, long allowedJoiningTimeMs, Handler eventHandler, VideoRendererEventListener eventListener, int maxDroppedFramesToNotify, DrmSessionManager drmSessionManager, boolean playClearSamplesWithoutKeys, - boolean disableLoopFilter, - boolean useSurfaceYuvOutput) { + boolean disableLoopFilter) { super(C.TRACK_TYPE_VIDEO); - this.scaleToFit = scaleToFit; this.disableLoopFilter = disableLoopFilter; this.allowedJoiningTimeMs = allowedJoiningTimeMs; this.maxDroppedFramesToNotify = maxDroppedFramesToNotify; this.drmSessionManager = drmSessionManager; this.playClearSamplesWithoutKeys = playClearSamplesWithoutKeys; - this.useSurfaceYuvOutput = useSurfaceYuvOutput; joiningDeadlineMs = C.TIME_UNSET; clearReportedVideoSize(); formatHolder = new FormatHolder(); @@ -364,24 +351,10 @@ public class LibvpxVideoRenderer extends BaseRenderer { clearReportedVideoSize(); clearRenderedFirstFrame(); try { + setSourceDrmSession(null); releaseDecoder(); } finally { - try { - if (drmSession != null) { - drmSessionManager.releaseSession(drmSession); - } - } finally { - try { - if (pendingDrmSession != null && pendingDrmSession != drmSession) { - drmSessionManager.releaseSession(pendingDrmSession); - } - } finally { - drmSession = null; - pendingDrmSession = null; - decoderCounters.ensureUpdated(); - eventDispatcher.disabled(decoderCounters); - } - } + eventDispatcher.disabled(decoderCounters); } } @@ -433,18 +406,35 @@ public class LibvpxVideoRenderer extends BaseRenderer { /** Releases the decoder. */ @CallSuper protected void releaseDecoder() { - if (decoder == null) { - return; - } - inputBuffer = null; outputBuffer = null; - decoder.release(); - decoder = null; - decoderCounters.decoderReleaseCount++; decoderReinitializationState = REINITIALIZATION_STATE_NONE; decoderReceivedBuffers = false; buffersInCodecCount = 0; + if (decoder != null) { + decoder.release(); + decoder = null; + decoderCounters.decoderReleaseCount++; + } + setDecoderDrmSession(null); + } + + private void setSourceDrmSession(@Nullable DrmSession session) { + DrmSession previous = sourceDrmSession; + sourceDrmSession = session; + releaseDrmSessionIfUnused(previous); + } + + private void setDecoderDrmSession(@Nullable DrmSession session) { + DrmSession previous = decoderDrmSession; + decoderDrmSession = session; + releaseDrmSessionIfUnused(previous); + } + + private void releaseDrmSessionIfUnused(@Nullable DrmSession session) { + if (session != null && session != decoderDrmSession && session != sourceDrmSession) { + drmSessionManager.releaseSession(session); + } } /** @@ -467,16 +457,20 @@ public class LibvpxVideoRenderer extends BaseRenderer { throw ExoPlaybackException.createForRenderer( new IllegalStateException("Media requires a DrmSessionManager"), getIndex()); } - pendingDrmSession = drmSessionManager.acquireSession(Looper.myLooper(), format.drmInitData); - if (pendingDrmSession == drmSession) { - drmSessionManager.releaseSession(pendingDrmSession); + DrmSession session = + drmSessionManager.acquireSession(Looper.myLooper(), newFormat.drmInitData); + if (session == decoderDrmSession || session == sourceDrmSession) { + // We already had this session. The manager must be reference counting, so release it once + // to get the count attributed to this renderer back down to 1. + drmSessionManager.releaseSession(session); } + setSourceDrmSession(session); } else { - pendingDrmSession = null; + setSourceDrmSession(null); } } - if (pendingDrmSession != drmSession) { + if (sourceDrmSession != decoderDrmSession) { if (decoderReceivedBuffers) { // Signal end of stream and wait for any final output buffers before re-initialization. decoderReinitializationState = REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM; @@ -579,18 +573,14 @@ public class LibvpxVideoRenderer extends BaseRenderer { */ protected void renderOutputBuffer(VpxOutputBuffer outputBuffer) throws VpxDecoderException { int bufferMode = outputBuffer.mode; - boolean renderRgb = bufferMode == VpxDecoder.OUTPUT_MODE_RGB && surface != null; boolean renderSurface = bufferMode == VpxDecoder.OUTPUT_MODE_SURFACE_YUV && surface != null; boolean renderYuv = bufferMode == VpxDecoder.OUTPUT_MODE_YUV && outputBufferRenderer != null; lastRenderTimeUs = SystemClock.elapsedRealtime() * 1000; - if (!renderRgb && !renderYuv && !renderSurface) { + if (!renderYuv && !renderSurface) { dropOutputBuffer(outputBuffer); } else { maybeNotifyVideoSizeChanged(outputBuffer.width, outputBuffer.height); - if (renderRgb) { - renderRgbFrame(outputBuffer, scaleToFit); - outputBuffer.release(); - } else if (renderYuv) { + if (renderYuv) { outputBufferRenderer.setOutputBuffer(outputBuffer); // The renderer will release the buffer. } else { // renderSurface @@ -668,8 +658,7 @@ public class LibvpxVideoRenderer extends BaseRenderer { this.surface = surface; this.outputBufferRenderer = outputBufferRenderer; if (surface != null) { - outputMode = - useSurfaceYuvOutput ? VpxDecoder.OUTPUT_MODE_SURFACE_YUV : VpxDecoder.OUTPUT_MODE_RGB; + outputMode = VpxDecoder.OUTPUT_MODE_SURFACE_YUV; } else { outputMode = outputBufferRenderer != null ? VpxDecoder.OUTPUT_MODE_YUV : VpxDecoder.OUTPUT_MODE_NONE; @@ -704,12 +693,13 @@ public class LibvpxVideoRenderer extends BaseRenderer { return; } - drmSession = pendingDrmSession; + setDecoderDrmSession(sourceDrmSession); + ExoMediaCrypto mediaCrypto = null; - if (drmSession != null) { - mediaCrypto = drmSession.getMediaCrypto(); + if (decoderDrmSession != null) { + mediaCrypto = decoderDrmSession.getMediaCrypto(); if (mediaCrypto == null) { - DrmSessionException drmError = drmSession.getError(); + DrmSessionException drmError = decoderDrmSession.getError(); if (drmError != null) { // Continue for now. We may be able to avoid failure if the session recovers, or if a new // input format causes the session to be replaced before it's used. @@ -731,8 +721,7 @@ public class LibvpxVideoRenderer extends BaseRenderer { NUM_OUTPUT_BUFFERS, initialInputBufferSize, mediaCrypto, - disableLoopFilter, - useSurfaceYuvOutput); + disableLoopFilter); decoder.setOutputMode(outputMode); TraceUtil.endSection(); long decoderInitializedTimestamp = SystemClock.elapsedRealtime(); @@ -922,33 +911,16 @@ public class LibvpxVideoRenderer extends BaseRenderer { } private boolean shouldWaitForKeys(boolean bufferEncrypted) throws ExoPlaybackException { - if (drmSession == null || (!bufferEncrypted && playClearSamplesWithoutKeys)) { + if (decoderDrmSession == null || (!bufferEncrypted && playClearSamplesWithoutKeys)) { return false; } - @DrmSession.State int drmSessionState = drmSession.getState(); + @DrmSession.State int drmSessionState = decoderDrmSession.getState(); if (drmSessionState == DrmSession.STATE_ERROR) { - throw ExoPlaybackException.createForRenderer(drmSession.getError(), getIndex()); + throw ExoPlaybackException.createForRenderer(decoderDrmSession.getError(), getIndex()); } return drmSessionState != DrmSession.STATE_OPENED_WITH_KEYS; } - private void renderRgbFrame(VpxOutputBuffer outputBuffer, boolean scale) { - if (bitmap == null - || bitmap.getWidth() != outputBuffer.width - || bitmap.getHeight() != outputBuffer.height) { - bitmap = Bitmap.createBitmap(outputBuffer.width, outputBuffer.height, Bitmap.Config.RGB_565); - } - bitmap.copyPixelsFromBuffer(outputBuffer.data); - Canvas canvas = surface.lockCanvas(null); - if (scale) { - canvas.scale( - ((float) canvas.getWidth()) / outputBuffer.width, - ((float) canvas.getHeight()) / outputBuffer.height); - } - canvas.drawBitmap(bitmap, 0, 0, null); - surface.unlockCanvasAndPost(canvas); - } - private void setJoiningDeadlineMs() { joiningDeadlineMs = allowedJoiningTimeMs > 0 ? (SystemClock.elapsedRealtime() + allowedJoiningTimeMs) : C.TIME_UNSET; diff --git a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java index 51ef8e9bcf..b157981487 100644 --- a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java +++ b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java @@ -31,8 +31,7 @@ import java.nio.ByteBuffer; public static final int OUTPUT_MODE_NONE = -1; public static final int OUTPUT_MODE_YUV = 0; - public static final int OUTPUT_MODE_RGB = 1; - public static final int OUTPUT_MODE_SURFACE_YUV = 2; + public static final int OUTPUT_MODE_SURFACE_YUV = 1; private static final int NO_ERROR = 0; private static final int DECODE_ERROR = 1; @@ -52,7 +51,6 @@ import java.nio.ByteBuffer; * @param exoMediaCrypto The {@link ExoMediaCrypto} object required for decoding encrypted * content. Maybe null and can be ignored if decoder does not handle encrypted content. * @param disableLoopFilter Disable the libvpx in-loop smoothing filter. - * @param enableSurfaceYuvOutputMode Whether OUTPUT_MODE_SURFACE_YUV is allowed. * @throws VpxDecoderException Thrown if an exception occurs when initializing the decoder. */ public VpxDecoder( @@ -60,8 +58,7 @@ import java.nio.ByteBuffer; int numOutputBuffers, int initialInputBufferSize, ExoMediaCrypto exoMediaCrypto, - boolean disableLoopFilter, - boolean enableSurfaceYuvOutputMode) + boolean disableLoopFilter) throws VpxDecoderException { super(new VpxInputBuffer[numInputBuffers], new VpxOutputBuffer[numOutputBuffers]); if (!VpxLibrary.isAvailable()) { @@ -71,7 +68,7 @@ import java.nio.ByteBuffer; if (exoMediaCrypto != null && !VpxLibrary.vpxIsSecureDecodeSupported()) { throw new VpxDecoderException("Vpx decoder does not support secure decode."); } - vpxDecContext = vpxInit(disableLoopFilter, enableSurfaceYuvOutputMode); + vpxDecContext = vpxInit(disableLoopFilter); if (vpxDecContext == 0) { throw new VpxDecoderException("Failed to initialize decoder"); } @@ -86,8 +83,8 @@ import java.nio.ByteBuffer; /** * Sets the output mode for frames rendered by the decoder. * - * @param outputMode The output mode. One of {@link #OUTPUT_MODE_NONE}, {@link #OUTPUT_MODE_RGB} - * and {@link #OUTPUT_MODE_YUV}. + * @param outputMode The output mode. One of {@link #OUTPUT_MODE_NONE} and {@link + * #OUTPUT_MODE_YUV}. */ public void setOutputMode(int outputMode) { this.outputMode = outputMode; @@ -168,7 +165,7 @@ import java.nio.ByteBuffer; } } - private native long vpxInit(boolean disableLoopFilter, boolean enableSurfaceYuvOutputMode); + private native long vpxInit(boolean disableLoopFilter); private native long vpxClose(long context); private native long vpxDecode(long context, ByteBuffer encoded, int length); diff --git a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxOutputBuffer.java b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxOutputBuffer.java index 725d94819b..f5cdb6e11a 100644 --- a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxOutputBuffer.java +++ b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxOutputBuffer.java @@ -60,36 +60,19 @@ public final class VpxOutputBuffer extends OutputBuffer { * Initializes the buffer. * * @param timeUs The presentation timestamp for the buffer, in microseconds. - * @param mode The output mode. One of {@link VpxDecoder#OUTPUT_MODE_NONE}, - * {@link VpxDecoder#OUTPUT_MODE_RGB} and {@link VpxDecoder#OUTPUT_MODE_YUV}. + * @param mode The output mode. One of {@link VpxDecoder#OUTPUT_MODE_NONE} and {@link + * VpxDecoder#OUTPUT_MODE_YUV}. */ public void init(long timeUs, int mode) { this.timeUs = timeUs; this.mode = mode; } - - /** - * Resizes the buffer based on the given dimensions. Called via JNI after decoding completes. - * @return Whether the buffer was resized successfully. - */ - public boolean initForRgbFrame(int width, int height) { - this.width = width; - this.height = height; - this.yuvPlanes = null; - if (!isSafeToMultiply(width, height) || !isSafeToMultiply(width * height, 2)) { - return false; - } - int minimumRgbSize = width * height * 2; - initData(minimumRgbSize); - return true; - } - /** * Resizes the buffer based on the given stride. Called via JNI after decoding completes. + * * @return Whether the buffer was resized successfully. */ - public boolean initForYuvFrame(int width, int height, int yStride, int uvStride, - int colorspace) { + public boolean initForYuvFrame(int width, int height, int yStride, int uvStride, int colorspace) { this.width = width; this.height = height; this.colorspace = colorspace; diff --git a/extensions/vp9/src/main/jni/Android.mk b/extensions/vp9/src/main/jni/Android.mk index fdcdc57b41..cb7571a1b0 100644 --- a/extensions/vp9/src/main/jni/Android.mk +++ b/extensions/vp9/src/main/jni/Android.mk @@ -17,12 +17,6 @@ WORKING_DIR := $(call my-dir) include $(CLEAR_VARS) LIBVPX_ROOT := $(WORKING_DIR)/libvpx -LIBYUV_ROOT := $(WORKING_DIR)/libyuv - -# build libyuv_static.a -LOCAL_PATH := $(WORKING_DIR) -LIBYUV_DISABLE_JPEG := "yes" -include $(LIBYUV_ROOT)/Android.mk # build libvpx.so LOCAL_PATH := $(WORKING_DIR) @@ -37,7 +31,7 @@ LOCAL_CPP_EXTENSION := .cc LOCAL_SRC_FILES := vpx_jni.cc LOCAL_LDLIBS := -llog -lz -lm -landroid LOCAL_SHARED_LIBRARIES := libvpx -LOCAL_STATIC_LIBRARIES := libyuv_static cpufeatures +LOCAL_STATIC_LIBRARIES := cpufeatures include $(BUILD_SHARED_LIBRARY) $(call import-module,android/cpufeatures) diff --git a/extensions/vp9/src/main/jni/vpx_jni.cc b/extensions/vp9/src/main/jni/vpx_jni.cc index 875e46d40f..c37545190c 100644 --- a/extensions/vp9/src/main/jni/vpx_jni.cc +++ b/extensions/vp9/src/main/jni/vpx_jni.cc @@ -30,8 +30,6 @@ #include #include -#include "libyuv.h" // NOLINT - #define VPX_CODEC_DISABLE_COMPAT 1 #include "vpx/vpx_decoder.h" #include "vpx/vp8dx.h" @@ -61,7 +59,6 @@ (JNIEnv* env, jobject thiz, ##__VA_ARGS__)\ // JNI references for VpxOutputBuffer class. -static jmethodID initForRgbFrame; static jmethodID initForYuvFrame; static jfieldID dataField; static jfieldID outputModeField; @@ -393,11 +390,7 @@ class JniBufferManager { }; struct JniCtx { - JniCtx(bool enableBufferManager) { - if (enableBufferManager) { - buffer_manager = new JniBufferManager(); - } - } + JniCtx() { buffer_manager = new JniBufferManager(); } ~JniCtx() { if (native_window) { @@ -440,9 +433,8 @@ int vpx_release_frame_buffer(void* priv, vpx_codec_frame_buffer_t* fb) { return buffer_manager->release(*(int*)fb->priv); } -DECODER_FUNC(jlong, vpxInit, jboolean disableLoopFilter, - jboolean enableBufferManager) { - JniCtx* context = new JniCtx(enableBufferManager); +DECODER_FUNC(jlong, vpxInit, jboolean disableLoopFilter) { + JniCtx* context = new JniCtx(); context->decoder = new vpx_codec_ctx_t(); vpx_codec_dec_cfg_t cfg = {0, 0, 0}; cfg.threads = android_getCpuCount(); @@ -469,14 +461,12 @@ DECODER_FUNC(jlong, vpxInit, jboolean disableLoopFilter, } #endif } - if (enableBufferManager) { - err = vpx_codec_set_frame_buffer_functions( - context->decoder, vpx_get_frame_buffer, vpx_release_frame_buffer, - context->buffer_manager); - if (err) { - LOGE("ERROR: Failed to set libvpx frame buffer functions, error = %d.", - err); - } + err = vpx_codec_set_frame_buffer_functions( + context->decoder, vpx_get_frame_buffer, vpx_release_frame_buffer, + context->buffer_manager); + if (err) { + LOGE("ERROR: Failed to set libvpx frame buffer functions, error = %d.", + err); } // Populate JNI References. @@ -484,8 +474,6 @@ DECODER_FUNC(jlong, vpxInit, jboolean disableLoopFilter, "com/google/android/exoplayer2/ext/vp9/VpxOutputBuffer"); initForYuvFrame = env->GetMethodID(outputBufferClass, "initForYuvFrame", "(IIIII)Z"); - initForRgbFrame = env->GetMethodID(outputBufferClass, "initForRgbFrame", - "(II)Z"); dataField = env->GetFieldID(outputBufferClass, "data", "Ljava/nio/ByteBuffer;"); outputModeField = env->GetFieldID(outputBufferClass, "mode", "I"); @@ -537,28 +525,10 @@ DECODER_FUNC(jint, vpxGetFrame, jlong jContext, jobject jOutputBuffer) { } const int kOutputModeYuv = 0; - const int kOutputModeRgb = 1; - const int kOutputModeSurfaceYuv = 2; + const int kOutputModeSurfaceYuv = 1; int outputMode = env->GetIntField(jOutputBuffer, outputModeField); - if (outputMode == kOutputModeRgb) { - // resize buffer if required. - jboolean initResult = env->CallBooleanMethod(jOutputBuffer, initForRgbFrame, - img->d_w, img->d_h); - if (env->ExceptionCheck() || !initResult) { - return -1; - } - - // get pointer to the data buffer. - const jobject dataObject = env->GetObjectField(jOutputBuffer, dataField); - uint8_t* const dst = - reinterpret_cast(env->GetDirectBufferAddress(dataObject)); - - libyuv::I420ToRGB565(img->planes[VPX_PLANE_Y], img->stride[VPX_PLANE_Y], - img->planes[VPX_PLANE_U], img->stride[VPX_PLANE_U], - img->planes[VPX_PLANE_V], img->stride[VPX_PLANE_V], - dst, img->d_w * 2, img->d_w, img->d_h); - } else if (outputMode == kOutputModeYuv) { + if (outputMode == kOutputModeYuv) { const int kColorspaceUnknown = 0; const int kColorspaceBT601 = 1; const int kColorspaceBT709 = 2; @@ -616,9 +586,6 @@ DECODER_FUNC(jint, vpxGetFrame, jlong jContext, jobject jOutputBuffer) { } } else if (outputMode == kOutputModeSurfaceYuv && img->fmt != VPX_IMG_FMT_I42016) { - if (!context->buffer_manager) { - return -1; // enableBufferManager was not set in vpxInit. - } int id = *(int*)img->fb_priv; context->buffer_manager->add_ref(id); JniFrameBuffer* jfb = context->buffer_manager->get_buffer(id); diff --git a/library/core/proguard-rules.txt b/library/core/proguard-rules.txt index 5aec86ba8c..d18c34b86d 100644 --- a/library/core/proguard-rules.txt +++ b/library/core/proguard-rules.txt @@ -3,7 +3,7 @@ # Constructors accessed via reflection in DefaultRenderersFactory -dontnote com.google.android.exoplayer2.ext.vp9.LibvpxVideoRenderer -keepclassmembers class com.google.android.exoplayer2.ext.vp9.LibvpxVideoRenderer { - (boolean, long, android.os.Handler, com.google.android.exoplayer2.video.VideoRendererEventListener, int); + (long, android.os.Handler, com.google.android.exoplayer2.video.VideoRendererEventListener, int); } -dontnote com.google.android.exoplayer2.ext.opus.LibopusAudioRenderer -keepclassmembers class com.google.android.exoplayer2.ext.opus.LibopusAudioRenderer { @@ -44,5 +44,22 @@ (android.net.Uri, java.util.List, com.google.android.exoplayer2.offlineDownloaderConstructorHelper); } +# Constructors accessed via reflection in DownloadHelper +-dontnote com.google.android.exoplayer2.source.dash.DashMediaSource$Factory +-keepclassmembers class com.google.android.exoplayer2.source.dash.DashMediaSource$Factory { + (com.google.android.exoplayer2.upstream.DataSource$Factory); + DashMediaSource createMediaSource(android.net.Uri); +} +-dontnote com.google.android.exoplayer2.source.hls.HlsMediaSource$Factory +-keepclassmembers class com.google.android.exoplayer2.source.hls.HlsMediaSource$Factory { + (com.google.android.exoplayer2.upstream.DataSource$Factory); + HlsMediaSource createMediaSource(android.net.Uri); +} +-dontnote com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource$Factory +-keepclassmembers class com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource$Factory { + (com.google.android.exoplayer2.upstream.DataSource$Factory); + SsMediaSource createMediaSource(android.net.Uri); +} + # Don't warn about checkerframework -dontwarn org.checkerframework.** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java index 73602d85aa..79192ade15 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java @@ -37,7 +37,7 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { private SampleStream stream; private Format[] streamFormats; private long streamOffsetUs; - private boolean readEndOfStream; + private long readingPositionUs; private boolean streamIsFinal; /** @@ -46,7 +46,7 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { */ public BaseRenderer(int trackType) { this.trackType = trackType; - readEndOfStream = true; + readingPositionUs = C.TIME_END_OF_SOURCE; } @Override @@ -98,7 +98,7 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { throws ExoPlaybackException { Assertions.checkState(!streamIsFinal); this.stream = stream; - readEndOfStream = false; + readingPositionUs = offsetUs; streamFormats = formats; streamOffsetUs = offsetUs; onStreamChanged(formats, offsetUs); @@ -111,7 +111,12 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { @Override public final boolean hasReadStreamToEnd() { - return readEndOfStream; + return readingPositionUs == C.TIME_END_OF_SOURCE; + } + + @Override + public final long getReadingPositionUs() { + return readingPositionUs; } @Override @@ -132,7 +137,7 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { @Override public final void resetPosition(long positionUs) throws ExoPlaybackException { streamIsFinal = false; - readEndOfStream = false; + readingPositionUs = positionUs; onPositionReset(positionUs, false); } @@ -303,10 +308,11 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { int result = stream.readData(formatHolder, buffer, formatRequired); if (result == C.RESULT_BUFFER_READ) { if (buffer.isEndOfStream()) { - readEndOfStream = true; + readingPositionUs = C.TIME_END_OF_SOURCE; return streamIsFinal ? C.RESULT_BUFFER_READ : C.RESULT_NOTHING_READ; } buffer.timeUs += streamOffsetUs; + readingPositionUs = Math.max(readingPositionUs, buffer.timeUs); } else if (result == C.RESULT_FORMAT_READ) { Format format = formatHolder.format; if (format.subsampleOffsetUs != Format.OFFSET_SAMPLE_RELATIVE) { @@ -332,7 +338,7 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { * Returns whether the upstream source is ready. */ protected final boolean isSourceReady() { - return readEndOfStream ? streamIsFinal : stream.isReady(); + return hasReadStreamToEnd() ? streamIsFinal : stream.isReady(); } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/C.java b/library/core/src/main/java/com/google/android/exoplayer2/C.java index 77d39fe866..d163fabd60 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/C.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/C.java @@ -460,8 +460,8 @@ public final class C { /** * Flags which can apply to a buffer containing a media sample. Possible flag values are {@link - * #BUFFER_FLAG_KEY_FRAME}, {@link #BUFFER_FLAG_END_OF_STREAM}, {@link #BUFFER_FLAG_ENCRYPTED} and - * {@link #BUFFER_FLAG_DECODE_ONLY}. + * #BUFFER_FLAG_KEY_FRAME}, {@link #BUFFER_FLAG_END_OF_STREAM}, {@link #BUFFER_FLAG_LAST_SAMPLE}, + * {@link #BUFFER_FLAG_ENCRYPTED} and {@link #BUFFER_FLAG_DECODE_ONLY}. */ @Documented @Retention(RetentionPolicy.SOURCE) @@ -470,6 +470,7 @@ public final class C { value = { BUFFER_FLAG_KEY_FRAME, BUFFER_FLAG_END_OF_STREAM, + BUFFER_FLAG_LAST_SAMPLE, BUFFER_FLAG_ENCRYPTED, BUFFER_FLAG_DECODE_ONLY }) @@ -482,6 +483,8 @@ public final class C { * Flag for empty buffers that signal that the end of the stream was reached. */ public static final int BUFFER_FLAG_END_OF_STREAM = MediaCodec.BUFFER_FLAG_END_OF_STREAM; + /** Indicates that a buffer is known to contain the last media sample of the stream. */ + public static final int BUFFER_FLAG_LAST_SAMPLE = 1 << 29; // 0x20000000 /** Indicates that a buffer is (at least partially) encrypted. */ public static final int BUFFER_FLAG_ENCRYPTED = 1 << 30; // 0x40000000 /** Indicates that a buffer should be decoded but not rendered. */ @@ -533,9 +536,7 @@ public final class C { */ public static final int SELECTION_FLAG_AUTOSELECT = 1 << 2; // 4 - /** - * Represents an undetermined language as an ISO 639 alpha-3 language code. - */ + /** Represents an undetermined language as an ISO 639-2 language code. */ public static final String LANGUAGE_UNDETERMINED = "und"; /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java index 6ccda2b8e9..ef0a008849 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2; import android.content.Context; +import android.media.MediaCodec; import android.os.Handler; import android.os.Looper; import android.support.annotation.IntDef; @@ -85,15 +86,18 @@ public class DefaultRenderersFactory implements RenderersFactory { protected static final int MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY = 50; private final Context context; - private final @Nullable DrmSessionManager drmSessionManager; - private final @ExtensionRendererMode int extensionRendererMode; - private final long allowedVideoJoiningTimeMs; + @Nullable private DrmSessionManager drmSessionManager; + @ExtensionRendererMode private int extensionRendererMode; + private long allowedVideoJoiningTimeMs; + private boolean playClearSamplesWithoutKeys; + private MediaCodecSelector mediaCodecSelector; - /** - * @param context A {@link Context}. - */ + /** @param context A {@link Context}. */ public DefaultRenderersFactory(Context context) { - this(context, EXTENSION_RENDERER_MODE_OFF); + this.context = context; + extensionRendererMode = EXTENSION_RENDERER_MODE_OFF; + allowedVideoJoiningTimeMs = DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS; + mediaCodecSelector = MediaCodecSelector.DEFAULT; } /** @@ -108,19 +112,20 @@ public class DefaultRenderersFactory implements RenderersFactory { } /** - * @param context A {@link Context}. - * @param extensionRendererMode The extension renderer mode, which determines if and how available - * extension renderers are used. Note that extensions must be included in the application - * build for them to be considered available. + * @deprecated Use {@link #DefaultRenderersFactory(Context)} and {@link + * #setExtensionRendererMode(int)}. */ + @Deprecated + @SuppressWarnings("deprecation") public DefaultRenderersFactory( Context context, @ExtensionRendererMode int extensionRendererMode) { this(context, extensionRendererMode, DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS); } /** - * @deprecated Use {@link #DefaultRenderersFactory(Context, int)} and pass {@link - * DrmSessionManager} directly to {@link SimpleExoPlayer} or {@link ExoPlayerFactory}. + * @deprecated Use {@link #DefaultRenderersFactory(Context)} and {@link + * #setExtensionRendererMode(int)}, and pass {@link DrmSessionManager} directly to {@link + * SimpleExoPlayer} or {@link ExoPlayerFactory}. */ @Deprecated @SuppressWarnings("deprecation") @@ -132,26 +137,22 @@ public class DefaultRenderersFactory implements RenderersFactory { } /** - * @param context A {@link Context}. - * @param extensionRendererMode The extension renderer mode, which determines if and how available - * extension renderers are used. Note that extensions must be included in the application - * build for them to be considered available. - * @param allowedVideoJoiningTimeMs The maximum duration for which video renderers can attempt to - * seamlessly join an ongoing playback. + * @deprecated Use {@link #DefaultRenderersFactory(Context)}, {@link + * #setExtensionRendererMode(int)} and {@link #setAllowedVideoJoiningTimeMs(long)}. */ + @Deprecated + @SuppressWarnings("deprecation") public DefaultRenderersFactory( Context context, @ExtensionRendererMode int extensionRendererMode, long allowedVideoJoiningTimeMs) { - this.context = context; - this.extensionRendererMode = extensionRendererMode; - this.allowedVideoJoiningTimeMs = allowedVideoJoiningTimeMs; - this.drmSessionManager = null; + this(context, null, extensionRendererMode, allowedVideoJoiningTimeMs); } /** - * @deprecated Use {@link #DefaultRenderersFactory(Context, int, long)} and pass {@link - * DrmSessionManager} directly to {@link SimpleExoPlayer} or {@link ExoPlayerFactory}. + * @deprecated Use {@link #DefaultRenderersFactory(Context)}, {@link + * #setExtensionRendererMode(int)} and {@link #setAllowedVideoJoiningTimeMs(long)}, and pass + * {@link DrmSessionManager} directly to {@link SimpleExoPlayer} or {@link ExoPlayerFactory}. */ @Deprecated public DefaultRenderersFactory( @@ -163,6 +164,70 @@ public class DefaultRenderersFactory implements RenderersFactory { this.extensionRendererMode = extensionRendererMode; this.allowedVideoJoiningTimeMs = allowedVideoJoiningTimeMs; this.drmSessionManager = drmSessionManager; + mediaCodecSelector = MediaCodecSelector.DEFAULT; + } + + /** + * Sets the extension renderer mode, which determines if and how available extension renderers are + * used. Note that extensions must be included in the application build for them to be considered + * available. + * + *

The default value is {@link #EXTENSION_RENDERER_MODE_OFF}. + * + * @param extensionRendererMode The extension renderer mode. + * @return This factory, for convenience. + */ + public DefaultRenderersFactory setExtensionRendererMode( + @ExtensionRendererMode int extensionRendererMode) { + this.extensionRendererMode = extensionRendererMode; + return this; + } + + /** + * Sets whether renderers are permitted to play clear regions of encrypted media prior to having + * obtained the keys necessary to decrypt encrypted regions of the media. For encrypted media that + * starts with a short clear region, this allows playback to begin in parallel with key + * acquisition, which can reduce startup latency. + * + *

The default value is {@code false}. + * + * @param playClearSamplesWithoutKeys Whether renderers are permitted to play clear regions of + * encrypted media prior to having obtained the keys necessary to decrypt encrypted regions of + * the media. + * @return This factory, for convenience. + */ + public DefaultRenderersFactory setPlayClearSamplesWithoutKeys( + boolean playClearSamplesWithoutKeys) { + this.playClearSamplesWithoutKeys = playClearSamplesWithoutKeys; + return this; + } + + /** + * Sets a {@link MediaCodecSelector} for use by {@link MediaCodec} based renderers. + * + *

The default value is {@link MediaCodecSelector#DEFAULT}. + * + * @param mediaCodecSelector The {@link MediaCodecSelector}. + * @return This factory, for convenience. + */ + public DefaultRenderersFactory setMediaCodecSelector(MediaCodecSelector mediaCodecSelector) { + this.mediaCodecSelector = mediaCodecSelector; + return this; + } + + /** + * Sets the maximum duration for which video renderers can attempt to seamlessly join an ongoing + * playback. + * + *

The default value is {@link #DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS}. + * + * @param allowedVideoJoiningTimeMs The maximum duration for which video renderers can attempt to + * seamlessly join an ongoing playback, in milliseconds. + * @return This factory, for convenience. + */ + public DefaultRenderersFactory setAllowedVideoJoiningTimeMs(long allowedVideoJoiningTimeMs) { + this.allowedVideoJoiningTimeMs = allowedVideoJoiningTimeMs; + return this; } @Override @@ -177,10 +242,26 @@ public class DefaultRenderersFactory implements RenderersFactory { drmSessionManager = this.drmSessionManager; } ArrayList renderersList = new ArrayList<>(); - buildVideoRenderers(context, drmSessionManager, allowedVideoJoiningTimeMs, - eventHandler, videoRendererEventListener, extensionRendererMode, renderersList); - buildAudioRenderers(context, drmSessionManager, buildAudioProcessors(), - eventHandler, audioRendererEventListener, extensionRendererMode, renderersList); + buildVideoRenderers( + context, + extensionRendererMode, + mediaCodecSelector, + drmSessionManager, + playClearSamplesWithoutKeys, + eventHandler, + videoRendererEventListener, + allowedVideoJoiningTimeMs, + renderersList); + buildAudioRenderers( + context, + extensionRendererMode, + mediaCodecSelector, + drmSessionManager, + playClearSamplesWithoutKeys, + buildAudioProcessors(), + eventHandler, + audioRendererEventListener, + renderersList); buildTextRenderers(context, textRendererOutput, eventHandler.getLooper(), extensionRendererMode, renderersList); buildMetadataRenderers(context, metadataRendererOutput, eventHandler.getLooper(), @@ -194,27 +275,36 @@ public class DefaultRenderersFactory implements RenderersFactory { * Builds video renderers for use by the player. * * @param context The {@link Context} associated with the player. - * @param drmSessionManager An optional {@link DrmSessionManager}. May be null if the player - * will not be used for DRM protected playbacks. - * @param allowedVideoJoiningTimeMs The maximum duration in milliseconds for which video - * renderers can attempt to seamlessly join an ongoing playback. + * @param extensionRendererMode The extension renderer mode. + * @param mediaCodecSelector A decoder selector. + * @param drmSessionManager An optional {@link DrmSessionManager}. May be null if the player will + * not be used for DRM protected playbacks. + * @param playClearSamplesWithoutKeys Whether renderers are permitted to play clear regions of + * encrypted media prior to having obtained the keys necessary to decrypt encrypted regions of + * the media. * @param eventHandler A handler associated with the main thread's looper. * @param eventListener An event listener. - * @param extensionRendererMode The extension renderer mode. + * @param allowedVideoJoiningTimeMs The maximum duration for which video renderers can attempt to + * seamlessly join an ongoing playback, in milliseconds. * @param out An array to which the built renderers should be appended. */ - protected void buildVideoRenderers(Context context, + protected void buildVideoRenderers( + Context context, + @ExtensionRendererMode int extensionRendererMode, + MediaCodecSelector mediaCodecSelector, @Nullable DrmSessionManager drmSessionManager, - long allowedVideoJoiningTimeMs, Handler eventHandler, - VideoRendererEventListener eventListener, @ExtensionRendererMode int extensionRendererMode, + boolean playClearSamplesWithoutKeys, + Handler eventHandler, + VideoRendererEventListener eventListener, + long allowedVideoJoiningTimeMs, ArrayList out) { out.add( new MediaCodecVideoRenderer( context, - MediaCodecSelector.DEFAULT, + mediaCodecSelector, allowedVideoJoiningTimeMs, drmSessionManager, - /* playClearSamplesWithoutKeys= */ false, + playClearSamplesWithoutKeys, eventHandler, eventListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY)); @@ -233,7 +323,6 @@ public class DefaultRenderersFactory implements RenderersFactory { Class clazz = Class.forName("com.google.android.exoplayer2.ext.vp9.LibvpxVideoRenderer"); Constructor constructor = clazz.getConstructor( - boolean.class, long.class, android.os.Handler.class, com.google.android.exoplayer2.video.VideoRendererEventListener.class, @@ -242,7 +331,6 @@ public class DefaultRenderersFactory implements RenderersFactory { Renderer renderer = (Renderer) constructor.newInstance( - true, allowedVideoJoiningTimeMs, eventHandler, eventListener, @@ -261,26 +349,35 @@ public class DefaultRenderersFactory implements RenderersFactory { * Builds audio renderers for use by the player. * * @param context The {@link Context} associated with the player. - * @param drmSessionManager An optional {@link DrmSessionManager}. May be null if the player - * will not be used for DRM protected playbacks. - * @param audioProcessors An array of {@link AudioProcessor}s that will process PCM audio - * buffers before output. May be empty. + * @param extensionRendererMode The extension renderer mode. + * @param mediaCodecSelector A decoder selector. + * @param drmSessionManager An optional {@link DrmSessionManager}. May be null if the player will + * not be used for DRM protected playbacks. + * @param playClearSamplesWithoutKeys Whether renderers are permitted to play clear regions of + * encrypted media prior to having obtained the keys necessary to decrypt encrypted regions of + * the media. + * @param audioProcessors An array of {@link AudioProcessor}s that will process PCM audio buffers + * before output. May be empty. * @param eventHandler A handler to use when invoking event listeners and outputs. * @param eventListener An event listener. - * @param extensionRendererMode The extension renderer mode. * @param out An array to which the built renderers should be appended. */ - protected void buildAudioRenderers(Context context, + protected void buildAudioRenderers( + Context context, + @ExtensionRendererMode int extensionRendererMode, + MediaCodecSelector mediaCodecSelector, @Nullable DrmSessionManager drmSessionManager, - AudioProcessor[] audioProcessors, Handler eventHandler, - AudioRendererEventListener eventListener, @ExtensionRendererMode int extensionRendererMode, + boolean playClearSamplesWithoutKeys, + AudioProcessor[] audioProcessors, + Handler eventHandler, + AudioRendererEventListener eventListener, ArrayList out) { out.add( new MediaCodecAudioRenderer( context, - MediaCodecSelector.DEFAULT, + mediaCodecSelector, drmSessionManager, - /* playClearSamplesWithoutKeys= */ false, + playClearSamplesWithoutKeys, eventHandler, eventListener, AudioCapabilities.getCapabilities(context), diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlaybackException.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlaybackException.java index 6b84245141..d5ceb3db30 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlaybackException.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlaybackException.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2; import android.support.annotation.IntDef; +import android.support.annotation.Nullable; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.util.Assertions; import java.io.IOException; @@ -34,7 +35,7 @@ public final class ExoPlaybackException extends Exception { */ @Documented @Retention(RetentionPolicy.SOURCE) - @IntDef({TYPE_SOURCE, TYPE_RENDERER, TYPE_UNEXPECTED}) + @IntDef({TYPE_SOURCE, TYPE_RENDERER, TYPE_UNEXPECTED, TYPE_REMOTE}) public @interface Type {} /** * The error occurred loading data from a {@link MediaSource}. @@ -54,6 +55,12 @@ public final class ExoPlaybackException extends Exception { * Call {@link #getUnexpectedException()} to retrieve the underlying cause. */ public static final int TYPE_UNEXPECTED = 2; + /** + * The error occurred in a remote component. + * + *

Call {@link #getMessage()} to retrieve the message associated with the error. + */ + public static final int TYPE_REMOTE = 3; /** * The type of the playback failure. One of {@link #TYPE_SOURCE}, {@link #TYPE_RENDERER} and @@ -66,7 +73,7 @@ public final class ExoPlaybackException extends Exception { */ public final int rendererIndex; - private final Throwable cause; + @Nullable private final Throwable cause; /** * Creates an instance of type {@link #TYPE_SOURCE}. @@ -99,6 +106,16 @@ public final class ExoPlaybackException extends Exception { return new ExoPlaybackException(TYPE_UNEXPECTED, cause, C.INDEX_UNSET); } + /** + * Creates an instance of type {@link #TYPE_REMOTE}. + * + * @param message The message associated with the error. + * @return The created instance. + */ + public static ExoPlaybackException createForRemote(String message) { + return new ExoPlaybackException(TYPE_REMOTE, message); + } + private ExoPlaybackException(@Type int type, Throwable cause, int rendererIndex) { super(cause); this.type = type; @@ -106,6 +123,13 @@ public final class ExoPlaybackException extends Exception { this.rendererIndex = rendererIndex; } + private ExoPlaybackException(@Type int type, String message) { + super(message); + this.type = type; + rendererIndex = C.INDEX_UNSET; + cause = null; + } + /** * Retrieves the underlying error when {@link #type} is {@link #TYPE_SOURCE}. * @@ -113,7 +137,7 @@ public final class ExoPlaybackException extends Exception { */ public IOException getSourceException() { Assertions.checkState(type == TYPE_SOURCE); - return (IOException) cause; + return (IOException) Assertions.checkNotNull(cause); } /** @@ -123,7 +147,7 @@ public final class ExoPlaybackException extends Exception { */ public Exception getRendererException() { Assertions.checkState(type == TYPE_RENDERER); - return (Exception) cause; + return (Exception) Assertions.checkNotNull(cause); } /** @@ -133,7 +157,7 @@ public final class ExoPlaybackException extends Exception { */ public RuntimeException getUnexpectedException() { Assertions.checkState(type == TYPE_UNEXPECTED); - return (RuntimeException) cause; + return (RuntimeException) Assertions.checkNotNull(cause); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java index 5ba2394c3f..db168d9c29 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java @@ -21,10 +21,10 @@ import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer; import com.google.android.exoplayer2.metadata.MetadataRenderer; import com.google.android.exoplayer2.source.ClippingMediaSource; import com.google.android.exoplayer2.source.ConcatenatingMediaSource; -import com.google.android.exoplayer2.source.ExtractorMediaSource; import com.google.android.exoplayer2.source.LoopingMediaSource; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MergingMediaSource; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.source.SingleSampleMediaSource; import com.google.android.exoplayer2.text.TextRenderer; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; @@ -48,7 +48,7 @@ import com.google.android.exoplayer2.video.MediaCodecVideoRenderer; *

  • A {@link MediaSource} that defines the media to be played, loads the media, and from * which the loaded media can be read. A MediaSource is injected via {@link * #prepare(MediaSource)} at the start of playback. The library modules provide default - * implementations for regular media files ({@link ExtractorMediaSource}), DASH + * implementations for progressive media files ({@link ProgressiveMediaSource}), DASH * (DashMediaSource), SmoothStreaming (SsMediaSource) and HLS (HlsMediaSource), an * implementation for loading single media samples ({@link SingleSampleMediaSource}) that's * most often used for side-loaded subtitle files, and implementations for building more diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java index c63dbc04d0..551895ad93 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java @@ -58,7 +58,8 @@ public final class ExoPlayerFactory { LoadControl loadControl, @Nullable DrmSessionManager drmSessionManager, @DefaultRenderersFactory.ExtensionRendererMode int extensionRendererMode) { - RenderersFactory renderersFactory = new DefaultRenderersFactory(context, extensionRendererMode); + RenderersFactory renderersFactory = + new DefaultRenderersFactory(context).setExtensionRendererMode(extensionRendererMode); return newSimpleInstance( context, renderersFactory, trackSelector, loadControl, drmSessionManager); } @@ -88,7 +89,9 @@ public final class ExoPlayerFactory { @DefaultRenderersFactory.ExtensionRendererMode int extensionRendererMode, long allowedVideoJoiningTimeMs) { RenderersFactory renderersFactory = - new DefaultRenderersFactory(context, extensionRendererMode, allowedVideoJoiningTimeMs); + new DefaultRenderersFactory(context) + .setExtensionRendererMode(extensionRendererMode) + .setAllowedVideoJoiningTimeMs(allowedVideoJoiningTimeMs); return newSimpleInstance( context, renderersFactory, trackSelector, loadControl, drmSessionManager); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index b4549362f3..670353c4f9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -1376,12 +1376,34 @@ import java.util.concurrent.atomic.AtomicBoolean; } } - if (!queue.updateQueuedPeriods(playingPeriodId, rendererPositionUs)) { + if (!queue.updateQueuedPeriods(rendererPositionUs, getMaxRendererReadPositionUs())) { seekToCurrentPosition(/* sendDiscontinuity= */ false); } handleLoadingMediaPeriodChanged(/* loadingTrackSelectionChanged= */ false); } + private long getMaxRendererReadPositionUs() { + MediaPeriodHolder readingHolder = queue.getReadingPeriod(); + if (readingHolder == null) { + return 0; + } + long maxReadPositionUs = readingHolder.getRendererOffset(); + for (int i = 0; i < renderers.length; i++) { + if (renderers[i].getState() == Renderer.STATE_DISABLED + || renderers[i].getStream() != readingHolder.sampleStreams[i]) { + // Ignore disabled renderers and renderers with sample streams from previous periods. + continue; + } + long readingPositionUs = renderers[i].getReadingPositionUs(); + if (readingPositionUs == C.TIME_END_OF_SOURCE) { + return C.TIME_END_OF_SOURCE; + } else { + maxReadPositionUs = Math.max(readingPositionUs, maxReadPositionUs); + } + } + return maxReadPositionUs; + } + private void handleSourceInfoRefreshEndedPlayback() { setState(Player.STATE_ENDED); // Reset, but retain the source so that it can still be used should a seek occur. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java index 792f6cf651..e3a2e1cd27 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java @@ -29,11 +29,11 @@ public final class ExoPlayerLibraryInfo { /** The version of the library expressed as a string, for example "1.2.3". */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION_INT) or vice versa. - public static final String VERSION = "2.9.3"; + public static final String VERSION = "2.9.5"; /** The version of the library expressed as {@code "ExoPlayerLib/" + VERSION}. */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final String VERSION_SLASHY = "ExoPlayerLib/2.9.3"; + public static final String VERSION_SLASHY = "ExoPlayerLib/2.9.5"; /** * The version of the library expressed as an integer, for example 1002003. @@ -43,7 +43,7 @@ public final class ExoPlayerLibraryInfo { * integer version 123045006 (123-045-006). */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final int VERSION_INT = 2009003; + public static final int VERSION_INT = 2009005; /** * Whether the library was compiled with {@link com.google.android.exoplayer2.util.Assertions} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Format.java b/library/core/src/main/java/com/google/android/exoplayer2/Format.java index 6c54c07cde..c3028e153c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Format.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Format.java @@ -159,7 +159,7 @@ public final class Format implements Parcelable { @C.SelectionFlags public final int selectionFlags; - /** The language, or null if unknown or not applicable. */ + /** The language as ISO 639-2/T three-letter code, or null if unknown or not applicable. */ public final @Nullable String language; /** @@ -932,7 +932,7 @@ public final class Format implements Parcelable { this.encoderDelay = encoderDelay == Format.NO_VALUE ? 0 : encoderDelay; this.encoderPadding = encoderPadding == Format.NO_VALUE ? 0 : encoderPadding; this.selectionFlags = selectionFlags; - this.language = language; + this.language = Util.normalizeLanguageCode(language); this.accessibilityChannel = accessibilityChannel; this.subsampleOffsetUs = subsampleOffsetUs; this.initializationData = diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java index 7becac7b55..be3fde0fca 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java @@ -89,7 +89,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; this.info = info; sampleStreams = new SampleStream[rendererCapabilities.length]; mayRetainStreamFlags = new boolean[rendererCapabilities.length]; - mediaPeriod = createMediaPeriod(info.id, mediaSource, allocator); + mediaPeriod = + createMediaPeriod( + info.id, mediaSource, allocator, info.startPositionUs, info.endPositionUs); } /** @@ -294,7 +296,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; public void release() { disableTrackSelectionsInResult(); trackSelectorResult = null; - releaseMediaPeriod(info.id, mediaSource, mediaPeriod); + releaseMediaPeriod(info.endPositionUs, mediaSource, mediaPeriod); } /** @@ -399,24 +401,25 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; /** Returns a media period corresponding to the given {@code id}. */ private static MediaPeriod createMediaPeriod( - MediaPeriodId id, MediaSource mediaSource, Allocator allocator) { - MediaPeriod mediaPeriod = mediaSource.createPeriod(id, allocator); - if (id.endPositionUs != C.TIME_UNSET && id.endPositionUs != C.TIME_END_OF_SOURCE) { + MediaPeriodId id, + MediaSource mediaSource, + Allocator allocator, + long startPositionUs, + long endPositionUs) { + MediaPeriod mediaPeriod = mediaSource.createPeriod(id, allocator, startPositionUs); + if (endPositionUs != C.TIME_UNSET && endPositionUs != C.TIME_END_OF_SOURCE) { mediaPeriod = new ClippingMediaPeriod( - mediaPeriod, - /* enableInitialDiscontinuity= */ true, - /* startUs= */ 0, - id.endPositionUs); + mediaPeriod, /* enableInitialDiscontinuity= */ true, /* startUs= */ 0, endPositionUs); } return mediaPeriod; } /** Releases the given {@code mediaPeriod}, logging and suppressing any errors. */ private static void releaseMediaPeriod( - MediaPeriodId id, MediaSource mediaSource, MediaPeriod mediaPeriod) { + long endPositionUs, MediaSource mediaSource, MediaPeriod mediaPeriod) { try { - if (id.endPositionUs != C.TIME_UNSET && id.endPositionUs != C.TIME_END_OF_SOURCE) { + if (endPositionUs != C.TIME_UNSET && endPositionUs != C.TIME_END_OF_SOURCE) { mediaSource.releasePeriod(((ClippingMediaPeriod) mediaPeriod).mediaPeriod); } else { mediaSource.releasePeriod(mediaPeriod); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodInfo.java index e57100931e..cd4e74b2ef 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodInfo.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodInfo.java @@ -33,7 +33,14 @@ import com.google.android.exoplayer2.util.Util; */ public final long contentPositionUs; /** - * The duration of the media period, like {@link MediaPeriodId#endPositionUs} but with {@link + * The end position to which the media period's content is clipped in order to play a following ad + * group, in microseconds, or {@link C#TIME_UNSET} if there is no following ad group or if this + * media period is an ad. The value {@link C#TIME_END_OF_SOURCE} indicates that a postroll ad + * follows at the end of this content media period. + */ + public final long endPositionUs; + /** + * The duration of the media period, like {@link #endPositionUs} but with {@link * C#TIME_END_OF_SOURCE} and {@link C#TIME_UNSET} resolved to the timeline period duration if * known. */ @@ -53,26 +60,51 @@ import com.google.android.exoplayer2.util.Util; MediaPeriodId id, long startPositionUs, long contentPositionUs, + long endPositionUs, long durationUs, boolean isLastInTimelinePeriod, boolean isFinal) { this.id = id; this.startPositionUs = startPositionUs; this.contentPositionUs = contentPositionUs; + this.endPositionUs = endPositionUs; this.durationUs = durationUs; this.isLastInTimelinePeriod = isLastInTimelinePeriod; this.isFinal = isFinal; } - /** Returns a copy of this instance with the start position set to the specified value. */ + /** + * Returns a copy of this instance with the start position set to the specified value. May return + * the same instance if nothing changed. + */ public MediaPeriodInfo copyWithStartPositionUs(long startPositionUs) { - return new MediaPeriodInfo( - id, - startPositionUs, - contentPositionUs, - durationUs, - isLastInTimelinePeriod, - isFinal); + return startPositionUs == this.startPositionUs + ? this + : new MediaPeriodInfo( + id, + startPositionUs, + contentPositionUs, + endPositionUs, + durationUs, + isLastInTimelinePeriod, + isFinal); + } + + /** + * Returns a copy of this instance with the content position set to the specified value. May + * return the same instance if nothing changed. + */ + public MediaPeriodInfo copyWithContentPositionUs(long contentPositionUs) { + return contentPositionUs == this.contentPositionUs + ? this + : new MediaPeriodInfo( + id, + startPositionUs, + contentPositionUs, + endPositionUs, + durationUs, + isLastInTimelinePeriod, + isFinal); } @Override @@ -86,6 +118,7 @@ import com.google.android.exoplayer2.util.Util; MediaPeriodInfo that = (MediaPeriodInfo) o; return startPositionUs == that.startPositionUs && contentPositionUs == that.contentPositionUs + && endPositionUs == that.endPositionUs && durationUs == that.durationUs && isLastInTimelinePeriod == that.isLastInTimelinePeriod && isFinal == that.isFinal @@ -98,6 +131,7 @@ import com.google.android.exoplayer2.util.Util; result = 31 * result + id.hashCode(); result = 31 * result + (int) startPositionUs; result = 31 * result + (int) contentPositionUs; + result = 31 * result + (int) endPositionUs; result = 31 * result + (int) durationUs; result = 31 * result + (isLastInTimelinePeriod ? 1 : 0); result = 31 * result + (isFinal ? 1 : 0); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java index 7fa2abe149..64719a0ab4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java @@ -61,8 +61,8 @@ import com.google.android.exoplayer2.util.Assertions; } /** - * Sets the {@link Timeline}. Call {@link #updateQueuedPeriods(MediaPeriodId, long)} to update the - * queued media periods to take into account the new timeline. + * Sets the {@link Timeline}. Call {@link #updateQueuedPeriods(long, long)} to update the queued + * media periods to take into account the new timeline. */ public void setTimeline(Timeline timeline) { this.timeline = timeline; @@ -292,54 +292,56 @@ import com.google.android.exoplayer2.util.Assertions; * current playback position. The method assumes that the first media period in the queue is still * consistent with the new timeline. * - * @param playingPeriodId The current playing media period identifier. * @param rendererPositionUs The current renderer position in microseconds. + * @param maxRendererReadPositionUs The maximum renderer position up to which renderers have read + * the current reading media period in microseconds, or {@link C#TIME_END_OF_SOURCE} if they + * have read to the end. * @return Whether the timeline change has been handled completely. */ - public boolean updateQueuedPeriods(MediaPeriodId playingPeriodId, long rendererPositionUs) { + public boolean updateQueuedPeriods(long rendererPositionUs, long maxRendererReadPositionUs) { // TODO: Merge this into setTimeline so that the queue gets updated as soon as the new timeline // is set, once all cases handled by ExoPlayerImplInternal.handleSourceInfoRefreshed can be // handled here. - int periodIndex = timeline.getIndexOfPeriod(playingPeriodId.periodUid); - // The front period is either playing now, or is being loaded and will become the playing - // period. MediaPeriodHolder previousPeriodHolder = null; MediaPeriodHolder periodHolder = getFrontPeriod(); while (periodHolder != null) { + MediaPeriodInfo oldPeriodInfo = periodHolder.info; + + // Get period info based on new timeline. + MediaPeriodInfo newPeriodInfo; if (previousPeriodHolder == null) { - long previousDurationUs = periodHolder.info.durationUs; - periodHolder.info = getUpdatedMediaPeriodInfo(periodHolder.info); - if (!canKeepAfterMediaPeriodHolder(periodHolder, previousDurationUs)) { - return !removeAfter(periodHolder); - } + // The id and start position of the first period have already been verified by + // ExoPlayerImplInternal.handleSourceInfoRefreshed. Just update duration, isLastInTimeline + // and isLastInPeriod flags. + newPeriodInfo = getUpdatedMediaPeriodInfo(oldPeriodInfo); } else { - // Check this period holder still follows the previous one, based on the new timeline. - if (periodIndex == C.INDEX_UNSET - || !periodHolder.uid.equals(timeline.getUidOfPeriod(periodIndex))) { - // The holder uid is inconsistent with the new timeline. - return !removeAfter(previousPeriodHolder); - } - MediaPeriodInfo periodInfo = - getFollowingMediaPeriodInfo(previousPeriodHolder, rendererPositionUs); - if (periodInfo == null) { + newPeriodInfo = getFollowingMediaPeriodInfo(previousPeriodHolder, rendererPositionUs); + if (newPeriodInfo == null) { // We've loaded a next media period that is not in the new timeline. return !removeAfter(previousPeriodHolder); } - // Update the period holder. - periodHolder.info = getUpdatedMediaPeriodInfo(periodHolder.info); - // Check the media period information matches the new timeline. - if (!canKeepMediaPeriodHolder(periodHolder, periodInfo)) { + if (!canKeepMediaPeriodHolder(oldPeriodInfo, newPeriodInfo)) { + // The new media period has a different id or start position. return !removeAfter(previousPeriodHolder); - } else if (!canKeepAfterMediaPeriodHolder(periodHolder, periodInfo.durationUs)) { - return !removeAfter(periodHolder); } } - if (periodHolder.info.isLastInTimelinePeriod) { - // Move on to the next timeline period index, if there is one. - periodIndex = - timeline.getNextPeriodIndex( - periodIndex, period, window, repeatMode, shuffleModeEnabled); + // Use new period info, but keep old content position. + periodHolder.info = newPeriodInfo.copyWithContentPositionUs(oldPeriodInfo.contentPositionUs); + + if (!areDurationsCompatible(oldPeriodInfo.durationUs, newPeriodInfo.durationUs)) { + // The period duration changed. Remove all subsequent periods and check whether we read + // beyond the new duration. + long newDurationInRendererTime = + newPeriodInfo.durationUs == C.TIME_UNSET + ? Long.MAX_VALUE + : periodHolder.toRendererTime(newPeriodInfo.durationUs); + boolean isReadingAndReadBeyondNewDuration = + periodHolder == reading + && (maxRendererReadPositionUs == C.TIME_END_OF_SOURCE + || maxRendererReadPositionUs >= newDurationInRendererTime); + boolean readingPeriodRemoved = removeAfter(periodHolder); + return !readingPeriodRemoved && !isReadingAndReadBeyondNewDuration; } previousPeriodHolder = periodHolder; @@ -364,13 +366,14 @@ import com.google.android.exoplayer2.util.Assertions; long durationUs = id.isAd() ? period.getAdDurationUs(id.adGroupIndex, id.adIndexInAdGroup) - : (id.endPositionUs == C.TIME_UNSET || id.endPositionUs == C.TIME_END_OF_SOURCE + : (info.endPositionUs == C.TIME_UNSET || info.endPositionUs == C.TIME_END_OF_SOURCE ? period.getDurationUs() - : id.endPositionUs); + : info.endPositionUs); return new MediaPeriodInfo( id, info.startPositionUs, info.contentPositionUs, + info.endPositionUs, durationUs, isLastInPeriod, isLastInTimeline); @@ -409,11 +412,7 @@ import com.google.android.exoplayer2.util.Assertions; int adGroupIndex = period.getAdGroupIndexForPositionUs(positionUs); if (adGroupIndex == C.INDEX_UNSET) { int nextAdGroupIndex = period.getAdGroupIndexAfterPositionUs(positionUs); - long endPositionUs = - nextAdGroupIndex == C.INDEX_UNSET - ? C.TIME_UNSET - : period.getAdGroupTimeUs(nextAdGroupIndex); - return new MediaPeriodId(periodUid, windowSequenceNumber, endPositionUs); + return new MediaPeriodId(periodUid, windowSequenceNumber, nextAdGroupIndex); } else { int adIndexInAdGroup = period.getFirstAdIndexToPlay(adGroupIndex); return new MediaPeriodId(periodUid, adGroupIndex, adIndexInAdGroup, windowSequenceNumber); @@ -465,22 +464,18 @@ import com.google.android.exoplayer2.util.Assertions; } /** - * Returns whether {@code periodHolder} can be kept for playing the media period described by - * {@code info}. + * Returns whether a period described by {@code oldInfo} can be kept for playing the media period + * described by {@code newInfo}. */ - private boolean canKeepMediaPeriodHolder(MediaPeriodHolder periodHolder, MediaPeriodInfo info) { - MediaPeriodInfo periodHolderInfo = periodHolder.info; - return periodHolderInfo.startPositionUs == info.startPositionUs - && periodHolderInfo.id.equals(info.id); + private boolean canKeepMediaPeriodHolder(MediaPeriodInfo oldInfo, MediaPeriodInfo newInfo) { + return oldInfo.startPositionUs == newInfo.startPositionUs && oldInfo.id.equals(newInfo.id); } /** - * Returns whether periods after {@code periodHolder} can be kept for playing given its previous - * duration. + * Returns whether a duration change of a period is compatible with keeping the following periods. */ - private boolean canKeepAfterMediaPeriodHolder( - MediaPeriodHolder periodHolder, long previousDurationUs) { - return previousDurationUs == C.TIME_UNSET || previousDurationUs == periodHolder.info.durationUs; + private boolean areDurationsCompatible(long previousDurationUs, long newDurationUs) { + return previousDurationUs == C.TIME_UNSET || previousDurationUs == newDurationUs; } /** @@ -645,7 +640,7 @@ import com.google.android.exoplayer2.util.Assertions; } } else { // Play the next ad group if it's available. - int nextAdGroupIndex = period.getAdGroupIndexForPositionUs(mediaPeriodInfo.id.endPositionUs); + int nextAdGroupIndex = period.getAdGroupIndexForPositionUs(mediaPeriodInfo.endPositionUs); if (nextAdGroupIndex == C.INDEX_UNSET) { // The next ad group can't be played. Play content from the previous end position instead. return getMediaPeriodInfoForContent( @@ -703,6 +698,7 @@ import com.google.android.exoplayer2.util.Assertions; id, startPositionUs, contentPositionUs, + /* endPositionUs= */ C.TIME_UNSET, durationUs, /* isLastInTimelinePeriod= */ false, /* isFinal= */ false); @@ -711,13 +707,13 @@ import com.google.android.exoplayer2.util.Assertions; private MediaPeriodInfo getMediaPeriodInfoForContent( Object periodUid, long startPositionUs, long windowSequenceNumber) { int nextAdGroupIndex = period.getAdGroupIndexAfterPositionUs(startPositionUs); + MediaPeriodId id = new MediaPeriodId(periodUid, windowSequenceNumber, nextAdGroupIndex); + boolean isLastInPeriod = isLastInPeriod(id); + boolean isLastInTimeline = isLastInTimeline(id, isLastInPeriod); long endPositionUs = nextAdGroupIndex != C.INDEX_UNSET ? period.getAdGroupTimeUs(nextAdGroupIndex) : C.TIME_UNSET; - MediaPeriodId id = new MediaPeriodId(periodUid, windowSequenceNumber, endPositionUs); - boolean isLastInPeriod = isLastInPeriod(id); - boolean isLastInTimeline = isLastInTimeline(id, isLastInPeriod); long durationUs = endPositionUs == C.TIME_UNSET || endPositionUs == C.TIME_END_OF_SOURCE ? period.durationUs @@ -726,13 +722,14 @@ import com.google.android.exoplayer2.util.Assertions; id, startPositionUs, /* contentPositionUs= */ C.TIME_UNSET, + endPositionUs, durationUs, isLastInPeriod, isLastInTimeline); } private boolean isLastInPeriod(MediaPeriodId id) { - return !id.isAd() && id.endPositionUs == C.TIME_UNSET; + return !id.isAd() && id.nextAdGroupIndex == C.INDEX_UNSET; } private boolean isLastInTimeline(MediaPeriodId id, boolean isLastMediaPeriodInPeriod) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/NoSampleRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/NoSampleRenderer.java index 6645850d3b..e6223dfe16 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/NoSampleRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/NoSampleRenderer.java @@ -122,6 +122,11 @@ public abstract class NoSampleRenderer implements Renderer, RendererCapabilities return true; } + @Override + public long getReadingPositionUs() { + return C.TIME_END_OF_SOURCE; + } + @Override public final void setCurrentStreamFinal() { streamIsFinal = true; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java b/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java index 1d4d587aeb..3434cc7603 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java @@ -160,6 +160,16 @@ public interface Renderer extends PlayerMessage.Target { */ boolean hasReadStreamToEnd(); + /** + * Returns the playback position up to which the renderer has read samples from the current {@link + * SampleStream}, in microseconds, or {@link C#TIME_END_OF_SOURCE} if the renderer has read the + * current {@link SampleStream} to the end. + * + *

    This method may be called when the renderer is in the following states: {@link + * #STATE_ENABLED}, {@link #STATE_STARTED}. + */ + long getReadingPositionUs(); + /** * Signals to the renderer that the current {@link SampleStream} will be the final one supplied * before it is next disabled or reset. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java index b44259f50b..35924a01fb 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -63,7 +63,6 @@ import java.util.concurrent.CopyOnWriteArraySet; * An {@link ExoPlayer} implementation that uses default {@link Renderer} components. Instances can * be obtained from {@link ExoPlayerFactory}. */ -@TargetApi(16) public class SimpleExoPlayer extends BasePlayer implements ExoPlayer, Player.AudioComponent, @@ -94,25 +93,25 @@ public class SimpleExoPlayer extends BasePlayer private final AudioFocusManager audioFocusManager; - private Format videoFormat; - private Format audioFormat; + @Nullable private Format videoFormat; + @Nullable private Format audioFormat; - private Surface surface; + @Nullable private Surface surface; private boolean ownsSurface; private @C.VideoScalingMode int videoScalingMode; - private SurfaceHolder surfaceHolder; - private TextureView textureView; + @Nullable private SurfaceHolder surfaceHolder; + @Nullable private TextureView textureView; private int surfaceWidth; private int surfaceHeight; - private DecoderCounters videoDecoderCounters; - private DecoderCounters audioDecoderCounters; + @Nullable private DecoderCounters videoDecoderCounters; + @Nullable private DecoderCounters audioDecoderCounters; private int audioSessionId; private AudioAttributes audioAttributes; private float audioVolume; - private MediaSource mediaSource; + @Nullable private MediaSource mediaSource; private List currentCues; - private VideoFrameMetadataListener videoFrameMetadataListener; - private CameraMotionListener cameraMotionListener; + @Nullable private VideoFrameMetadataListener videoFrameMetadataListener; + @Nullable private CameraMotionListener cameraMotionListener; private boolean hasNotifiedFullWrongThreadWarning; /** @@ -558,30 +557,26 @@ public class SimpleExoPlayer extends BasePlayer setPlaybackParameters(playbackParameters); } - /** - * Returns the video format currently being played, or null if no video is being played. - */ + /** Returns the video format currently being played, or null if no video is being played. */ + @Nullable public Format getVideoFormat() { return videoFormat; } - /** - * Returns the audio format currently being played, or null if no audio is being played. - */ + /** Returns the audio format currently being played, or null if no audio is being played. */ + @Nullable public Format getAudioFormat() { return audioFormat; } - /** - * Returns {@link DecoderCounters} for video, or null if no video is being played. - */ + /** Returns {@link DecoderCounters} for video, or null if no video is being played. */ + @Nullable public DecoderCounters getVideoDecoderCounters() { return videoDecoderCounters; } - /** - * Returns {@link DecoderCounters} for audio, or null if no audio is being played. - */ + /** Returns {@link DecoderCounters} for audio, or null if no audio is being played. */ + @Nullable public DecoderCounters getAudioDecoderCounters() { return audioDecoderCounters; } @@ -1053,7 +1048,8 @@ public class SimpleExoPlayer extends BasePlayer } @Override - public @Nullable Object getCurrentManifest() { + @Nullable + public Object getCurrentManifest() { verifyApplicationThread(); return player.getCurrentManifest(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java index 113add612a..154cc11dca 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java @@ -129,12 +129,13 @@ public class AnalyticsCollector /** * Sets the player for which data will be collected. Must only be called if no player has been set - * yet. + * yet or the current player is idle. * * @param player The {@link Player} for which data will be collected. */ public void setPlayer(Player player) { - Assertions.checkState(this.player == null); + Assertions.checkState( + this.player == null || mediaPeriodQueueTracker.mediaPeriodInfoQueue.isEmpty()); this.player = Assertions.checkNotNull(player); } @@ -488,7 +489,10 @@ public class AnalyticsCollector @Override public final void onPlayerError(ExoPlaybackException error) { - EventTime eventTime = generatePlayingMediaPeriodEventTime(); + EventTime eventTime = + error.type == ExoPlaybackException.TYPE_SOURCE + ? generateLoadingMediaPeriodEventTime() + : generatePlayingMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { listener.onPlayerError(eventTime, error); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java index eff7bc8de2..48fbea75b4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java @@ -147,6 +147,7 @@ public interface AudioRendererEventListener { * Invokes {@link AudioRendererEventListener#onAudioDisabled(DecoderCounters)}. */ public void disabled(final DecoderCounters counters) { + counters.ensureUpdated(); if (listener != null) { handler.post( () -> { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index fc515dbdb3..6f3ee63d3d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -418,7 +418,7 @@ public final class DefaultAudioSink implements AudioSink { isInputPcm = Util.isEncodingLinearPcm(inputEncoding); shouldConvertHighResIntPcmToFloat = enableConvertHighResIntPcmToFloat - && supportsOutput(channelCount, C.ENCODING_PCM_32BIT) + && supportsOutput(channelCount, C.ENCODING_PCM_FLOAT) && Util.isEncodingHighResolutionIntegerPcm(inputEncoding); if (isInputPcm) { pcmFrameSize = Util.getPcmFrameSize(inputEncoding, channelCount); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index 49c391c4cc..04c6b2ec9c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -16,7 +16,6 @@ package com.google.android.exoplayer2.audio; import android.annotation.SuppressLint; -import android.annotation.TargetApi; import android.content.Context; import android.media.MediaCodec; import android.media.MediaCrypto; @@ -66,7 +65,6 @@ import java.util.List; * underlying audio track. * */ -@TargetApi(16) public class MediaCodecAudioRenderer extends MediaCodecRenderer implements MediaClock { /** @@ -548,7 +546,6 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media try { super.onDisabled(); } finally { - decoderCounters.ensureUpdated(); eventDispatcher.disabled(decoderCounters); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java index bfd7bbc165..f2e8a23811 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java @@ -106,8 +106,8 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements ? extends AudioDecoderException> decoder; private DecoderInputBuffer inputBuffer; private SimpleOutputBuffer outputBuffer; - private DrmSession drmSession; - private DrmSession pendingDrmSession; + @Nullable private DrmSession decoderDrmSession; + @Nullable private DrmSession sourceDrmSession; @ReinitializationState private int decoderReinitializationState; private boolean decoderReceivedBuffers; @@ -462,12 +462,12 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements } private boolean shouldWaitForKeys(boolean bufferEncrypted) throws ExoPlaybackException { - if (drmSession == null || (!bufferEncrypted && playClearSamplesWithoutKeys)) { + if (decoderDrmSession == null || (!bufferEncrypted && playClearSamplesWithoutKeys)) { return false; } - @DrmSession.State int drmSessionState = drmSession.getState(); + @DrmSession.State int drmSessionState = decoderDrmSession.getState(); if (drmSessionState == DrmSession.STATE_ERROR) { - throw ExoPlaybackException.createForRenderer(drmSession.getError(), getIndex()); + throw ExoPlaybackException.createForRenderer(decoderDrmSession.getError(), getIndex()); } return drmSessionState != DrmSession.STATE_OPENED_WITH_KEYS; } @@ -568,25 +568,11 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements audioTrackNeedsConfigure = true; waitingForKeys = false; try { + setSourceDrmSession(null); releaseDecoder(); audioSink.reset(); } finally { - try { - if (drmSession != null) { - drmSessionManager.releaseSession(drmSession); - } - } finally { - try { - if (pendingDrmSession != null && pendingDrmSession != drmSession) { - drmSessionManager.releaseSession(pendingDrmSession); - } - } finally { - drmSession = null; - pendingDrmSession = null; - decoderCounters.ensureUpdated(); - eventDispatcher.disabled(decoderCounters); - } - } + eventDispatcher.disabled(decoderCounters); } } @@ -615,12 +601,13 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements return; } - drmSession = pendingDrmSession; + setDecoderDrmSession(sourceDrmSession); + ExoMediaCrypto mediaCrypto = null; - if (drmSession != null) { - mediaCrypto = drmSession.getMediaCrypto(); + if (decoderDrmSession != null) { + mediaCrypto = decoderDrmSession.getMediaCrypto(); if (mediaCrypto == null) { - DrmSessionException drmError = drmSession.getError(); + DrmSessionException drmError = decoderDrmSession.getError(); if (drmError != null) { // Continue for now. We may be able to avoid failure if the session recovers, or if a new // input format causes the session to be replaced before it's used. @@ -646,17 +633,34 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements } private void releaseDecoder() { - if (decoder == null) { - return; - } - inputBuffer = null; outputBuffer = null; - decoder.release(); - decoder = null; - decoderCounters.decoderReleaseCount++; decoderReinitializationState = REINITIALIZATION_STATE_NONE; decoderReceivedBuffers = false; + if (decoder != null) { + decoder.release(); + decoder = null; + decoderCounters.decoderReleaseCount++; + } + setDecoderDrmSession(null); + } + + private void setSourceDrmSession(@Nullable DrmSession session) { + DrmSession previous = sourceDrmSession; + sourceDrmSession = session; + releaseDrmSessionIfUnused(previous); + } + + private void setDecoderDrmSession(@Nullable DrmSession session) { + DrmSession previous = decoderDrmSession; + decoderDrmSession = session; + releaseDrmSessionIfUnused(previous); + } + + private void releaseDrmSessionIfUnused(@Nullable DrmSession session) { + if (session != null && session != decoderDrmSession && session != sourceDrmSession) { + drmSessionManager.releaseSession(session); + } } private void onInputFormatChanged(Format newFormat) throws ExoPlaybackException { @@ -671,13 +675,16 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements throw ExoPlaybackException.createForRenderer( new IllegalStateException("Media requires a DrmSessionManager"), getIndex()); } - pendingDrmSession = drmSessionManager.acquireSession(Looper.myLooper(), - inputFormat.drmInitData); - if (pendingDrmSession == drmSession) { - drmSessionManager.releaseSession(pendingDrmSession); + DrmSession session = + drmSessionManager.acquireSession(Looper.myLooper(), newFormat.drmInitData); + if (session == decoderDrmSession || session == sourceDrmSession) { + // We already had this session. The manager must be reference counting, so release it once + // to get the count attributed to this renderer back down to 1. + drmSessionManager.releaseSession(session); } + setSourceDrmSession(session); } else { - pendingDrmSession = null; + setSourceDrmSession(null); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/database/DatabaseProvider.java b/library/core/src/main/java/com/google/android/exoplayer2/database/DatabaseProvider.java new file mode 100644 index 0000000000..2bb5f260ba --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/database/DatabaseProvider.java @@ -0,0 +1,56 @@ +/* + * 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.database; + +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; + +/** + * Provides {@link SQLiteDatabase} instances to ExoPlayer components, which may read and write + * tables prefixed with {@link #TABLE_PREFIX}. + */ +public interface DatabaseProvider { + + /** Prefix for tables that can be read and written by ExoPlayer components. */ + String TABLE_PREFIX = "ExoPlayer"; + + /** + * Creates and/or opens a database that will be used for reading and writing. + * + *

    Once opened successfully, the database is cached, so you can call this method every time you + * need to write to the database. Errors such as bad permissions or a full disk may cause this + * method to fail, but future attempts may succeed if the problem is fixed. + * + * @throws SQLiteException If the database cannot be opened for writing. + * @return A read/write database object. + */ + SQLiteDatabase getWritableDatabase(); + + /** + * Creates and/or opens a database. This will be the same object returned by {@link + * #getWritableDatabase()} unless some problem, such as a full disk, requires the database to be + * opened read-only. In that case, a read-only database object will be returned. If the problem is + * fixed, a future call to {@link #getWritableDatabase()} may succeed, in which case the read-only + * database object will be closed and the read/write object will be returned in the future. + * + *

    Once opened successfully, the database is cached, so you can call this method every time you + * need to read from the database. + * + * @throws SQLiteException If the database cannot be opened. + * @return A database object valid until {@link #getWritableDatabase()} is called. + */ + SQLiteDatabase getReadableDatabase(); +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/database/DefaultDatabaseProvider.java b/library/core/src/main/java/com/google/android/exoplayer2/database/DefaultDatabaseProvider.java new file mode 100644 index 0000000000..c04683b434 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/database/DefaultDatabaseProvider.java @@ -0,0 +1,42 @@ +/* + * 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.database; + +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +/** A {@link DatabaseProvider} that provides instances obtained from a {@link SQLiteOpenHelper}. */ +public final class DefaultDatabaseProvider implements DatabaseProvider { + + private final SQLiteOpenHelper sqliteOpenHelper; + + /** + * @param sqliteOpenHelper An {@link SQLiteOpenHelper} from which to obtain database instances. + */ + public DefaultDatabaseProvider(SQLiteOpenHelper sqliteOpenHelper) { + this.sqliteOpenHelper = sqliteOpenHelper; + } + + @Override + public SQLiteDatabase getWritableDatabase() { + return sqliteOpenHelper.getWritableDatabase(); + } + + @Override + public SQLiteDatabase getReadableDatabase() { + return sqliteOpenHelper.getReadableDatabase(); + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/database/ExoDatabaseProvider.java b/library/core/src/main/java/com/google/android/exoplayer2/database/ExoDatabaseProvider.java new file mode 100644 index 0000000000..ece8e57ae7 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/database/ExoDatabaseProvider.java @@ -0,0 +1,152 @@ +/* + * 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.database; + +import android.content.Context; +import android.content.ContextWrapper; +import android.database.Cursor; +import android.database.DatabaseErrorHandler; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.util.Log; +import java.io.File; + +/** + * An {@link SQLiteOpenHelper} that provides instances of a standalone ExoPlayer database. + * + *

    Suitable for use by applications that do not already have their own database, or which would + * prefer to keep ExoPlayer tables isolated in their own database. Other applications should prefer + * to use {@link DefaultDatabaseProvider} with their own {@link SQLiteOpenHelper}. + */ +public final class ExoDatabaseProvider extends SQLiteOpenHelper implements DatabaseProvider { + + /** The file name used for the standalone ExoPlayer database. */ + public static final String DATABASE_NAME = "exoplayer_internal.db"; + + private static final int VERSION = 1; + private static final String TAG = "ExoDatabaseProvider"; + + /** + * Provides instances of the database located by passing {@link #DATABASE_NAME} to {@link + * Context#getDatabasePath(String)}. + * + * @param context Any context. + */ + public ExoDatabaseProvider(Context context) { + super(context.getApplicationContext(), DATABASE_NAME, /* factory= */ null, VERSION); + } + + /** + * Provides instances of the database located at the specified file. + * + * @param file The database file. + */ + public ExoDatabaseProvider(File file) { + super(new DatabaseFileProvidingContext(file), file.getName(), /* factory= */ null, VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + // Features create their own tables. + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + // Features handle their own upgrades. + } + + @Override + public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) { + wipeDatabase(db); + } + + /** + * Makes a best effort to wipe the existing database. The wipe may be incomplete if the database + * contains foreign key constraints. + */ + private static void wipeDatabase(SQLiteDatabase db) { + String[] columns = {"type", "name"}; + try (Cursor cursor = + db.query( + "sqlite_master", + columns, + /* selection= */ null, + /* selectionArgs= */ null, + /* groupBy= */ null, + /* having= */ null, + /* orderBy= */ null)) { + while (cursor.moveToNext()) { + String type = cursor.getString(0); + String name = cursor.getString(1); + if (!"sqlite_sequence".equals(name)) { + // If it's not an SQL-controlled entity, drop it + String sql = "DROP " + type + " IF EXISTS " + name; + try { + db.execSQL(sql); + } catch (SQLException e) { + Log.e(TAG, "Error executing " + sql, e); + } + } + } + } + } + + // TODO: This is fragile. Stop using it if/when SQLiteOpenHelper can be instantiated without a + // context [Internal ref: b/123351819], or by injecting a Context into all components that need + // to instantiate an ExoDatabaseProvider. + /** A {@link Context} that implements methods called by {@link SQLiteOpenHelper}. */ + private static class DatabaseFileProvidingContext extends ContextWrapper { + + private final File file; + + @SuppressWarnings("nullness:argument.type.incompatible") + public DatabaseFileProvidingContext(File file) { + super(/* base= */ null); + this.file = file; + } + + @Override + public File getDatabasePath(String name) { + return file; + } + + @Override + public SQLiteDatabase openOrCreateDatabase( + String name, int mode, SQLiteDatabase.CursorFactory factory) { + return openOrCreateDatabase(name, mode, factory, /* errorHandler= */ null); + } + + @Override + @SuppressWarnings("nullness:argument.type.incompatible") + public SQLiteDatabase openOrCreateDatabase( + String name, + int mode, + SQLiteDatabase.CursorFactory factory, + @Nullable DatabaseErrorHandler errorHandler) { + File databasePath = getDatabasePath(name); + int flags = SQLiteDatabase.CREATE_IF_NECESSARY; + if ((mode & MODE_ENABLE_WRITE_AHEAD_LOGGING) != 0) { + flags |= SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING; + } + if ((mode & MODE_NO_LOCALIZED_COLLATORS) != 0) { + flags |= SQLiteDatabase.NO_LOCALIZED_COLLATORS; + } + return SQLiteDatabase.openDatabase(databasePath.getPath(), factory, flags, errorHandler); + } + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/database/VersionTable.java b/library/core/src/main/java/com/google/android/exoplayer2/database/VersionTable.java new file mode 100644 index 0000000000..cdcca7a350 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/database/VersionTable.java @@ -0,0 +1,117 @@ +/* + * 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.database; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; +import android.support.annotation.IntDef; +import android.support.annotation.VisibleForTesting; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Utility methods for accessing versions of ExoPlayer database components. This allows them to be + * versioned independently to the version of the containing database. + */ +public final class VersionTable { + + /** Returned by {@link #getVersion(SQLiteDatabase, int)} if the version is unset. */ + public static final int VERSION_UNSET = -1; + /** Version of tables used for offline functionality. */ + public static final int FEATURE_OFFLINE = 0; + /** Version of tables used for cache content metadata. */ + public static final int FEATURE_CACHE_CONTENT_METADATA = 1; + /** Version of tables used for cache file metadata. */ + public static final int FEATURE_CACHE_FILE_METADATA = 2; + + private static final String TABLE_NAME = DatabaseProvider.TABLE_PREFIX + "Versions"; + + private static final String COLUMN_FEATURE = "feature"; + private static final String COLUMN_VERSION = "version"; + + private static final String SQL_CREATE_TABLE_IF_NOT_EXISTS = + "CREATE TABLE IF NOT EXISTS " + + TABLE_NAME + + " (" + + COLUMN_FEATURE + + " INTEGER PRIMARY KEY NOT NULL," + + COLUMN_VERSION + + " INTEGER NOT NULL)"; + + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({FEATURE_OFFLINE, FEATURE_CACHE_CONTENT_METADATA, FEATURE_CACHE_FILE_METADATA}) + private @interface Feature {} + + private VersionTable() {} + + /** + * Sets the version of tables belonging to the specified feature. + * + * @param writableDatabase The database to update. + * @param feature The feature. + * @param version The version. + */ + public static void setVersion( + SQLiteDatabase writableDatabase, @Feature int feature, int version) { + writableDatabase.execSQL(SQL_CREATE_TABLE_IF_NOT_EXISTS); + ContentValues values = new ContentValues(); + values.put(COLUMN_FEATURE, feature); + values.put(COLUMN_VERSION, version); + writableDatabase.replace(TABLE_NAME, /* nullColumnHack= */ null, values); + } + + /** + * Returns the version of tables belonging to the specified feature, or {@link #VERSION_UNSET} if + * no version information is available. + * + * @param database The database to query. + * @param feature The feature. + */ + public static int getVersion(SQLiteDatabase database, @Feature int feature) { + if (!tableExists(database, TABLE_NAME)) { + return VERSION_UNSET; + } + String selection = COLUMN_FEATURE + " = ?"; + String[] selectionArgs = {Integer.toString(feature)}; + try (Cursor cursor = + database.query( + TABLE_NAME, + new String[] {COLUMN_VERSION}, + selection, + selectionArgs, + /* groupBy= */ null, + /* having= */ null, + /* orderBy= */ null)) { + if (cursor.getCount() == 0) { + return VERSION_UNSET; + } + cursor.moveToNext(); + return cursor.getInt(/* COLUMN_VERSION index */ 0); + } + } + + @VisibleForTesting + /* package */ static boolean tableExists(SQLiteDatabase readableDatabase, String tableName) { + long count = + DatabaseUtils.queryNumEntries( + readableDatabase, "sqlite_master", "tbl_name = ?", new String[] {tableName}); + return count > 0; + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/decoder/CryptoInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/decoder/CryptoInfo.java index ec17de8d74..379ca971b5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/decoder/CryptoInfo.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/decoder/CryptoInfo.java @@ -62,7 +62,7 @@ public final class CryptoInfo { private final PatternHolderV24 patternHolder; public CryptoInfo() { - frameworkCryptoInfo = Util.SDK_INT >= 16 ? newFrameworkCryptoInfoV16() : null; + frameworkCryptoInfo = new android.media.MediaCodec.CryptoInfo(); patternHolder = Util.SDK_INT >= 24 ? new PatternHolderV24(frameworkCryptoInfo) : null; } @@ -79,34 +79,8 @@ public final class CryptoInfo { this.mode = mode; this.encryptedBlocks = encryptedBlocks; this.clearBlocks = clearBlocks; - if (Util.SDK_INT >= 16) { - updateFrameworkCryptoInfoV16(); - } - } - - /** - * Returns an equivalent {@link android.media.MediaCodec.CryptoInfo} instance. - *

    - * Successive calls to this method on a single {@link CryptoInfo} will return the same instance. - * Changes to the {@link CryptoInfo} will be reflected in the returned object. The return object - * should not be modified directly. - * - * @return The equivalent {@link android.media.MediaCodec.CryptoInfo} instance. - */ - @TargetApi(16) - public android.media.MediaCodec.CryptoInfo getFrameworkCryptoInfoV16() { - return frameworkCryptoInfo; - } - - @TargetApi(16) - private android.media.MediaCodec.CryptoInfo newFrameworkCryptoInfoV16() { - return new android.media.MediaCodec.CryptoInfo(); - } - - @TargetApi(16) - private void updateFrameworkCryptoInfoV16() { - // Update fields directly because the framework's CryptoInfo.set performs an unnecessary object - // allocation on Android N. + // Update frameworkCryptoInfo fields directly because CryptoInfo.set performs an unnecessary + // object allocation on Android N. frameworkCryptoInfo.numSubSamples = numSubSamples; frameworkCryptoInfo.numBytesOfClearData = numBytesOfClearData; frameworkCryptoInfo.numBytesOfEncryptedData = numBytesOfEncryptedData; @@ -118,6 +92,25 @@ public final class CryptoInfo { } } + /** + * Returns an equivalent {@link android.media.MediaCodec.CryptoInfo} instance. + * + *

    Successive calls to this method on a single {@link CryptoInfo} will return the same + * instance. Changes to the {@link CryptoInfo} will be reflected in the returned object. The + * return object should not be modified directly. + * + * @return The equivalent {@link android.media.MediaCodec.CryptoInfo} instance. + */ + public android.media.MediaCodec.CryptoInfo getFrameworkCryptoInfo() { + return frameworkCryptoInfo; + } + + /** @deprecated Use {@link #getFrameworkCryptoInfo()}. */ + @Deprecated + public android.media.MediaCodec.CryptoInfo getFrameworkCryptoInfoV16() { + return getFrameworkCryptoInfo(); + } + @TargetApi(24) private static final class PatternHolderV24 { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSession.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSession.java index a68415287e..a23f26f067 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSession.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSession.java @@ -15,7 +15,6 @@ */ package com.google.android.exoplayer2.drm; -import android.annotation.TargetApi; import android.media.MediaDrm; import android.support.annotation.IntDef; import android.support.annotation.Nullable; @@ -27,7 +26,6 @@ import java.util.Map; /** * A DRM session. */ -@TargetApi(16) public interface DrmSession { /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSessionManager.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSessionManager.java index cf3d97d0b2..d8093507a4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSessionManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSessionManager.java @@ -15,14 +15,12 @@ */ package com.google.android.exoplayer2.drm; -import android.annotation.TargetApi; import android.os.Looper; import com.google.android.exoplayer2.drm.DrmInitData.SchemeData; /** * Manages a DRM session. */ -@TargetApi(16) public interface DrmSessionManager { /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaCrypto.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaCrypto.java index d5a4f6add5..feba7eaaf4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaCrypto.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaCrypto.java @@ -15,14 +15,5 @@ */ package com.google.android.exoplayer2.drm; -/** - * An opaque {@link android.media.MediaCrypto} equivalent. - */ -public interface ExoMediaCrypto { - - /** - * @see android.media.MediaCrypto#requiresSecureDecoderComponent(String) - */ - boolean requiresSecureDecoderComponent(String mimeType); - -} +/** An opaque {@link android.media.MediaCrypto} equivalent. */ +public interface ExoMediaCrypto {} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaDrm.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaDrm.java index 24c3ddbbd0..aca56139de 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaDrm.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaDrm.java @@ -265,11 +265,9 @@ public interface ExoMediaDrm { /** * @see android.media.MediaCrypto#MediaCrypto(UUID, byte[]) - * - * @param initData Opaque initialization data specific to the crypto scheme. + * @param sessionId The DRM session ID. * @return An object extends {@link ExoMediaCrypto}, using opaque crypto scheme specific data. * @throws MediaCryptoException If the instance can't be created. */ - T createMediaCrypto(byte[] initData) throws MediaCryptoException; - + T createMediaCrypto(byte[] sessionId) throws MediaCryptoException; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaCrypto.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaCrypto.java index 4e58ed6a31..7211b5fcde 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaCrypto.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaCrypto.java @@ -15,50 +15,35 @@ */ package com.google.android.exoplayer2.drm; -import android.annotation.TargetApi; import android.media.MediaCrypto; -import com.google.android.exoplayer2.util.Assertions; +import java.util.UUID; /** - * An {@link ExoMediaCrypto} implementation that wraps the framework {@link MediaCrypto}. + * An {@link ExoMediaCrypto} implementation that contains the necessary information to build or + * update a framework {@link MediaCrypto}. */ -@TargetApi(16) public final class FrameworkMediaCrypto implements ExoMediaCrypto { - private final MediaCrypto mediaCrypto; - private final boolean forceAllowInsecureDecoderComponents; + /** The DRM scheme UUID. */ + public final UUID uuid; + /** The DRM session id. */ + public final byte[] sessionId; + /** + * Whether to allow use of insecure decoder components even if the underlying platform says + * otherwise. + */ + public final boolean forceAllowInsecureDecoderComponents; /** - * @param mediaCrypto The {@link MediaCrypto} to wrap. + * @param uuid The DRM scheme UUID. + * @param sessionId The DRM session id. + * @param forceAllowInsecureDecoderComponents Whether to allow use of insecure decoder components + * even if the underlying platform says otherwise. */ - public FrameworkMediaCrypto(MediaCrypto mediaCrypto) { - this(mediaCrypto, false); - } - - /** - * @param mediaCrypto The {@link MediaCrypto} to wrap. - * @param forceAllowInsecureDecoderComponents Whether to force - * {@link #requiresSecureDecoderComponent(String)} to return {@code false}, rather than - * {@link MediaCrypto#requiresSecureDecoderComponent(String)} of the wrapped - * {@link MediaCrypto}. - */ - public FrameworkMediaCrypto(MediaCrypto mediaCrypto, - boolean forceAllowInsecureDecoderComponents) { - this.mediaCrypto = Assertions.checkNotNull(mediaCrypto); + public FrameworkMediaCrypto( + UUID uuid, byte[] sessionId, boolean forceAllowInsecureDecoderComponents) { + this.uuid = uuid; + this.sessionId = sessionId; this.forceAllowInsecureDecoderComponents = forceAllowInsecureDecoderComponents; } - - /** - * Returns the wrapped {@link MediaCrypto}. - */ - public MediaCrypto getWrappedMediaCrypto() { - return mediaCrypto; - } - - @Override - public boolean requiresSecureDecoderComponent(String mimeType) { - return !forceAllowInsecureDecoderComponents - && mediaCrypto.requiresSecureDecoderComponent(mimeType); - } - } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java index fda85a759c..b139288f98 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java @@ -18,7 +18,6 @@ package com.google.android.exoplayer2.drm; import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.media.DeniedByServerException; -import android.media.MediaCrypto; import android.media.MediaCryptoException; import android.media.MediaDrm; import android.media.MediaDrmException; @@ -210,7 +209,7 @@ public final class FrameworkMediaDrm implements ExoMediaDrm schemeDatas) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackSampleTable.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackSampleTable.java index 56851fc1e0..59ea386335 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackSampleTable.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackSampleTable.java @@ -64,6 +64,9 @@ import com.google.android.exoplayer2.util.Util; this.flags = flags; this.durationUs = durationUs; sampleCount = offsets.length; + if (flags.length > 0) { + flags[flags.length - 1] |= C.BUFFER_FLAG_LAST_SAMPLE; + } } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/DefaultTsPayloadReaderFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/DefaultTsPayloadReaderFactory.java index a5506e2cfb..88805d9362 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/DefaultTsPayloadReaderFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/DefaultTsPayloadReaderFactory.java @@ -50,7 +50,8 @@ public final class DefaultTsPayloadReaderFactory implements TsPayloadReader.Fact FLAG_IGNORE_H264_STREAM, FLAG_DETECT_ACCESS_UNITS, FLAG_IGNORE_SPLICE_INFO_STREAM, - FLAG_OVERRIDE_CAPTION_DESCRIPTORS + FLAG_OVERRIDE_CAPTION_DESCRIPTORS, + FLAG_IGNORE_HDMV_DTS_STREAM }) public @interface Flags {} @@ -86,6 +87,12 @@ public final class DefaultTsPayloadReaderFactory implements TsPayloadReader.Fact * closedCaptionFormats} will be ignored if the PMT contains closed captions service descriptors. */ public static final int FLAG_OVERRIDE_CAPTION_DESCRIPTORS = 1 << 5; + /** + * Prevents the creation of {@link DtsReader} instances when receiving {@link + * TsExtractor#TS_STREAM_TYPE_HDMV_DTS} as stream type. Enabling this flag prevents a stream type + * collision between HDMV DTS audio and SCTE-35 subtitles. + */ + public static final int FLAG_IGNORE_HDMV_DTS_STREAM = 1 << 6; private static final int DESCRIPTOR_TAG_CAPTION_SERVICE = 0x86; @@ -142,8 +149,12 @@ public final class DefaultTsPayloadReaderFactory implements TsPayloadReader.Fact case TsExtractor.TS_STREAM_TYPE_AC3: case TsExtractor.TS_STREAM_TYPE_E_AC3: return new PesReader(new Ac3Reader(esInfo.language)); - case TsExtractor.TS_STREAM_TYPE_DTS: case TsExtractor.TS_STREAM_TYPE_HDMV_DTS: + if (isSet(FLAG_IGNORE_HDMV_DTS_STREAM)) { + return null; + } + // Fall through. + case TsExtractor.TS_STREAM_TYPE_DTS: return new PesReader(new DtsReader(esInfo.language)); case TsExtractor.TS_STREAM_TYPE_H262: return new PesReader(new H262Reader(buildUserDataReader(esInfo))); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsPayloadReader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsPayloadReader.java index a034b05696..9289ba4d2e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsPayloadReader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsPayloadReader.java @@ -100,7 +100,7 @@ public interface TsPayloadReader { public final byte[] initializationData; /** - * @param language The ISO 639-2 three character language. + * @param language The ISO 639-2 three-letter language code. * @param type The subtitling type. * @param initializationData The composition and ancillary page ids. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java index 107ab9efd8..c9493e1208 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java @@ -31,7 +31,6 @@ import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; /** Information about a {@link MediaCodec} for a given mime type. */ -@TargetApi(16) @SuppressWarnings("InlinedApi") public final class MediaCodecInfo { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index b578467933..e3fcf9397b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -20,6 +20,7 @@ import android.media.MediaCodec; import android.media.MediaCodec.CodecException; import android.media.MediaCodec.CryptoException; import android.media.MediaCrypto; +import android.media.MediaCryptoException; import android.media.MediaFormat; import android.os.Bundle; import android.os.Looper; @@ -57,7 +58,6 @@ import java.util.List; /** * An abstract renderer that uses {@link MediaCodec} to decode samples for rendering. */ -@TargetApi(16) public abstract class MediaCodecRenderer extends BaseRenderer { /** @@ -239,14 +239,21 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @Documented @Retention(RetentionPolicy.SOURCE) - @IntDef({DRAIN_ACTION_NONE, DRAIN_ACTION_FLUSH, DRAIN_ACTION_REINITIALIZE}) + @IntDef({ + DRAIN_ACTION_NONE, + DRAIN_ACTION_FLUSH, + DRAIN_ACTION_UPDATE_DRM_SESSION, + DRAIN_ACTION_REINITIALIZE + }) private @interface DrainAction {} /** No special action should be taken. */ private static final int DRAIN_ACTION_NONE = 0; /** The codec should be flushed. */ private static final int DRAIN_ACTION_FLUSH = 1; - /** The codec should be re-initialized. */ - private static final int DRAIN_ACTION_REINITIALIZE = 2; + /** The codec should be flushed and updated to use the pending DRM session. */ + private static final int DRAIN_ACTION_UPDATE_DRM_SESSION = 2; + /** The codec should be reinitialized. */ + private static final int DRAIN_ACTION_REINITIALIZE = 3; @Documented @Retention(RetentionPolicy.SOURCE) @@ -287,13 +294,15 @@ public abstract class MediaCodecRenderer extends BaseRenderer { private final DecoderInputBuffer flagsOnlyBuffer; private final FormatHolder formatHolder; private final TimedValueQueue formatQueue; - private final List decodeOnlyPresentationTimestamps; + private final ArrayList decodeOnlyPresentationTimestamps; private final MediaCodec.BufferInfo outputBufferInfo; @Nullable private Format inputFormat; private Format outputFormat; - private DrmSession drmSession; - private DrmSession pendingDrmSession; + @Nullable private DrmSession codecDrmSession; + @Nullable private DrmSession sourceDrmSession; + @Nullable private MediaCrypto mediaCrypto; + private boolean mediaCryptoRequiresSecureDecoder; private long renderTimeLimitMs; private float rendererOperatingRate; @Nullable private MediaCodec codec; @@ -356,7 +365,6 @@ public abstract class MediaCodecRenderer extends BaseRenderer { boolean playClearSamplesWithoutKeys, float assumedMinimumCodecOperatingRate) { super(trackType); - Assertions.checkState(Util.SDK_INT >= 16); this.mediaCodecSelector = Assertions.checkNotNull(mediaCodecSelector); this.drmSessionManager = drmSessionManager; this.playClearSamplesWithoutKeys = playClearSamplesWithoutKeys; @@ -457,29 +465,36 @@ public abstract class MediaCodecRenderer extends BaseRenderer { return; } - drmSession = pendingDrmSession; + setCodecDrmSession(sourceDrmSession); + String mimeType = inputFormat.sampleMimeType; - MediaCrypto wrappedMediaCrypto = null; - boolean drmSessionRequiresSecureDecoder = false; - if (drmSession != null) { - FrameworkMediaCrypto mediaCrypto = drmSession.getMediaCrypto(); + if (codecDrmSession != null) { if (mediaCrypto == null) { - DrmSessionException drmError = drmSession.getError(); - if (drmError != null) { - // Continue for now. We may be able to avoid failure if the session recovers, or if a new - // input format causes the session to be replaced before it's used. + FrameworkMediaCrypto sessionMediaCrypto = codecDrmSession.getMediaCrypto(); + if (sessionMediaCrypto == null) { + DrmSessionException drmError = codecDrmSession.getError(); + if (drmError != null) { + // Continue for now. We may be able to avoid failure if the session recovers, or if a + // new input format causes the session to be replaced before it's used. + } else { + // The drm session isn't open yet. + return; + } } else { - // The drm session isn't open yet. - return; + try { + mediaCrypto = new MediaCrypto(sessionMediaCrypto.uuid, sessionMediaCrypto.sessionId); + } catch (MediaCryptoException e) { + throw ExoPlaybackException.createForRenderer(e, getIndex()); + } + mediaCryptoRequiresSecureDecoder = + !sessionMediaCrypto.forceAllowInsecureDecoderComponents + && mediaCrypto.requiresSecureDecoderComponent(mimeType); } - } else { - wrappedMediaCrypto = mediaCrypto.getWrappedMediaCrypto(); - drmSessionRequiresSecureDecoder = mediaCrypto.requiresSecureDecoderComponent(mimeType); } if (deviceNeedsDrmKeysToConfigureCodecWorkaround()) { - @DrmSession.State int drmSessionState = drmSession.getState(); + @DrmSession.State int drmSessionState = codecDrmSession.getState(); if (drmSessionState == DrmSession.STATE_ERROR) { - throw ExoPlaybackException.createForRenderer(drmSession.getError(), getIndex()); + throw ExoPlaybackException.createForRenderer(codecDrmSession.getError(), getIndex()); } else if (drmSessionState != DrmSession.STATE_OPENED_WITH_KEYS) { // Wait for keys. return; @@ -488,7 +503,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } try { - maybeInitCodecWithFallback(wrappedMediaCrypto, drmSessionRequiresSecureDecoder); + maybeInitCodecWithFallback(mediaCrypto, mediaCryptoRequiresSecureDecoder); } catch (DecoderInitializationException e) { throw ExoPlaybackException.createForRenderer(e, getIndex()); } @@ -537,7 +552,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { inputStreamEnded = false; outputStreamEnded = false; - flushOrReinitCodec(); + flushOrReinitializeCodec(); formatQueue.clear(); } @@ -552,7 +567,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @Override protected void onDisabled() { inputFormat = null; - if (drmSession != null || pendingDrmSession != null) { + if (sourceDrmSession != null || codecDrmSession != null) { // TODO: Do something better with this case. onReset(); } else { @@ -565,51 +580,40 @@ public abstract class MediaCodecRenderer extends BaseRenderer { try { releaseCodec(); } finally { - try { - if (drmSession != null) { - drmSessionManager.releaseSession(drmSession); - } - } finally { - try { - if (pendingDrmSession != null && pendingDrmSession != drmSession) { - drmSessionManager.releaseSession(pendingDrmSession); - } - } finally { - drmSession = null; - pendingDrmSession = null; - } - } + setSourceDrmSession(null); } } protected void releaseCodec() { availableCodecInfos = null; - if (codec != null) { - codecInfo = null; - codecFormat = null; - resetInputBuffer(); - resetOutputBuffer(); - resetCodecBuffers(); - waitingForKeys = false; - codecHotswapDeadlineMs = C.TIME_UNSET; - decodeOnlyPresentationTimestamps.clear(); - decoderCounters.decoderReleaseCount++; - try { - codec.stop(); - } finally { + codecInfo = null; + codecFormat = null; + resetInputBuffer(); + resetOutputBuffer(); + resetCodecBuffers(); + waitingForKeys = false; + codecHotswapDeadlineMs = C.TIME_UNSET; + decodeOnlyPresentationTimestamps.clear(); + try { + if (codec != null) { + decoderCounters.decoderReleaseCount++; try { - codec.release(); + codec.stop(); } finally { - codec = null; - if (drmSession != null && pendingDrmSession != drmSession) { - try { - drmSessionManager.releaseSession(drmSession); - } finally { - drmSession = null; - } - } + codec.release(); } } + } finally { + codec = null; + try { + if (mediaCrypto != null) { + mediaCrypto.release(); + } + } finally { + mediaCrypto = null; + mediaCryptoRequiresSecureDecoder = false; + setCodecDrmSession(null); + } } } @@ -680,12 +684,15 @@ public abstract class MediaCodecRenderer extends BaseRenderer { *

    The implementation of this method calls {@link #flushOrReleaseCodec()}, and {@link * #maybeInitCodec()} if the codec needs to be re-instantiated. * + * @return Whether the codec was released and reinitialized, rather than being flushed. * @throws ExoPlaybackException If an error occurs re-instantiating the codec. */ - protected final void flushOrReinitCodec() throws ExoPlaybackException { - if (flushOrReleaseCodec()) { + protected final boolean flushOrReinitializeCodec() throws ExoPlaybackException { + boolean released = flushOrReleaseCodec(); + if (released) { maybeInitCodec(); } + return released; } /** @@ -729,18 +736,18 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } private void maybeInitCodecWithFallback( - MediaCrypto crypto, boolean drmSessionRequiresSecureDecoder) + MediaCrypto crypto, boolean mediaCryptoRequiresSecureDecoder) throws DecoderInitializationException { if (availableCodecInfos == null) { try { availableCodecInfos = - new ArrayDeque<>(getAvailableCodecInfos(drmSessionRequiresSecureDecoder)); + new ArrayDeque<>(getAvailableCodecInfos(mediaCryptoRequiresSecureDecoder)); preferredDecoderInitializationException = null; } catch (DecoderQueryException e) { throw new DecoderInitializationException( inputFormat, e, - drmSessionRequiresSecureDecoder, + mediaCryptoRequiresSecureDecoder, DecoderInitializationException.DECODER_QUERY_ERROR); } } @@ -749,7 +756,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { throw new DecoderInitializationException( inputFormat, /* cause= */ null, - drmSessionRequiresSecureDecoder, + mediaCryptoRequiresSecureDecoder, DecoderInitializationException.NO_SUITABLE_DECODER_ERROR); } @@ -768,7 +775,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { availableCodecInfos.removeFirst(); DecoderInitializationException exception = new DecoderInitializationException( - inputFormat, e, drmSessionRequiresSecureDecoder, codecInfo.name); + inputFormat, e, mediaCryptoRequiresSecureDecoder, codecInfo.name); if (preferredDecoderInitializationException == null) { preferredDecoderInitializationException = exception; } else { @@ -784,11 +791,11 @@ public abstract class MediaCodecRenderer extends BaseRenderer { availableCodecInfos = null; } - private List getAvailableCodecInfos(boolean drmSessionRequiresSecureDecoder) + private List getAvailableCodecInfos(boolean mediaCryptoRequiresSecureDecoder) throws DecoderQueryException { List codecInfos = - getDecoderInfos(mediaCodecSelector, inputFormat, drmSessionRequiresSecureDecoder); - if (codecInfos.isEmpty() && drmSessionRequiresSecureDecoder) { + getDecoderInfos(mediaCodecSelector, inputFormat, mediaCryptoRequiresSecureDecoder); + if (codecInfos.isEmpty() && mediaCryptoRequiresSecureDecoder) { // The drm session indicates that a secure decoder is required, but the device does not // have one. Assuming that supportsFormat indicated support for the media being played, we // know that it does not require a secure output path. Most CDM implementations allow @@ -928,6 +935,24 @@ public abstract class MediaCodecRenderer extends BaseRenderer { outputBuffer = null; } + private void setSourceDrmSession(@Nullable DrmSession session) { + DrmSession previous = sourceDrmSession; + sourceDrmSession = session; + releaseDrmSessionIfUnused(previous); + } + + private void setCodecDrmSession(@Nullable DrmSession session) { + DrmSession previous = codecDrmSession; + codecDrmSession = session; + releaseDrmSessionIfUnused(previous); + } + + private void releaseDrmSessionIfUnused(@Nullable DrmSession session) { + if (session != null && session != sourceDrmSession && session != codecDrmSession) { + drmSessionManager.releaseSession(session); + } + } + /** * @return Whether it may be possible to feed more input data. * @throws ExoPlaybackException If an error occurs feeding the input buffer. @@ -1082,12 +1107,12 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } private boolean shouldWaitForKeys(boolean bufferEncrypted) throws ExoPlaybackException { - if (drmSession == null || (!bufferEncrypted && playClearSamplesWithoutKeys)) { + if (codecDrmSession == null || (!bufferEncrypted && playClearSamplesWithoutKeys)) { return false; } - @DrmSession.State int drmSessionState = drmSession.getState(); + @DrmSession.State int drmSessionState = codecDrmSession.getState(); if (drmSessionState == DrmSession.STATE_ERROR) { - throw ExoPlaybackException.createForRenderer(drmSession.getError(), getIndex()); + throw ExoPlaybackException.createForRenderer(codecDrmSession.getError(), getIndex()); } return drmSessionState != DrmSession.STATE_OPENED_WITH_KEYS; } @@ -1126,13 +1151,16 @@ public abstract class MediaCodecRenderer extends BaseRenderer { throw ExoPlaybackException.createForRenderer( new IllegalStateException("Media requires a DrmSessionManager"), getIndex()); } - pendingDrmSession = + DrmSession session = drmSessionManager.acquireSession(Looper.myLooper(), newFormat.drmInitData); - if (pendingDrmSession == drmSession) { - drmSessionManager.releaseSession(pendingDrmSession); + if (session == sourceDrmSession || session == codecDrmSession) { + // We already had this session. The manager must be reference counting, so release it once + // to get the count attributed to this renderer back down to 1. + drmSessionManager.releaseSession(session); } + setSourceDrmSession(session); } else { - pendingDrmSession = null; + setSourceDrmSession(null); } } @@ -1143,40 +1171,58 @@ public abstract class MediaCodecRenderer extends BaseRenderer { // We have an existing codec that we may need to reconfigure or re-initialize. If the existing // codec instance is being kept then its operating rate may need to be updated. - if (pendingDrmSession != drmSession) { + + if ((sourceDrmSession == null && codecDrmSession != null) + || (sourceDrmSession != null && codecDrmSession == null) + || (sourceDrmSession != null && !codecInfo.secure) + || (Util.SDK_INT < 23 && sourceDrmSession != codecDrmSession)) { + // We might need to switch between the clear and protected output paths, or we're using DRM + // prior to API level 23 where the codec needs to be re-initialized to switch to the new DRM + // session. drainAndReinitializeCodec(); - } else { - switch (canKeepCodec(codec, codecInfo, codecFormat, newFormat)) { - case KEEP_CODEC_RESULT_NO: - drainAndReinitializeCodec(); - break; - case KEEP_CODEC_RESULT_YES_WITH_FLUSH: + return; + } + + switch (canKeepCodec(codec, codecInfo, codecFormat, newFormat)) { + case KEEP_CODEC_RESULT_NO: + drainAndReinitializeCodec(); + break; + case KEEP_CODEC_RESULT_YES_WITH_FLUSH: + codecFormat = newFormat; + updateCodecOperatingRate(); + if (sourceDrmSession != codecDrmSession) { + drainAndUpdateCodecDrmSession(); + } else { drainAndFlushCodec(); + } + break; + case KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION: + if (codecNeedsReconfigureWorkaround) { + drainAndReinitializeCodec(); + } else { + codecReconfigured = true; + codecReconfigurationState = RECONFIGURATION_STATE_WRITE_PENDING; + codecNeedsAdaptationWorkaroundBuffer = + codecAdaptationWorkaroundMode == ADAPTATION_WORKAROUND_MODE_ALWAYS + || (codecAdaptationWorkaroundMode == ADAPTATION_WORKAROUND_MODE_SAME_RESOLUTION + && newFormat.width == codecFormat.width + && newFormat.height == codecFormat.height); codecFormat = newFormat; updateCodecOperatingRate(); - break; - case KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION: - if (codecNeedsReconfigureWorkaround) { - drainAndReinitializeCodec(); - } else { - codecReconfigured = true; - codecReconfigurationState = RECONFIGURATION_STATE_WRITE_PENDING; - codecNeedsAdaptationWorkaroundBuffer = - codecAdaptationWorkaroundMode == ADAPTATION_WORKAROUND_MODE_ALWAYS - || (codecAdaptationWorkaroundMode == ADAPTATION_WORKAROUND_MODE_SAME_RESOLUTION - && newFormat.width == codecFormat.width - && newFormat.height == codecFormat.height); - codecFormat = newFormat; - updateCodecOperatingRate(); + if (sourceDrmSession != codecDrmSession) { + drainAndUpdateCodecDrmSession(); } - break; - case KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION: - codecFormat = newFormat; - updateCodecOperatingRate(); - break; - default: - throw new IllegalStateException(); // Never happens. - } + } + break; + case KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION: + codecFormat = newFormat; + updateCodecOperatingRate(); + if (sourceDrmSession != codecDrmSession) { + drainAndUpdateCodecDrmSession(); + } + break; + default: + throw new IllegalStateException(); // Never happens. } } @@ -1311,6 +1357,27 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } } + /** + * Starts draining the codec to update its DRM session. The update may occur immediately if no + * buffers have been queued to the codec. + * + * @throws ExoPlaybackException If an error occurs updating the codec's DRM session. + */ + private void drainAndUpdateCodecDrmSession() throws ExoPlaybackException { + if (Util.SDK_INT < 23) { + // The codec needs to be re-initialized to switch to the source DRM session. + drainAndReinitializeCodec(); + return; + } + if (codecReceivedBuffers) { + codecDrainState = DRAIN_STATE_SIGNAL_END_OF_STREAM; + codecDrainAction = DRAIN_ACTION_UPDATE_DRM_SESSION; + } else { + // Nothing has been queued to the decoder, so we can do the update immediately. + updateDrmSessionOrReinitializeCodecV23(); + } + } + /** * Starts draining the codec for re-initialization. Re-initialization may occur immediately if no * buffers have been queued to the codec. @@ -1323,8 +1390,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { codecDrainAction = DRAIN_ACTION_REINITIALIZE; } else { // Nothing has been queued to the decoder, so we can re-initialize immediately. - releaseCodec(); - maybeInitCodec(); + reinitializeCodec(); } } @@ -1528,11 +1594,13 @@ public abstract class MediaCodecRenderer extends BaseRenderer { private void processEndOfStream() throws ExoPlaybackException { switch (codecDrainAction) { case DRAIN_ACTION_REINITIALIZE: - releaseCodec(); - maybeInitCodec(); + reinitializeCodec(); + break; + case DRAIN_ACTION_UPDATE_DRM_SESSION: + updateDrmSessionOrReinitializeCodecV23(); break; case DRAIN_ACTION_FLUSH: - flushOrReinitCodec(); + flushOrReinitializeCodec(); break; case DRAIN_ACTION_NONE: default: @@ -1542,6 +1610,41 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } } + private void reinitializeCodec() throws ExoPlaybackException { + releaseCodec(); + maybeInitCodec(); + } + + @TargetApi(23) + private void updateDrmSessionOrReinitializeCodecV23() throws ExoPlaybackException { + FrameworkMediaCrypto sessionMediaCrypto = sourceDrmSession.getMediaCrypto(); + if (sessionMediaCrypto == null) { + // We'd only expect this to happen if the CDM from which the pending session is obtained needs + // provisioning. This is unlikely to happen (it probably requires a switch from one DRM scheme + // to another, where the new CDM hasn't been used before and needs provisioning). It would be + // possible to handle this case more efficiently (i.e. with a new renderer state that waits + // for provisioning to finish and then calls mediaCrypto.setMediaDrmSession), but the extra + // complexity is not warranted given how unlikely the case is to occur. + reinitializeCodec(); + return; + } + + if (flushOrReinitializeCodec()) { + // The codec was reinitialized. The new codec will be using the new DRM session, so there's + // nothing more to do. + return; + } + + try { + mediaCrypto.setMediaDrmSession(sessionMediaCrypto.sessionId); + } catch (MediaCryptoException e) { + throw ExoPlaybackException.createForRenderer(e, getIndex()); + } + setCodecDrmSession(sourceDrmSession); + codecDrainState = DRAIN_STATE_NONE; + codecDrainAction = DRAIN_ACTION_NONE; + } + private boolean shouldSkipOutputBuffer(long presentationTimeUs) { // We avoid using decodeOnlyPresentationTimestamps.remove(presentationTimeUs) because it would // box presentationTimeUs, creating a Long object that would need to be garbage collected. @@ -1557,7 +1660,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { private static MediaCodec.CryptoInfo getFrameworkCryptoInfo( DecoderInputBuffer buffer, int adaptiveReconfigurationBytes) { - MediaCodec.CryptoInfo cryptoInfo = buffer.cryptoInfo.getFrameworkCryptoInfoV16(); + MediaCodec.CryptoInfo cryptoInfo = buffer.cryptoInfo.getFrameworkCryptoInfo(); if (adaptiveReconfigurationBytes == 0) { return cryptoInfo; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java index 4d971d461e..95cf82ff6c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java @@ -39,7 +39,6 @@ import java.util.regex.Pattern; /** * A utility class for querying the available codecs. */ -@TargetApi(16) @SuppressLint("InlinedApi") public final class MediaCodecUtil { @@ -59,8 +58,6 @@ public final class MediaCodecUtil { private static final String TAG = "MediaCodecUtil"; private static final Pattern PROFILE_PATTERN = Pattern.compile("^\\D?(\\d+)$"); - private static final RawAudioCodecComparator RAW_AUDIO_CODEC_COMPARATOR = - new RawAudioCodecComparator(); private static final HashMap> decoderInfosCache = new HashMap<>(); @@ -312,30 +309,6 @@ public final class MediaCodecUtil { return false; } - // Work around https://github.com/google/ExoPlayer/issues/398. - if (Util.SDK_INT < 18 && "OMX.SEC.MP3.Decoder".equals(name)) { - return false; - } - - // Work around https://github.com/google/ExoPlayer/issues/4519. - if ("OMX.SEC.mp3.dec".equals(name) - && (Util.MODEL.startsWith("GT-I9152") - || Util.MODEL.startsWith("GT-I9515") - || Util.MODEL.startsWith("GT-P5220") - || Util.MODEL.startsWith("GT-S7580") - || Util.MODEL.startsWith("SM-G350") - || Util.MODEL.startsWith("SM-G386") - || Util.MODEL.startsWith("SM-T231") - || Util.MODEL.startsWith("SM-T530"))) { - return false; - } - if ("OMX.brcm.audio.mp3.decoder".equals(name) - && (Util.MODEL.startsWith("GT-I9152") - || Util.MODEL.startsWith("GT-S7580") - || Util.MODEL.startsWith("SM-G350"))) { - return false; - } - // Work around https://github.com/google/ExoPlayer/issues/1528 and // https://github.com/google/ExoPlayer/issues/3171. if (Util.SDK_INT < 18 && "OMX.MTK.AUDIO.DECODER.AAC".equals(name) @@ -422,7 +395,18 @@ public final class MediaCodecUtil { */ private static void applyWorkarounds(String mimeType, List decoderInfos) { if (MimeTypes.AUDIO_RAW.equals(mimeType)) { - Collections.sort(decoderInfos, RAW_AUDIO_CODEC_COMPARATOR); + Collections.sort(decoderInfos, new RawAudioCodecComparator()); + } else if (Util.SDK_INT < 21 && decoderInfos.size() > 1) { + String firstCodecName = decoderInfos.get(0).name; + if ("OMX.SEC.mp3.dec".equals(firstCodecName) + || "OMX.SEC.MP3.Decoder".equals(firstCodecName) + || "OMX.brcm.audio.mp3.decoder".equals(firstCodecName)) { + // Prefer OMX.google codecs over OMX.SEC.mp3.dec, OMX.SEC.MP3.Decoder and + // OMX.brcm.audio.mp3.decoder on older devices. See: + // https://github.com/google/ExoPlayer/issues/398 and + // https://github.com/google/ExoPlayer/issues/4519. + Collections.sort(decoderInfos, new PreferOmxGoogleCodecComparator()); + } } } @@ -461,9 +445,10 @@ public final class MediaCodecUtil { Log.w(TAG, "Unknown HEVC profile string: " + profileString); return null; } - Integer level = HEVC_CODEC_STRING_TO_PROFILE_LEVEL.get(parts[3]); + String levelString = parts[3]; + Integer level = HEVC_CODEC_STRING_TO_PROFILE_LEVEL.get(levelString); if (level == null) { - Log.w(TAG, "Unknown HEVC level string: " + matcher.group(1)); + Log.w(TAG, "Unknown HEVC level string: " + levelString); return null; } return new Pair<>(profile, level); @@ -728,6 +713,18 @@ public final class MediaCodecUtil { } } + /** Comparator for preferring OMX.google media codecs. */ + private static final class PreferOmxGoogleCodecComparator implements Comparator { + @Override + public int compare(MediaCodecInfo a, MediaCodecInfo b) { + return scoreMediaCodecInfo(a) - scoreMediaCodecInfo(b); + } + + private static int scoreMediaCodecInfo(MediaCodecInfo mediaCodecInfo) { + return mediaCodecInfo.name.startsWith("OMX.google") ? -1 : 0; + } + } + static { AVC_PROFILE_NUMBER_TO_CONST = new SparseIntArray(); AVC_PROFILE_NUMBER_TO_CONST.put(66, CodecProfileLevel.AVCProfileBaseline); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaFormatUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaFormatUtil.java index 3cfefc0736..95cc5d4a37 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaFormatUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaFormatUtil.java @@ -15,7 +15,6 @@ */ package com.google.android.exoplayer2.mediacodec; -import android.annotation.TargetApi; import android.media.MediaFormat; import android.support.annotation.Nullable; import com.google.android.exoplayer2.Format; @@ -24,7 +23,6 @@ import java.nio.ByteBuffer; import java.util.List; /** Helper class for configuring {@link MediaFormat} instances. */ -@TargetApi(16) public final class MediaFormatUtil { private MediaFormatUtil() {} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java new file mode 100644 index 0000000000..c30260b11b --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java @@ -0,0 +1,350 @@ +/* + * 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.offline; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import com.google.android.exoplayer2.database.DatabaseProvider; +import com.google.android.exoplayer2.database.VersionTable; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; + +/** + * A {@link DownloadIndex} which uses SQLite to persist {@link DownloadState}s. + * + *

    Database access may take a long time, do not call methods of this class from + * the application main thread. + */ +public final class DefaultDownloadIndex implements DownloadIndex { + + @VisibleForTesting + /* package */ static final String TABLE_NAME = DatabaseProvider.TABLE_PREFIX + "Downloads"; + + @VisibleForTesting /* package */ static final int TABLE_VERSION = 1; + + private static final String COLUMN_ID = "id"; + private static final String COLUMN_TYPE = "title"; + private static final String COLUMN_URI = "subtitle"; + private static final String COLUMN_CACHE_KEY = "cache_key"; + private static final String COLUMN_STATE = "state"; + private static final String COLUMN_DOWNLOAD_PERCENTAGE = "download_percentage"; + private static final String COLUMN_DOWNLOADED_BYTES = "downloaded_bytes"; + private static final String COLUMN_TOTAL_BYTES = "total_bytes"; + private static final String COLUMN_FAILURE_REASON = "failure_reason"; + private static final String COLUMN_STOP_FLAGS = "stop_flags"; + private static final String COLUMN_NOT_MET_REQUIREMENTS = "not_met_requirements"; + private static final String COLUMN_MANUAL_STOP_REASON = "manual_stop_reason"; + private static final String COLUMN_START_TIME_MS = "start_time_ms"; + private static final String COLUMN_UPDATE_TIME_MS = "update_time_ms"; + private static final String COLUMN_STREAM_KEYS = "stream_keys"; + private static final String COLUMN_CUSTOM_METADATA = "custom_metadata"; + + private static final int COLUMN_INDEX_ID = 0; + private static final int COLUMN_INDEX_TYPE = 1; + private static final int COLUMN_INDEX_URI = 2; + private static final int COLUMN_INDEX_CACHE_KEY = 3; + private static final int COLUMN_INDEX_STATE = 4; + private static final int COLUMN_INDEX_DOWNLOAD_PERCENTAGE = 5; + private static final int COLUMN_INDEX_DOWNLOADED_BYTES = 6; + private static final int COLUMN_INDEX_TOTAL_BYTES = 7; + private static final int COLUMN_INDEX_FAILURE_REASON = 8; + private static final int COLUMN_INDEX_STOP_FLAGS = 9; + private static final int COLUMN_INDEX_NOT_MET_REQUIREMENTS = 10; + private static final int COLUMN_INDEX_MANUAL_STOP_REASON = 11; + private static final int COLUMN_INDEX_START_TIME_MS = 12; + private static final int COLUMN_INDEX_UPDATE_TIME_MS = 13; + private static final int COLUMN_INDEX_STREAM_KEYS = 14; + private static final int COLUMN_INDEX_CUSTOM_METADATA = 15; + + private static final String COLUMN_SELECTION_ID = COLUMN_ID + " = ?"; + + private static final String[] COLUMNS = + new String[] { + COLUMN_ID, + COLUMN_TYPE, + COLUMN_URI, + COLUMN_CACHE_KEY, + COLUMN_STATE, + COLUMN_DOWNLOAD_PERCENTAGE, + COLUMN_DOWNLOADED_BYTES, + COLUMN_TOTAL_BYTES, + COLUMN_FAILURE_REASON, + COLUMN_STOP_FLAGS, + COLUMN_NOT_MET_REQUIREMENTS, + COLUMN_MANUAL_STOP_REASON, + COLUMN_START_TIME_MS, + COLUMN_UPDATE_TIME_MS, + COLUMN_STREAM_KEYS, + COLUMN_CUSTOM_METADATA + }; + + private static final String SQL_DROP_TABLE_IF_EXISTS = "DROP TABLE IF EXISTS " + TABLE_NAME; + private static final String SQL_CREATE_TABLE = + "CREATE TABLE " + + TABLE_NAME + + " (" + + COLUMN_ID + + " TEXT PRIMARY KEY NOT NULL," + + COLUMN_TYPE + + " TEXT NOT NULL," + + COLUMN_URI + + " TEXT NOT NULL," + + COLUMN_CACHE_KEY + + " TEXT," + + COLUMN_STATE + + " INTEGER NOT NULL," + + COLUMN_DOWNLOAD_PERCENTAGE + + " REAL NOT NULL," + + COLUMN_DOWNLOADED_BYTES + + " INTEGER NOT NULL," + + COLUMN_TOTAL_BYTES + + " INTEGER NOT NULL," + + COLUMN_FAILURE_REASON + + " INTEGER NOT NULL," + + COLUMN_STOP_FLAGS + + " INTEGER NOT NULL," + + COLUMN_NOT_MET_REQUIREMENTS + + " INTEGER NOT NULL," + + COLUMN_MANUAL_STOP_REASON + + " INTEGER NOT NULL," + + COLUMN_START_TIME_MS + + " INTEGER NOT NULL," + + COLUMN_UPDATE_TIME_MS + + " INTEGER NOT NULL," + + COLUMN_STREAM_KEYS + + " TEXT NOT NULL," + + COLUMN_CUSTOM_METADATA + + " BLOB NOT NULL)"; + + private final DatabaseProvider databaseProvider; + + private boolean initialized; + + /** + * Creates a DefaultDownloadIndex which stores the {@link DownloadState}s on a SQLite database + * provided by {@code databaseProvider}. + * + * @param databaseProvider A DatabaseProvider which provides the database which will be used to + * store DownloadStatus table. + */ + public DefaultDownloadIndex(DatabaseProvider databaseProvider) { + this.databaseProvider = databaseProvider; + } + + @Override + @Nullable + public DownloadState getDownloadState(String id) { + ensureInitialized(); + try (Cursor cursor = getCursor(COLUMN_SELECTION_ID, new String[] {id})) { + if (cursor.getCount() == 0) { + return null; + } + cursor.moveToNext(); + DownloadState downloadState = getDownloadStateForCurrentRow(cursor); + Assertions.checkState(id.equals(downloadState.id)); + return downloadState; + } + } + + @Override + public DownloadStateCursor getDownloadStates(@DownloadState.State int... states) { + ensureInitialized(); + String selection = null; + if (states.length > 0) { + StringBuilder selectionBuilder = new StringBuilder(); + selectionBuilder.append(COLUMN_STATE).append(" IN ("); + for (int i = 0; i < states.length; i++) { + if (i > 0) { + selectionBuilder.append(','); + } + selectionBuilder.append(states[i]); + } + selectionBuilder.append(')'); + selection = selectionBuilder.toString(); + } + Cursor cursor = getCursor(selection, /* selectionArgs= */ null); + return new DownloadStateCursorImpl(cursor); + } + + @Override + public void putDownloadState(DownloadState downloadState) { + ensureInitialized(); + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + ContentValues values = new ContentValues(); + values.put(COLUMN_ID, downloadState.id); + values.put(COLUMN_TYPE, downloadState.type); + values.put(COLUMN_URI, downloadState.uri.toString()); + values.put(COLUMN_CACHE_KEY, downloadState.cacheKey); + values.put(COLUMN_STATE, downloadState.state); + values.put(COLUMN_DOWNLOAD_PERCENTAGE, downloadState.downloadPercentage); + values.put(COLUMN_DOWNLOADED_BYTES, downloadState.downloadedBytes); + values.put(COLUMN_TOTAL_BYTES, downloadState.totalBytes); + values.put(COLUMN_FAILURE_REASON, downloadState.failureReason); + values.put(COLUMN_STOP_FLAGS, downloadState.stopFlags); + values.put(COLUMN_NOT_MET_REQUIREMENTS, downloadState.notMetRequirements); + values.put(COLUMN_MANUAL_STOP_REASON, downloadState.manualStopReason); + values.put(COLUMN_START_TIME_MS, downloadState.startTimeMs); + values.put(COLUMN_UPDATE_TIME_MS, downloadState.updateTimeMs); + values.put(COLUMN_STREAM_KEYS, encodeStreamKeys(downloadState.streamKeys)); + values.put(COLUMN_CUSTOM_METADATA, downloadState.customMetadata); + writableDatabase.replace(TABLE_NAME, /* nullColumnHack= */ null, values); + } + + @Override + public void removeDownloadState(String id) { + ensureInitialized(); + databaseProvider + .getWritableDatabase() + .delete(TABLE_NAME, COLUMN_SELECTION_ID, new String[] {id}); + } + + private void ensureInitialized() { + if (initialized) { + return; + } + SQLiteDatabase readableDatabase = databaseProvider.getReadableDatabase(); + int version = VersionTable.getVersion(readableDatabase, VersionTable.FEATURE_OFFLINE); + if (version == VersionTable.VERSION_UNSET || version > TABLE_VERSION) { + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.beginTransaction(); + try { + VersionTable.setVersion(writableDatabase, VersionTable.FEATURE_OFFLINE, TABLE_VERSION); + writableDatabase.execSQL(SQL_DROP_TABLE_IF_EXISTS); + writableDatabase.execSQL(SQL_CREATE_TABLE); + writableDatabase.setTransactionSuccessful(); + } finally { + writableDatabase.endTransaction(); + } + } else if (version < TABLE_VERSION) { + // There is no previous version currently. + throw new IllegalStateException(); + } + initialized = true; + } + + private Cursor getCursor(@Nullable String selection, @Nullable String[] selectionArgs) { + String sortOrder = COLUMN_START_TIME_MS + " ASC"; + return databaseProvider + .getReadableDatabase() + .query( + TABLE_NAME, + COLUMNS, + selection, + selectionArgs, + /* groupBy= */ null, + /* having= */ null, + sortOrder); + } + + private static DownloadState getDownloadStateForCurrentRow(Cursor cursor) { + return new DownloadState( + cursor.getString(COLUMN_INDEX_ID), + cursor.getString(COLUMN_INDEX_TYPE), + Uri.parse(cursor.getString(COLUMN_INDEX_URI)), + cursor.getString(COLUMN_INDEX_CACHE_KEY), + cursor.getInt(COLUMN_INDEX_STATE), + cursor.getFloat(COLUMN_INDEX_DOWNLOAD_PERCENTAGE), + cursor.getLong(COLUMN_INDEX_DOWNLOADED_BYTES), + cursor.getLong(COLUMN_INDEX_TOTAL_BYTES), + cursor.getInt(COLUMN_INDEX_FAILURE_REASON), + cursor.getInt(COLUMN_INDEX_STOP_FLAGS), + cursor.getInt(COLUMN_INDEX_NOT_MET_REQUIREMENTS), + cursor.getInt(COLUMN_INDEX_MANUAL_STOP_REASON), + cursor.getLong(COLUMN_INDEX_START_TIME_MS), + cursor.getLong(COLUMN_INDEX_UPDATE_TIME_MS), + decodeStreamKeys(cursor.getString(COLUMN_INDEX_STREAM_KEYS)), + cursor.getBlob(COLUMN_INDEX_CUSTOM_METADATA)); + } + + private static String encodeStreamKeys(StreamKey[] streamKeys) { + StringBuilder stringBuilder = new StringBuilder(); + for (StreamKey streamKey : streamKeys) { + stringBuilder + .append(streamKey.periodIndex) + .append('.') + .append(streamKey.groupIndex) + .append('.') + .append(streamKey.trackIndex) + .append(','); + } + if (stringBuilder.length() > 0) { + stringBuilder.setLength(stringBuilder.length() - 1); + } + return stringBuilder.toString(); + } + + private static StreamKey[] decodeStreamKeys(String encodedStreamKeys) { + if (encodedStreamKeys.isEmpty()) { + return new StreamKey[0]; + } + String[] streamKeysStrings = Util.split(encodedStreamKeys, ","); + int streamKeysCount = streamKeysStrings.length; + StreamKey[] streamKeys = new StreamKey[streamKeysCount]; + for (int i = 0; i < streamKeysCount; i++) { + String[] indices = Util.split(streamKeysStrings[i], "\\."); + Assertions.checkState(indices.length == 3); + streamKeys[i] = + new StreamKey( + Integer.parseInt(indices[0]), + Integer.parseInt(indices[1]), + Integer.parseInt(indices[2])); + } + return streamKeys; + } + + private static final class DownloadStateCursorImpl implements DownloadStateCursor { + + private final Cursor cursor; + + private DownloadStateCursorImpl(Cursor cursor) { + this.cursor = cursor; + } + + @Override + public DownloadState getDownloadState() { + return getDownloadStateForCurrentRow(cursor); + } + + @Override + public int getCount() { + return cursor.getCount(); + } + + @Override + public int getPosition() { + return cursor.getPosition(); + } + + @Override + public boolean moveToPosition(int position) { + return cursor.moveToPosition(position); + } + + @Override + public void close() { + cursor.close(); + } + + @Override + public boolean isClosed() { + return cursor.isClosed(); + } + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadAction.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadAction.java index d809ab4754..40ea094b5e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadAction.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadAction.java @@ -156,7 +156,7 @@ public final class DownloadAction { ArrayList mutableKeys = new ArrayList<>(keys); Collections.sort(mutableKeys); this.keys = Collections.unmodifiableList(mutableKeys); - this.data = data != null ? data : Util.EMPTY_BYTE_ARRAY; + this.data = data != null ? Arrays.copyOf(data, data.length) : Util.EMPTY_BYTE_ARRAY; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java index e799aff4b2..d12013673f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java @@ -17,8 +17,11 @@ package com.google.android.exoplayer2.offline; import android.net.Uri; import android.os.Handler; +import android.os.HandlerThread; import android.os.Looper; +import android.os.Message; import android.support.annotation.Nullable; +import android.util.Pair; import android.util.SparseIntArray; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; @@ -27,6 +30,8 @@ import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; @@ -36,11 +41,16 @@ import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.Paramet import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelectorResult; +import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.BandwidthMeter; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultAllocator; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -58,19 +68,17 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; *

    A typical usage of DownloadHelper follows these steps: * *

      - *
    1. Construct the download helper with information about the {@link RenderersFactory renderers} - * and {@link DefaultTrackSelector.Parameters parameters} for track selection. + *
    2. Build the helper using one of the {@code forXXX} methods. *
    3. Prepare the helper using {@link #prepare(Callback)} and wait for the callback. *
    4. Optional: Inspect the selected tracks using {@link #getMappedTrackInfo(int)} and {@link * #getTrackSelections(int, int)}, and make adjustments using {@link * #clearTrackSelections(int)}, {@link #replaceTrackSelections(int, Parameters)} and {@link * #addTrackSelection(int, Parameters)}. - *
    5. Create download actions for the selected track using {@link #getDownloadAction(byte[])}. + *
    6. Create a download action for the selected track using {@link #getDownloadAction(byte[])}. + *
    7. Release the helper using {@link #release()}. *
    - * - * @param The manifest type. */ -public abstract class DownloadHelper { +public final class DownloadHelper { /** * The default parameters used for track selection for downloading. This default selects the @@ -87,7 +95,7 @@ public abstract class DownloadHelper { * * @param helper The reporting {@link DownloadHelper}. */ - void onPrepared(DownloadHelper helper); + void onPrepared(DownloadHelper helper); /** * Called when preparation fails. @@ -95,18 +103,222 @@ public abstract class DownloadHelper { * @param helper The reporting {@link DownloadHelper}. * @param e The error. */ - void onPrepareError(DownloadHelper helper, IOException e); + void onPrepareError(DownloadHelper helper, IOException e); + } + + @Nullable private static final Constructor DASH_FACTORY_CONSTRUCTOR; + @Nullable private static final Constructor HLS_FACTORY_CONSTRUCTOR; + @Nullable private static final Constructor SS_FACTORY_CONSTRUCTOR; + @Nullable private static final Method DASH_FACTORY_CREATE_METHOD; + @Nullable private static final Method HLS_FACTORY_CREATE_METHOD; + @Nullable private static final Method SS_FACTORY_CREATE_METHOD; + + static { + Pair<@NullableType Constructor, @NullableType Method> dashFactoryMethods = + getMediaSourceFactoryMethods( + "com.google.android.exoplayer2.source.dash.DashMediaSource$Factory"); + DASH_FACTORY_CONSTRUCTOR = dashFactoryMethods.first; + DASH_FACTORY_CREATE_METHOD = dashFactoryMethods.second; + Pair<@NullableType Constructor, @NullableType Method> hlsFactoryMethods = + getMediaSourceFactoryMethods( + "com.google.android.exoplayer2.source.hls.HlsMediaSource$Factory"); + HLS_FACTORY_CONSTRUCTOR = hlsFactoryMethods.first; + HLS_FACTORY_CREATE_METHOD = hlsFactoryMethods.second; + Pair<@NullableType Constructor, @NullableType Method> ssFactoryMethods = + getMediaSourceFactoryMethods( + "com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource$Factory"); + SS_FACTORY_CONSTRUCTOR = ssFactoryMethods.first; + SS_FACTORY_CREATE_METHOD = ssFactoryMethods.second; + } + + /** + * Creates a {@link DownloadHelper} for progressive streams. + * + * @param uri A stream {@link Uri}. + * @return A {@link DownloadHelper} for progressive streams. + */ + public static DownloadHelper forProgressive(Uri uri) { + return forProgressive(uri, /* cacheKey= */ null); + } + + /** + * Creates a {@link DownloadHelper} for progressive streams. + * + * @param uri A stream {@link Uri}. + * @param cacheKey An optional cache key. + * @return A {@link DownloadHelper} for progressive streams. + */ + public static DownloadHelper forProgressive(Uri uri, @Nullable String cacheKey) { + return new DownloadHelper( + DownloadAction.TYPE_PROGRESSIVE, + uri, + cacheKey, + /* mediaSource= */ null, + DEFAULT_TRACK_SELECTOR_PARAMETERS, + /* rendererCapabilities= */ new RendererCapabilities[0]); + } + + /** + * Creates a {@link DownloadHelper} for DASH streams. + * + * @param uri A manifest {@link Uri}. + * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest. + * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are + * selected. + * @return A {@link DownloadHelper} for DASH streams. + * @throws IllegalStateException If the DASH module is missing. + */ + public static DownloadHelper forDash( + Uri uri, DataSource.Factory dataSourceFactory, RenderersFactory renderersFactory) { + return forDash( + uri, + dataSourceFactory, + renderersFactory, + /* drmSessionManager= */ null, + DEFAULT_TRACK_SELECTOR_PARAMETERS); + } + + /** + * Creates a {@link DownloadHelper} for DASH streams. + * + * @param uri A manifest {@link Uri}. + * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest. + * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are + * selected. + * @param drmSessionManager An optional {@link DrmSessionManager} used by the renderers created by + * {@code renderersFactory}. + * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for + * downloading. + * @return A {@link DownloadHelper} for DASH streams. + * @throws IllegalStateException If the DASH module is missing. + */ + public static DownloadHelper forDash( + Uri uri, + DataSource.Factory dataSourceFactory, + RenderersFactory renderersFactory, + @Nullable DrmSessionManager drmSessionManager, + DefaultTrackSelector.Parameters trackSelectorParameters) { + return new DownloadHelper( + DownloadAction.TYPE_DASH, + uri, + /* cacheKey= */ null, + createMediaSource( + uri, dataSourceFactory, DASH_FACTORY_CONSTRUCTOR, DASH_FACTORY_CREATE_METHOD), + trackSelectorParameters, + Util.getRendererCapabilities(renderersFactory, drmSessionManager)); + } + + /** + * Creates a {@link DownloadHelper} for HLS streams. + * + * @param uri A playlist {@link Uri}. + * @param dataSourceFactory A {@link DataSource.Factory} used to load the playlist. + * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are + * selected. + * @return A {@link DownloadHelper} for HLS streams. + * @throws IllegalStateException If the HLS module is missing. + */ + public static DownloadHelper forHls( + Uri uri, DataSource.Factory dataSourceFactory, RenderersFactory renderersFactory) { + return forHls( + uri, + dataSourceFactory, + renderersFactory, + /* drmSessionManager= */ null, + DEFAULT_TRACK_SELECTOR_PARAMETERS); + } + + /** + * Creates a {@link DownloadHelper} for HLS streams. + * + * @param uri A playlist {@link Uri}. + * @param dataSourceFactory A {@link DataSource.Factory} used to load the playlist. + * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are + * selected. + * @param drmSessionManager An optional {@link DrmSessionManager} used by the renderers created by + * {@code renderersFactory}. + * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for + * downloading. + * @return A {@link DownloadHelper} for HLS streams. + * @throws IllegalStateException If the HLS module is missing. + */ + public static DownloadHelper forHls( + Uri uri, + DataSource.Factory dataSourceFactory, + RenderersFactory renderersFactory, + @Nullable DrmSessionManager drmSessionManager, + DefaultTrackSelector.Parameters trackSelectorParameters) { + return new DownloadHelper( + DownloadAction.TYPE_HLS, + uri, + /* cacheKey= */ null, + createMediaSource( + uri, dataSourceFactory, HLS_FACTORY_CONSTRUCTOR, HLS_FACTORY_CREATE_METHOD), + trackSelectorParameters, + Util.getRendererCapabilities(renderersFactory, drmSessionManager)); + } + + /** + * Creates a {@link DownloadHelper} for SmoothStreaming streams. + * + * @param uri A manifest {@link Uri}. + * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest. + * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are + * selected. + * @return A {@link DownloadHelper} for SmoothStreaming streams. + * @throws IllegalStateException If the SmoothStreaming module is missing. + */ + public static DownloadHelper forSmoothStreaming( + Uri uri, DataSource.Factory dataSourceFactory, RenderersFactory renderersFactory) { + return forSmoothStreaming( + uri, + dataSourceFactory, + renderersFactory, + /* drmSessionManager= */ null, + DEFAULT_TRACK_SELECTOR_PARAMETERS); + } + + /** + * Creates a {@link DownloadHelper} for SmoothStreaming streams. + * + * @param uri A manifest {@link Uri}. + * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest. + * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are + * selected. + * @param drmSessionManager An optional {@link DrmSessionManager} used by the renderers created by + * {@code renderersFactory}. + * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for + * downloading. + * @return A {@link DownloadHelper} for SmoothStreaming streams. + * @throws IllegalStateException If the SmoothStreaming module is missing. + */ + public static DownloadHelper forSmoothStreaming( + Uri uri, + DataSource.Factory dataSourceFactory, + RenderersFactory renderersFactory, + @Nullable DrmSessionManager drmSessionManager, + DefaultTrackSelector.Parameters trackSelectorParameters) { + return new DownloadHelper( + DownloadAction.TYPE_SS, + uri, + /* cacheKey= */ null, + createMediaSource(uri, dataSourceFactory, SS_FACTORY_CONSTRUCTOR, SS_FACTORY_CREATE_METHOD), + trackSelectorParameters, + Util.getRendererCapabilities(renderersFactory, drmSessionManager)); } private final String downloadType; private final Uri uri; @Nullable private final String cacheKey; + @Nullable private final MediaSource mediaSource; private final DefaultTrackSelector trackSelector; private final RendererCapabilities[] rendererCapabilities; private final SparseIntArray scratchSet; - private int currentTrackSelectionPeriodIndex; - @Nullable private T manifest; + private boolean isPreparedWithMedia; + private @MonotonicNonNull Callback callback; + private @MonotonicNonNull Handler callbackHandler; + private @MonotonicNonNull MediaPreparer mediaPreparer; private TrackGroupArray @MonotonicNonNull [] trackGroupArrays; private MappedTrackInfo @MonotonicNonNull [] mappedTrackInfos; private List @MonotonicNonNull [][] trackSelectionsByPeriodAndRenderer; @@ -118,25 +330,26 @@ public abstract class DownloadHelper { * @param downloadType A download type. This value will be used as {@link DownloadAction#type}. * @param uri A {@link Uri}. * @param cacheKey An optional cache key. + * @param mediaSource A {@link MediaSource} for which tracks are selected, or null if no track + * selection needs to be made. * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for * downloading. - * @param renderersFactory The {@link RenderersFactory} creating the renderers for which tracks + * @param rendererCapabilities The {@link RendererCapabilities} of the renderers for which tracks * are selected. - * @param drmSessionManager An optional {@link DrmSessionManager} used by the renderers created by - * {@code renderersFactory}. */ public DownloadHelper( String downloadType, Uri uri, @Nullable String cacheKey, + @Nullable MediaSource mediaSource, DefaultTrackSelector.Parameters trackSelectorParameters, - RenderersFactory renderersFactory, - @Nullable DrmSessionManager drmSessionManager) { + RendererCapabilities[] rendererCapabilities) { this.downloadType = downloadType; this.uri = uri; this.cacheKey = cacheKey; + this.mediaSource = mediaSource; this.trackSelector = new DefaultTrackSelector(new DownloadTrackSelection.Factory()); - this.rendererCapabilities = Util.getRendererCapabilities(renderersFactory, drmSessionManager); + this.rendererCapabilities = rendererCapabilities; this.scratchSet = new SparseIntArray(); trackSelector.setParameters(trackSelectorParameters); trackSelector.init(/* listener= */ () -> {}, new DummyBandwidthMeter()); @@ -148,43 +361,49 @@ public abstract class DownloadHelper { * @param callback A callback to be notified when preparation completes or fails. The callback * will be invoked on the calling thread unless that thread does not have an associated {@link * Looper}, in which case it will be called on the application's main thread. + * @throws IllegalStateException If the download helper has already been prepared. */ - public final void prepare(Callback callback) { - Handler handler = + public void prepare(Callback callback) { + Assertions.checkState(this.callback == null); + this.callback = callback; + callbackHandler = new Handler(Looper.myLooper() != null ? Looper.myLooper() : Looper.getMainLooper()); - new Thread( - () -> { - try { - manifest = loadManifest(uri); - trackGroupArrays = getTrackGroupArrays(manifest); - initializeTrackSelectionLists(trackGroupArrays.length, rendererCapabilities.length); - mappedTrackInfos = new MappedTrackInfo[trackGroupArrays.length]; - for (int i = 0; i < trackGroupArrays.length; i++) { - TrackSelectorResult trackSelectorResult = runTrackSelection(/* periodIndex= */ i); - trackSelector.onSelectionActivated(trackSelectorResult.info); - mappedTrackInfos[i] = - Assertions.checkNotNull(trackSelector.getCurrentMappedTrackInfo()); - } - handler.post(() -> callback.onPrepared(DownloadHelper.this)); - } catch (final IOException e) { - handler.post(() -> callback.onPrepareError(DownloadHelper.this, e)); - } - }) - .start(); + if (mediaSource != null) { + mediaPreparer = new MediaPreparer(mediaSource, /* downloadHelper= */ this); + } else { + callbackHandler.post(() -> callback.onPrepared(this)); + } } - /** Returns the manifest. Must not be called until after preparation completes. */ - public final T getManifest() { - Assertions.checkNotNull(manifest); - return manifest; + /** Releases the helper and all resources it is holding. */ + public void release() { + if (mediaPreparer != null) { + mediaPreparer.release(); + } + } + + /** + * Returns the manifest, or null if no manifest is loaded. Must not be called until after + * preparation completes. + */ + @Nullable + public Object getManifest() { + if (mediaSource == null) { + return null; + } + assertPreparedWithMedia(); + return mediaPreparer.manifest; } /** * Returns the number of periods for which media is available. Must not be called until after * preparation completes. */ - public final int getPeriodCount() { - Assertions.checkNotNull(trackGroupArrays); + public int getPeriodCount() { + if (mediaSource == null) { + return 0; + } + assertPreparedWithMedia(); return trackGroupArrays.length; } @@ -198,8 +417,8 @@ public abstract class DownloadHelper { * @return The track groups for the period. May be {@link TrackGroupArray#EMPTY} for single stream * content. */ - public final TrackGroupArray getTrackGroups(int periodIndex) { - Assertions.checkNotNull(trackGroupArrays); + public TrackGroupArray getTrackGroups(int periodIndex) { + assertPreparedWithMedia(); return trackGroupArrays[periodIndex]; } @@ -210,8 +429,8 @@ public abstract class DownloadHelper { * @param periodIndex The period index. * @return The {@link MappedTrackInfo} for the period. */ - public final MappedTrackInfo getMappedTrackInfo(int periodIndex) { - Assertions.checkNotNull(mappedTrackInfos); + public MappedTrackInfo getMappedTrackInfo(int periodIndex) { + assertPreparedWithMedia(); return mappedTrackInfos[periodIndex]; } @@ -223,8 +442,8 @@ public abstract class DownloadHelper { * @param rendererIndex The renderer index. * @return A list of selected {@link TrackSelection track selections}. */ - public final List getTrackSelections(int periodIndex, int rendererIndex) { - Assertions.checkNotNull(immutableTrackSelectionsByPeriodAndRenderer); + public List getTrackSelections(int periodIndex, int rendererIndex) { + assertPreparedWithMedia(); return immutableTrackSelectionsByPeriodAndRenderer[periodIndex][rendererIndex]; } @@ -234,8 +453,8 @@ public abstract class DownloadHelper { * * @param periodIndex The period index for which track selections are cleared. */ - public final void clearTrackSelections(int periodIndex) { - Assertions.checkNotNull(trackSelectionsByPeriodAndRenderer); + public void clearTrackSelections(int periodIndex) { + assertPreparedWithMedia(); for (int i = 0; i < rendererCapabilities.length; i++) { trackSelectionsByPeriodAndRenderer[periodIndex][i].clear(); } @@ -249,7 +468,7 @@ public abstract class DownloadHelper { * @param trackSelectorParameters The {@link DefaultTrackSelector.Parameters} to obtain the new * selection of tracks. */ - public final void replaceTrackSelections( + public void replaceTrackSelections( int periodIndex, DefaultTrackSelector.Parameters trackSelectorParameters) { clearTrackSelections(periodIndex); addTrackSelection(periodIndex, trackSelectorParameters); @@ -263,14 +482,71 @@ public abstract class DownloadHelper { * @param trackSelectorParameters The {@link DefaultTrackSelector.Parameters} to obtain the new * selection of tracks. */ - public final void addTrackSelection( + public void addTrackSelection( int periodIndex, DefaultTrackSelector.Parameters trackSelectorParameters) { - Assertions.checkNotNull(trackGroupArrays); - Assertions.checkNotNull(trackSelectionsByPeriodAndRenderer); + assertPreparedWithMedia(); trackSelector.setParameters(trackSelectorParameters); runTrackSelection(periodIndex); } + /** + * Convenience method to add selections of tracks for all specified audio languages. If an audio + * track in one of the specified languages is not available, the default fallback audio track is + * used instead. Must not be called until after preparation completes. + * + * @param languages A list of audio languages for which tracks should be added to the download + * selection, as ISO 639-1 two-letter or ISO 639-2 three-letter codes. + */ + public void addAudioLanguagesToSelection(String... languages) { + assertPreparedWithMedia(); + for (int periodIndex = 0; periodIndex < mappedTrackInfos.length; periodIndex++) { + DefaultTrackSelector.ParametersBuilder parametersBuilder = + DEFAULT_TRACK_SELECTOR_PARAMETERS.buildUpon(); + MappedTrackInfo mappedTrackInfo = mappedTrackInfos[periodIndex]; + int rendererCount = mappedTrackInfo.getRendererCount(); + for (int rendererIndex = 0; rendererIndex < rendererCount; rendererIndex++) { + if (mappedTrackInfo.getRendererType(rendererIndex) != C.TRACK_TYPE_AUDIO) { + parametersBuilder.setRendererDisabled(rendererIndex, /* disabled= */ true); + } + } + for (String language : languages) { + parametersBuilder.setPreferredAudioLanguage(language); + addTrackSelection(periodIndex, parametersBuilder.build()); + } + } + } + + /** + * Convenience method to add selections of tracks for all specified text languages. Must not be + * called until after preparation completes. + * + * @param selectUndeterminedTextLanguage Whether a text track with undetermined language should be + * selected for downloading if no track with one of the specified {@code languages} is + * available. + * @param languages A list of text languages for which tracks should be added to the download + * selection, as ISO 639-1 two-letter or ISO 639-2 three-letter codes. + */ + public void addTextLanguagesToSelection( + boolean selectUndeterminedTextLanguage, String... languages) { + assertPreparedWithMedia(); + for (int periodIndex = 0; periodIndex < mappedTrackInfos.length; periodIndex++) { + DefaultTrackSelector.ParametersBuilder parametersBuilder = + DEFAULT_TRACK_SELECTOR_PARAMETERS.buildUpon(); + MappedTrackInfo mappedTrackInfo = mappedTrackInfos[periodIndex]; + int rendererCount = mappedTrackInfo.getRendererCount(); + for (int rendererIndex = 0; rendererIndex < rendererCount; rendererIndex++) { + if (mappedTrackInfo.getRendererType(rendererIndex) != C.TRACK_TYPE_TEXT) { + parametersBuilder.setRendererDisabled(rendererIndex, /* disabled= */ true); + } + } + parametersBuilder.setSelectUndeterminedTextLanguage(selectUndeterminedTextLanguage); + for (String language : languages) { + parametersBuilder.setPreferredTextLanguage(language); + addTrackSelection(periodIndex, parametersBuilder.build()); + } + } + } + /** * Builds a {@link DownloadAction} for downloading the selected tracks. Must not be called until * after preparation completes. @@ -278,27 +554,22 @@ public abstract class DownloadHelper { * @param data Application provided data to store in {@link DownloadAction#data}. * @return The built {@link DownloadAction}. */ - public final DownloadAction getDownloadAction(@Nullable byte[] data) { - Assertions.checkNotNull(trackSelectionsByPeriodAndRenderer); - Assertions.checkNotNull(trackGroupArrays); + public DownloadAction getDownloadAction(@Nullable byte[] data) { + if (mediaSource == null) { + return DownloadAction.createDownloadAction( + downloadType, uri, /* keys= */ Collections.emptyList(), cacheKey, data); + } + assertPreparedWithMedia(); List streamKeys = new ArrayList<>(); + List allSelections = new ArrayList<>(); int periodCount = trackSelectionsByPeriodAndRenderer.length; for (int periodIndex = 0; periodIndex < periodCount; periodIndex++) { + allSelections.clear(); int rendererCount = trackSelectionsByPeriodAndRenderer[periodIndex].length; for (int rendererIndex = 0; rendererIndex < rendererCount; rendererIndex++) { - List trackSelectionList = - trackSelectionsByPeriodAndRenderer[periodIndex][rendererIndex]; - for (int selectionIndex = 0; selectionIndex < trackSelectionList.size(); selectionIndex++) { - TrackSelection trackSelection = trackSelectionList.get(selectionIndex); - int trackGroupIndex = - trackGroupArrays[periodIndex].indexOf(trackSelection.getTrackGroup()); - int trackCount = trackSelection.length(); - for (int trackListIndex = 0; trackListIndex < trackCount; trackListIndex++) { - int trackIndex = trackSelection.getIndexInTrackGroup(trackListIndex); - streamKeys.add(toStreamKey(periodIndex, trackGroupIndex, trackIndex)); - } - } + allSelections.addAll(trackSelectionsByPeriodAndRenderer[periodIndex][rendererIndex]); } + streamKeys.addAll(mediaPreparer.mediaPeriods[periodIndex].getStreamKeys(allSelections)); } return DownloadAction.createDownloadAction(downloadType, uri, streamKeys, cacheKey, data); } @@ -308,40 +579,18 @@ public abstract class DownloadHelper { * * @return The built {@link DownloadAction}. */ - public final DownloadAction getRemoveAction() { + public DownloadAction getRemoveAction() { return DownloadAction.createRemoveAction(downloadType, uri, cacheKey); } - /** - * Loads the manifest. This method is called on a background thread. - * - * @param uri The manifest uri. - * @throws IOException If loading fails. - */ - protected abstract T loadManifest(Uri uri) throws IOException; - - /** - * Returns the track group arrays for each period in the manifest. - * - * @param manifest The manifest. - * @return An array of {@link TrackGroupArray}s. One for each period in the manifest. - */ - protected abstract TrackGroupArray[] getTrackGroupArrays(T manifest); - - /** - * Converts a track of a track group of a period to the corresponding {@link StreamKey}. - * - * @param periodIndex The index of the containing period. - * @param trackGroupIndex The index of the containing track group within the period. - * @param trackIndexInTrackGroup The index of the track within the track group. - * @return The corresponding {@link StreamKey}. - */ - protected abstract StreamKey toStreamKey( - int periodIndex, int trackGroupIndex, int trackIndexInTrackGroup); - + // Initialization of array of Lists. @SuppressWarnings("unchecked") - @EnsuresNonNull("trackSelectionsByPeriodAndRenderer") - private void initializeTrackSelectionLists(int periodCount, int rendererCount) { + private void onMediaPrepared() { + Assertions.checkNotNull(mediaPreparer); + Assertions.checkNotNull(mediaPreparer.mediaPeriods); + Assertions.checkNotNull(mediaPreparer.timeline); + int periodCount = mediaPreparer.mediaPeriods.length; + int rendererCount = rendererCapabilities.length; trackSelectionsByPeriodAndRenderer = (List[][]) new List[periodCount][rendererCount]; immutableTrackSelectionsByPeriodAndRenderer = @@ -353,6 +602,49 @@ public abstract class DownloadHelper { Collections.unmodifiableList(trackSelectionsByPeriodAndRenderer[i][j]); } } + trackGroupArrays = new TrackGroupArray[periodCount]; + mappedTrackInfos = new MappedTrackInfo[periodCount]; + for (int i = 0; i < periodCount; i++) { + trackGroupArrays[i] = mediaPreparer.mediaPeriods[i].getTrackGroups(); + TrackSelectorResult trackSelectorResult = runTrackSelection(/* periodIndex= */ i); + trackSelector.onSelectionActivated(trackSelectorResult.info); + mappedTrackInfos[i] = Assertions.checkNotNull(trackSelector.getCurrentMappedTrackInfo()); + } + setPreparedWithMedia(); + Assertions.checkNotNull(callbackHandler) + .post(() -> Assertions.checkNotNull(callback).onPrepared(this)); + } + + private void onMediaPreparationFailed(IOException error) { + Assertions.checkNotNull(callbackHandler) + .post(() -> Assertions.checkNotNull(callback).onPrepareError(this, error)); + } + + @RequiresNonNull({ + "trackGroupArrays", + "mappedTrackInfos", + "trackSelectionsByPeriodAndRenderer", + "immutableTrackSelectionsByPeriodAndRenderer", + "mediaPreparer", + "mediaPreparer.timeline", + "mediaPreparer.mediaPeriods" + }) + private void setPreparedWithMedia() { + isPreparedWithMedia = true; + } + + @EnsuresNonNull({ + "trackGroupArrays", + "mappedTrackInfos", + "trackSelectionsByPeriodAndRenderer", + "immutableTrackSelectionsByPeriodAndRenderer", + "mediaPreparer", + "mediaPreparer.timeline", + "mediaPreparer.mediaPeriods" + }) + @SuppressWarnings("nullness:contracts.postcondition.not.satisfied") + private void assertPreparedWithMedia() { + Assertions.checkState(isPreparedWithMedia); } /** @@ -361,26 +653,27 @@ public abstract class DownloadHelper { */ // Intentional reference comparison of track group instances. @SuppressWarnings("ReferenceEquality") - @RequiresNonNull({"trackGroupArrays", "trackSelectionsByPeriodAndRenderer"}) + @RequiresNonNull({ + "trackGroupArrays", + "trackSelectionsByPeriodAndRenderer", + "mediaPreparer", + "mediaPreparer.timeline" + }) private TrackSelectorResult runTrackSelection(int periodIndex) { - // TODO: Use actual timeline and media period id. - MediaPeriodId dummyMediaPeriodId = new MediaPeriodId(new Object()); - Timeline dummyTimeline = Timeline.EMPTY; - currentTrackSelectionPeriodIndex = periodIndex; try { TrackSelectorResult trackSelectorResult = trackSelector.selectTracks( rendererCapabilities, trackGroupArrays[periodIndex], - dummyMediaPeriodId, - dummyTimeline); + new MediaPeriodId(mediaPreparer.timeline.getUidOfPeriod(periodIndex)), + mediaPreparer.timeline); for (int i = 0; i < trackSelectorResult.length; i++) { TrackSelection newSelection = trackSelectorResult.selections.get(i); if (newSelection == null) { continue; } List existingSelectionList = - trackSelectionsByPeriodAndRenderer[currentTrackSelectionPeriodIndex][i]; + trackSelectionsByPeriodAndRenderer[periodIndex][i]; boolean mergedWithExistingSelection = false; for (int j = 0; j < existingSelectionList.size(); j++) { TrackSelection existingSelection = existingSelectionList.get(j); @@ -414,6 +707,156 @@ public abstract class DownloadHelper { } } + private static Pair<@NullableType Constructor, @NullableType Method> + getMediaSourceFactoryMethods(String className) { + Constructor constructor = null; + Method createMethod = null; + try { + // LINT.IfChange + Class factoryClazz = Class.forName(className); + constructor = factoryClazz.getConstructor(DataSource.Factory.class); + createMethod = factoryClazz.getMethod("createMediaSource", Uri.class); + // LINT.ThenChange(../../../../../../../../proguard-rules.txt) + } catch (Exception e) { + // Expected if the app was built without the respective module. + } + return Pair.create(constructor, createMethod); + } + + private static MediaSource createMediaSource( + Uri uri, + DataSource.Factory dataSourceFactory, + @Nullable Constructor factoryConstructor, + @Nullable Method createMediaSourceMethod) { + if (factoryConstructor == null || createMediaSourceMethod == null) { + throw new IllegalStateException("Module missing to create media source."); + } + try { + Object factory = factoryConstructor.newInstance(dataSourceFactory); + return (MediaSource) Assertions.checkNotNull(createMediaSourceMethod.invoke(factory, uri)); + } catch (Exception e) { + throw new IllegalStateException("Failed to instantiate media source.", e); + } + } + + private static final class MediaPreparer + implements MediaSource.SourceInfoRefreshListener, MediaPeriod.Callback, Handler.Callback { + + private static final int MESSAGE_PREPARE_SOURCE = 0; + private static final int MESSAGE_CHECK_FOR_FAILURE = 1; + private static final int MESSAGE_CONTINUE_LOADING = 2; + + private final MediaSource mediaSource; + private final DownloadHelper downloadHelper; + private final Allocator allocator; + private final HandlerThread mediaSourceThread; + private final Handler mediaSourceHandler; + + @Nullable public Object manifest; + public @MonotonicNonNull Timeline timeline; + public MediaPeriod @MonotonicNonNull [] mediaPeriods; + + private final ArrayList pendingMediaPeriods; + + public MediaPreparer(MediaSource mediaSource, DownloadHelper downloadHelper) { + this.mediaSource = mediaSource; + this.downloadHelper = downloadHelper; + allocator = new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE); + mediaSourceThread = new HandlerThread("DownloadHelper"); + mediaSourceThread.start(); + mediaSourceHandler = Util.createHandler(mediaSourceThread.getLooper(), /* callback= */ this); + mediaSourceHandler.sendEmptyMessage(MESSAGE_PREPARE_SOURCE); + pendingMediaPeriods = new ArrayList<>(); + } + + public void release() { + if (mediaPeriods != null) { + for (MediaPeriod mediaPeriod : mediaPeriods) { + mediaSource.releasePeriod(mediaPeriod); + } + } + mediaSource.releaseSource(this); + mediaSourceThread.quit(); + } + + // Handler.Callback + + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MESSAGE_PREPARE_SOURCE: + mediaSource.prepareSource(/* listener= */ this, /* mediaTransferListener= */ null); + mediaSourceHandler.sendEmptyMessage(MESSAGE_CHECK_FOR_FAILURE); + return true; + case MESSAGE_CHECK_FOR_FAILURE: + try { + if (mediaPeriods == null) { + mediaSource.maybeThrowSourceInfoRefreshError(); + } else { + for (int i = 0; i < pendingMediaPeriods.size(); i++) { + pendingMediaPeriods.get(i).maybeThrowPrepareError(); + } + } + mediaSourceHandler.sendEmptyMessageDelayed( + MESSAGE_CHECK_FOR_FAILURE, /* delayMillis= */ 100); + } catch (IOException e) { + downloadHelper.onMediaPreparationFailed(e); + } + return true; + case MESSAGE_CONTINUE_LOADING: + MediaPeriod mediaPeriod = (MediaPeriod) msg.obj; + if (pendingMediaPeriods.contains(mediaPeriod)) { + mediaPeriod.continueLoading(/* positionUs= */ 0); + } + return true; + default: + return false; + } + } + + // MediaSource.SourceInfoRefreshListener implementation. + + @Override + public void onSourceInfoRefreshed( + MediaSource source, Timeline timeline, @Nullable Object manifest) { + if (this.timeline != null) { + // Ignore dynamic updates. + return; + } + this.timeline = timeline; + this.manifest = manifest; + mediaPeriods = new MediaPeriod[timeline.getPeriodCount()]; + for (int i = 0; i < mediaPeriods.length; i++) { + MediaPeriod mediaPeriod = + mediaSource.createPeriod( + new MediaPeriodId(timeline.getUidOfPeriod(/* periodIndex= */ i)), + allocator, + /* startPositionUs= */ 0); + mediaPeriods[i] = mediaPeriod; + pendingMediaPeriods.add(mediaPeriod); + mediaPeriod.prepare(/* callback= */ this, /* positionUs= */ 0); + } + } + + // MediaPeriod.Callback implementation. + + @Override + public void onPrepared(MediaPeriod mediaPeriod) { + pendingMediaPeriods.remove(mediaPeriod); + if (pendingMediaPeriods.isEmpty()) { + mediaSourceHandler.removeMessages(MESSAGE_CHECK_FOR_FAILURE); + downloadHelper.onMediaPrepared(); + } + } + + @Override + public void onContinueLoadingRequested(MediaPeriod mediaPeriod) { + if (pendingMediaPeriods.contains(mediaPeriod)) { + mediaSourceHandler.obtainMessage(MESSAGE_CONTINUE_LOADING, mediaPeriod).sendToTarget(); + } + } + } + private static final class DownloadTrackSelection extends BaseTrackSelection { private static final class Factory implements TrackSelection.Factory { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadIndex.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadIndex.java new file mode 100644 index 0000000000..7b903d3321 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadIndex.java @@ -0,0 +1,50 @@ +/* + * 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.offline; + +import android.support.annotation.Nullable; + +/** Persists {@link DownloadState}s. */ +interface DownloadIndex { + + /** + * Returns the {@link DownloadState} with the given {@code id}, or null. + * + * @param id ID of a {@link DownloadState}. + * @return The {@link DownloadState} with the given {@code id}, or null if a download state with + * this id doesn't exist. + */ + @Nullable + DownloadState getDownloadState(String id); + + /** + * Returns a {@link DownloadStateCursor} to {@link DownloadState}s with the given {@code states}. + * + * @param states Returns only the {@link DownloadState}s with this states. If empty, returns all. + * @return A cursor to {@link DownloadState}s with the given {@code states}. + */ + DownloadStateCursor getDownloadStates(@DownloadState.State int... states); + + /** + * Adds or replaces a {@link DownloadState}. + * + * @param downloadState The {@link DownloadState} to be added. + */ + void putDownloadState(DownloadState downloadState); + + /** Removes the {@link DownloadState} with the given {@code id}. */ + void removeDownloadState(String id); +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadIndexUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadIndexUtil.java new file mode 100644 index 0000000000..f9a33f3e7b --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadIndexUtil.java @@ -0,0 +1,83 @@ +/* + * 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.offline; + +import android.support.annotation.Nullable; +import java.io.IOException; + +/** {@link DownloadIndex} related utility methods. */ +public final class DownloadIndexUtil { + + /** An interface to provide custom download ids during ActionFile upgrade. */ + public interface DownloadIdProvider { + + /** + * Returns a custom download id for given action. + * + * @param downloadAction The action which is an id requested for. + * @return A custom download id for given action. + */ + String getId(DownloadAction downloadAction); + } + + private DownloadIndexUtil() {} + + /** + * Upgrades an {@link ActionFile} to {@link DownloadIndex}. + * + *

    This method shouldn't be called while {@link DownloadIndex} is used by {@link + * DownloadManager}. + * + * @param actionFile The action file to upgrade. + * @param downloadIndex Actions are converted to {@link DownloadState}s and stored in this index. + * @param downloadIdProvider A nullable custom download id provider. + * @throws IOException If there is an error during loading actions. + */ + public static void upgradeActionFile( + ActionFile actionFile, + DownloadIndex downloadIndex, + @Nullable DownloadIdProvider downloadIdProvider) + throws IOException { + if (downloadIdProvider == null) { + downloadIdProvider = downloadAction -> downloadAction.id; + } + for (DownloadAction action : actionFile.load()) { + addAction(downloadIndex, downloadIdProvider.getId(action), action); + } + } + + /** + * Converts a {@link DownloadAction} to {@link DownloadState} and stored in the given {@link + * DownloadIndex}. + * + *

    This method shouldn't be called while {@link DownloadIndex} is used by {@link + * DownloadManager}. + * + * @param downloadIndex The action is converted to {@link DownloadState} and stored in this index. + * @param id A nullable custom download id which overwrites {@link DownloadAction#id}. + * @param action The action to be stored in {@link DownloadIndex}. + */ + public static void addAction( + DownloadIndex downloadIndex, @Nullable String id, DownloadAction action) { + DownloadState downloadState = downloadIndex.getDownloadState(id != null ? id : action.id); + if (downloadState != null) { + downloadState = downloadState.mergeAction(action); + } else { + downloadState = new DownloadState(action); + } + downloadIndex.putDownloadState(downloadState); + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java index c8c02d4980..731f7fc43e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java @@ -25,21 +25,28 @@ import static com.google.android.exoplayer2.offline.DownloadState.STATE_REMOVED; import static com.google.android.exoplayer2.offline.DownloadState.STATE_REMOVING; import static com.google.android.exoplayer2.offline.DownloadState.STATE_RESTARTING; import static com.google.android.exoplayer2.offline.DownloadState.STATE_STOPPED; -import static com.google.android.exoplayer2.offline.DownloadState.STOP_FLAG_DOWNLOAD_MANAGER_NOT_READY; -import static com.google.android.exoplayer2.offline.DownloadState.STOP_FLAG_STOPPED; +import static com.google.android.exoplayer2.offline.DownloadState.STOP_FLAG_MANUAL; +import static com.google.android.exoplayer2.offline.DownloadState.STOP_FLAG_REQUIREMENTS_NOT_MET; +import android.content.Context; import android.os.ConditionVariable; import android.os.Handler; import android.os.HandlerThread; import android.os.Looper; +import android.support.annotation.IntDef; import android.support.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.scheduler.Requirements; +import com.google.android.exoplayer2.scheduler.RequirementsWatcher; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import java.io.File; import java.io.IOException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.HashMap; import java.util.concurrent.CopyOnWriteArraySet; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @@ -74,22 +81,55 @@ public final class DownloadManager { * @param downloadManager The reporting instance. */ void onIdle(DownloadManager downloadManager); + + /** + * Called when the download requirements state changed. + * + * @param downloadManager The reporting instance. + * @param requirements Requirements needed to be met to start downloads. + * @param notMetRequirements {@link Requirements.RequirementFlags RequirementFlags} that are not + * met, or 0. + */ + void onRequirementsStateChanged( + DownloadManager downloadManager, + Requirements requirements, + @Requirements.RequirementFlags int notMetRequirements); } /** The default maximum number of simultaneous downloads. */ public static final int DEFAULT_MAX_SIMULTANEOUS_DOWNLOADS = 1; /** The default minimum number of times a download must be retried before failing. */ public static final int DEFAULT_MIN_RETRY_COUNT = 5; + /** The default requirement is that the device has network connectivity. */ + public static final Requirements DEFAULT_REQUIREMENTS = + new Requirements(Requirements.NETWORK_TYPE_ANY, false, false); + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + START_THREAD_SUCCEEDED, + START_THREAD_WAIT_REMOVAL_TO_FINISH, + START_THREAD_WAIT_DOWNLOAD_CANCELLATION, + START_THREAD_TOO_MANY_DOWNLOADS, + START_THREAD_NOT_ALLOWED + }) + private @interface StartThreadResults {} + + private static final int START_THREAD_SUCCEEDED = 0; + private static final int START_THREAD_WAIT_REMOVAL_TO_FINISH = 1; + private static final int START_THREAD_WAIT_DOWNLOAD_CANCELLATION = 2; + private static final int START_THREAD_TOO_MANY_DOWNLOADS = 3; + private static final int START_THREAD_NOT_ALLOWED = 4; private static final String TAG = "DownloadManager"; private static final boolean DEBUG = false; - private final int maxActiveDownloads; + private final int maxSimultaneousDownloads; private final int minRetryCount; + private final Context context; private final ActionFile actionFile; private final DownloaderFactory downloaderFactory; private final ArrayList downloads; - private final ArrayList activeDownloads; + private final HashMap activeDownloads; private final Handler handler; private final HandlerThread fileIOThread; private final Handler fileIOHandler; @@ -98,40 +138,55 @@ public final class DownloadManager { private boolean initialized; private boolean released; - @DownloadState.StopFlags private int stickyStopFlags; + @DownloadState.StopFlags private int stopFlags; + @Requirements.RequirementFlags private int notMetRequirements; + private int manualStopReason; + private RequirementsWatcher requirementsWatcher; + private int simultaneousDownloads; /** * Constructs a {@link DownloadManager}. * + * @param context Any context. * @param actionFile The file in which active actions are saved. * @param downloaderFactory A factory for creating {@link Downloader}s. */ - public DownloadManager(File actionFile, DownloaderFactory downloaderFactory) { + public DownloadManager(Context context, File actionFile, DownloaderFactory downloaderFactory) { this( - actionFile, downloaderFactory, DEFAULT_MAX_SIMULTANEOUS_DOWNLOADS, DEFAULT_MIN_RETRY_COUNT); + context, + actionFile, + downloaderFactory, + DEFAULT_MAX_SIMULTANEOUS_DOWNLOADS, + DEFAULT_MIN_RETRY_COUNT, + DEFAULT_REQUIREMENTS); } /** * Constructs a {@link DownloadManager}. * + * @param context Any context. * @param actionFile The file in which active actions are saved. * @param downloaderFactory A factory for creating {@link Downloader}s. * @param maxSimultaneousDownloads The maximum number of simultaneous downloads. * @param minRetryCount The minimum number of times a download must be retried before failing. + * @param requirements The requirements needed to be met to start downloads. */ public DownloadManager( + Context context, File actionFile, DownloaderFactory downloaderFactory, int maxSimultaneousDownloads, - int minRetryCount) { + int minRetryCount, + Requirements requirements) { + this.context = context.getApplicationContext(); this.actionFile = new ActionFile(actionFile); this.downloaderFactory = downloaderFactory; - this.maxActiveDownloads = maxSimultaneousDownloads; + this.maxSimultaneousDownloads = maxSimultaneousDownloads; this.minRetryCount = minRetryCount; - this.stickyStopFlags = STOP_FLAG_STOPPED | STOP_FLAG_DOWNLOAD_MANAGER_NOT_READY; + stopFlags = STOP_FLAG_MANUAL; downloads = new ArrayList<>(); - activeDownloads = new ArrayList<>(); + activeDownloads = new HashMap<>(); Looper looper = Looper.myLooper(); if (looper == null) { @@ -146,10 +201,30 @@ public final class DownloadManager { listeners = new CopyOnWriteArraySet<>(); actionQueue = new ArrayDeque<>(); + setNotMetRequirements(watchRequirements(requirements)); loadActions(); logd("Created"); } + /** + * Sets the requirements needed to be met to start downloads. + * + * @param requirements Need to be met to start downloads. + */ + public void setRequirements(Requirements requirements) { + Assertions.checkState(!released); + if (requirements.equals(requirementsWatcher.getRequirements())) { + return; + } + requirementsWatcher.stop(); + onRequirementsStateChanged(watchRequirements(requirements)); + } + + /** Returns the requirements needed to be met to start downloads. */ + public Requirements getRequirements() { + return requirementsWatcher.getRequirements(); + } + /** * Adds a {@link Listener}. * @@ -168,33 +243,35 @@ public final class DownloadManager { listeners.remove(listener); } - /** Starts the downloads. */ + /** + * Clears {@link DownloadState#STOP_FLAG_MANUAL} flag of all downloads. Downloads are started if + * the requirements are met. + */ public void startDownloads() { - clearStopFlags(STOP_FLAG_STOPPED); + logd("manual stopped is cancelled"); + manualStopReason = 0; + stopFlags &= ~STOP_FLAG_MANUAL; + for (int i = 0; i < downloads.size(); i++) { + downloads.get(i).clearManualStopReason(); + } } - /** Stops all of the downloads. Call {@link #startDownloads()} to restart downloads. */ + /** Signals all downloads to stop. Call {@link #startDownloads()} to let them to be started. */ public void stopDownloads() { - setStopFlags(STOP_FLAG_STOPPED); + stopDownloads(0); } - private void setStopFlags(int flags) { - updateStopFlags(flags, flags); - } - - private void clearStopFlags(int flags) { - updateStopFlags(flags, 0); - } - - private void updateStopFlags(int flags, int values) { - Assertions.checkState(!released); - int updatedStickyStopFlags = (values & flags) | (stickyStopFlags & ~flags); - if (stickyStopFlags != updatedStickyStopFlags) { - stickyStopFlags = updatedStickyStopFlags; - for (int i = 0; i < downloads.size(); i++) { - downloads.get(i).updateStopFlags(flags, values); - } - logdFlags("Sticky stop flags are updated", updatedStickyStopFlags); + /** + * Signals all downloads to stop. Call {@link #startDownloads()} to let them to be started. + * + * @param manualStopReason An application defined stop reason. + */ + public void stopDownloads(int manualStopReason) { + logd("downloads are stopped manually"); + this.manualStopReason = manualStopReason; + stopFlags |= STOP_FLAG_MANUAL; + for (int i = 0; i < downloads.size(); i++) { + downloads.get(i).setManualStopReason(this.manualStopReason); } } @@ -256,15 +333,7 @@ public final class DownloadManager { /** Returns whether there are no active downloads. */ public boolean isIdle() { Assertions.checkState(!released); - if (!initialized) { - return false; - } - for (int i = 0; i < downloads.size(); i++) { - if (!downloads.get(i).isIdle()) { - return false; - } - } - return true; + return initialized && activeDownloads.isEmpty(); } /** @@ -276,8 +345,11 @@ public final class DownloadManager { if (released) { return; } - setStopFlags(STOP_FLAG_DOWNLOAD_MANAGER_NOT_READY); released = true; + stopAllDownloadThreads(); + if (requirementsWatcher != null) { + requirementsWatcher.stop(); + } final ConditionVariable fileIOFinishedCondition = new ConditionVariable(); fileIOHandler.post(fileIOFinishedCondition::open); fileIOFinishedCondition.block(); @@ -293,20 +365,11 @@ public final class DownloadManager { return; } } - Download download = - new Download(this, downloaderFactory, action, minRetryCount, stickyStopFlags); + Download download = new Download(this, action, stopFlags, notMetRequirements, manualStopReason); downloads.add(download); logd("Download is added", download); } - private void maybeStartDownload(Download download) { - if (activeDownloads.size() < maxActiveDownloads) { - if (download.start()) { - activeDownloads.add(download); - } - } - } - private void maybeNotifyListenersIdle() { if (!isIdle()) { return; @@ -321,21 +384,11 @@ public final class DownloadManager { if (released) { return; } - boolean idle = download.isIdle(); - if (idle) { - activeDownloads.remove(download); - } notifyListenersDownloadStateChange(download); if (download.isFinished()) { downloads.remove(download); saveActions(); } - if (idle) { - for (int i = 0; i < downloads.size(); i++) { - maybeStartDownload(downloads.get(i)); - } - maybeNotifyListenersIdle(); - } } private void notifyListenersDownloadStateChange(Download download) { @@ -346,6 +399,27 @@ public final class DownloadManager { } } + private void onRequirementsStateChanged(@Requirements.RequirementFlags int notMetRequirements) { + setNotMetRequirements(notMetRequirements); + logdFlags("Not met requirements are changed", notMetRequirements); + Requirements requirements = requirementsWatcher.getRequirements(); + for (Listener listener : listeners) { + listener.onRequirementsStateChanged(DownloadManager.this, requirements, notMetRequirements); + } + for (int i = 0; i < downloads.size(); i++) { + downloads.get(i).setNotMetRequirements(notMetRequirements); + } + } + + private void setNotMetRequirements(@Requirements.RequirementFlags int notMetRequirements) { + this.notMetRequirements = notMetRequirements; + if (notMetRequirements == 0) { + stopFlags &= ~STOP_FLAG_REQUIREMENTS_NOT_MET; + } else { + stopFlags |= STOP_FLAG_REQUIREMENTS_NOT_MET; + } + } + private void loadActions() { fileIOHandler.post( () -> { @@ -377,7 +451,9 @@ public final class DownloadManager { for (Listener listener : listeners) { listener.onInitialized(DownloadManager.this); } - clearStopFlags(STOP_FLAG_DOWNLOAD_MANAGER_NOT_READY); + for (int i = 0; i < downloads.size(); i++) { + downloads.get(i).start(); + } }); }); } @@ -420,37 +496,125 @@ public final class DownloadManager { } } + @Requirements.RequirementFlags + private int watchRequirements(Requirements requirements) { + RequirementsWatcher.Listener listener = + (requirementsWatcher, notMetRequirements) -> onRequirementsStateChanged(notMetRequirements); + requirementsWatcher = new RequirementsWatcher(context, listener, requirements); + @Requirements.RequirementFlags int notMetRequirements = requirementsWatcher.start(); + if (notMetRequirements == 0) { + startDownloads(); + } else { + stopDownloads(); + } + return notMetRequirements; + } + + @StartThreadResults + private int startDownloadThread(Download download, DownloadAction action) { + if (!initialized || released) { + return START_THREAD_NOT_ALLOWED; + } + if (activeDownloads.containsKey(download)) { + if (stopDownloadThread(download)) { + return START_THREAD_WAIT_DOWNLOAD_CANCELLATION; + } + return START_THREAD_WAIT_REMOVAL_TO_FINISH; + } + if (!action.isRemoveAction) { + if (simultaneousDownloads == maxSimultaneousDownloads) { + return START_THREAD_TOO_MANY_DOWNLOADS; + } + simultaneousDownloads++; + } + Downloader downloader = downloaderFactory.createDownloader(action); + DownloadThread downloadThread = new DownloadThread(download, downloader, action.isRemoveAction); + activeDownloads.put(download, downloadThread); + logd("Download is started", download); + return START_THREAD_SUCCEEDED; + } + + private boolean stopDownloadThread(Download download) { + DownloadThread downloadThread = activeDownloads.get(download); + if (downloadThread != null && !downloadThread.isRemoveThread) { + downloadThread.cancel(); + logd("Download is cancelled", download); + return true; + } + return false; + } + + private void stopAllDownloadThreads() { + for (Download download : activeDownloads.keySet()) { + stopDownloadThread(download); + } + } + + private void onDownloadThreadStopped(DownloadThread downloadThread, Throwable finalError) { + Download download = downloadThread.download; + logd("Download is stopped", download); + activeDownloads.remove(download); + boolean tryToStartDownloads = false; + if (!downloadThread.isRemoveThread) { + // If maxSimultaneousDownloads was hit, there might be a download waiting for a slot. + tryToStartDownloads = simultaneousDownloads == maxSimultaneousDownloads; + simultaneousDownloads--; + } + download.onDownloadThreadStopped(downloadThread.isCanceled, finalError); + if (tryToStartDownloads) { + for (int i = 0; + simultaneousDownloads < maxSimultaneousDownloads && i < downloads.size(); + i++) { + downloads.get(i).start(); + } + } + maybeNotifyListenersIdle(); + } + + @Nullable + private Downloader getDownloader(Download download) { + DownloadThread downloadThread = activeDownloads.get(download); + if (downloadThread != null) { + return downloadThread.downloader; + } + return null; + } + private static final class Download { private final String id; private final DownloadManager downloadManager; - private final DownloaderFactory downloaderFactory; - private final int minRetryCount; private final long startTimeMs; private final ArrayDeque actionQueue; - /** The current state of the download. */ - @DownloadState.State private int state; - @MonotonicNonNull private Downloader downloader; - @MonotonicNonNull private DownloadThread downloadThread; + @DownloadState.State private int state; @MonotonicNonNull @DownloadState.FailureReason private int failureReason; @DownloadState.StopFlags private int stopFlags; + @Requirements.RequirementFlags private int notMetRequirements; + private int manualStopReason; private Download( DownloadManager downloadManager, - DownloaderFactory downloaderFactory, DownloadAction action, - int minRetryCount, - int stopFlags) { + @DownloadState.StopFlags int stopFlags, + @Requirements.RequirementFlags int notMetRequirements, + int manualStopReason) { this.id = action.id; this.downloadManager = downloadManager; - this.downloaderFactory = downloaderFactory; - this.minRetryCount = minRetryCount; + this.notMetRequirements = notMetRequirements; + this.manualStopReason = manualStopReason; this.stopFlags = stopFlags; this.startTimeMs = System.currentTimeMillis(); actionQueue = new ArrayDeque<>(); actionQueue.add(action); - initialize(/* restart= */ false); + + // Set to queued state but don't notify listeners until we make sure we don't switch to + // another state immediately. + state = STATE_QUEUED; + initialize(); + if (state == STATE_QUEUED) { + downloadManager.onDownloadStateChange(this); + } } public boolean addAction(DownloadAction newAction) { @@ -472,12 +636,9 @@ public final class DownloadManager { setState(STATE_REMOVING); } } else if (!action.equals(updatedAction)) { - if (state == STATE_DOWNLOADING) { - stopDownloadThread(); - } else { - Assertions.checkState(state == STATE_QUEUED || state == STATE_STOPPED); - initialize(/* restart= */ false); - } + Assertions.checkState( + state == STATE_DOWNLOADING || state == STATE_QUEUED || state == STATE_STOPPED); + initialize(); } return true; } @@ -486,6 +647,7 @@ public final class DownloadManager { float downloadPercentage = C.PERCENTAGE_UNSET; long downloadedBytes = 0; long totalBytes = C.LENGTH_UNSET; + Downloader downloader = downloadManager.getDownloader(this); if (downloader != null) { downloadPercentage = downloader.getDownloadPercentage(); downloadedBytes = downloader.getDownloadedBytes(); @@ -503,6 +665,8 @@ public final class DownloadManager { totalBytes, failureReason, stopFlags, + notMetRequirements, + manualStopReason, startTimeMs, /* updateTimeMs= */ System.currentTimeMillis(), action.keys.toArray(new StreamKey[0]), @@ -522,123 +686,119 @@ public final class DownloadManager { return id + ' ' + DownloadState.getStateString(state); } - public boolean start() { - if (state != STATE_QUEUED) { - return false; + public void start() { + if (state == STATE_QUEUED || state == STATE_DOWNLOADING) { + startOrQueue(); + } else if (state == STATE_REMOVING || state == STATE_RESTARTING) { + downloadManager.startDownloadThread(this, actionQueue.peek()); } - startDownloadThread(actionQueue.peek()); - setState(STATE_DOWNLOADING); - return true; } - public void setStopFlags(int flags) { - updateStopFlags(flags, flags); + public void setNotMetRequirements(@Requirements.RequirementFlags int notMetRequirements) { + this.notMetRequirements = notMetRequirements; + updateStopFlags(STOP_FLAG_REQUIREMENTS_NOT_MET, /* setFlags= */ notMetRequirements != 0); } - public void clearStopFlags(int flags) { - updateStopFlags(flags, 0); + public void setManualStopReason(int manualStopReason) { + this.manualStopReason = manualStopReason; + updateStopFlags(STOP_FLAG_MANUAL, /* setFlags= */ true); } - public void updateStopFlags(int flags, int values) { - stopFlags = (values & flags) | (stopFlags & ~flags); + public void clearManualStopReason() { + this.manualStopReason = 0; + updateStopFlags(STOP_FLAG_MANUAL, /* setFlags= */ false); + } + + private void updateStopFlags(int flags, boolean setFlags) { + if (setFlags) { + stopFlags |= flags; + } else { + stopFlags &= ~flags; + } if (stopFlags != 0) { - if (state == STATE_DOWNLOADING) { - stopDownloadThread(); - } else if (state == STATE_QUEUED) { + if (state == STATE_DOWNLOADING || state == STATE_QUEUED) { + downloadManager.stopDownloadThread(this); setState(STATE_STOPPED); } } else if (state == STATE_STOPPED) { - startOrQueue(/* restart= */ false); + startOrQueue(); } } - private void initialize(boolean restart) { + private void initialize() { DownloadAction action = actionQueue.peek(); if (action.isRemoveAction) { - if (!downloadManager.released) { - startDownloadThread(action); - } + int result = downloadManager.startDownloadThread(this, action); + Assertions.checkState( + result == START_THREAD_SUCCEEDED + || result == START_THREAD_WAIT_DOWNLOAD_CANCELLATION + || result == START_THREAD_NOT_ALLOWED); setState(actionQueue.size() == 1 ? STATE_REMOVING : STATE_RESTARTING); } else if (stopFlags != 0) { setState(STATE_STOPPED); } else { - startOrQueue(restart); + startOrQueue(); } } - private void startOrQueue(boolean restart) { - // Set to queued state but don't notify listeners until we make sure we can't start now. - state = STATE_QUEUED; - if (restart) { - start(); + private void startOrQueue() { + DownloadAction action = Assertions.checkNotNull(actionQueue.peek()); + Assertions.checkState(!action.isRemoveAction); + @StartThreadResults int result = downloadManager.startDownloadThread(this, action); + Assertions.checkState(result != START_THREAD_WAIT_REMOVAL_TO_FINISH); + if (result == START_THREAD_SUCCEEDED || result == START_THREAD_WAIT_DOWNLOAD_CANCELLATION) { + setState(STATE_DOWNLOADING); } else { - downloadManager.maybeStartDownload(this); - } - if (state == STATE_QUEUED) { - downloadManager.onDownloadStateChange(this); + setState(STATE_QUEUED); } } private void setState(@DownloadState.State int newState) { - state = newState; - downloadManager.onDownloadStateChange(this); - } - - private void startDownloadThread(DownloadAction action) { - downloader = downloaderFactory.createDownloader(action); - downloadThread = - new DownloadThread( - this, downloader, action.isRemoveAction, minRetryCount, downloadManager.handler); - } - - private void stopDownloadThread() { - Assertions.checkNotNull(downloadThread).cancel(); - } - - private void onDownloadThreadStopped(@Nullable Throwable finalError) { - failureReason = FAILURE_REASON_NONE; - if (!downloadThread.isCanceled) { - if (finalError != null && state != STATE_REMOVING && state != STATE_RESTARTING) { - failureReason = FAILURE_REASON_UNKNOWN; - setState(STATE_FAILED); - return; - } - if (actionQueue.size() == 1) { - if (state == STATE_REMOVING) { - setState(STATE_REMOVED); - } else { - Assertions.checkState(state == STATE_DOWNLOADING); - setState(STATE_COMPLETED); - } - return; - } - actionQueue.remove(); + if (state != newState) { + state = newState; + downloadManager.onDownloadStateChange(this); } - initialize(/* restart= */ state == STATE_DOWNLOADING); + } + + private void onDownloadThreadStopped(boolean isCanceled, @Nullable Throwable error) { + failureReason = FAILURE_REASON_NONE; + if (isCanceled) { + if (!isIdle()) { + downloadManager.startDownloadThread(this, actionQueue.peek()); + } + return; + } + if (error != null && state == STATE_DOWNLOADING) { + failureReason = FAILURE_REASON_UNKNOWN; + setState(STATE_FAILED); + return; + } + if (actionQueue.size() == 1) { + if (state == STATE_REMOVING) { + setState(STATE_REMOVED); + } else { + Assertions.checkState(state == STATE_DOWNLOADING); + setState(STATE_COMPLETED); + } + return; + } + actionQueue.remove(); + initialize(); } } - private static class DownloadThread implements Runnable { + private class DownloadThread implements Runnable { private final Download download; private final Downloader downloader; - private final boolean remove; - private final int minRetryCount; - private final Handler callbackHandler; + private final boolean isRemoveThread; private final Thread thread; private volatile boolean isCanceled; - private DownloadThread( - Download download, - Downloader downloader, - boolean remove, - int minRetryCount, - Handler callbackHandler) { + private DownloadThread(Download download, Downloader downloader, boolean isRemoveThread) { this.download = download; this.downloader = downloader; - this.remove = remove; - this.minRetryCount = minRetryCount; - this.callbackHandler = callbackHandler; + this.isRemoveThread = isRemoveThread; thread = new Thread(this); thread.start(); } @@ -653,10 +813,10 @@ public final class DownloadManager { @Override public void run() { - logd("Download is started", download); + logd("Download started", download); Throwable error = null; try { - if (remove) { + if (isRemoveThread) { downloader.remove(); } else { int errorCount = 0; @@ -686,11 +846,12 @@ public final class DownloadManager { error = e; } final Throwable finalError = error; - callbackHandler.post(() -> download.onDownloadThreadStopped(isCanceled ? null : finalError)); + handler.post(() -> onDownloadThreadStopped(this, finalError)); } private int getRetryDelayMillis(int errorCount) { return Math.min((errorCount - 1) * 1000, 5000); } } + } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java index 3031a032db..305620d5f3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java @@ -25,8 +25,8 @@ import android.os.Looper; import android.support.annotation.Nullable; import android.support.annotation.StringRes; import com.google.android.exoplayer2.scheduler.Requirements; -import com.google.android.exoplayer2.scheduler.RequirementsWatcher; import com.google.android.exoplayer2.scheduler.Scheduler; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.NotificationUtil; import com.google.android.exoplayer2.util.Util; @@ -43,10 +43,6 @@ public abstract class DownloadService extends Service { /** Starts a download service, adding a new {@link DownloadAction} to be executed. */ public static final String ACTION_ADD = "com.google.android.exoplayer.downloadService.action.ADD"; - /** Reloads the download requirements. */ - public static final String ACTION_RELOAD_REQUIREMENTS = - "com.google.android.exoplayer.downloadService.action.RELOAD_REQUIREMENTS"; - /** Like {@link #ACTION_INIT}, but with {@link #KEY_FOREGROUND} implicitly set to true. */ private static final String ACTION_RESTART = "com.google.android.exoplayer.downloadService.action.RESTART"; @@ -70,20 +66,16 @@ public abstract class DownloadService extends Service { private static final String TAG = "DownloadService"; private static final boolean DEBUG = false; - // Keep the requirements helper for each DownloadService as long as there are downloads (and the - // process is running). This allows downloads to resume when there's no scheduler. It may also - // allow downloads the resume more quickly than when relying on the scheduler alone. - private static final HashMap, RequirementsHelper> - requirementsHelpers = new HashMap<>(); - private static final Requirements DEFAULT_REQUIREMENTS = - new Requirements(Requirements.NETWORK_TYPE_ANY, false, false); + // Keep DownloadManagerListeners for each DownloadService as long as there are downloads (and the + // process is running). This allows DownloadService to restart when there's no scheduler. + private static final HashMap, DownloadManagerHelper> + downloadManagerListeners = new HashMap<>(); private final @Nullable ForegroundNotificationUpdater foregroundNotificationUpdater; private final @Nullable String channelId; private final @StringRes int channelName; private DownloadManager downloadManager; - private DownloadManagerListener downloadManagerListener; private int lastStartId; private boolean startedInForeground; private boolean taskRemoved; @@ -227,9 +219,16 @@ public abstract class DownloadService extends Service { NotificationUtil.createNotificationChannel( this, channelId, channelName, NotificationUtil.IMPORTANCE_LOW); } - downloadManager = getDownloadManager(); - downloadManagerListener = new DownloadManagerListener(); - downloadManager.addListener(downloadManagerListener); + Class clazz = getClass(); + DownloadManagerHelper downloadManagerHelper = downloadManagerListeners.get(clazz); + if (downloadManagerHelper == null) { + downloadManagerHelper = + new DownloadManagerHelper( + getApplicationContext(), getDownloadManager(), getScheduler(), clazz); + downloadManagerListeners.put(clazz, downloadManagerHelper); + } + downloadManager = downloadManagerHelper.downloadManager; + downloadManagerHelper.attachService(this); } @Override @@ -264,22 +263,11 @@ public abstract class DownloadService extends Service { } } break; - case ACTION_RELOAD_REQUIREMENTS: - stopWatchingRequirements(); - break; default: Log.e(TAG, "Ignoring unrecognized action: " + intentAction); break; } - Requirements requirements = getRequirements(); - if (requirements.checkRequirements(this)) { - downloadManager.startDownloads(); - } else { - downloadManager.stopDownloads(); - } - maybeStartWatchingRequirements(requirements); - if (downloadManager.isIdle()) { stop(); } @@ -295,11 +283,12 @@ public abstract class DownloadService extends Service { @Override public void onDestroy() { logd("onDestroy"); + DownloadManagerHelper downloadManagerHelper = downloadManagerListeners.get(getClass()); + boolean unschedule = downloadManager.getDownloadCount() <= 0; + downloadManagerHelper.detachService(this, unschedule); if (foregroundNotificationUpdater != null) { foregroundNotificationUpdater.stopPeriodicUpdates(); } - downloadManager.removeListener(downloadManagerListener); - maybeStopWatchingRequirements(); } /** DownloadService isn't designed to be bound. */ @@ -311,9 +300,7 @@ public abstract class DownloadService extends Service { /** * Returns a {@link DownloadManager} to be used to downloaded content. Called only once in the - * life cycle of the service. The service will call {@link DownloadManager#startDownloads()} and - * {@link DownloadManager#stopDownloads} as necessary when requirements returned by {@link - * #getRequirements()} are met or stop being met. + * life cycle of the process. */ protected abstract DownloadManager getDownloadManager(); @@ -324,14 +311,6 @@ public abstract class DownloadService extends Service { */ protected abstract @Nullable Scheduler getScheduler(); - /** - * Returns requirements for downloads to take place. By default the only requirement is that the - * device has network connectivity. - */ - protected Requirements getRequirements() { - return DEFAULT_REQUIREMENTS; - } - /** * Should be overridden in the subclass if the service will be run in the foreground. * @@ -363,32 +342,16 @@ public abstract class DownloadService extends Service { // Do nothing. } - private void maybeStartWatchingRequirements(Requirements requirements) { - if (downloadManager.getDownloadCount() == 0) { - return; - } - Class clazz = getClass(); - RequirementsHelper requirementsHelper = requirementsHelpers.get(clazz); - if (requirementsHelper == null) { - requirementsHelper = new RequirementsHelper(this, requirements, getScheduler(), clazz); - requirementsHelpers.put(clazz, requirementsHelper); - requirementsHelper.start(); - logd("started watching requirements"); - } - } - - private void maybeStopWatchingRequirements() { - if (downloadManager.getDownloadCount() > 0) { - return; - } - stopWatchingRequirements(); - } - - private void stopWatchingRequirements() { - RequirementsHelper requirementsHelper = requirementsHelpers.remove(getClass()); - if (requirementsHelper != null) { - requirementsHelper.stop(); - logd("stopped watching requirements"); + private void notifyDownloadStateChange(DownloadState downloadState) { + onDownloadStateChanged(downloadState); + if (foregroundNotificationUpdater != null) { + if (downloadState.state == DownloadState.STATE_DOWNLOADING + || downloadState.state == DownloadState.STATE_REMOVING + || downloadState.state == DownloadState.STATE_RESTARTING) { + foregroundNotificationUpdater.startPeriodicUpdates(); + } else { + foregroundNotificationUpdater.update(); + } } } @@ -420,33 +383,6 @@ public abstract class DownloadService extends Service { return new Intent(context, clazz).setAction(action); } - private final class DownloadManagerListener implements DownloadManager.Listener { - @Override - public void onInitialized(DownloadManager downloadManager) { - maybeStartWatchingRequirements(getRequirements()); - } - - @Override - public void onDownloadStateChanged( - DownloadManager downloadManager, DownloadState downloadState) { - DownloadService.this.onDownloadStateChanged(downloadState); - if (foregroundNotificationUpdater != null) { - if (downloadState.state == DownloadState.STATE_DOWNLOADING - || downloadState.state == DownloadState.STATE_REMOVING - || downloadState.state == DownloadState.STATE_RESTARTING) { - foregroundNotificationUpdater.startPeriodicUpdates(); - } else { - foregroundNotificationUpdater.update(); - } - } - } - - @Override - public final void onIdle(DownloadManager downloadManager) { - stop(); - } - } - private final class ForegroundNotificationUpdater implements Runnable { private final int notificationId; @@ -494,58 +430,87 @@ public abstract class DownloadService extends Service { } } - private static final class RequirementsHelper implements RequirementsWatcher.Listener { + private static final class DownloadManagerHelper implements DownloadManager.Listener { private final Context context; - private final Requirements requirements; - private final @Nullable Scheduler scheduler; + private final DownloadManager downloadManager; + @Nullable private final Scheduler scheduler; private final Class serviceClass; - private final RequirementsWatcher requirementsWatcher; + @Nullable private DownloadService downloadService; - private RequirementsHelper( + private DownloadManagerHelper( Context context, - Requirements requirements, + DownloadManager downloadManager, @Nullable Scheduler scheduler, Class serviceClass) { this.context = context; - this.requirements = requirements; + this.downloadManager = downloadManager; this.scheduler = scheduler; this.serviceClass = serviceClass; - requirementsWatcher = new RequirementsWatcher(context, this, requirements); - } - - public void start() { - requirementsWatcher.start(); - } - - public void stop() { - requirementsWatcher.stop(); + downloadManager.addListener(this); if (scheduler != null) { + Requirements requirements = downloadManager.getRequirements(); + setSchedulerEnabled(/* enabled= */ !requirements.checkRequirements(context), requirements); + } + } + + public void attachService(DownloadService downloadService) { + Assertions.checkState(this.downloadService == null); + this.downloadService = downloadService; + } + + public void detachService(DownloadService downloadService, boolean unschedule) { + Assertions.checkState(this.downloadService == downloadService); + this.downloadService = null; + if (scheduler != null && unschedule) { scheduler.cancel(); } } @Override - public void requirementsMet(RequirementsWatcher requirementsWatcher) { - try { - notifyService(); - } catch (Exception e) { - /* If we can't notify the service, don't stop the scheduler. */ - return; - } - if (scheduler != null) { - scheduler.cancel(); + public void onInitialized(DownloadManager downloadManager) { + // Do nothing. + } + + @Override + public void onDownloadStateChanged( + DownloadManager downloadManager, DownloadState downloadState) { + if (downloadService != null) { + downloadService.notifyDownloadStateChange(downloadState); } } @Override - public void requirementsNotMet(RequirementsWatcher requirementsWatcher) { - try { - notifyService(); - } catch (Exception e) { - /* Do nothing. The service isn't running anyway. */ + public final void onIdle(DownloadManager downloadManager) { + if (downloadService != null) { + downloadService.stop(); + } + } + + @Override + public void onRequirementsStateChanged( + DownloadManager downloadManager, + Requirements requirements, + @Requirements.RequirementFlags int notMetRequirements) { + boolean requirementsMet = notMetRequirements == 0; + if (downloadService == null && requirementsMet) { + try { + Intent intent = getIntent(context, serviceClass, DownloadService.ACTION_INIT); + context.startService(intent); + } catch (IllegalStateException e) { + /* startService fails if the app is in the background then don't stop the scheduler. */ + return; + } } if (scheduler != null) { + setSchedulerEnabled(/* enabled= */ !requirementsMet, requirements); + } + } + + private void setSchedulerEnabled(boolean enabled, Requirements requirements) { + if (!enabled) { + scheduler.cancel(); + } else { String servicePackage = context.getPackageName(); boolean success = scheduler.schedule(requirements, servicePackage, ACTION_RESTART); if (!success) { @@ -553,15 +518,5 @@ public abstract class DownloadService extends Service { } } } - - private void notifyService() throws Exception { - Intent intent = getIntent(context, serviceClass, DownloadService.ACTION_INIT); - try { - context.startService(intent); - } catch (IllegalStateException e) { - /* startService will fail if the app is in the background and the service isn't running. */ - throw new Exception(e); - } - } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadState.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadState.java index eed32720a3..b32288fa3f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadState.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadState.java @@ -13,17 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.android.exoplayer2.offline; import android.net.Uri; import android.support.annotation.IntDef; import android.support.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.scheduler.Requirements; +import com.google.android.exoplayer2.scheduler.Requirements.RequirementFlags; import com.google.android.exoplayer2.util.Assertions; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import java.util.Collections; +import java.util.HashSet; /** Represents state of a download. */ public final class DownloadState { @@ -74,19 +77,19 @@ public final class DownloadState { public static final int FAILURE_REASON_UNKNOWN = 1; /** - * Download stop flags. Possible flag values are {@link #STOP_FLAG_DOWNLOAD_MANAGER_NOT_READY} and - * {@link #STOP_FLAG_STOPPED}. + * Download stop flags. Possible flag values are {@link #STOP_FLAG_MANUAL} and {@link + * #STOP_FLAG_REQUIREMENTS_NOT_MET}. */ @Documented @Retention(RetentionPolicy.SOURCE) @IntDef( flag = true, - value = {STOP_FLAG_DOWNLOAD_MANAGER_NOT_READY, STOP_FLAG_STOPPED}) + value = {STOP_FLAG_MANUAL, STOP_FLAG_REQUIREMENTS_NOT_MET}) public @interface StopFlags {} - /** Download can't be started as the manager isn't ready. */ - public static final int STOP_FLAG_DOWNLOAD_MANAGER_NOT_READY = 1; - /** All downloads are stopped by the application. */ - public static final int STOP_FLAG_STOPPED = 1 << 1; + /** Download is stopped by the application. */ + public static final int STOP_FLAG_MANUAL = 1; + /** Download is stopped as the requirements are not met. */ + public static final int STOP_FLAG_REQUIREMENTS_NOT_MET = 1 << 1; /** Returns the state string for the given state value. */ public static String getStateString(@State int state) { @@ -154,7 +157,40 @@ public final class DownloadState { */ @FailureReason public final int failureReason; /** Download stop flags. These flags stop downloading any content. */ - public final int stopFlags; + @StopFlags public final int stopFlags; + /** Not met requirements to download. */ + @Requirements.RequirementFlags public final int notMetRequirements; + /** If {@link #STOP_FLAG_MANUAL} is set then this field holds the manual stop reason. */ + public final int manualStopReason; + + /** + * Creates a {@link DownloadState} using a {@link DownloadAction}. + * + * @param action The {@link DownloadAction}. + */ + public DownloadState(DownloadAction action) { + this(action, System.currentTimeMillis()); + } + + private DownloadState(DownloadAction action, long currentTimeMs) { + this( + action.id, + action.type, + action.uri, + action.customCacheKey, + /* state= */ action.isRemoveAction ? STATE_REMOVING : STATE_QUEUED, + /* downloadPercentage= */ C.PERCENTAGE_UNSET, + /* downloadedBytes= */ 0, + /* totalBytes= */ C.LENGTH_UNSET, + FAILURE_REASON_NONE, + /* stopFlags= */ 0, + /* notMetRequirements= */ 0, + /* manualStopReason= */ 0, + /* startTimeMs= */ currentTimeMs, + /* updateTimeMs= */ currentTimeMs, + action.keys.toArray(new StreamKey[0]), + action.data); + } /* package */ DownloadState( String id, @@ -167,28 +203,91 @@ public final class DownloadState { long totalBytes, @FailureReason int failureReason, @StopFlags int stopFlags, + @RequirementFlags int notMetRequirements, + int manualStopReason, long startTimeMs, long updateTimeMs, StreamKey[] streamKeys, byte[] customMetadata) { - this.stopFlags = stopFlags; + Assertions.checkState((failureReason == FAILURE_REASON_NONE) == (state != STATE_FAILED)); + Assertions.checkState(stopFlags == 0 || (state != STATE_DOWNLOADING && state != STATE_QUEUED)); Assertions.checkState( - failureReason == FAILURE_REASON_NONE ? state != STATE_FAILED : state == STATE_FAILED); - // TODO enable this when we start changing state immediately - // Assertions.checkState(stopFlags == 0 || (state != STATE_DOWNLOADING && state != - // STATE_QUEUED)); + ((stopFlags & STOP_FLAG_REQUIREMENTS_NOT_MET) == 0) == (notMetRequirements == 0)); + Assertions.checkState(((stopFlags & STOP_FLAG_MANUAL) != 0) || (manualStopReason == 0)); this.id = id; this.type = type; this.uri = uri; this.cacheKey = cacheKey; - this.streamKeys = streamKeys; - this.customMetadata = customMetadata; this.state = state; this.downloadPercentage = downloadPercentage; this.downloadedBytes = downloadedBytes; this.totalBytes = totalBytes; this.failureReason = failureReason; + this.stopFlags = stopFlags; + this.notMetRequirements = notMetRequirements; + this.manualStopReason = manualStopReason; this.startTimeMs = startTimeMs; this.updateTimeMs = updateTimeMs; + this.streamKeys = streamKeys; + this.customMetadata = customMetadata; + } + + /** + * Merges the given {@link DownloadAction} and creates a new {@link DownloadState}. The action + * must have the same id and type. + * + * @param action The {@link DownloadAction} to be merged. + * @return A new {@link DownloadState}. + */ + public DownloadState mergeAction(DownloadAction action) { + Assertions.checkArgument(action.id.equals(id)); + Assertions.checkArgument(action.type.equals(type)); + return new DownloadState( + id, + type, + action.uri, + action.customCacheKey, + getNextState(action, state), + /* downloadPercentage= */ C.PERCENTAGE_UNSET, + downloadedBytes, + /* totalBytes= */ C.LENGTH_UNSET, + FAILURE_REASON_NONE, + stopFlags, + notMetRequirements, + manualStopReason, + startTimeMs, + updateTimeMs, + mergeStreamKeys(this, action), + action.data); + } + + private static int getNextState(DownloadAction action, int currentState) { + int newState; + if (action.isRemoveAction) { + newState = STATE_REMOVING; + } else { + if (currentState == STATE_REMOVING || currentState == STATE_RESTARTING) { + newState = STATE_RESTARTING; + } else if (currentState == STATE_STOPPED) { + newState = STATE_STOPPED; + } else { + newState = STATE_QUEUED; + } + } + return newState; + } + + private static StreamKey[] mergeStreamKeys(DownloadState downloadState, DownloadAction action) { + StreamKey[] streamKeys = downloadState.streamKeys; + if (!action.isRemoveAction && streamKeys.length > 0) { + if (action.keys.isEmpty()) { + streamKeys = new StreamKey[0]; + } else { + HashSet keys = new HashSet<>(action.keys); + Collections.addAll(keys, downloadState.streamKeys); + streamKeys = keys.toArray(new StreamKey[0]); + } + } + return streamKeys; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadStateCursor.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadStateCursor.java new file mode 100644 index 0000000000..06511c8930 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadStateCursor.java @@ -0,0 +1,127 @@ +/* + * 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.offline; + +/** Provides random read-write access to the result set returned by a database query. */ +public interface DownloadStateCursor { + + /** Returns the DownloadState at the current position. */ + DownloadState getDownloadState(); + + /** Returns the numbers of DownloadStates in the cursor. */ + int getCount(); + + /** + * Returns the current position of the cursor in the DownloadState set. The value is zero-based. + * When the DownloadState set is first returned the cursor will be at positon -1, which is before + * the first DownloadState. After the last DownloadState is returned another call to next() will + * leave the cursor past the last entry, at a position of count(). + * + * @return the current cursor position. + */ + int getPosition(); + + /** + * Move the cursor to an absolute position. The valid range of values is -1 <= position <= + * count. + * + *

    This method will return true if the request destination was reachable, otherwise, it returns + * false. + * + * @param position the zero-based position to move to. + * @return whether the requested move fully succeeded. + */ + boolean moveToPosition(int position); + + /** + * Move the cursor to the first DownloadState. + * + *

    This method will return false if the cursor is empty. + * + * @return whether the move succeeded. + */ + default boolean moveToFirst() { + return moveToPosition(0); + } + + /** + * Move the cursor to the last DownloadState. + * + *

    This method will return false if the cursor is empty. + * + * @return whether the move succeeded. + */ + default boolean moveToLast() { + return moveToPosition(getCount() - 1); + } + + /** + * Move the cursor to the next DownloadState. + * + *

    This method will return false if the cursor is already past the last entry in the result + * set. + * + * @return whether the move succeeded. + */ + default boolean moveToNext() { + return moveToPosition(getPosition() + 1); + } + + /** + * Move the cursor to the previous DownloadState. + * + *

    This method will return false if the cursor is already before the first entry in the result + * set. + * + * @return whether the move succeeded. + */ + default boolean moveToPrevious() { + return moveToPosition(getPosition() - 1); + } + + /** Returns whether the cursor is pointing to the first DownloadState. */ + default boolean isFirst() { + return getPosition() == 0 && getCount() != 0; + } + + /** Returns whether the cursor is pointing to the last DownloadState. */ + default boolean isLast() { + int count = getCount(); + return getPosition() == (count - 1) && count != 0; + } + + /** Returns whether the cursor is pointing to the position before the first DownloadState. */ + default boolean isBeforeFirst() { + if (getCount() == 0) { + return true; + } + return getPosition() == -1; + } + + /** Returns whether the cursor is pointing to the position after the last DownloadState. */ + default boolean isAfterLast() { + if (getCount() == 0) { + return true; + } + return getPosition() == getCount(); + } + + /** Closes the Cursor, releasing all of its resources and making it completely invalid. */ + void close(); + + /** Returns whether the cursor is closed */ + boolean isClosed(); +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloaderConstructorHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloaderConstructorHelper.java index 100e1a03fe..48e70e37fc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloaderConstructorHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloaderConstructorHelper.java @@ -109,16 +109,16 @@ public final class DownloaderConstructorHelper { cacheReadDataSourceFactory != null ? cacheReadDataSourceFactory : new FileDataSourceFactory(); - DataSink.Factory writeDataSinkFactory = - cacheWriteDataSinkFactory != null - ? cacheWriteDataSinkFactory - : new CacheDataSinkFactory(cache, CacheDataSink.DEFAULT_MAX_CACHE_FILE_SIZE); + if (cacheWriteDataSinkFactory == null) { + cacheWriteDataSinkFactory = + new CacheDataSinkFactory(cache, CacheDataSink.DEFAULT_FRAGMENT_SIZE); + } onlineCacheDataSourceFactory = new CacheDataSourceFactory( cache, upstreamFactory, readDataSourceFactory, - writeDataSinkFactory, + cacheWriteDataSinkFactory, CacheDataSource.FLAG_BLOCK_ON_CACHE, /* eventListener= */ null, cacheKeyFactory); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloadHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloadHelper.java deleted file mode 100644 index 2ec14368ca..0000000000 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloadHelper.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * 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.offline; - -import android.net.Uri; -import android.support.annotation.Nullable; -import com.google.android.exoplayer2.Renderer; -import com.google.android.exoplayer2.source.TrackGroupArray; - -/** A {@link DownloadHelper} for progressive streams. */ -public final class ProgressiveDownloadHelper extends DownloadHelper { - - /** - * Creates download helper for progressive streams. - * - * @param uri The stream {@link Uri}. - */ - public ProgressiveDownloadHelper(Uri uri) { - this(uri, /* cacheKey= */ null); - } - - /** - * Creates download helper for progressive streams. - * - * @param uri The stream {@link Uri}. - * @param cacheKey An optional cache key. - */ - public ProgressiveDownloadHelper(Uri uri, @Nullable String cacheKey) { - super( - DownloadAction.TYPE_PROGRESSIVE, - uri, - cacheKey, - DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS, - (handler, videoListener, audioListener, metadata, text, drm) -> new Renderer[0], - /* drmSessionManager= */ null); - } - - @Override - protected Void loadManifest(Uri uri) { - return null; - } - - @Override - protected TrackGroupArray[] getTrackGroupArrays(Void manifest) { - return new TrackGroupArray[] {TrackGroupArray.EMPTY}; - } - - @Override - protected StreamKey toStreamKey( - int periodIndex, int trackGroupIndex, int trackIndexInTrackGroup) { - return new StreamKey(periodIndex, trackGroupIndex, trackIndexInTrackGroup); - } -} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java index 41f0944b75..25b4e07bcd 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java @@ -53,7 +53,11 @@ public final class ProgressiveDownloader implements Downloader { Uri uri, @Nullable String customCacheKey, DownloaderConstructorHelper constructorHelper) { this.dataSpec = new DataSpec( - uri, /* absoluteStreamPosition= */ 0, C.LENGTH_UNSET, customCacheKey, /* flags= */ 0); + uri, + /* absoluteStreamPosition= */ 0, + C.LENGTH_UNSET, + customCacheKey, + /* flags= */ DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION); this.cache = constructorHelper.getCache(); this.dataSource = constructorHelper.createCacheDataSource(); this.cacheKeyFactory = constructorHelper.getCacheKeyFactory(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java index 4019d1ae70..508c3393c5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java @@ -155,16 +155,14 @@ public final class Requirements { * @return Whether the requirements are met. */ public boolean checkRequirements(Context context) { - return checkNetworkRequirements(context) - && checkChargingRequirement(context) - && checkIdleRequirement(context); + return getNotMetRequirements(context) == 0; } /** - * Returns the requirement flags that are not met, or 0. + * Returns {@link RequirementFlags} that are not met, or 0. * * @param context Any context. - * @return The requirement flags that are not met, or 0. + * @return RequirementFlags that are not met, or 0. */ @RequirementFlags public int getNotMetRequirements(Context context) { @@ -202,7 +200,7 @@ public final class Requirements { logd("Roaming: " + roaming); return !roaming; } - boolean activeNetworkMetered = isActiveNetworkMetered(connectivityManager, networkInfo); + boolean activeNetworkMetered = connectivityManager.isActiveNetworkMetered(); logd("Metered network: " + activeNetworkMetered); if (networkRequirement == NETWORK_TYPE_UNMETERED) { return !activeNetworkMetered; @@ -257,17 +255,6 @@ public final class Requirements { return !validated; } - private static boolean isActiveNetworkMetered( - ConnectivityManager connectivityManager, NetworkInfo networkInfo) { - if (Util.SDK_INT >= 16) { - return connectivityManager.isActiveNetworkMetered(); - } - int type = networkInfo.getType(); - return type != ConnectivityManager.TYPE_WIFI - && type != ConnectivityManager.TYPE_BLUETOOTH - && type != ConnectivityManager.TYPE_ETHERNET; - } - private static void logd(String message) { if (Scheduler.DEBUG) { Log.d(TAG, message); @@ -285,4 +272,20 @@ public final class Requirements { + (isIdleRequired() ? ",idle" : "") + '}'; } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + return requirements == ((Requirements) o).requirements; + } + + @Override + public int hashCode() { + return requirements; + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java index 021c10439a..dfced7c0ab 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java @@ -42,21 +42,16 @@ public final class RequirementsWatcher { * Requirements} are met. */ public interface Listener { - /** - * Called when all of the requirements are met. + * Called when there is a change on the met requirements. * * @param requirementsWatcher Calling instance. + * @param notMetRequirements {@link Requirements.RequirementFlags RequirementFlags} that are not + * met, or 0. */ - void requirementsMet(RequirementsWatcher requirementsWatcher); - - /** - * Called when there is at least one not met requirement and there is a change on which of the - * requirements are not met. - * - * @param requirementsWatcher Calling instance. - */ - void requirementsNotMet(RequirementsWatcher requirementsWatcher); + void onRequirementsStateChanged( + RequirementsWatcher requirementsWatcher, + @Requirements.RequirementFlags int notMetRequirements); } private static final String TAG = "RequirementsWatcher"; @@ -66,8 +61,9 @@ public final class RequirementsWatcher { private final Requirements requirements; private DeviceStatusChangeReceiver receiver; - private int notMetRequirements; + @Requirements.RequirementFlags private int notMetRequirements; private CapabilityValidatedCallback networkCallback; + private Handler handler; /** * @param context Any context. @@ -84,9 +80,13 @@ public final class RequirementsWatcher { /** * Starts watching for changes. Must be called from a thread that has an associated {@link * Looper}. Listener methods are called on the caller thread. + * + * @return Initial {@link Requirements.RequirementFlags RequirementFlags} that are not met, or 0. */ - public void start() { + @Requirements.RequirementFlags + public int start() { Assertions.checkNotNull(Looper.myLooper()); + handler = new Handler(); notMetRequirements = requirements.getNotMetRequirements(context); @@ -111,8 +111,9 @@ public final class RequirementsWatcher { } } receiver = new DeviceStatusChangeReceiver(); - context.registerReceiver(receiver, filter, null, new Handler()); + context.registerReceiver(receiver, filter, null, handler); logd(this + " started"); + return notMetRequirements; } /** Stops watching for changes. */ @@ -160,18 +161,12 @@ public final class RequirementsWatcher { } private void checkRequirements() { + @Requirements.RequirementFlags int notMetRequirements = requirements.getNotMetRequirements(context); - if (this.notMetRequirements == notMetRequirements) { - logd("notMetRequirements hasn't changed: " + notMetRequirements); - return; - } - this.notMetRequirements = notMetRequirements; - if (notMetRequirements == 0) { - logd("start job"); - listener.requirementsMet(this); - } else { - logd("stop job"); - listener.requirementsNotMet(this); + if (this.notMetRequirements != notMetRequirements) { + this.notMetRequirements = notMetRequirements; + logd("notMetRequirements has changed: " + notMetRequirements); + listener.onRequirementsStateChanged(this, notMetRequirements); } } @@ -195,16 +190,22 @@ public final class RequirementsWatcher { private final class CapabilityValidatedCallback extends ConnectivityManager.NetworkCallback { @Override public void onAvailable(Network network) { - super.onAvailable(network); - logd(RequirementsWatcher.this + " NetworkCallback.onAvailable"); - checkRequirements(); + onNetworkCallback(); } @Override public void onLost(Network network) { - super.onLost(network); - logd(RequirementsWatcher.this + " NetworkCallback.onLost"); - checkRequirements(); + onNetworkCallback(); + } + + private void onNetworkCallback() { + handler.post( + () -> { + if (networkCallback != null) { + logd(RequirementsWatcher.this + " NetworkCallback"); + checkRequirements(); + } + }); } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaSource.java index 0ab6eeae18..d3b8226822 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaSource.java @@ -206,10 +206,10 @@ public final class ClippingMediaSource extends CompositeMediaSource { } @Override - public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { ClippingMediaPeriod mediaPeriod = new ClippingMediaPeriod( - mediaSource.createPeriod(id, allocator), + mediaSource.createPeriod(id, allocator, startPositionUs), enableInitialDiscontinuity, periodStartUs, periodEndUs); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java index db52a87ef8..d223f653e1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java @@ -28,7 +28,6 @@ import com.google.android.exoplayer2.source.ShuffleOrder.DefaultShuffleOrder; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; -import com.google.android.exoplayer2.util.EventDispatcher; import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.util.ArrayList; @@ -36,9 +35,11 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.IdentityHashMap; import java.util.List; import java.util.Map; +import java.util.Set; /** * Concatenates multiple {@link MediaSource}s. The list of {@link MediaSource}s can be modified @@ -51,12 +52,19 @@ public class ConcatenatingMediaSource extends CompositeMediaSource mediaSourcesPublic; - @Nullable private Handler playbackThreadHandler; + + @GuardedBy("this") + private final Set pendingOnCompletionActions; + + @GuardedBy("this") + @Nullable + private Handler playbackThreadHandler; // Accessed on the playback thread only. private final List mediaSourceHolders; @@ -67,8 +75,8 @@ public class ConcatenatingMediaSource extends CompositeMediaSource pendingOnCompletionActions; + private boolean timelineUpdateScheduled; + private Set nextTimelineUpdateOnCompletionActions; private ShuffleOrder shuffleOrder; private int windowCount; private int periodCount; @@ -127,7 +135,8 @@ public class ConcatenatingMediaSource extends CompositeMediaSource(); this.mediaSourcesPublic = new ArrayList<>(); this.mediaSourceHolders = new ArrayList<>(); - this.pendingOnCompletionActions = new EventDispatcher<>(); + this.nextTimelineUpdateOnCompletionActions = new HashSet<>(); + this.pendingOnCompletionActions = new HashSet<>(); this.isAtomic = isAtomic; this.useLazyPreparation = useLazyPreparation; window = new Timeline.Window(); @@ -148,13 +157,13 @@ public class ConcatenatingMediaSource extends CompositeMediaSource mediaSources, Handler handler, Runnable actionOnCompletion) { - addPublicMediaSources(mediaSourcesPublic.size(), mediaSources, handler, actionOnCompletion); + Collection mediaSources, Handler handler, Runnable onCompletionAction) { + addPublicMediaSources(mediaSourcesPublic.size(), mediaSources, handler, onCompletionAction); } /** @@ -226,7 +235,7 @@ public class ConcatenatingMediaSource extends CompositeMediaSource mediaSources) { - addPublicMediaSources(index, mediaSources, /* handler= */ null, /* actionOnCompletion= */ null); + addPublicMediaSources(index, mediaSources, /* handler= */ null, /* onCompletionAction= */ null); } /** @@ -236,16 +245,16 @@ public class ConcatenatingMediaSource extends CompositeMediaSource mediaSources, Handler handler, - Runnable actionOnCompletion) { - addPublicMediaSources(index, mediaSources, handler, actionOnCompletion); + Runnable onCompletionAction) { + addPublicMediaSources(index, mediaSources, handler, onCompletionAction); } /** @@ -261,7 +270,7 @@ public class ConcatenatingMediaSource extends CompositeMediaSource mediaSources, @Nullable Handler handler, - @Nullable Runnable actionOnCompletion) { - Assertions.checkArgument((handler == null) == (actionOnCompletion == null)); + @Nullable Runnable onCompletionAction) { + Assertions.checkArgument((handler == null) == (onCompletionAction == null)); + Handler playbackThreadHandler = this.playbackThreadHandler; for (MediaSource mediaSource : mediaSources) { Assertions.checkNotNull(mediaSource); } @@ -527,12 +545,12 @@ public class ConcatenatingMediaSource extends CompositeMediaSource(index, mediaSourceHolders, handler, actionOnCompletion)) + .obtainMessage(MSG_ADD, new MessageData<>(index, mediaSourceHolders, callbackAction)) .sendToTarget(); - } else if (actionOnCompletion != null && handler != null) { - handler.post(actionOnCompletion); + } else if (onCompletionAction != null && handler != null) { + handler.post(onCompletionAction); } } @@ -541,16 +559,17 @@ public class ConcatenatingMediaSource extends CompositeMediaSource(fromIndex, toIndex, handler, actionOnCompletion)) + .obtainMessage(MSG_REMOVE, new MessageData<>(fromIndex, toIndex, callbackAction)) .sendToTarget(); - } else if (actionOnCompletion != null && handler != null) { - handler.post(actionOnCompletion); + } else if (onCompletionAction != null && handler != null) { + handler.post(onCompletionAction); } } @@ -559,23 +578,24 @@ public class ConcatenatingMediaSource extends CompositeMediaSource(currentIndex, newIndex, handler, actionOnCompletion)) + .obtainMessage(MSG_MOVE, new MessageData<>(currentIndex, newIndex, callbackAction)) .sendToTarget(); - } else if (actionOnCompletion != null && handler != null) { - handler.post(actionOnCompletion); + } else if (onCompletionAction != null && handler != null) { + handler.post(onCompletionAction); } } @GuardedBy("this") private void setPublicShuffleOrder( - ShuffleOrder shuffleOrder, @Nullable Handler handler, @Nullable Runnable actionOnCompletion) { - Assertions.checkArgument((handler == null) == (actionOnCompletion == null)); + ShuffleOrder shuffleOrder, @Nullable Handler handler, @Nullable Runnable onCompletionAction) { + Assertions.checkArgument((handler == null) == (onCompletionAction == null)); Handler playbackThreadHandler = this.playbackThreadHandler; if (playbackThreadHandler != null) { int size = getSize(); @@ -585,35 +605,44 @@ public class ConcatenatingMediaSource extends CompositeMediaSource(/* index= */ 0, shuffleOrder, handler, actionOnCompletion)) + new MessageData<>(/* index= */ 0, shuffleOrder, callbackAction)) .sendToTarget(); } else { this.shuffleOrder = shuffleOrder.getLength() > 0 ? shuffleOrder.cloneAndClear() : shuffleOrder; - if (actionOnCompletion != null && handler != null) { - handler.post(actionOnCompletion); + if (onCompletionAction != null && handler != null) { + handler.post(onCompletionAction); } } } + @GuardedBy("this") + @Nullable + private HandlerAndRunnable createOnCompletionAction( + @Nullable Handler handler, @Nullable Runnable runnable) { + if (handler == null || runnable == null) { + return null; + } + HandlerAndRunnable handlerAndRunnable = new HandlerAndRunnable(handler, runnable); + pendingOnCompletionActions.add(handlerAndRunnable); + return handlerAndRunnable; + } + // Internal methods. Called on the playback thread. @SuppressWarnings("unchecked") private boolean handleMessage(Message msg) { - if (playbackThreadHandler == null) { - // Stale event. - return false; - } switch (msg.what) { case MSG_ADD: MessageData> addMessage = (MessageData>) Util.castNonNull(msg.obj); shuffleOrder = shuffleOrder.cloneAndInsert(addMessage.index, addMessage.customData.size()); addMediaSourcesInternal(addMessage.index, addMessage.customData); - scheduleListenerNotification(addMessage.handler, addMessage.actionOnCompletion); + scheduleTimelineUpdate(addMessage.onCompletionAction); break; case MSG_REMOVE: MessageData removeMessage = (MessageData) Util.castNonNull(msg.obj); @@ -627,29 +656,27 @@ public class ConcatenatingMediaSource extends CompositeMediaSource= fromIndex; index--) { removeMediaSourceInternal(index); } - scheduleListenerNotification(removeMessage.handler, removeMessage.actionOnCompletion); + scheduleTimelineUpdate(removeMessage.onCompletionAction); break; case MSG_MOVE: MessageData moveMessage = (MessageData) Util.castNonNull(msg.obj); shuffleOrder = shuffleOrder.cloneAndRemove(moveMessage.index, moveMessage.index + 1); shuffleOrder = shuffleOrder.cloneAndInsert(moveMessage.customData, 1); moveMediaSourceInternal(moveMessage.index, moveMessage.customData); - scheduleListenerNotification(moveMessage.handler, moveMessage.actionOnCompletion); + scheduleTimelineUpdate(moveMessage.onCompletionAction); break; case MSG_SET_SHUFFLE_ORDER: MessageData shuffleOrderMessage = (MessageData) Util.castNonNull(msg.obj); shuffleOrder = shuffleOrderMessage.customData; - scheduleListenerNotification( - shuffleOrderMessage.handler, shuffleOrderMessage.actionOnCompletion); + scheduleTimelineUpdate(shuffleOrderMessage.onCompletionAction); break; - case MSG_NOTIFY_LISTENER: - notifyListener(); + case MSG_UPDATE_TIMELINE: + updateTimelineAndScheduleOnCompletionActions(); break; case MSG_ON_COMPLETION: - EventDispatcher actionsOnCompletion = - (EventDispatcher) Util.castNonNull(msg.obj); - actionsOnCompletion.dispatch(Runnable::run); + Set actions = (Set) Util.castNonNull(msg.obj); + dispatchOnCompletionActions(actions); break; default: throw new IllegalStateException(); @@ -657,36 +684,48 @@ public class ConcatenatingMediaSource extends CompositeMediaSource actionsOnCompletion = pendingOnCompletionActions; - pendingOnCompletionActions = new EventDispatcher<>(); + private void updateTimelineAndScheduleOnCompletionActions() { + timelineUpdateScheduled = false; + Set onCompletionActions = nextTimelineUpdateOnCompletionActions; + nextTimelineUpdateOnCompletionActions = new HashSet<>(); refreshSourceInfo( new ConcatenatedTimeline( mediaSourceHolders, windowCount, periodCount, shuffleOrder, isAtomic), /* manifest= */ null); - Assertions.checkNotNull(playbackThreadHandler) - .obtainMessage(MSG_ON_COMPLETION, actionsOnCompletion) + getPlaybackThreadHandlerOnPlaybackThread() + .obtainMessage(MSG_ON_COMPLETION, onCompletionActions) .sendToTarget(); } + @SuppressWarnings("GuardedBy") + private Handler getPlaybackThreadHandlerOnPlaybackThread() { + // Write access to this value happens on the playback thread only, so playback thread reads + // don't need to be synchronized. + return Assertions.checkNotNull(playbackThreadHandler); + } + + private synchronized void dispatchOnCompletionActions( + Set onCompletionActions) { + for (HandlerAndRunnable pendingAction : onCompletionActions) { + pendingAction.dispatch(); + } + pendingOnCompletionActions.removeAll(onCompletionActions); + } + private void addMediaSourcesInternal( int index, Collection mediaSourceHolders) { for (MediaSourceHolder mediaSourceHolder : mediaSourceHolders) { @@ -761,6 +800,7 @@ public class ConcatenatingMediaSource extends CompositeMediaSourceIf the possible input stream container formats are known, pass a factory that instantiates - * extractors for them to the constructor. Otherwise, pass a {@link DefaultExtractorsFactory} to use - * the default extractors. When reading a new stream, the first {@link Extractor} in the array of - * extractors created by the factory that returns {@code true} from {@link Extractor#sniff} will be - * used to extract samples from the input stream. - * - *

    Note that the built-in extractors for AAC, MPEG PS/TS and FLV streams do not support seeking. - */ +/** @deprecated Use {@link ProgressiveMediaSource} instead. */ +@Deprecated +@SuppressWarnings("deprecation") public final class ExtractorMediaSource extends BaseMediaSource - implements ExtractorMediaPeriod.Listener { + implements MediaSource.SourceInfoRefreshListener { - /** - * Listener of {@link ExtractorMediaSource} events. - * - * @deprecated Use {@link MediaSourceEventListener}. - */ + /** @deprecated Use {@link MediaSourceEventListener} instead. */ @Deprecated public interface EventListener { @@ -70,7 +59,8 @@ public final class ExtractorMediaSource extends BaseMediaSource } - /** Factory for {@link ExtractorMediaSource}s. */ + /** Use {@link ProgressiveMediaSource.Factory} instead. */ + @Deprecated public static final class Factory implements AdsMediaSource.MediaSourceFactory { private final DataSource.Factory dataSourceFactory; @@ -232,23 +222,11 @@ public final class ExtractorMediaSource extends BaseMediaSource } } - /** - * The default number of bytes that should be loaded between each each invocation of {@link - * MediaPeriod.Callback#onContinueLoadingRequested(SequenceableLoader)}. - */ - public static final int DEFAULT_LOADING_CHECK_INTERVAL_BYTES = 1024 * 1024; + @Deprecated + public static final int DEFAULT_LOADING_CHECK_INTERVAL_BYTES = + ProgressiveMediaSource.DEFAULT_LOADING_CHECK_INTERVAL_BYTES; - private final Uri uri; - private final DataSource.Factory dataSourceFactory; - private final ExtractorsFactory extractorsFactory; - private final LoadErrorHandlingPolicy loadableLoadErrorHandlingPolicy; - private final String customCacheKey; - private final int continueLoadingCheckIntervalBytes; - private final @Nullable Object tag; - - private long timelineDurationUs; - private boolean timelineIsSeekable; - private @Nullable TransferListener transferListener; + private final ProgressiveMediaSource progressiveMediaSource; /** * @param uri The {@link Uri} of the media stream. @@ -261,7 +239,6 @@ public final class ExtractorMediaSource extends BaseMediaSource * @deprecated Use {@link Factory} instead. */ @Deprecated - @SuppressWarnings("deprecation") public ExtractorMediaSource( Uri uri, DataSource.Factory dataSourceFactory, @@ -284,7 +261,6 @@ public final class ExtractorMediaSource extends BaseMediaSource * @deprecated Use {@link Factory} instead. */ @Deprecated - @SuppressWarnings("deprecation") public ExtractorMediaSource( Uri uri, DataSource.Factory dataSourceFactory, @@ -317,7 +293,6 @@ public final class ExtractorMediaSource extends BaseMediaSource * @deprecated Use {@link Factory} instead. */ @Deprecated - @SuppressWarnings("deprecation") public ExtractorMediaSource( Uri uri, DataSource.Factory dataSourceFactory, @@ -347,93 +322,57 @@ public final class ExtractorMediaSource extends BaseMediaSource @Nullable String customCacheKey, int continueLoadingCheckIntervalBytes, @Nullable Object tag) { - this.uri = uri; - this.dataSourceFactory = dataSourceFactory; - this.extractorsFactory = extractorsFactory; - this.loadableLoadErrorHandlingPolicy = loadableLoadErrorHandlingPolicy; - this.customCacheKey = customCacheKey; - this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes; - this.timelineDurationUs = C.TIME_UNSET; - this.tag = tag; + progressiveMediaSource = + new ProgressiveMediaSource( + uri, + dataSourceFactory, + extractorsFactory, + loadableLoadErrorHandlingPolicy, + customCacheKey, + continueLoadingCheckIntervalBytes, + tag); } @Override @Nullable public Object getTag() { - return tag; + return progressiveMediaSource.getTag(); } @Override public void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { - transferListener = mediaTransferListener; - notifySourceInfoRefreshed(timelineDurationUs, /* isSeekable= */ false); + progressiveMediaSource.prepareSource(/* listener= */ this, mediaTransferListener); } @Override public void maybeThrowSourceInfoRefreshError() throws IOException { - // Do nothing. + progressiveMediaSource.maybeThrowSourceInfoRefreshError(); } @Override - public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { - DataSource dataSource = dataSourceFactory.createDataSource(); - if (transferListener != null) { - dataSource.addTransferListener(transferListener); - } - return new ExtractorMediaPeriod( - uri, - dataSource, - extractorsFactory.createExtractors(), - loadableLoadErrorHandlingPolicy, - createEventDispatcher(id), - this, - allocator, - customCacheKey, - continueLoadingCheckIntervalBytes); + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + return progressiveMediaSource.createPeriod(id, allocator, startPositionUs); } @Override public void releasePeriod(MediaPeriod mediaPeriod) { - ((ExtractorMediaPeriod) mediaPeriod).release(); + progressiveMediaSource.releasePeriod(mediaPeriod); } @Override public void releaseSourceInternal() { - // Do nothing. + progressiveMediaSource.releaseSource(/* listener= */ this); } - // ExtractorMediaPeriod.Listener implementation. - @Override - public void onSourceInfoRefreshed(long durationUs, boolean isSeekable) { - // If we already have the duration from a previous source info refresh, use it. - durationUs = durationUs == C.TIME_UNSET ? timelineDurationUs : durationUs; - if (timelineDurationUs == durationUs && timelineIsSeekable == isSeekable) { - // Suppress no-op source info changes. - return; - } - notifySourceInfoRefreshed(durationUs, isSeekable); + public void onSourceInfoRefreshed( + MediaSource source, Timeline timeline, @Nullable Object manifest) { + refreshSourceInfo(timeline, manifest); } - // Internal methods. - - private void notifySourceInfoRefreshed(long durationUs, boolean isSeekable) { - timelineDurationUs = durationUs; - timelineIsSeekable = isSeekable; - // TODO: Make timeline dynamic until its duration is known. This is non-trivial. See b/69703223. - refreshSourceInfo( - new SinglePeriodTimeline( - timelineDurationUs, timelineIsSeekable, /* isDynamic= */ false, tag), - /* manifest= */ null); - } - - /** - * Wraps a deprecated {@link EventListener}, invoking its callback from the equivalent callback in - * {@link MediaSourceEventListener}. - */ @Deprecated - @SuppressWarnings("deprecation") private static final class EventListenerWrapper extends DefaultMediaSourceEventListener { + private final EventListener eventListener; public EventListenerWrapper(EventListener eventListener) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java index 3cd4ea1dab..e19a02b7b3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java @@ -31,7 +31,7 @@ import java.util.Map; * Loops a {@link MediaSource} a specified number of times. * *

    Note: To loop a {@link MediaSource} indefinitely, it is usually better to use {@link - * ExoPlayer#setRepeatMode(int)}. + * ExoPlayer#setRepeatMode(int)} instead of this class. */ public final class LoopingMediaSource extends CompositeMediaSource { @@ -77,14 +77,15 @@ public final class LoopingMediaSource extends CompositeMediaSource { } @Override - public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { if (loopCount == Integer.MAX_VALUE) { - return childSource.createPeriod(id, allocator); + return childSource.createPeriod(id, allocator, startPositionUs); } Object childPeriodUid = LoopingTimeline.getChildPeriodUidFromConcatenatedUid(id.periodUid); MediaPeriodId childMediaPeriodId = id.copyWithPeriodUid(childPeriodUid); childMediaPeriodIdToMediaPeriodId.put(childMediaPeriodId, id); - MediaPeriod mediaPeriod = childSource.createPeriod(childMediaPeriodId, allocator); + MediaPeriod mediaPeriod = + childSource.createPeriod(childMediaPeriodId, allocator, startPositionUs); mediaPeriodToChildMediaPeriodId.put(mediaPeriod, childMediaPeriodId); return mediaPeriod; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java index 532131ba7d..b40bbb35d1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java @@ -87,18 +87,18 @@ public interface MediaPeriod extends SequenceableLoader { TrackGroupArray getTrackGroups(); /** - * Returns a list of {@link StreamKey stream keys} which allow to filter the media in this period - * to load only the parts needed to play the provided {@link TrackSelection}. + * Returns a list of {@link StreamKey StreamKeys} which allow to filter the media in this period + * to load only the parts needed to play the provided {@link TrackSelection TrackSelections}. * *

    This method is only called after the period has been prepared. * - * @param trackSelection The {@link TrackSelection} describing the tracks for which stream keys - * are requested. - * @return The corresponding {@link StreamKey stream keys} for the selected tracks, or an empty + * @param trackSelections The {@link TrackSelection TrackSelections} describing the tracks for + * which stream keys are requested. + * @return The corresponding {@link StreamKey StreamKeys} for the selected tracks, or an empty * list if filtering is not possible and the entire media needs to be loaded to play the * selected tracks. */ - default List getStreamKeys(TrackSelection trackSelection) { + default List getStreamKeys(List trackSelections) { return Collections.emptyList(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSource.java index 2d80fb7f13..759208c67b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSource.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.source; import android.os.Handler; import android.support.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.TransferListener; @@ -34,8 +35,8 @@ import java.io.IOException; * on the {@link SourceInfoRefreshListener}s passed to {@link * #prepareSource(SourceInfoRefreshListener, TransferListener)}. *

  • To provide {@link MediaPeriod} instances for the periods in its timeline. MediaPeriods are - * obtained by calling {@link #createPeriod(MediaPeriodId, Allocator)}, and provide a way for - * the player to load and read the media. + * obtained by calling {@link #createPeriod(MediaPeriodId, Allocator, long)}, and provide a + * way for the player to load and read the media. * * * All methods are called on the player's internal playback thread, as described in the {@link @@ -89,12 +90,10 @@ public interface MediaSource { public final long windowSequenceNumber; /** - * The end position to which the media period's content is clipped in order to play a following - * ad group, in microseconds, or {@link C#TIME_UNSET} if there is no following ad group or if - * this media period is an ad. The value {@link C#TIME_END_OF_SOURCE} indicates that a postroll - * ad follows at the end of this content media period. + * The index of the next ad group to which the media period's content is clipped, or {@link + * C#INDEX_UNSET} if there is no following ad group or if this media period is an ad. */ - public final long endPositionUs; + public final int nextAdGroupIndex; /** * Creates a media period identifier for a dummy period which is not part of a buffered sequence @@ -103,7 +102,7 @@ public interface MediaSource { * @param periodUid The unique id of the timeline period. */ public MediaPeriodId(Object periodUid) { - this(periodUid, C.INDEX_UNSET); + this(periodUid, /* windowSequenceNumber= */ C.INDEX_UNSET); } /** @@ -114,7 +113,12 @@ public interface MediaSource { * windows this media period is part of. */ public MediaPeriodId(Object periodUid, long windowSequenceNumber) { - this(periodUid, C.INDEX_UNSET, C.INDEX_UNSET, windowSequenceNumber, C.TIME_UNSET); + this( + periodUid, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET, + windowSequenceNumber, + /* nextAdGroupIndex= */ C.INDEX_UNSET); } /** @@ -123,11 +127,16 @@ public interface MediaSource { * @param periodUid The unique id of the timeline period. * @param windowSequenceNumber The sequence number of the window in the buffered sequence of * windows this media period is part of. - * @param endPositionUs The end position of the media period within the timeline period, in - * microseconds. + * @param nextAdGroupIndex The index of the next ad group to which the media period's content is + * clipped. */ - public MediaPeriodId(Object periodUid, long windowSequenceNumber, long endPositionUs) { - this(periodUid, C.INDEX_UNSET, C.INDEX_UNSET, windowSequenceNumber, endPositionUs); + public MediaPeriodId(Object periodUid, long windowSequenceNumber, int nextAdGroupIndex) { + this( + periodUid, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET, + windowSequenceNumber, + nextAdGroupIndex); } /** @@ -142,7 +151,12 @@ public interface MediaSource { */ public MediaPeriodId( Object periodUid, int adGroupIndex, int adIndexInAdGroup, long windowSequenceNumber) { - this(periodUid, adGroupIndex, adIndexInAdGroup, windowSequenceNumber, C.TIME_UNSET); + this( + periodUid, + adGroupIndex, + adIndexInAdGroup, + windowSequenceNumber, + /* nextAdGroupIndex= */ C.INDEX_UNSET); } private MediaPeriodId( @@ -150,12 +164,12 @@ public interface MediaSource { int adGroupIndex, int adIndexInAdGroup, long windowSequenceNumber, - long endPositionUs) { + int nextAdGroupIndex) { this.periodUid = periodUid; this.adGroupIndex = adGroupIndex; this.adIndexInAdGroup = adIndexInAdGroup; this.windowSequenceNumber = windowSequenceNumber; - this.endPositionUs = endPositionUs; + this.nextAdGroupIndex = nextAdGroupIndex; } /** Returns a copy of this period identifier but with {@code newPeriodUid} as its period uid. */ @@ -163,7 +177,7 @@ public interface MediaSource { return periodUid.equals(newPeriodUid) ? this : new MediaPeriodId( - newPeriodUid, adGroupIndex, adIndexInAdGroup, windowSequenceNumber, endPositionUs); + newPeriodUid, adGroupIndex, adIndexInAdGroup, windowSequenceNumber, nextAdGroupIndex); } /** @@ -187,7 +201,7 @@ public interface MediaSource { && adGroupIndex == periodId.adGroupIndex && adIndexInAdGroup == periodId.adIndexInAdGroup && windowSequenceNumber == periodId.windowSequenceNumber - && endPositionUs == periodId.endPositionUs; + && nextAdGroupIndex == periodId.nextAdGroupIndex; } @Override @@ -197,7 +211,7 @@ public interface MediaSource { result = 31 * result + adGroupIndex; result = 31 * result + adIndexInAdGroup; result = 31 * result + (int) windowSequenceNumber; - result = 31 * result + (int) endPositionUs; + result = 31 * result + nextAdGroupIndex; return result; } } @@ -224,7 +238,6 @@ public interface MediaSource { default Object getTag() { return null; } - /** * Starts source preparation if not yet started, and adds a listener for timeline and/or manifest * updates. @@ -243,8 +256,7 @@ public interface MediaSource { * and other data. */ void prepareSource( - SourceInfoRefreshListener listener, - @Nullable TransferListener mediaTransferListener); + SourceInfoRefreshListener listener, @Nullable TransferListener mediaTransferListener); /** * Throws any pending error encountered while loading or refreshing source information. @@ -261,9 +273,10 @@ public interface MediaSource { * * @param id The identifier of the period. * @param allocator An {@link Allocator} from which to obtain media buffer allocations. + * @param startPositionUs The expected start position, in microseconds. * @return A new {@link MediaPeriod}. */ - MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator); + MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs); /** * Releases the period. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaSource.java index fdeb2b6184..1ea3404e81 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaSource.java @@ -120,13 +120,13 @@ public final class MergingMediaSource extends CompositeMediaSource { } @Override - public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { MediaPeriod[] periods = new MediaPeriod[mediaSources.length]; int periodIndex = timelines[0].getIndexOfPeriod(id.periodUid); for (int i = 0; i < periods.length; i++) { MediaPeriodId childMediaPeriodId = id.copyWithPeriodUid(timelines[i].getUidOfPeriod(periodIndex)); - periods[i] = mediaSources[i].createPeriod(childMediaPeriodId, allocator); + periods[i] = mediaSources[i].createPeriod(childMediaPeriodId, allocator, startPositionUs); } return new MergingMediaPeriod(compositeSequenceableLoaderFactory, periods); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java similarity index 97% rename from library/core/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaPeriod.java rename to library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java index 21cb4aa226..ab14554a21 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java @@ -54,12 +54,13 @@ import java.io.IOException; import java.util.Arrays; import org.checkerframework.checker.nullness.compatqual.NullableType; -/** - * A {@link MediaPeriod} that extracts data using an {@link Extractor}. - */ -/* package */ final class ExtractorMediaPeriod implements MediaPeriod, ExtractorOutput, - Loader.Callback, Loader.ReleaseCallback, - UpstreamFormatChangedListener { +/** A {@link MediaPeriod} that extracts data using an {@link Extractor}. */ +/* package */ final class ProgressiveMediaPeriod + implements MediaPeriod, + ExtractorOutput, + Loader.Callback, + Loader.ReleaseCallback, + UpstreamFormatChangedListener { /** * Listener for information about the period. @@ -145,7 +146,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; "nullness:argument.type.incompatible", "nullness:methodref.receiver.bound.invalid" }) - public ExtractorMediaPeriod( + public ProgressiveMediaPeriod( Uri uri, DataSource dataSource, Extractor[] extractors, @@ -163,14 +164,15 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; this.allocator = allocator; this.customCacheKey = customCacheKey; this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes; - loader = new Loader("Loader:ExtractorMediaPeriod"); + loader = new Loader("Loader:ProgressiveMediaPeriod"); extractorHolder = new ExtractorHolder(extractors); loadCondition = new ConditionVariable(); maybeFinishPrepareRunnable = this::maybeFinishPrepare; onContinueLoadingRequestedRunnable = () -> { if (!released) { - Assertions.checkNotNull(callback).onContinueLoadingRequested(ExtractorMediaPeriod.this); + Assertions.checkNotNull(callback) + .onContinueLoadingRequested(ProgressiveMediaPeriod.this); } }; handler = new Handler(); @@ -356,18 +358,18 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } else if (isPendingReset()) { return pendingResetPositionUs; } - long largestQueuedTimestampUs; + long largestQueuedTimestampUs = Long.MAX_VALUE; if (haveAudioVideoTracks) { // Ignore non-AV tracks, which may be sparse or poorly interleaved. - largestQueuedTimestampUs = Long.MAX_VALUE; int trackCount = sampleQueues.length; for (int i = 0; i < trackCount; i++) { - if (trackIsAudioVideoFlags[i]) { + if (trackIsAudioVideoFlags[i] && !sampleQueues[i].isLastSampleQueued()) { largestQueuedTimestampUs = Math.min(largestQueuedTimestampUs, sampleQueues[i].getLargestQueuedTimestampUs()); } } - } else { + } + if (largestQueuedTimestampUs == Long.MAX_VALUE) { largestQueuedTimestampUs = getLargestQueuedTimestampUs(); } return largestQueuedTimestampUs == Long.MIN_VALUE ? lastSeekPositionUs @@ -851,23 +853,23 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; @Override public boolean isReady() { - return ExtractorMediaPeriod.this.isReady(track); + return ProgressiveMediaPeriod.this.isReady(track); } @Override public void maybeThrowError() throws IOException { - ExtractorMediaPeriod.this.maybeThrowError(); + ProgressiveMediaPeriod.this.maybeThrowError(); } @Override public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, boolean formatRequired) { - return ExtractorMediaPeriod.this.readData(track, formatHolder, buffer, formatRequired); + return ProgressiveMediaPeriod.this.readData(track, formatHolder, buffer, formatRequired); } @Override public int skipData(long positionUs) { - return ExtractorMediaPeriod.this.skipData(track, positionUs); + return ProgressiveMediaPeriod.this.skipData(track, positionUs); } } @@ -988,7 +990,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; position, C.LENGTH_UNSET, customCacheKey, - DataSpec.FLAG_ALLOW_ICY_METADATA | DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN); + DataSpec.FLAG_ALLOW_ICY_METADATA + | DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN + | DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION); } private void setLoadPosition(long position, long timeUs) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java new file mode 100644 index 0000000000..dac340cede --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java @@ -0,0 +1,281 @@ +/* + * 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.source; + +import android.net.Uri; +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; +import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.extractor.ExtractorsFactory; +import com.google.android.exoplayer2.source.ads.AdsMediaSource; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; + +/** + * Provides one period that loads data from a {@link Uri} and extracted using an {@link Extractor}. + * + *

    If the possible input stream container formats are known, pass a factory that instantiates + * extractors for them to the constructor. Otherwise, pass a {@link DefaultExtractorsFactory} to use + * the default extractors. When reading a new stream, the first {@link Extractor} in the array of + * extractors created by the factory that returns {@code true} from {@link Extractor#sniff} will be + * used to extract samples from the input stream. + * + *

    Note that the built-in extractor for FLV streams does not support seeking. + */ +public final class ProgressiveMediaSource extends BaseMediaSource + implements ProgressiveMediaPeriod.Listener { + + /** Factory for {@link ProgressiveMediaSource}s. */ + public static final class Factory implements AdsMediaSource.MediaSourceFactory { + + private final DataSource.Factory dataSourceFactory; + + @Nullable private ExtractorsFactory extractorsFactory; + @Nullable private String customCacheKey; + @Nullable private Object tag; + private LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private int continueLoadingCheckIntervalBytes; + private boolean isCreateCalled; + + /** + * Creates a new factory for {@link ProgressiveMediaSource}s. + * + * @param dataSourceFactory A factory for {@link DataSource}s to read the media. + */ + public Factory(DataSource.Factory dataSourceFactory) { + this.dataSourceFactory = dataSourceFactory; + loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); + continueLoadingCheckIntervalBytes = DEFAULT_LOADING_CHECK_INTERVAL_BYTES; + } + + /** + * Sets the factory for {@link Extractor}s to process the media stream. The default value is an + * instance of {@link DefaultExtractorsFactory}. + * + * @param extractorsFactory A factory for {@link Extractor}s to process the media stream. If the + * possible formats are known, pass a factory that instantiates extractors for those + * formats. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setExtractorsFactory(ExtractorsFactory extractorsFactory) { + Assertions.checkState(!isCreateCalled); + this.extractorsFactory = extractorsFactory; + return this; + } + + /** + * Sets the custom key that uniquely identifies the original stream. Used for cache indexing. + * The default value is {@code null}. + * + * @param customCacheKey A custom key that uniquely identifies the original stream. Used for + * cache indexing. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setCustomCacheKey(String customCacheKey) { + Assertions.checkState(!isCreateCalled); + this.customCacheKey = customCacheKey; + return this; + } + + /** + * Sets a tag for the media source which will be published in the {@link + * com.google.android.exoplayer2.Timeline} of the source as {@link + * com.google.android.exoplayer2.Timeline.Window#tag}. + * + * @param tag A tag for the media source. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setTag(Object tag) { + Assertions.checkState(!isCreateCalled); + this.tag = tag; + return this; + } + + /** + * Sets the {@link LoadErrorHandlingPolicy}. The default value is created by calling {@link + * DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy()}. + * + * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + Assertions.checkState(!isCreateCalled); + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + return this; + } + + /** + * Sets the number of bytes that should be loaded between each invocation of {@link + * MediaPeriod.Callback#onContinueLoadingRequested(SequenceableLoader)}. The default value is + * {@link #DEFAULT_LOADING_CHECK_INTERVAL_BYTES}. + * + * @param continueLoadingCheckIntervalBytes The number of bytes that should be loaded between + * each invocation of {@link + * MediaPeriod.Callback#onContinueLoadingRequested(SequenceableLoader)}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setContinueLoadingCheckIntervalBytes(int continueLoadingCheckIntervalBytes) { + Assertions.checkState(!isCreateCalled); + this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes; + return this; + } + + /** + * Returns a new {@link ProgressiveMediaSource} using the current parameters. + * + * @param uri The {@link Uri}. + * @return The new {@link ProgressiveMediaSource}. + */ + @Override + public ProgressiveMediaSource createMediaSource(Uri uri) { + isCreateCalled = true; + if (extractorsFactory == null) { + extractorsFactory = new DefaultExtractorsFactory(); + } + return new ProgressiveMediaSource( + uri, + dataSourceFactory, + extractorsFactory, + loadErrorHandlingPolicy, + customCacheKey, + continueLoadingCheckIntervalBytes, + tag); + } + + @Override + public int[] getSupportedTypes() { + return new int[] {C.TYPE_OTHER}; + } + } + + /** + * The default number of bytes that should be loaded between each each invocation of {@link + * MediaPeriod.Callback#onContinueLoadingRequested(SequenceableLoader)}. + */ + public static final int DEFAULT_LOADING_CHECK_INTERVAL_BYTES = 1024 * 1024; + + private final Uri uri; + private final DataSource.Factory dataSourceFactory; + private final ExtractorsFactory extractorsFactory; + private final LoadErrorHandlingPolicy loadableLoadErrorHandlingPolicy; + @Nullable private final String customCacheKey; + private final int continueLoadingCheckIntervalBytes; + @Nullable private final Object tag; + + private long timelineDurationUs; + private boolean timelineIsSeekable; + @Nullable private TransferListener transferListener; + + // TODO: Make private when ExtractorMediaSource is deleted. + /* package */ ProgressiveMediaSource( + Uri uri, + DataSource.Factory dataSourceFactory, + ExtractorsFactory extractorsFactory, + LoadErrorHandlingPolicy loadableLoadErrorHandlingPolicy, + @Nullable String customCacheKey, + int continueLoadingCheckIntervalBytes, + @Nullable Object tag) { + this.uri = uri; + this.dataSourceFactory = dataSourceFactory; + this.extractorsFactory = extractorsFactory; + this.loadableLoadErrorHandlingPolicy = loadableLoadErrorHandlingPolicy; + this.customCacheKey = customCacheKey; + this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes; + this.timelineDurationUs = C.TIME_UNSET; + this.tag = tag; + } + + @Override + @Nullable + public Object getTag() { + return tag; + } + + @Override + public void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + transferListener = mediaTransferListener; + notifySourceInfoRefreshed(timelineDurationUs, timelineIsSeekable); + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + // Do nothing. + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + DataSource dataSource = dataSourceFactory.createDataSource(); + if (transferListener != null) { + dataSource.addTransferListener(transferListener); + } + return new ProgressiveMediaPeriod( + uri, + dataSource, + extractorsFactory.createExtractors(), + loadableLoadErrorHandlingPolicy, + createEventDispatcher(id), + this, + allocator, + customCacheKey, + continueLoadingCheckIntervalBytes); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + ((ProgressiveMediaPeriod) mediaPeriod).release(); + } + + @Override + public void releaseSourceInternal() { + // Do nothing. + } + + // ProgressiveMediaPeriod.Listener implementation. + + @Override + public void onSourceInfoRefreshed(long durationUs, boolean isSeekable) { + // If we already have the duration from a previous source info refresh, use it. + durationUs = durationUs == C.TIME_UNSET ? timelineDurationUs : durationUs; + if (timelineDurationUs == durationUs && timelineIsSeekable == isSeekable) { + // Suppress no-op source info changes. + return; + } + notifySourceInfoRefreshed(durationUs, isSeekable); + } + + // Internal methods. + + private void notifySourceInfoRefreshed(long durationUs, boolean isSeekable) { + timelineDurationUs = durationUs; + timelineIsSeekable = isSeekable; + // TODO: Make timeline dynamic until its duration is known. This is non-trivial. See b/69703223. + refreshSourceInfo( + new SinglePeriodTimeline( + timelineDurationUs, timelineIsSeekable, /* isDynamic= */ false, tag), + /* manifest= */ null); + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleMetadataQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleMetadataQueue.java index e5b950cf2e..ab5c5e57d9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleMetadataQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleMetadataQueue.java @@ -57,6 +57,7 @@ import com.google.android.exoplayer2.util.Util; private long largestDiscardedTimestampUs; private long largestQueuedTimestampUs; + private boolean isLastSampleQueued; private boolean upstreamKeyframeRequired; private boolean upstreamFormatRequired; private Format upstreamFormat; @@ -93,6 +94,7 @@ import com.google.android.exoplayer2.util.Util; upstreamKeyframeRequired = true; largestDiscardedTimestampUs = Long.MIN_VALUE; largestQueuedTimestampUs = Long.MIN_VALUE; + isLastSampleQueued = false; if (resetUpstreamFormat) { upstreamFormat = null; upstreamFormatRequired = true; @@ -118,6 +120,7 @@ import com.google.android.exoplayer2.util.Util; Assertions.checkArgument(0 <= discardCount && discardCount <= (length - readPosition)); length -= discardCount; largestQueuedTimestampUs = Math.max(largestDiscardedTimestampUs, getLargestTimestamp(length)); + isLastSampleQueued = discardCount == 0 && isLastSampleQueued; if (length == 0) { return 0; } else { @@ -186,6 +189,19 @@ import com.google.android.exoplayer2.util.Util; return largestQueuedTimestampUs; } + /** + * Returns whether the last sample of the stream has knowingly been queued. A return value of + * {@code false} means that the last sample had not been queued or that it's unknown whether the + * last sample has been queued. + * + *

    Samples that were discarded by calling {@link #discardUpstreamSamples(int)} are not + * considered as having been queued. Samples that were dequeued from the front of the queue are + * considered as having been queued. + */ + public synchronized boolean isLastSampleQueued() { + return isLastSampleQueued; + } + /** Returns the timestamp of the first sample, or {@link Long#MIN_VALUE} if the queue is empty. */ public synchronized long getFirstTimestampUs() { return length == 0 ? Long.MIN_VALUE : timesUs[relativeFirstIndex]; @@ -224,7 +240,7 @@ import com.google.android.exoplayer2.util.Util; boolean formatRequired, boolean loadingFinished, Format downstreamFormat, SampleExtrasHolder extrasHolder) { if (!hasNextSample()) { - if (loadingFinished) { + if (loadingFinished || isLastSampleQueued) { buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); return C.RESULT_BUFFER_READ; } else if (upstreamFormat != null @@ -388,7 +404,9 @@ import com.google.android.exoplayer2.util.Util; upstreamKeyframeRequired = false; } Assertions.checkState(!upstreamFormatRequired); - commitSampleTimestamp(timeUs); + + isLastSampleQueued = (sampleFlags & C.BUFFER_FLAG_LAST_SAMPLE) != 0; + largestQueuedTimestampUs = Math.max(largestQueuedTimestampUs, timeUs); int relativeEndIndex = getRelativeIndex(length); timesUs[relativeEndIndex] = timeUs; @@ -439,10 +457,6 @@ import com.google.android.exoplayer2.util.Util; } } - public synchronized void commitSampleTimestamp(long timeUs) { - largestQueuedTimestampUs = Math.max(largestQueuedTimestampUs, timeUs); - } - /** * Attempts to discard samples from the end of the queue to allow samples starting from the * specified timestamp to be spliced in. Samples will not be discarded prior to the read position. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java index ecc720c656..0886e79d21 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java @@ -224,6 +224,15 @@ public class SampleQueue implements TrackOutput { return metadataQueue.getLargestQueuedTimestampUs(); } + /** + * Returns whether the last sample of the stream has knowingly been queued. A return value of + * {@code false} means that the last sample had not been queued or that it's unknown whether the + * last sample has been queued. + */ + public boolean isLastSampleQueued() { + return metadataQueue.isLastSampleQueued(); + } + /** Returns the timestamp of the first sample, or {@link Long#MIN_VALUE} if the queue is empty. */ public long getFirstTimestampUs() { return metadataQueue.getFirstTimestampUs(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java index 358875eb1e..7611d76260 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java @@ -138,12 +138,12 @@ public final class SingleSampleMediaSource extends BaseMediaSource { } /** - * Returns a new {@link ExtractorMediaSource} using the current parameters. + * Returns a new {@link SingleSampleMediaSource} using the current parameters. * * @param uri The {@link Uri}. * @param format The {@link Format} of the media stream. * @param durationUs The duration of the media stream in microseconds. - * @return The new {@link ExtractorMediaSource}. + * @return The new {@link SingleSampleMediaSource}. */ public SingleSampleMediaSource createMediaSource(Uri uri, Format format, long durationUs) { isCreateCalled = true; @@ -313,7 +313,7 @@ public final class SingleSampleMediaSource extends BaseMediaSource { } @Override - public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { return new SingleSampleMediaPeriod( dataSpec, dataSourceFactory, diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java index 663f9c64fc..e80c797eb9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java @@ -25,13 +25,13 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.CompositeMediaSource; import com.google.android.exoplayer2.source.DeferredMediaPeriod; -import com.google.android.exoplayer2.source.ExtractorMediaSource; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.MediaSourceEventListener; import com.google.android.exoplayer2.source.MediaSourceEventListener.LoadEventInfo; import com.google.android.exoplayer2.source.MediaSourceEventListener.MediaLoadData; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; @@ -203,7 +203,7 @@ public final class AdsMediaSource extends CompositeMediaSource { /** * Constructs a new source that inserts ads linearly with the content specified by {@code - * contentMediaSource}. Ad media is loaded using {@link ExtractorMediaSource}. + * contentMediaSource}. Ad media is loaded using {@link ProgressiveMediaSource}. * * @param contentMediaSource The {@link MediaSource} providing the content to play. * @param dataSourceFactory Factory for data sources used to load ad media. @@ -217,7 +217,7 @@ public final class AdsMediaSource extends CompositeMediaSource { ViewGroup adUiViewGroup) { this( contentMediaSource, - new ExtractorMediaSource.Factory(dataSourceFactory), + new ProgressiveMediaSource.Factory(dataSourceFactory), adsLoader, adUiViewGroup, /* eventHandler= */ null, @@ -249,7 +249,7 @@ public final class AdsMediaSource extends CompositeMediaSource { /** * Constructs a new source that inserts ads linearly with the content specified by {@code - * contentMediaSource}. Ad media is loaded using {@link ExtractorMediaSource}. + * contentMediaSource}. Ad media is loaded using {@link ProgressiveMediaSource}. * * @param contentMediaSource The {@link MediaSource} providing the content to play. * @param dataSourceFactory Factory for data sources used to load ad media. @@ -273,7 +273,7 @@ public final class AdsMediaSource extends CompositeMediaSource { @Nullable EventListener eventListener) { this( contentMediaSource, - new ExtractorMediaSource.Factory(dataSourceFactory), + new ProgressiveMediaSource.Factory(dataSourceFactory), adsLoader, adUiViewGroup, eventHandler, @@ -334,7 +334,7 @@ public final class AdsMediaSource extends CompositeMediaSource { } @Override - public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { if (adPlaybackState.adGroupCount > 0 && id.isAd()) { int adGroupIndex = id.adGroupIndex; int adIndexInAdGroup = id.adIndexInAdGroup; @@ -353,7 +353,8 @@ public final class AdsMediaSource extends CompositeMediaSource { prepareChildSource(id, adMediaSource); } MediaSource mediaSource = adGroupMediaSources[adGroupIndex][adIndexInAdGroup]; - DeferredMediaPeriod deferredMediaPeriod = new DeferredMediaPeriod(mediaSource, id, allocator); + DeferredMediaPeriod deferredMediaPeriod = + new DeferredMediaPeriod(mediaSource, id, allocator, startPositionUs); deferredMediaPeriod.setPrepareErrorListener( new AdPrepareErrorListener(adUri, adGroupIndex, adIndexInAdGroup)); List mediaPeriods = deferredMediaPeriodByAdMediaSource.get(mediaSource); @@ -369,7 +370,8 @@ public final class AdsMediaSource extends CompositeMediaSource { } return deferredMediaPeriod; } else { - DeferredMediaPeriod mediaPeriod = new DeferredMediaPeriod(contentMediaSource, id, allocator); + DeferredMediaPeriod mediaPeriod = + new DeferredMediaPeriod(contentMediaSource, id, allocator, startPositionUs); mediaPeriod.createPeriod(id); return mediaPeriod; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java index 9f05b2e183..d7e325628b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java @@ -431,6 +431,7 @@ public final class Cea608Decoder extends CeaDecoder { } private void handleCtrl(byte cc1, byte cc2, boolean repeatedControlPossible) { + incomingDataTargetChannel = getChannelBit(cc1); // Most control commands are sent twice in succession to ensure they are received properly. We diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java index b39a5d19f0..dd2d0001f8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java @@ -391,7 +391,7 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { this.minTimeBetweenBufferReevaluationMs = minTimeBetweenBufferReevaluationMs; this.clock = clock; playbackSpeed = 1f; - reason = C.SELECTION_REASON_INITIAL; + reason = C.SELECTION_REASON_UNKNOWN; lastBufferEvaluationMs = C.TIME_UNSET; trackBitrateEstimator = TrackBitrateEstimator.DEFAULT; formats = new Format[length]; @@ -403,9 +403,6 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { formats[i] = format; formatBitrates[i] = formats[i].bitrate; } - @SuppressWarnings("nullness:method.invocation.invalid") - int selectedIndex = determineIdealSelectedIndex(Long.MIN_VALUE, formatBitrates); - this.selectedIndex = selectedIndex; } /** @@ -453,6 +450,13 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { // Update the estimated track bitrates. trackBitrateEstimator.getBitrates(formats, queue, mediaChunkIterators, trackBitrates); + // Make initial selection + if (reason == C.SELECTION_REASON_UNKNOWN) { + reason = C.SELECTION_REASON_INITIAL; + selectedIndex = determineIdealSelectedIndex(nowMs, trackBitrates); + return; + } + // Stash the current selection, then make a new one. int currentSelectedIndex = selectedIndex; selectedIndex = determineIdealSelectedIndex(nowMs, trackBitrates); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/BufferSizeAdaptationBuilder.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/BufferSizeAdaptationBuilder.java index 5c8350cb1d..ee1d1c62da 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/BufferSizeAdaptationBuilder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/BufferSizeAdaptationBuilder.java @@ -43,7 +43,7 @@ public final class BufferSizeAdaptationBuilder { public interface DynamicFormatFilter { /** Filter which allows all formats. */ - DynamicFormatFilter NO_FILTER = (format, trackBitrate) -> true; + DynamicFormatFilter NO_FILTER = (format, trackBitrate, isInitialSelection) -> true; /** * Called when updating the selected track to determine whether a candidate track is allowed. If @@ -52,8 +52,9 @@ public final class BufferSizeAdaptationBuilder { * @param format The {@link Format} of the candidate track. * @param trackBitrate The estimated bitrate of the track. May differ from {@link * Format#bitrate} if a more accurate estimate of the current track bitrate is available. + * @param isInitialSelection Whether this is for the initial track selection. */ - boolean isFormatAllowed(Format format, int trackBitrate); + boolean isFormatAllowed(Format format, int trackBitrate, boolean isInitialSelection); } /** @@ -344,7 +345,7 @@ public final class BufferSizeAdaptationBuilder { formatBitrates = new int[length]; maxBitrate = getFormat(/* index= */ 0).bitrate; minBitrate = getFormat(/* index= */ length - 1).bitrate; - selectionReason = C.SELECTION_REASON_INITIAL; + selectionReason = C.SELECTION_REASON_UNKNOWN; playbackSpeed = 1.0f; // We use a log-linear function to map from bitrate to buffer size: @@ -354,9 +355,6 @@ public final class BufferSizeAdaptationBuilder { (maxBufferUs - hysteresisBufferUs - minBufferUs) / Math.log(maxBitrate / minBitrate); bitrateToBufferFunctionIntercept = minBufferUs - bitrateToBufferFunctionSlope * Math.log(minBitrate); - - updateFormatBitrates(/* nowMs= */ Long.MIN_VALUE); - selectedIndex = selectIdealIndexUsingBandwidth(); } @Override @@ -393,6 +391,14 @@ public final class BufferSizeAdaptationBuilder { List queue, MediaChunkIterator[] mediaChunkIterators) { updateFormatBitrates(/* nowMs= */ clock.elapsedRealtime()); + + // Make initial selection + if (selectionReason == C.SELECTION_REASON_UNKNOWN) { + selectionReason = C.SELECTION_REASON_INITIAL; + selectedIndex = selectIdealIndexUsingBandwidth(/* isInitialSelection= */ true); + return; + } + long bufferUs = getCurrentPeriodBufferedDurationUs(playbackPositionUs, bufferedDurationUs); int oldSelectedIndex = selectedIndex; if (isInSteadyState) { @@ -428,7 +434,8 @@ public final class BufferSizeAdaptationBuilder { for (int i = 0; i < formatBitrates.length; i++) { if (formatBitrates[i] != BITRATE_BLACKLISTED) { if (getTargetBufferForBitrateUs(formatBitrates[i]) < bufferUs - && dynamicFormatFilter.isFormatAllowed(getFormat(i), formatBitrates[i])) { + && dynamicFormatFilter.isFormatAllowed( + getFormat(i), formatBitrates[i], /* isInitialSelection= */ false)) { return i; } lowestBitrateNonBlacklistedIndex = i; @@ -440,7 +447,7 @@ public final class BufferSizeAdaptationBuilder { // Startup. private void selectIndexStartUpPhase(long bufferUs) { - int startUpSelectedIndex = selectIdealIndexUsingBandwidth(); + int startUpSelectedIndex = selectIdealIndexUsingBandwidth(/* isInitialSelection= */ false); int steadyStateSelectedIndex = selectIdealIndexUsingBufferSize(bufferUs); if (steadyStateSelectedIndex <= selectedIndex) { // Switch to steady state if we have enough buffer to maintain current selection. @@ -457,14 +464,15 @@ public final class BufferSizeAdaptationBuilder { } } - private int selectIdealIndexUsingBandwidth() { + private int selectIdealIndexUsingBandwidth(boolean isInitialSelection) { long effectiveBitrate = (long) (bandwidthMeter.getBitrateEstimate() * startUpBandwidthFraction); int lowestBitrateNonBlacklistedIndex = 0; for (int i = 0; i < formatBitrates.length; i++) { if (formatBitrates[i] != BITRATE_BLACKLISTED) { if (Math.round(formatBitrates[i] * playbackSpeed) <= effectiveBitrate - && dynamicFormatFilter.isFormatAllowed(getFormat(i), formatBitrates[i])) { + && dynamicFormatFilter.isFormatAllowed( + getFormat(i), formatBitrates[i], isInitialSelection)) { return i; } lowestBitrateNonBlacklistedIndex = i; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java index 06b11b3d67..43157c5866 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java @@ -159,10 +159,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { * A builder for {@link Parameters}. See the {@link Parameters} documentation for explanations of * the parameters that can be configured using this builder. */ - public static final class ParametersBuilder { - - private final SparseArray> selectionOverrides; - private final SparseBooleanArray rendererDisabledFlags; + public static final class ParametersBuilder extends TrackSelectionParameters.Builder { // Video private int maxVideoWidth; @@ -176,22 +173,20 @@ public class DefaultTrackSelector extends MappingTrackSelector { private int viewportHeight; private boolean viewportOrientationMayChange; // Audio - @Nullable private String preferredAudioLanguage; private int maxAudioChannelCount; private int maxAudioBitrate; private boolean exceedAudioConstraintsIfNecessary; private boolean allowAudioMixedMimeTypeAdaptiveness; private boolean allowAudioMixedSampleRateAdaptiveness; - // Text - @Nullable private String preferredTextLanguage; - private boolean selectUndeterminedTextLanguage; - private int disabledTextTrackSelectionFlags; // General private boolean forceLowestBitrate; private boolean forceHighestSupportedBitrate; private boolean exceedRendererCapabilitiesIfNecessary; private int tunnelingAudioSessionId; + private final SparseArray> selectionOverrides; + private final SparseBooleanArray rendererDisabledFlags; + /** Creates a builder with default initial values. */ public ParametersBuilder() { this(Parameters.DEFAULT); @@ -202,6 +197,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { * obtained. */ private ParametersBuilder(Parameters initialValues) { + super(initialValues); // Video maxVideoWidth = initialValues.maxVideoWidth; maxVideoHeight = initialValues.maxVideoHeight; @@ -214,16 +210,11 @@ public class DefaultTrackSelector extends MappingTrackSelector { viewportHeight = initialValues.viewportHeight; viewportOrientationMayChange = initialValues.viewportOrientationMayChange; // Audio - preferredAudioLanguage = initialValues.preferredAudioLanguage; maxAudioChannelCount = initialValues.maxAudioChannelCount; maxAudioBitrate = initialValues.maxAudioBitrate; exceedAudioConstraintsIfNecessary = initialValues.exceedAudioConstraintsIfNecessary; allowAudioMixedMimeTypeAdaptiveness = initialValues.allowAudioMixedMimeTypeAdaptiveness; allowAudioMixedSampleRateAdaptiveness = initialValues.allowAudioMixedSampleRateAdaptiveness; - // Text - preferredTextLanguage = initialValues.preferredTextLanguage; - selectUndeterminedTextLanguage = initialValues.selectUndeterminedTextLanguage; - disabledTextTrackSelectionFlags = initialValues.disabledTextTrackSelectionFlags; // General forceLowestBitrate = initialValues.forceLowestBitrate; forceHighestSupportedBitrate = initialValues.forceHighestSupportedBitrate; @@ -362,13 +353,9 @@ public class DefaultTrackSelector extends MappingTrackSelector { // Audio - /** - * See {@link Parameters#preferredAudioLanguage}. - * - * @return This builder. - */ - public ParametersBuilder setPreferredAudioLanguage(String preferredAudioLanguage) { - this.preferredAudioLanguage = preferredAudioLanguage; + @Override + public ParametersBuilder setPreferredAudioLanguage(@Nullable String preferredAudioLanguage) { + super.setPreferredAudioLanguage(preferredAudioLanguage); return this; } @@ -427,38 +414,25 @@ public class DefaultTrackSelector extends MappingTrackSelector { // Text - /** - * See {@link Parameters#preferredTextLanguage}. - * - * @return This builder. - */ - public ParametersBuilder setPreferredTextLanguage(String preferredTextLanguage) { - this.preferredTextLanguage = preferredTextLanguage; + @Override + public ParametersBuilder setPreferredTextLanguage(@Nullable String preferredTextLanguage) { + super.setPreferredTextLanguage(preferredTextLanguage); return this; } - /** - * See {@link Parameters#selectUndeterminedTextLanguage}. - * - * @return This builder. - */ + @Override public ParametersBuilder setSelectUndeterminedTextLanguage( boolean selectUndeterminedTextLanguage) { - this.selectUndeterminedTextLanguage = selectUndeterminedTextLanguage; + super.setSelectUndeterminedTextLanguage(selectUndeterminedTextLanguage); return this; } - /** - * See {@link Parameters#disabledTextTrackSelectionFlags}. - * - * @return This builder. - */ + @Override public ParametersBuilder setDisabledTextTrackSelectionFlags( - int disabledTextTrackSelectionFlags) { - this.disabledTextTrackSelectionFlags = disabledTextTrackSelectionFlags; + @C.SelectionFlags int disabledTextTrackSelectionFlags) { + super.setDisabledTextTrackSelectionFlags(disabledTextTrackSelectionFlags); return this; } - // General /** @@ -522,10 +496,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { * C#AUDIO_SESSION_ID_UNSET} to disable tunneling. */ public ParametersBuilder setTunnelingAudioSessionId(int tunnelingAudioSessionId) { - if (this.tunnelingAudioSessionId != tunnelingAudioSessionId) { - this.tunnelingAudioSessionId = tunnelingAudioSessionId; - return this; - } + this.tunnelingAudioSessionId = tunnelingAudioSessionId; return this; } @@ -666,7 +637,6 @@ public class DefaultTrackSelector extends MappingTrackSelector { forceHighestSupportedBitrate, exceedRendererCapabilitiesIfNecessary, tunnelingAudioSessionId, - // Overrides selectionOverrides, rendererDisabledFlags); } @@ -681,16 +651,15 @@ public class DefaultTrackSelector extends MappingTrackSelector { } } - /** Constraint parameters for {@link DefaultTrackSelector}. */ - public static final class Parameters implements Parcelable { + /** + * Extends {@link TrackSelectionParameters} by adding fields that are specific to {@link + * DefaultTrackSelector}. + */ + public static final class Parameters extends TrackSelectionParameters { /** An instance with default values. */ public static final Parameters DEFAULT = new Parameters(); - // Overrides - private final SparseArray> selectionOverrides; - private final SparseBooleanArray rendererDisabledFlags; - // Video /** * Maximum allowed video width. The default value is {@link Integer#MAX_VALUE} (i.e. no @@ -755,14 +724,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { * The default value is {@code true}. */ public final boolean viewportOrientationMayChange; - // Audio - /** - * The preferred language for audio and forced text tracks, as an ISO 639-2/T tag. {@code null} - * selects the default track, or the first track if there's no default. The default value is - * {@code null}. - */ - @Nullable public final String preferredAudioLanguage; /** * Maximum allowed audio channel count. The default value is {@link Integer#MAX_VALUE} (i.e. no * constraint). @@ -788,24 +750,6 @@ public class DefaultTrackSelector extends MappingTrackSelector { */ public final boolean allowAudioMixedSampleRateAdaptiveness; - // Text - /** - * The preferred language for text tracks as an ISO 639-2/T tag. {@code null} selects the - * default track if there is one, or no track otherwise. The default value is {@code null}. - */ - @Nullable public final String preferredTextLanguage; - /** - * Whether a text track with undetermined language should be selected if no track with {@link - * #preferredTextLanguage} is available, or if {@link #preferredTextLanguage} is unset. The - * default value is {@code false}. - */ - public final boolean selectUndeterminedTextLanguage; - /** - * Bitmask of selection flags that are disabled for text track selections. See {@link - * C.SelectionFlags}. The default value is {@code 0} (i.e. no flags). - */ - public final int disabledTextTrackSelectionFlags; - // General /** * Whether to force selection of the single lowest bitrate audio and video tracks that comply @@ -841,6 +785,10 @@ public class DefaultTrackSelector extends MappingTrackSelector { */ public final int tunnelingAudioSessionId; + // Overrides + private final SparseArray> selectionOverrides; + private final SparseBooleanArray rendererDisabledFlags; + private Parameters() { this( // Video @@ -855,24 +803,23 @@ public class DefaultTrackSelector extends MappingTrackSelector { /* viewportHeight= */ Integer.MAX_VALUE, /* viewportOrientationMayChange= */ true, // Audio - /* preferredAudioLanguage= */ null, + TrackSelectionParameters.DEFAULT.preferredAudioLanguage, /* maxAudioChannelCount= */ Integer.MAX_VALUE, /* maxAudioBitrate= */ Integer.MAX_VALUE, /* exceedAudioConstraintsIfNecessary= */ true, /* allowAudioMixedMimeTypeAdaptiveness= */ false, /* allowAudioMixedSampleRateAdaptiveness= */ false, // Text - /* preferredTextLanguage= */ null, - /* selectUndeterminedTextLanguage= */ false, - /* disabledTextTrackSelectionFlags= */ 0, + TrackSelectionParameters.DEFAULT.preferredTextLanguage, + TrackSelectionParameters.DEFAULT.selectUndeterminedTextLanguage, + TrackSelectionParameters.DEFAULT.disabledTextTrackSelectionFlags, // General /* forceLowestBitrate= */ false, /* forceHighestSupportedBitrate= */ false, /* exceedRendererCapabilitiesIfNecessary= */ true, /* tunnelingAudioSessionId= */ C.AUDIO_SESSION_ID_UNSET, - // Overrides - /* selectionOverrides= */ new SparseArray<>(), - /* rendererDisabledFlags= */ new SparseBooleanArray()); + new SparseArray<>(), + new SparseBooleanArray()); } /* package */ Parameters( @@ -897,7 +844,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { // Text @Nullable String preferredTextLanguage, boolean selectUndeterminedTextLanguage, - int disabledTextTrackSelectionFlags, + @C.SelectionFlags int disabledTextTrackSelectionFlags, // General boolean forceLowestBitrate, boolean forceHighestSupportedBitrate, @@ -906,6 +853,11 @@ public class DefaultTrackSelector extends MappingTrackSelector { // Overrides SparseArray> selectionOverrides, SparseBooleanArray rendererDisabledFlags) { + super( + preferredAudioLanguage, + preferredTextLanguage, + selectUndeterminedTextLanguage, + disabledTextTrackSelectionFlags); // Video this.maxVideoWidth = maxVideoWidth; this.maxVideoHeight = maxVideoHeight; @@ -918,30 +870,26 @@ public class DefaultTrackSelector extends MappingTrackSelector { this.viewportHeight = viewportHeight; this.viewportOrientationMayChange = viewportOrientationMayChange; // Audio - this.preferredAudioLanguage = Util.normalizeLanguageCode(preferredAudioLanguage); this.maxAudioChannelCount = maxAudioChannelCount; this.maxAudioBitrate = maxAudioBitrate; this.exceedAudioConstraintsIfNecessary = exceedAudioConstraintsIfNecessary; this.allowAudioMixedMimeTypeAdaptiveness = allowAudioMixedMimeTypeAdaptiveness; this.allowAudioMixedSampleRateAdaptiveness = allowAudioMixedSampleRateAdaptiveness; - // Text - this.preferredTextLanguage = Util.normalizeLanguageCode(preferredTextLanguage); - this.selectUndeterminedTextLanguage = selectUndeterminedTextLanguage; - this.disabledTextTrackSelectionFlags = disabledTextTrackSelectionFlags; // General this.forceLowestBitrate = forceLowestBitrate; this.forceHighestSupportedBitrate = forceHighestSupportedBitrate; this.exceedRendererCapabilitiesIfNecessary = exceedRendererCapabilitiesIfNecessary; this.tunnelingAudioSessionId = tunnelingAudioSessionId; - // Overrides - this.selectionOverrides = selectionOverrides; - this.rendererDisabledFlags = rendererDisabledFlags; // Deprecated fields. this.allowMixedMimeAdaptiveness = allowVideoMixedMimeTypeAdaptiveness; this.allowNonSeamlessAdaptiveness = allowVideoNonSeamlessAdaptiveness; + // Overrides + this.selectionOverrides = selectionOverrides; + this.rendererDisabledFlags = rendererDisabledFlags; } /* package */ Parameters(Parcel in) { + super(in); // Video this.maxVideoWidth = in.readInt(); this.maxVideoHeight = in.readInt(); @@ -954,16 +902,11 @@ public class DefaultTrackSelector extends MappingTrackSelector { this.viewportHeight = in.readInt(); this.viewportOrientationMayChange = Util.readBoolean(in); // Audio - this.preferredAudioLanguage = in.readString(); this.maxAudioChannelCount = in.readInt(); this.maxAudioBitrate = in.readInt(); this.exceedAudioConstraintsIfNecessary = Util.readBoolean(in); this.allowAudioMixedMimeTypeAdaptiveness = Util.readBoolean(in); this.allowAudioMixedSampleRateAdaptiveness = Util.readBoolean(in); - // Text - this.preferredTextLanguage = in.readString(); - this.selectUndeterminedTextLanguage = Util.readBoolean(in); - this.disabledTextTrackSelectionFlags = in.readInt(); // General this.forceLowestBitrate = Util.readBoolean(in); this.forceHighestSupportedBitrate = Util.readBoolean(in); @@ -1012,9 +955,8 @@ public class DefaultTrackSelector extends MappingTrackSelector { return overrides != null ? overrides.get(groups) : null; } - /** - * Creates a new {@link ParametersBuilder}, copying the initial values from this instance. - */ + /** Creates a new {@link ParametersBuilder}, copying the initial values from this instance. */ + @Override public ParametersBuilder buildUpon() { return new ParametersBuilder(this); } @@ -1028,7 +970,9 @@ public class DefaultTrackSelector extends MappingTrackSelector { return false; } Parameters other = (Parameters) obj; - return maxVideoWidth == other.maxVideoWidth + return super.equals(obj) + // Video + && maxVideoWidth == other.maxVideoWidth && maxVideoHeight == other.maxVideoHeight && maxVideoFrameRate == other.maxVideoFrameRate && maxVideoBitrate == other.maxVideoBitrate @@ -1039,16 +983,11 @@ public class DefaultTrackSelector extends MappingTrackSelector { && viewportWidth == other.viewportWidth && viewportHeight == other.viewportHeight // Audio - && TextUtils.equals(preferredAudioLanguage, other.preferredAudioLanguage) && maxAudioChannelCount == other.maxAudioChannelCount && maxAudioBitrate == other.maxAudioBitrate && exceedAudioConstraintsIfNecessary == other.exceedAudioConstraintsIfNecessary && allowAudioMixedMimeTypeAdaptiveness == other.allowAudioMixedMimeTypeAdaptiveness && allowAudioMixedSampleRateAdaptiveness == other.allowAudioMixedSampleRateAdaptiveness - // Text - && TextUtils.equals(preferredTextLanguage, other.preferredTextLanguage) - && selectUndeterminedTextLanguage == other.selectUndeterminedTextLanguage - && disabledTextTrackSelectionFlags == other.disabledTextTrackSelectionFlags // General && forceLowestBitrate == other.forceLowestBitrate && forceHighestSupportedBitrate == other.forceHighestSupportedBitrate @@ -1061,7 +1000,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { @Override public int hashCode() { - int result = 1; + int result = super.hashCode(); // Video result = 31 * result + maxVideoWidth; result = 31 * result + maxVideoHeight; @@ -1074,17 +1013,11 @@ public class DefaultTrackSelector extends MappingTrackSelector { result = 31 * result + viewportWidth; result = 31 * result + viewportHeight; // Audio - result = - 31 * result + (preferredAudioLanguage == null ? 0 : preferredAudioLanguage.hashCode()); result = 31 * result + maxAudioChannelCount; result = 31 * result + maxAudioBitrate; result = 31 * result + (exceedAudioConstraintsIfNecessary ? 1 : 0); result = 31 * result + (allowAudioMixedMimeTypeAdaptiveness ? 1 : 0); result = 31 * result + (allowAudioMixedSampleRateAdaptiveness ? 1 : 0); - // Text - result = 31 * result + (preferredTextLanguage == null ? 0 : preferredTextLanguage.hashCode()); - result = 31 * result + (selectUndeterminedTextLanguage ? 1 : 0); - result = 31 * result + disabledTextTrackSelectionFlags; // General result = 31 * result + (forceLowestBitrate ? 1 : 0); result = 31 * result + (forceHighestSupportedBitrate ? 1 : 0); @@ -1103,6 +1036,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { @Override public void writeToParcel(Parcel dest, int flags) { + super.writeToParcel(dest, flags); // Video dest.writeInt(maxVideoWidth); dest.writeInt(maxVideoHeight); @@ -1115,16 +1049,11 @@ public class DefaultTrackSelector extends MappingTrackSelector { dest.writeInt(viewportHeight); Util.writeBoolean(dest, viewportOrientationMayChange); // Audio - dest.writeString(preferredAudioLanguage); dest.writeInt(maxAudioChannelCount); dest.writeInt(maxAudioBitrate); Util.writeBoolean(dest, exceedAudioConstraintsIfNecessary); Util.writeBoolean(dest, allowAudioMixedMimeTypeAdaptiveness); Util.writeBoolean(dest, allowAudioMixedSampleRateAdaptiveness); - // Text - dest.writeString(preferredTextLanguage); - Util.writeBoolean(dest, selectUndeterminedTextLanguage); - dest.writeInt(disabledTextTrackSelectionFlags); // General Util.writeBoolean(dest, forceLowestBitrate); Util.writeBoolean(dest, forceHighestSupportedBitrate); @@ -2313,8 +2242,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { * null. */ protected static boolean formatHasLanguage(Format format, @Nullable String language) { - return language != null - && TextUtils.equals(language, Util.normalizeLanguageCode(format.language)); + return language != null && TextUtils.equals(language, format.language); } private static List getViewportFilteredTrackIndices(TrackGroup group, int viewportWidth, diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java new file mode 100644 index 0000000000..f411d431e2 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java @@ -0,0 +1,240 @@ +/* + * 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.trackselection; + +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.util.Util; + +/** Constraint parameters for track selection. */ +public class TrackSelectionParameters implements Parcelable { + + /** + * A builder for {@link TrackSelectionParameters}. See the {@link TrackSelectionParameters} + * documentation for explanations of the parameters that can be configured using this builder. + */ + public static class Builder { + + // Audio + @Nullable /* package */ String preferredAudioLanguage; + // Text + @Nullable /* package */ String preferredTextLanguage; + /* package */ boolean selectUndeterminedTextLanguage; + @C.SelectionFlags /* package */ int disabledTextTrackSelectionFlags; + + /** Creates a builder with default initial values. */ + public Builder() { + this(DEFAULT); + } + + /** + * @param initialValues The {@link TrackSelectionParameters} from which the initial values of + * the builder are obtained. + */ + /* package */ Builder(TrackSelectionParameters initialValues) { + // Audio + preferredAudioLanguage = initialValues.preferredAudioLanguage; + // Text + preferredTextLanguage = initialValues.preferredTextLanguage; + selectUndeterminedTextLanguage = initialValues.selectUndeterminedTextLanguage; + disabledTextTrackSelectionFlags = initialValues.disabledTextTrackSelectionFlags; + } + + /** + * See {@link TrackSelectionParameters#preferredAudioLanguage}. + * + * @param preferredAudioLanguage Preferred audio language as an ISO 639-1 two-letter or ISO + * 639-2 three-letter code. + * @return This builder. + */ + public Builder setPreferredAudioLanguage(@Nullable String preferredAudioLanguage) { + this.preferredAudioLanguage = preferredAudioLanguage; + return this; + } + + // Text + + /** + * See {@link TrackSelectionParameters#preferredTextLanguage}. + * + * @param preferredTextLanguage Preferred text language as an ISO 639-1 two-letter or ISO 639-2 + * three-letter code. + * @return This builder. + */ + public Builder setPreferredTextLanguage(@Nullable String preferredTextLanguage) { + this.preferredTextLanguage = preferredTextLanguage; + return this; + } + + /** + * See {@link TrackSelectionParameters#selectUndeterminedTextLanguage}. + * + * @return This builder. + */ + public Builder setSelectUndeterminedTextLanguage(boolean selectUndeterminedTextLanguage) { + this.selectUndeterminedTextLanguage = selectUndeterminedTextLanguage; + return this; + } + + /** + * See {@link TrackSelectionParameters#disabledTextTrackSelectionFlags}. + * + * @return This builder. + */ + public Builder setDisabledTextTrackSelectionFlags( + @C.SelectionFlags int disabledTextTrackSelectionFlags) { + this.disabledTextTrackSelectionFlags = disabledTextTrackSelectionFlags; + return this; + } + + /** Builds a {@link TrackSelectionParameters} instance with the selected values. */ + public TrackSelectionParameters build() { + return new TrackSelectionParameters( + // Audio + preferredAudioLanguage, + // Text + preferredTextLanguage, + selectUndeterminedTextLanguage, + disabledTextTrackSelectionFlags); + } + } + + /** An instance with default values. */ + public static final TrackSelectionParameters DEFAULT = new TrackSelectionParameters(); + + /** + * The preferred language for audio and forced text tracks, as an ISO 639-2/T tag. {@code null} + * selects the default track, or the first track if there's no default. The default value is + * {@code null}. + */ + @Nullable public final String preferredAudioLanguage; + // Text + /** + * The preferred language for text tracks as an ISO 639-2/T tag. {@code null} selects the default + * track if there is one, or no track otherwise. The default value is {@code null}. + */ + @Nullable public final String preferredTextLanguage; + /** + * Whether a text track with undetermined language should be selected if no track with {@link + * #preferredTextLanguage} is available, or if {@link #preferredTextLanguage} is unset. The + * default value is {@code false}. + */ + public final boolean selectUndeterminedTextLanguage; + /** + * Bitmask of selection flags that are disabled for text track selections. See {@link + * C.SelectionFlags}. The default value is {@code 0} (i.e. no flags). + */ + @C.SelectionFlags public final int disabledTextTrackSelectionFlags; + + /* package */ TrackSelectionParameters() { + this( + /* preferredAudioLanguage= */ null, + // Text + /* preferredTextLanguage= */ null, + /* selectUndeterminedTextLanguage= */ false, + /* disabledTextTrackSelectionFlags= */ 0); + } + + /* package */ TrackSelectionParameters( + @Nullable String preferredAudioLanguage, + @Nullable String preferredTextLanguage, + boolean selectUndeterminedTextLanguage, + @C.SelectionFlags int disabledTextTrackSelectionFlags) { + // Audio + this.preferredAudioLanguage = Util.normalizeLanguageCode(preferredAudioLanguage); + // Text + this.preferredTextLanguage = Util.normalizeLanguageCode(preferredTextLanguage); + this.selectUndeterminedTextLanguage = selectUndeterminedTextLanguage; + this.disabledTextTrackSelectionFlags = disabledTextTrackSelectionFlags; + } + + /* package */ TrackSelectionParameters(Parcel in) { + // Audio + this.preferredAudioLanguage = in.readString(); + // Text + this.preferredTextLanguage = in.readString(); + this.selectUndeterminedTextLanguage = Util.readBoolean(in); + this.disabledTextTrackSelectionFlags = in.readInt(); + } + + /** Creates a new {@link Builder}, copying the initial values from this instance. */ + public Builder buildUpon() { + return new Builder(this); + } + + @Override + @SuppressWarnings("EqualsGetClass") + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + TrackSelectionParameters other = (TrackSelectionParameters) obj; + return TextUtils.equals(preferredAudioLanguage, other.preferredAudioLanguage) + // Text + && TextUtils.equals(preferredTextLanguage, other.preferredTextLanguage) + && selectUndeterminedTextLanguage == other.selectUndeterminedTextLanguage + && disabledTextTrackSelectionFlags == other.disabledTextTrackSelectionFlags; + } + + @Override + public int hashCode() { + int result = 1; + // Audio + result = 31 * result + (preferredAudioLanguage == null ? 0 : preferredAudioLanguage.hashCode()); + // Text + result = 31 * result + (preferredTextLanguage == null ? 0 : preferredTextLanguage.hashCode()); + result = 31 * result + (selectUndeterminedTextLanguage ? 1 : 0); + result = 31 * result + disabledTextTrackSelectionFlags; + return result; + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + // Audio + dest.writeString(preferredAudioLanguage); + // Text + dest.writeString(preferredTextLanguage); + Util.writeBoolean(dest, selectUndeterminedTextLanguage); + dest.writeInt(disabledTextTrackSelectionFlags); + } + + public static final Creator CREATOR = + new Creator() { + + @Override + public TrackSelectionParameters createFromParcel(Parcel in) { + return new TrackSelectionParameters(in); + } + + @Override + public TrackSelectionParameters[] newArray(int size) { + return new TrackSelectionParameters[size]; + } + }; +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java index 75af3e96df..2996de4527 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java @@ -32,14 +32,19 @@ public final class DataSpec { /** * The flags that apply to any request for data. Possible flag values are {@link - * #FLAG_ALLOW_GZIP}, {@link #FLAG_ALLOW_ICY_METADATA} and {@link - * #FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN}. + * #FLAG_ALLOW_GZIP}, {@link #FLAG_ALLOW_ICY_METADATA}, {@link #FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN} + * and {@link #FLAG_ALLOW_CACHE_FRAGMENTATION}. */ @Documented @Retention(RetentionPolicy.SOURCE) @IntDef( flag = true, - value = {FLAG_ALLOW_GZIP, FLAG_ALLOW_ICY_METADATA, FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN}) + value = { + FLAG_ALLOW_GZIP, + FLAG_ALLOW_ICY_METADATA, + FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN, + FLAG_ALLOW_CACHE_FRAGMENTATION + }) public @interface Flags {} /** * Allows an underlying network stack to request that the server use gzip compression. @@ -53,12 +58,17 @@ public final class DataSpec { * DataSource#read(byte[], int, int)} will be the decompressed data. */ public static final int FLAG_ALLOW_GZIP = 1; - /** Allows an underlying network stack to request that the stream contain ICY metadata. */ public static final int FLAG_ALLOW_ICY_METADATA = 1 << 1; // 2 - /** Prevents caching if the length cannot be resolved when the {@link DataSource} is opened. */ public static final int FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN = 1 << 2; // 4 + /** + * Allows fragmentation of this request into multiple cache files, meaning a cache eviction policy + * will be able to evict individual fragments of the data. Depending on the cache implementation, + * setting this flag may also enable more concurrent access to the data (e.g. reading one fragment + * whilst writing another). + */ + public static final int FLAG_ALLOW_CACHE_FRAGMENTATION = 1 << 4; // 8 /** * The set of HTTP methods that are supported by ExoPlayer {@link HttpDataSource}s. One of {@link diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java index 1da2c3f636..2caf4c92f8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java @@ -37,20 +37,21 @@ import java.io.OutputStream; */ public final class CacheDataSink implements DataSink { - /** Default {@code maxCacheFileSize} recommended for caching use cases. */ - public static final long DEFAULT_MAX_CACHE_FILE_SIZE = 5 * 1024 * 1024; + /** Default {@code fragmentSize} recommended for caching use cases. */ + public static final long DEFAULT_FRAGMENT_SIZE = 5 * 1024 * 1024; /** Default buffer size in bytes. */ public static final int DEFAULT_BUFFER_SIZE = 20 * 1024; - private static final long MIN_RECOMMENDED_MAX_CACHE_FILE_SIZE = 2 * 1024 * 1024; + private static final long MIN_RECOMMENDED_FRAGMENT_SIZE = 2 * 1024 * 1024; private static final String TAG = "CacheDataSink"; private final Cache cache; - private final long maxCacheFileSize; + private final long fragmentSize; private final int bufferSize; private boolean syncFileDescriptor; private DataSpec dataSpec; + private long dataSpecFragmentSize; private File file; private OutputStream outputStream; private FileOutputStream underlyingFileOutputStream; @@ -73,42 +74,39 @@ public final class CacheDataSink implements DataSink { * Constructs an instance using {@link #DEFAULT_BUFFER_SIZE}. * * @param cache The cache into which data should be written. - * @param maxCacheFileSize The maximum size of a cache file, in bytes. If a request results in - * data being written whose size exceeds this value, then the data will be fragmented into - * multiple cache files. If set to {@link C#LENGTH_UNSET} then no fragmentation will occur. - * Using a small value allows for finer-grained cache eviction policies, at the cost of - * increased overhead both on the cache implementation and the file system. Values under - * {@code (2 * 1024 * 1024)} are not recommended. + * @param fragmentSize For requests that should be fragmented into multiple cache files, this is + * the maximum size of a cache file in bytes. If set to {@link C#LENGTH_UNSET} then no + * fragmentation will occur. Using a small value allows for finer-grained cache eviction + * policies, at the cost of increased overhead both on the cache implementation and the file + * system. Values under {@code (2 * 1024 * 1024)} are not recommended. */ - public CacheDataSink(Cache cache, long maxCacheFileSize) { - this(cache, maxCacheFileSize, DEFAULT_BUFFER_SIZE); + public CacheDataSink(Cache cache, long fragmentSize) { + this(cache, fragmentSize, DEFAULT_BUFFER_SIZE); } /** * @param cache The cache into which data should be written. - * @param maxCacheFileSize The maximum size of a cache file, in bytes. If a request results in - * data being written whose size exceeds this value, then the data will be fragmented into - * multiple cache files. If set to {@link C#LENGTH_UNSET} then no fragmentation will occur. - * Using a small value allows for finer-grained cache eviction policies, at the cost of - * increased overhead both on the cache implementation and the file system. Values under - * {@code (2 * 1024 * 1024)} are not recommended. + * @param fragmentSize For requests that should be fragmented into multiple cache files, this is + * the maximum size of a cache file in bytes. If set to {@link C#LENGTH_UNSET} then no + * fragmentation will occur. Using a small value allows for finer-grained cache eviction + * policies, at the cost of increased overhead both on the cache implementation and the file + * system. Values under {@code (2 * 1024 * 1024)} are not recommended. * @param bufferSize The buffer size in bytes for writing to a cache file. A zero or negative * value disables buffering. */ - public CacheDataSink(Cache cache, long maxCacheFileSize, int bufferSize) { + public CacheDataSink(Cache cache, long fragmentSize, int bufferSize) { Assertions.checkState( - maxCacheFileSize > 0 || maxCacheFileSize == C.LENGTH_UNSET, - "maxCacheFileSize must be positive or C.LENGTH_UNSET."); - if (maxCacheFileSize != C.LENGTH_UNSET - && maxCacheFileSize < MIN_RECOMMENDED_MAX_CACHE_FILE_SIZE) { + fragmentSize > 0 || fragmentSize == C.LENGTH_UNSET, + "fragmentSize must be positive or C.LENGTH_UNSET."); + if (fragmentSize != C.LENGTH_UNSET && fragmentSize < MIN_RECOMMENDED_FRAGMENT_SIZE) { Log.w( TAG, - "maxCacheFileSize is below the minimum recommended value of " - + MIN_RECOMMENDED_MAX_CACHE_FILE_SIZE + "fragmentSize is below the minimum recommended value of " + + MIN_RECOMMENDED_FRAGMENT_SIZE + ". This may cause poor cache performance."); } this.cache = Assertions.checkNotNull(cache); - this.maxCacheFileSize = maxCacheFileSize == C.LENGTH_UNSET ? Long.MAX_VALUE : maxCacheFileSize; + this.fragmentSize = fragmentSize == C.LENGTH_UNSET ? Long.MAX_VALUE : fragmentSize; this.bufferSize = bufferSize; syncFileDescriptor = true; } @@ -116,8 +114,7 @@ public final class CacheDataSink implements DataSink { /** * Sets whether file descriptors are synced when closing output streams. * - *

    This method is experimental, and will be renamed or removed in a future release. It should - * only be called before the renderer is used. + *

    This method is experimental, and will be renamed or removed in a future release. * * @param syncFileDescriptor Whether file descriptors are synced when closing output streams. */ @@ -133,6 +130,8 @@ public final class CacheDataSink implements DataSink { return; } this.dataSpec = dataSpec; + this.dataSpecFragmentSize = + dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION) ? fragmentSize : Long.MAX_VALUE; dataSpecBytesWritten = 0; try { openNextOutputStream(); @@ -149,12 +148,12 @@ public final class CacheDataSink implements DataSink { try { int bytesWritten = 0; while (bytesWritten < length) { - if (outputStreamBytesWritten == maxCacheFileSize) { + if (outputStreamBytesWritten == dataSpecFragmentSize) { closeCurrentOutputStream(); openNextOutputStream(); } - int bytesToWrite = (int) Math.min(length - bytesWritten, - maxCacheFileSize - outputStreamBytesWritten); + int bytesToWrite = + (int) Math.min(length - bytesWritten, dataSpecFragmentSize - outputStreamBytesWritten); outputStream.write(buffer, offset + bytesWritten, bytesToWrite); bytesWritten += bytesToWrite; outputStreamBytesWritten += bytesToWrite; @@ -181,7 +180,7 @@ public final class CacheDataSink implements DataSink { long length = dataSpec.length == C.LENGTH_UNSET ? C.LENGTH_UNSET - : Math.min(dataSpec.length - dataSpecBytesWritten, maxCacheFileSize); + : Math.min(dataSpec.length - dataSpecBytesWritten, dataSpecFragmentSize); file = cache.startFile( dataSpec.key, dataSpec.absoluteStreamPosition + dataSpecBytesWritten, length); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java index 6dcb14f5fb..856e9db168 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java @@ -23,27 +23,37 @@ import com.google.android.exoplayer2.upstream.DataSink; public final class CacheDataSinkFactory implements DataSink.Factory { private final Cache cache; - private final long maxCacheFileSize; + private final long fragmentSize; private final int bufferSize; - /** - * @see CacheDataSink#CacheDataSink(Cache, long) - */ - public CacheDataSinkFactory(Cache cache, long maxCacheFileSize) { - this(cache, maxCacheFileSize, CacheDataSink.DEFAULT_BUFFER_SIZE); + private boolean syncFileDescriptor; + + /** @see CacheDataSink#CacheDataSink(Cache, long) */ + public CacheDataSinkFactory(Cache cache, long fragmentSize) { + this(cache, fragmentSize, CacheDataSink.DEFAULT_BUFFER_SIZE); + } + + /** @see CacheDataSink#CacheDataSink(Cache, long, int) */ + public CacheDataSinkFactory(Cache cache, long fragmentSize, int bufferSize) { + this.cache = cache; + this.fragmentSize = fragmentSize; + this.bufferSize = bufferSize; } /** - * @see CacheDataSink#CacheDataSink(Cache, long, int) + * See {@link CacheDataSink#experimental_setSyncFileDescriptor(boolean)}. + * + *

    This method is experimental, and will be renamed or removed in a future release. */ - public CacheDataSinkFactory(Cache cache, long maxCacheFileSize, int bufferSize) { - this.cache = cache; - this.maxCacheFileSize = maxCacheFileSize; - this.bufferSize = bufferSize; + public CacheDataSinkFactory experimental_setSyncFileDescriptor(boolean syncFileDescriptor) { + this.syncFileDescriptor = syncFileDescriptor; + return this; } @Override public DataSink createDataSink() { - return new CacheDataSink(cache, maxCacheFileSize, bufferSize); + CacheDataSink dataSink = new CacheDataSink(cache, fragmentSize, bufferSize); + dataSink.experimental_setSyncFileDescriptor(syncFileDescriptor); + return dataSink; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java index 579f5d05e9..909bd40023 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java @@ -172,7 +172,7 @@ public final class CacheDataSource implements DataSource { cache, upstream, new FileDataSource(), - new CacheDataSink(cache, CacheDataSink.DEFAULT_MAX_CACHE_FILE_SIZE), + new CacheDataSink(cache, CacheDataSink.DEFAULT_FRAGMENT_SIZE), flags, /* eventListener= */ null); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java index e25c3d7a4a..9675aa1762 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java @@ -50,7 +50,7 @@ public final class CacheDataSourceFactory implements DataSource.Factory { cache, upstreamFactory, new FileDataSourceFactory(), - new CacheDataSinkFactory(cache, CacheDataSink.DEFAULT_MAX_CACHE_FILE_SIZE), + new CacheDataSinkFactory(cache, CacheDataSink.DEFAULT_FRAGMENT_SIZE), flags, /* eventListener= */ null); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheFileMetadata.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheFileMetadata.java new file mode 100644 index 0000000000..492b98a0de --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheFileMetadata.java @@ -0,0 +1,28 @@ +/* + * 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.upstream.cache; + +/** Metadata associated with a cache file. */ +/* package */ final class CacheFileMetadata { + + public final long length; + public final long lastAccessTimestamp; + + public CacheFileMetadata(long length, long lastAccessTimestamp) { + this.length = length; + this.lastAccessTimestamp = lastAccessTimestamp; + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheFileMetadataIndex.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheFileMetadataIndex.java new file mode 100644 index 0000000000..96213b4bbc --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheFileMetadataIndex.java @@ -0,0 +1,171 @@ +/* + * 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.upstream.cache; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import com.google.android.exoplayer2.database.DatabaseProvider; +import com.google.android.exoplayer2.database.VersionTable; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** Maintains an index of cache file metadata. */ +/* package */ final class CacheFileMetadataIndex { + + private static final String TABLE_NAME = DatabaseProvider.TABLE_PREFIX + "CacheFileMetadata"; + private static final int TABLE_VERSION = 1; + + private static final String COLUMN_NAME = "name"; + private static final String COLUMN_LENGTH = "length"; + private static final String COLUMN_LAST_ACCESS_TIMESTAMP = "last_access_timestamp"; + + private static final int COLUMN_INDEX_NAME = 0; + private static final int COLUMN_INDEX_LENGTH = 1; + private static final int COLUMN_INDEX_LAST_ACCESS_TIMESTAMP = 2; + + private static final String WHERE_NAME_EQUALS = COLUMN_INDEX_NAME + " = ?"; + + private static final String[] COLUMNS = + new String[] { + COLUMN_NAME, COLUMN_LENGTH, COLUMN_LAST_ACCESS_TIMESTAMP, + }; + + private static final String SQL_DROP_TABLE_IF_EXISTS = "DROP TABLE IF EXISTS " + TABLE_NAME; + private static final String SQL_CREATE_TABLE = + "CREATE TABLE " + + TABLE_NAME + + " (" + + COLUMN_NAME + + " TEXT PRIMARY KEY NOT NULL," + + COLUMN_LENGTH + + " INTEGER NOT NULL," + + COLUMN_LAST_ACCESS_TIMESTAMP + + " INTEGER NOT NULL)"; + + private final DatabaseProvider databaseProvider; + + private boolean initialized; + + public CacheFileMetadataIndex(DatabaseProvider databaseProvider) { + this.databaseProvider = databaseProvider; + } + + /** + * Returns all file metadata keyed by file name. The returned map is mutable and may be modified + * by the caller. + */ + public Map getAll() { + ensureInitialized(); + try (Cursor cursor = getCursor()) { + Map fileMetadata = new HashMap<>(cursor.getCount()); + while (cursor.moveToNext()) { + String name = cursor.getString(COLUMN_INDEX_NAME); + long length = cursor.getLong(COLUMN_INDEX_LENGTH); + long lastAccessTimestamp = cursor.getLong(COLUMN_INDEX_LAST_ACCESS_TIMESTAMP); + fileMetadata.put(name, new CacheFileMetadata(length, lastAccessTimestamp)); + } + return fileMetadata; + } + } + + /** + * Sets metadata for a given file. + * + * @param name The name of the file. + * @param length The file length. + * @param lastAccessTimestamp The file last access timestamp. + */ + public void set(String name, long length, long lastAccessTimestamp) { + ensureInitialized(); + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + ContentValues values = new ContentValues(); + values.put(COLUMN_NAME, name); + values.put(COLUMN_LENGTH, length); + values.put(COLUMN_LAST_ACCESS_TIMESTAMP, lastAccessTimestamp); + writableDatabase.replace(TABLE_NAME, /* nullColumnHack= */ null, values); + } + + /** + * Removes metadata. + * + * @param name The name of the file whose metadata is to be removed. + */ + public void remove(String name) { + ensureInitialized(); + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.delete(TABLE_NAME, WHERE_NAME_EQUALS, new String[] {name}); + } + + /** + * Removes metadata. + * + * @param names The names of the files whose metadata is to be removed. + */ + public void removeAll(Set names) { + ensureInitialized(); + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.beginTransaction(); + try { + for (String name : names) { + writableDatabase.delete(TABLE_NAME, WHERE_NAME_EQUALS, new String[] {name}); + } + writableDatabase.setTransactionSuccessful(); + } finally { + writableDatabase.endTransaction(); + } + } + + private void ensureInitialized() { + if (initialized) { + return; + } + SQLiteDatabase readableDatabase = databaseProvider.getReadableDatabase(); + int version = + VersionTable.getVersion(readableDatabase, VersionTable.FEATURE_CACHE_FILE_METADATA); + if (version == VersionTable.VERSION_UNSET || version > TABLE_VERSION) { + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.beginTransaction(); + try { + VersionTable.setVersion( + writableDatabase, VersionTable.FEATURE_CACHE_FILE_METADATA, TABLE_VERSION); + writableDatabase.execSQL(SQL_DROP_TABLE_IF_EXISTS); + writableDatabase.execSQL(SQL_CREATE_TABLE); + writableDatabase.setTransactionSuccessful(); + } finally { + writableDatabase.endTransaction(); + } + } else if (version < TABLE_VERSION) { + // There is no previous version currently. + throw new IllegalStateException(); + } + initialized = true; + } + + private Cursor getCursor() { + return databaseProvider + .getReadableDatabase() + .query( + TABLE_NAME, + COLUMNS, + /* selection */ null, + /* selectionArgs= */ null, + /* groupBy= */ null, + /* having= */ null, + /* orderBy= */ null); + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java index 5494454d54..80b50d862a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java @@ -16,18 +16,15 @@ package com.google.android.exoplayer2.upstream.cache; import android.support.annotation.Nullable; -import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; import com.google.android.exoplayer2.util.Assertions; -import java.io.DataInputStream; -import java.io.DataOutputStream; -import java.io.IOException; +import com.google.android.exoplayer2.util.Log; +import java.io.File; import java.util.TreeSet; /** Defines the cached content for a single stream. */ /* package */ final class CachedContent { - private static final int VERSION_METADATA_INTRODUCED = 2; - private static final int VERSION_MAX = Integer.MAX_VALUE; + private static final String TAG = "CachedContent"; /** The cache file id that uniquely identifies the original stream. */ public final int id; @@ -40,29 +37,6 @@ import java.util.TreeSet; /** Whether the content is locked. */ private boolean locked; - /** - * Reads an instance from a {@link DataInputStream}. - * - * @param version Version of the encoded data. - * @param input Input stream containing values needed to initialize CachedContent instance. - * @throws IOException If an error occurs during reading values. - */ - public static CachedContent readFromStream(int version, DataInputStream input) - throws IOException { - int id = input.readInt(); - String key = input.readUTF(); - CachedContent cachedContent = new CachedContent(id, key); - if (version < VERSION_METADATA_INTRODUCED) { - long length = input.readLong(); - ContentMetadataMutations mutations = new ContentMetadataMutations(); - ContentMetadataMutations.setContentLength(mutations, length); - cachedContent.applyMetadataMutations(mutations); - } else { - cachedContent.metadata = DefaultContentMetadata.readFromStream(input); - } - return cachedContent; - } - /** * Creates a CachedContent. * @@ -70,26 +44,18 @@ import java.util.TreeSet; * @param key The cache stream key. */ public CachedContent(int id, String key) { + this(id, key, DefaultContentMetadata.EMPTY); + } + + public CachedContent(int id, String key, DefaultContentMetadata metadata) { this.id = id; this.key = key; - this.metadata = DefaultContentMetadata.EMPTY; + this.metadata = metadata; this.cachedSpans = new TreeSet<>(); } - /** - * Writes the instance to a {@link DataOutputStream}. - * - * @param output Output stream to store the values. - * @throws IOException If an error occurs during writing values to output. - */ - public void writeToStream(DataOutputStream output) throws IOException { - output.writeInt(id); - output.writeUTF(key); - metadata.writeToStream(output); - } - /** Returns the metadata. */ - public ContentMetadata getMetadata() { + public DefaultContentMetadata getMetadata() { return metadata; } @@ -175,21 +141,30 @@ import java.util.TreeSet; } /** - * Copies the given span with an updated last access time. Passed span becomes invalid after this - * call. + * Sets the given span's last access timestamp. The passed span becomes invalid after this call. * * @param cacheSpan Span to be copied and updated. - * @return a span with the updated last access time. - * @throws CacheException If renaming of the underlying span file failed. + * @param lastAccessTimestamp The new last access timestamp. + * @param updateFile Whether the span file should be renamed to have its timestamp match the new + * last access time. + * @return A span with the updated last access timestamp. */ - public SimpleCacheSpan touch(SimpleCacheSpan cacheSpan) throws CacheException { - SimpleCacheSpan newCacheSpan = cacheSpan.copyWithUpdatedLastAccessTime(id); - if (!cacheSpan.file.renameTo(newCacheSpan.file)) { - throw new CacheException("Renaming of " + cacheSpan.file + " to " + newCacheSpan.file - + " failed."); - } - // Replace the in-memory representation of the span. + public SimpleCacheSpan setLastAccessTimestamp( + SimpleCacheSpan cacheSpan, long lastAccessTimestamp, boolean updateFile) { Assertions.checkState(cachedSpans.remove(cacheSpan)); + File file = cacheSpan.file; + if (updateFile) { + File directory = file.getParentFile(); + long position = cacheSpan.position; + File newFile = SimpleCacheSpan.getCacheFile(directory, id, position, lastAccessTimestamp); + if (file.renameTo(newFile)) { + file = newFile; + } else { + Log.w(TAG, "Failed to rename " + file + " to " + newFile + "."); + } + } + SimpleCacheSpan newCacheSpan = + cacheSpan.copyWithFileAndLastAccessTimestamp(file, lastAccessTimestamp); cachedSpans.add(newCacheSpan); return newCacheSpan; } @@ -208,26 +183,11 @@ import java.util.TreeSet; return false; } - /** - * Calculates a hash code for the header of this {@code CachedContent} which is compatible with - * the index file with {@code version}. - */ - public int headerHashCode(int version) { - int result = id; - result = 31 * result + key.hashCode(); - if (version < VERSION_METADATA_INTRODUCED) { - long length = ContentMetadata.getContentLength(metadata); - result = 31 * result + (int) (length ^ (length >>> 32)); - } else { - result = 31 * result + metadata.hashCode(); - } - return result; - } - @Override public int hashCode() { - int result = headerHashCode(VERSION_MAX); - result = 31 * result + cachedSpans.hashCode(); + int result = id; + result = 31 * result + key.hashCode(); + result = 31 * result + metadata.hashCode(); return result; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java index a744917230..b22645c9d2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java @@ -15,15 +15,25 @@ */ package com.google.android.exoplayer2.upstream.cache; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.support.annotation.Nullable; import android.support.annotation.VisibleForTesting; import android.util.SparseArray; import android.util.SparseBooleanArray; +import com.google.android.exoplayer2.database.DatabaseProvider; +import com.google.android.exoplayer2.database.ExoDatabaseProvider; +import com.google.android.exoplayer2.database.VersionTable; import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.AtomicFile; import com.google.android.exoplayer2.util.ReusableBufferedOutputStream; import com.google.android.exoplayer2.util.Util; import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.File; @@ -33,8 +43,10 @@ import java.io.OutputStream; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; +import java.util.Arrays; import java.util.Collection; import java.util.HashMap; +import java.util.Map; import java.util.Random; import java.util.Set; import javax.crypto.Cipher; @@ -48,9 +60,12 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; /** Maintains the index of cached content. */ /* package */ class CachedContentIndex { - public static final String FILE_NAME = "cached_content_index.exi"; + /* package */ static final String FILE_NAME_ATOMIC = "cached_content_index.exi"; + private static final String FILE_NAME_DATABASE = "cached_content_index.db"; private static final int VERSION = 2; + private static final int VERSION_METADATA_INTRODUCED = 2; + private static final int INCREMENTAL_METADATA_READ_LENGTH = 10 * 1024 * 1024; private static final int FLAG_ENCRYPTED_INDEX = 1; @@ -79,12 +94,17 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; */ private final SparseBooleanArray removedIds; - private final AtomicFile atomicFile; - private final Cipher cipher; - private final SecretKeySpec secretKeySpec; - private final boolean encrypt; - private boolean changed; - private ReusableBufferedOutputStream bufferedOutputStream; + private Storage storage; + @Nullable private Storage previousStorage; + + /** + * Returns whether the file is an index file, or an auxiliary file associated with an index file + * (e.g. an atomic file backup or auxiliary database file). + */ + public static final boolean isIndexFile(String fileName) { + // Atomic file backups and auxiliary database files add additional suffixes to the file name. + return fileName.startsWith(FILE_NAME_ATOMIC) || fileName.startsWith(FILE_NAME_DATABASE); + } /** * Creates a CachedContentIndex which works on the index file in the given cacheDir. @@ -114,7 +134,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; * secretKey} is null. */ public CachedContentIndex(File cacheDir, byte[] secretKey, boolean encrypt) { - this.encrypt = encrypt; + Cipher cipher = null; + SecretKeySpec secretKeySpec = null; if (secretKey != null) { Assertions.checkArgument(secretKey.length == 16); try { @@ -125,32 +146,47 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } } else { Assertions.checkState(!encrypt); - cipher = null; - secretKeySpec = null; } keyToContent = new HashMap<>(); idToKey = new SparseArray<>(); removedIds = new SparseBooleanArray(); - atomicFile = new AtomicFile(new File(cacheDir, FILE_NAME)); + Random random = new Random(); + Storage atomicFileStorage = + new AtomicFileStorage( + new File(cacheDir, FILE_NAME_ATOMIC), random, encrypt, cipher, secretKeySpec); + // Storage sqliteStorage = + // new SQLiteStorage( + // new File(cacheDir, FILE_NAME_DATABASE), random, encrypt, cipher, secretKeySpec); + storage = atomicFileStorage; + previousStorage = null; } /** Loads the index file. */ public void load() { - Assertions.checkState(!changed); - if (!readFile()) { - atomicFile.delete(); - keyToContent.clear(); - idToKey.clear(); + if (!storage.exists() && previousStorage != null && previousStorage.exists()) { + // Copy from previous storage into current storage. + loadFrom(previousStorage); + try { + storage.storeFully(keyToContent); + } catch (CacheException e) { + // We failed to copy into current storage, so keep using previous storage. + storage.release(); + storage = previousStorage; + previousStorage = null; + } + } else { + // Load from the current storage. + loadFrom(storage); + } + if (previousStorage != null) { + previousStorage.release(/* delete= */ true); + previousStorage = null; } } /** Stores the index data to index file if there is a change. */ public void store() throws CacheException { - if (!changed) { - return; - } - writeFile(); - changed = false; + storage.storeIncremental(keyToContent); // Make ids that were removed since the index was last stored eligible for re-use. int removedIdCount = removedIds.size(); for (int i = 0; i < removedIdCount; i++) { @@ -159,6 +195,14 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; removedIds.clear(); } + /** Releases any underlying resources. */ + public void release() { + storage.release(); + if (previousStorage != null) { + previousStorage.release(); + } + } + /** * Adds the given key to the index if it isn't there already. * @@ -201,7 +245,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; CachedContent cachedContent = keyToContent.get(key); if (cachedContent != null && cachedContent.isEmpty() && !cachedContent.isLocked()) { keyToContent.remove(key); - changed = true; + storage.onRemove(cachedContent); // Keep an entry in idToKey to stop the id from being reused until the index is next stored. idToKey.put(cachedContent.id, /* value= */ null); // Track that the entry should be removed from idToKey when the index is next stored. @@ -235,7 +279,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; public void applyContentMetadataMutations(String key, ContentMetadataMutations mutations) { CachedContent cachedContent = getOrAdd(key); if (cachedContent.applyMetadataMutations(mutations)) { - changed = true; + storage.onUpdate(cachedContent); } } @@ -245,114 +289,21 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; return cachedContent != null ? cachedContent.getMetadata() : DefaultContentMetadata.EMPTY; } - private boolean readFile() { - DataInputStream input = null; - try { - InputStream inputStream = new BufferedInputStream(atomicFile.openRead()); - input = new DataInputStream(inputStream); - int version = input.readInt(); - if (version < 0 || version > VERSION) { - return false; - } - - int flags = input.readInt(); - if ((flags & FLAG_ENCRYPTED_INDEX) != 0) { - if (cipher == null) { - return false; - } - byte[] initializationVector = new byte[16]; - input.readFully(initializationVector); - IvParameterSpec ivParameterSpec = new IvParameterSpec(initializationVector); - try { - cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec); - } catch (InvalidKeyException | InvalidAlgorithmParameterException e) { - throw new IllegalStateException(e); - } - input = new DataInputStream(new CipherInputStream(inputStream, cipher)); - } else if (encrypt) { - changed = true; // Force index to be rewritten encrypted after read. - } - - int count = input.readInt(); - int hashCode = 0; - for (int i = 0; i < count; i++) { - CachedContent cachedContent = CachedContent.readFromStream(version, input); - add(cachedContent); - hashCode += cachedContent.headerHashCode(version); - } - int fileHashCode = input.readInt(); - boolean isEOF = input.read() == -1; - if (fileHashCode != hashCode || !isEOF) { - return false; - } - } catch (IOException e) { - return false; - } finally { - if (input != null) { - Util.closeQuietly(input); - } - } - return true; - } - - private void writeFile() throws CacheException { - DataOutputStream output = null; - try { - OutputStream outputStream = atomicFile.startWrite(); - if (bufferedOutputStream == null) { - bufferedOutputStream = new ReusableBufferedOutputStream(outputStream); - } else { - bufferedOutputStream.reset(outputStream); - } - output = new DataOutputStream(bufferedOutputStream); - output.writeInt(VERSION); - - int flags = encrypt ? FLAG_ENCRYPTED_INDEX : 0; - output.writeInt(flags); - - if (encrypt) { - byte[] initializationVector = new byte[16]; - new Random().nextBytes(initializationVector); - output.write(initializationVector); - IvParameterSpec ivParameterSpec = new IvParameterSpec(initializationVector); - try { - cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec); - } catch (InvalidKeyException | InvalidAlgorithmParameterException e) { - throw new IllegalStateException(e); // Should never happen. - } - output.flush(); - output = new DataOutputStream(new CipherOutputStream(bufferedOutputStream, cipher)); - } - - output.writeInt(keyToContent.size()); - int hashCode = 0; - for (CachedContent cachedContent : keyToContent.values()) { - cachedContent.writeToStream(output); - hashCode += cachedContent.headerHashCode(VERSION); - } - output.writeInt(hashCode); - atomicFile.endWrite(output); - // Avoid calling close twice. Duplicate CipherOutputStream.close calls did - // not used to be no-ops: https://android-review.googlesource.com/#/c/272799/ - output = null; - } catch (IOException e) { - throw new CacheException(e); - } finally { - Util.closeQuietly(output); + /** Loads the index from the specified storage. */ + private void loadFrom(Storage storage) { + if (!storage.load(keyToContent, idToKey)) { + keyToContent.clear(); + idToKey.clear(); } } private CachedContent addNew(String key) { int id = getNewId(idToKey); CachedContent cachedContent = new CachedContent(id, key); - add(cachedContent); - changed = true; - return cachedContent; - } - - private void add(CachedContent cachedContent) { keyToContent.put(cachedContent.key, cachedContent); idToKey.put(cachedContent.id, cachedContent.key); + storage.onUpdate(cachedContent); + return cachedContent; } private static Cipher getCipher() throws NoSuchPaddingException, NoSuchAlgorithmException { @@ -373,7 +324,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; * returns the smallest unused non-negative integer. */ @VisibleForTesting - public static int getNewId(SparseArray idToKey) { + /* package */ static int getNewId(SparseArray idToKey) { int size = idToKey.size(); int id = size == 0 ? 0 : (idToKey.keyAt(size - 1) + 1); if (id < 0) { // In case if we pass max int value. @@ -387,4 +338,587 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; return id; } + /** + * Deserializes a {@link DefaultContentMetadata} from the given input stream. + * + * @param input Input stream to read from. + * @return a {@link DefaultContentMetadata} instance. + * @throws IOException If an error occurs during reading from input. + */ + private static DefaultContentMetadata readContentMetadata(DataInputStream input) + throws IOException { + int size = input.readInt(); + HashMap metadata = new HashMap<>(); + for (int i = 0; i < size; i++) { + String name = input.readUTF(); + int valueSize = input.readInt(); + if (valueSize < 0) { + throw new IOException("Invalid value size: " + valueSize); + } + // Grow the array incrementally to avoid OutOfMemoryError in the case that a corrupt (and very + // large) valueSize was read. In such cases the implementation below is expected to throw + // IOException from one of the readFully calls, due to the end of the input being reached. + int bytesRead = 0; + int nextBytesToRead = Math.min(valueSize, INCREMENTAL_METADATA_READ_LENGTH); + byte[] value = Util.EMPTY_BYTE_ARRAY; + while (bytesRead != valueSize) { + value = Arrays.copyOf(value, bytesRead + nextBytesToRead); + input.readFully(value, bytesRead, nextBytesToRead); + bytesRead += nextBytesToRead; + nextBytesToRead = Math.min(valueSize - bytesRead, INCREMENTAL_METADATA_READ_LENGTH); + } + metadata.put(name, value); + } + return new DefaultContentMetadata(metadata); + } + + /** + * Serializes itself to a {@link DataOutputStream}. + * + * @param output Output stream to store the values. + * @throws IOException If an error occurs during writing values to output. + */ + private static void writeContentMetadata(DefaultContentMetadata metadata, DataOutputStream output) + throws IOException { + Set> entrySet = metadata.entrySet(); + output.writeInt(entrySet.size()); + for (Map.Entry entry : entrySet) { + output.writeUTF(entry.getKey()); + byte[] value = entry.getValue(); + output.writeInt(value.length); + output.write(value); + } + } + + /** Interface for the persistent index. */ + private interface Storage { + + /** Returns whether the persisted index exists. */ + boolean exists(); + + /** Releases any held resources. */ + default void release() { + release(/* delete= */ false); + } + + /** Releases and held resources and optionally deletes the persisted index. */ + void release(boolean delete); + + /** + * Loads the persisted index into {@code content} and {@code idToKey}, creating it if it doesn't + * already exist. + * + * @param content The key to content map to populate with persisted data. + * @param idToKey The id to key map to populate with persisted data. + * @return Whether the load was successful. + */ + boolean load(HashMap content, SparseArray<@NullableType String> idToKey); + + /** + * Writes the persisted index, creating it if it doesn't already exist and replacing any + * existing content if it does. + * + * @param content The key to content map to persist. + * @throws CacheException If an error occurs persisting the index. + */ + void storeFully(HashMap content) throws CacheException; + + /** + * Ensures incremental changes to the index since the last {@link #load()} or {@link + * #storeFully(HashMap)} are persisted. The storage will have been notified of all such changes + * via {@link #onUpdate(CachedContent)} and {@link #onRemove(CachedContent)}. + * + * @param content The key to content map to persist. + * @throws CacheException If an error occurs persisting the index. + */ + void storeIncremental(HashMap content) throws CacheException; + + /** + * Called when a {@link CachedContent} is added or updated. + * + * @param cachedContent The updated {@link CachedContent}. + */ + void onUpdate(CachedContent cachedContent); + + /** + * Called when a {@link CachedContent} is removed. + * + * @param cachedContent The removed {@link CachedContent}. + */ + void onRemove(CachedContent cachedContent); + } + + /** {@link Storage} implementation that uses an {@link AtomicFile}. */ + private static class AtomicFileStorage implements Storage { + + private final Random random; + private final boolean encrypt; + @Nullable private final Cipher cipher; + @Nullable private final SecretKeySpec secretKeySpec; + private final AtomicFile atomicFile; + + private boolean changed; + @Nullable private ReusableBufferedOutputStream bufferedOutputStream; + + public AtomicFileStorage( + File file, + Random random, + boolean encrypt, + @Nullable Cipher cipher, + @Nullable SecretKeySpec secretKeySpec) { + this.random = random; + this.encrypt = encrypt; + this.cipher = cipher; + this.secretKeySpec = secretKeySpec; + atomicFile = new AtomicFile(file); + } + + @Override + public boolean exists() { + return atomicFile.exists(); + } + + @Override + public void release(boolean delete) { + if (delete) { + atomicFile.delete(); + } + } + + @Override + public boolean load( + HashMap content, SparseArray<@NullableType String> idToKey) { + Assertions.checkState(!changed); + if (!readFile(content, idToKey)) { + atomicFile.delete(); + return false; + } + return true; + } + + @Override + public void storeFully(HashMap content) throws CacheException { + writeFile(content); + changed = false; + } + + @Override + public void storeIncremental(HashMap content) throws CacheException { + if (!changed) { + return; + } + storeFully(content); + } + + @Override + public void onUpdate(CachedContent cachedContent) { + changed = true; + } + + @Override + public void onRemove(CachedContent cachedContent) { + changed = true; + } + + private boolean readFile( + HashMap content, SparseArray<@NullableType String> idToKey) { + DataInputStream input = null; + try { + InputStream inputStream = new BufferedInputStream(atomicFile.openRead()); + input = new DataInputStream(inputStream); + int version = input.readInt(); + if (version < 0 || version > VERSION) { + return false; + } + + int flags = input.readInt(); + if ((flags & FLAG_ENCRYPTED_INDEX) != 0) { + if (cipher == null) { + return false; + } + byte[] initializationVector = new byte[16]; + input.readFully(initializationVector); + IvParameterSpec ivParameterSpec = new IvParameterSpec(initializationVector); + try { + cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec); + } catch (InvalidKeyException | InvalidAlgorithmParameterException e) { + throw new IllegalStateException(e); + } + input = new DataInputStream(new CipherInputStream(inputStream, cipher)); + } else if (encrypt) { + changed = true; // Force index to be rewritten encrypted after read. + } + + int count = input.readInt(); + int hashCode = 0; + for (int i = 0; i < count; i++) { + CachedContent cachedContent = readCachedContent(version, input); + content.put(cachedContent.key, cachedContent); + idToKey.put(cachedContent.id, cachedContent.key); + hashCode += hashCachedContent(cachedContent, version); + } + int fileHashCode = input.readInt(); + boolean isEOF = input.read() == -1; + if (fileHashCode != hashCode || !isEOF) { + return false; + } + } catch (IOException e) { + return false; + } finally { + if (input != null) { + Util.closeQuietly(input); + } + } + return true; + } + + private void writeFile(HashMap content) throws CacheException { + DataOutputStream output = null; + try { + OutputStream outputStream = atomicFile.startWrite(); + if (bufferedOutputStream == null) { + bufferedOutputStream = new ReusableBufferedOutputStream(outputStream); + } else { + bufferedOutputStream.reset(outputStream); + } + output = new DataOutputStream(bufferedOutputStream); + output.writeInt(VERSION); + + int flags = encrypt ? FLAG_ENCRYPTED_INDEX : 0; + output.writeInt(flags); + + if (encrypt) { + byte[] initializationVector = new byte[16]; + random.nextBytes(initializationVector); + output.write(initializationVector); + IvParameterSpec ivParameterSpec = new IvParameterSpec(initializationVector); + try { + cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec); + } catch (InvalidKeyException | InvalidAlgorithmParameterException e) { + throw new IllegalStateException(e); // Should never happen. + } + output.flush(); + output = new DataOutputStream(new CipherOutputStream(bufferedOutputStream, cipher)); + } + + output.writeInt(content.size()); + int hashCode = 0; + for (CachedContent cachedContent : content.values()) { + writeCachedContent(cachedContent, output); + hashCode += hashCachedContent(cachedContent, VERSION); + } + output.writeInt(hashCode); + atomicFile.endWrite(output); + // Avoid calling close twice. Duplicate CipherOutputStream.close calls did + // not used to be no-ops: https://android-review.googlesource.com/#/c/272799/ + output = null; + } catch (IOException e) { + throw new CacheException(e); + } finally { + Util.closeQuietly(output); + } + } + + /** + * Calculates a hash code for a {@link CachedContent} which is compatible with a particular + * index version. + */ + private int hashCachedContent(CachedContent cachedContent, int version) { + int result = cachedContent.id; + result = 31 * result + cachedContent.key.hashCode(); + if (version < VERSION_METADATA_INTRODUCED) { + long length = ContentMetadata.getContentLength(cachedContent.getMetadata()); + result = 31 * result + (int) (length ^ (length >>> 32)); + } else { + result = 31 * result + cachedContent.getMetadata().hashCode(); + } + return result; + } + + /** + * Reads a {@link CachedContent} from a {@link DataInputStream}. + * + * @param version Version of the encoded data. + * @param input Input stream containing values needed to initialize CachedContent instance. + * @throws IOException If an error occurs during reading values. + */ + private CachedContent readCachedContent(int version, DataInputStream input) throws IOException { + int id = input.readInt(); + String key = input.readUTF(); + DefaultContentMetadata metadata; + if (version < VERSION_METADATA_INTRODUCED) { + long length = input.readLong(); + ContentMetadataMutations mutations = new ContentMetadataMutations(); + ContentMetadataMutations.setContentLength(mutations, length); + metadata = DefaultContentMetadata.EMPTY.copyWithMutationsApplied(mutations); + } else { + metadata = readContentMetadata(input); + } + return new CachedContent(id, key, metadata); + } + + /** + * Writes a {@link CachedContent} to a {@link DataOutputStream}. + * + * @param output Output stream to store the values. + * @throws IOException If an error occurs during writing values to output. + */ + private void writeCachedContent(CachedContent cachedContent, DataOutputStream output) + throws IOException { + output.writeInt(cachedContent.id); + output.writeUTF(cachedContent.key); + writeContentMetadata(cachedContent.getMetadata(), output); + } + } + + /** {@link Storage} implementation that uses an SQL database. */ + // TODO: + // 1. Implement upgrade/downgrade paths from/to AtomicFileStorage. + // 2. If encryption is enabled having previously written data, decide whether we need to rewrite + // the entire table. Currently this implementation only encrypts new and updated entries. + private static final class SQLiteStorage implements Storage { + + private static final String TABLE_NAME = DatabaseProvider.TABLE_PREFIX + "CacheContentMetadata"; + private static final int TABLE_VERSION = 1; + + private static final String COLUMN_ID = "id"; + private static final String COLUMN_FLAGS = "flags"; + private static final String COLUMN_DATA = "data"; + + private static final int COLUMN_INDEX_ID = 0; + private static final int COLUMN_INDEX_FLAGS = 1; + private static final int COLUMN_INDEX_DATA = 2; + + private static final String COLUMN_SELECTION_ID = COLUMN_ID + " = ?"; + + private static final String[] COLUMNS = new String[] {COLUMN_ID, COLUMN_FLAGS, COLUMN_DATA}; + + private static final String SQL_DROP_TABLE_IF_EXISTS = "DROP TABLE IF EXISTS " + TABLE_NAME; + private static final String SQL_CREATE_TABLE = + "CREATE TABLE " + + TABLE_NAME + + " (" + + COLUMN_ID + + " INTEGER PRIMARY KEY NOT NULL," + + COLUMN_FLAGS + + " INTEGER NOT NULL," + + COLUMN_DATA + + " BLOB NOT NULL)"; + + private static final int FLAG_ENCRYPTED = 1; + + private final File file; + private final Random random; + private final boolean encrypt; + @Nullable private final Cipher cipher; + @Nullable private final SecretKeySpec secretKeySpec; + private final ExoDatabaseProvider databaseProvider; + private final SparseArray pendingUpdates; + + @Nullable private ReusableBufferedOutputStream bufferedOutputStream; + + public SQLiteStorage( + File file, + Random random, + boolean encrypt, + @Nullable Cipher cipher, + @Nullable SecretKeySpec secretKeySpec) { + this.file = file; + this.random = random; + this.encrypt = encrypt; + this.cipher = cipher; + this.secretKeySpec = secretKeySpec; + databaseProvider = new ExoDatabaseProvider(file); + pendingUpdates = new SparseArray<>(); + } + + @Override + public boolean exists() { + return file.exists() + && VersionTable.getVersion( + databaseProvider.getReadableDatabase(), + VersionTable.FEATURE_CACHE_CONTENT_METADATA) + != VersionTable.VERSION_UNSET; + } + + @Override + public void release(boolean delete) { + release(); + if (delete) { + SQLiteDatabase.deleteDatabase(file); + } + } + + @Override + public boolean load( + HashMap content, SparseArray<@NullableType String> idToKey) { + Assertions.checkState(pendingUpdates.size() == 0); + try { + int version = + VersionTable.getVersion( + databaseProvider.getReadableDatabase(), + VersionTable.FEATURE_CACHE_CONTENT_METADATA); + if (version == VersionTable.VERSION_UNSET || version > TABLE_VERSION) { + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.beginTransaction(); + try { + initializeTable(writableDatabase); + writableDatabase.setTransactionSuccessful(); + } finally { + writableDatabase.endTransaction(); + } + } else if (version < TABLE_VERSION) { + // There is no previous version currently. + throw new IllegalStateException(); + } + + try (Cursor cursor = getCursor()) { + while (cursor.moveToNext()) { + int id = cursor.getInt(COLUMN_INDEX_ID); + boolean encrypted = (cursor.getInt(COLUMN_INDEX_FLAGS) & FLAG_ENCRYPTED) != 0; + byte[] data = cursor.getBlob(COLUMN_INDEX_DATA); + + ByteArrayInputStream inputStream = new ByteArrayInputStream(data); + DataInputStream input = new DataInputStream(inputStream); + if (encrypted) { + byte[] initializationVector = new byte[16]; + input.readFully(initializationVector); + IvParameterSpec ivParameterSpec = new IvParameterSpec(initializationVector); + try { + cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec); + } catch (InvalidKeyException | InvalidAlgorithmParameterException e) { + throw new IllegalStateException(e); + } + input = new DataInputStream(new CipherInputStream(inputStream, cipher)); + } + String key = input.readUTF(); + DefaultContentMetadata metadata = readContentMetadata(input); + + CachedContent cachedContent = new CachedContent(id, key, metadata); + content.put(cachedContent.key, cachedContent); + idToKey.put(cachedContent.id, cachedContent.key); + } + } + return true; + } catch (IOException | SQLiteException e) { + return false; + } + } + + @Override + public void storeFully(HashMap content) throws CacheException { + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.beginTransaction(); + try { + initializeTable(writableDatabase); + for (CachedContent cachedContent : content.values()) { + addOrUpdateRow(writableDatabase, cachedContent); + } + writableDatabase.setTransactionSuccessful(); + pendingUpdates.clear(); + } catch (IOException | SQLiteException e) { + throw new CacheException(e); + } finally { + writableDatabase.endTransaction(); + } + } + + @Override + public void storeIncremental(HashMap content) throws CacheException { + if (pendingUpdates.size() == 0) { + return; + } + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.beginTransaction(); + try { + for (int i = 0; i < pendingUpdates.size(); i++) { + CachedContent cachedContent = pendingUpdates.valueAt(i); + if (cachedContent == null) { + deleteRow(writableDatabase, pendingUpdates.keyAt(i)); + } else { + addOrUpdateRow(writableDatabase, cachedContent); + } + } + writableDatabase.setTransactionSuccessful(); + pendingUpdates.clear(); + } catch (IOException | SQLiteException e) { + throw new CacheException(e); + } finally { + writableDatabase.endTransaction(); + } + } + + @Override + public void onUpdate(CachedContent cachedContent) { + pendingUpdates.put(cachedContent.id, cachedContent); + } + + @Override + public void onRemove(CachedContent cachedContent) { + pendingUpdates.put(cachedContent.id, null); + } + + private Cursor getCursor() { + return databaseProvider + .getReadableDatabase() + .query( + TABLE_NAME, + COLUMNS, + /* selection= */ null, + /* selectionArgs= */ null, + /* groupBy= */ null, + /* having= */ null, + /* orderBy= */ null); + } + + private void initializeTable(SQLiteDatabase writableDatabase) { + VersionTable.setVersion( + writableDatabase, VersionTable.FEATURE_CACHE_CONTENT_METADATA, TABLE_VERSION); + writableDatabase.execSQL(SQL_DROP_TABLE_IF_EXISTS); + writableDatabase.execSQL(SQL_CREATE_TABLE); + } + + private void deleteRow(SQLiteDatabase writableDatabase, int key) { + String[] selectionArgs = {Integer.toString(key)}; + writableDatabase.delete(TABLE_NAME, COLUMN_SELECTION_ID, selectionArgs); + } + + private void addOrUpdateRow(SQLiteDatabase writableDatabase, CachedContent cachedContent) + throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + if (bufferedOutputStream == null) { + bufferedOutputStream = new ReusableBufferedOutputStream(outputStream); + } else { + bufferedOutputStream.reset(outputStream); + } + DataOutputStream output = new DataOutputStream(bufferedOutputStream); + try { + if (encrypt) { + byte[] initializationVector = new byte[16]; + random.nextBytes(initializationVector); + output.write(initializationVector); + IvParameterSpec ivParameterSpec = new IvParameterSpec(initializationVector); + try { + cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec); + } catch (InvalidKeyException | InvalidAlgorithmParameterException e) { + throw new IllegalStateException(e); // Should never happen. + } + output.flush(); + output = new DataOutputStream(new CipherOutputStream(bufferedOutputStream, cipher)); + } + output.writeUTF(cachedContent.key); + writeContentMetadata(cachedContent.getMetadata(), output); + } finally { + // Necessary to finalize the cipher. + Util.closeQuietly(output); + } + byte[] data = outputStream.toByteArray(); + + ContentValues values = new ContentValues(); + values.put(COLUMN_ID, cachedContent.id); + values.put(COLUMN_FLAGS, encrypt ? FLAG_ENCRYPTED : 0); + values.put(COLUMN_DATA, data); + writableDatabase.replace(TABLE_NAME, /* nullColumnHack= */ null, values); + } + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java index 843dd19444..9e878ebfbd 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java @@ -17,9 +17,6 @@ package com.google.android.exoplayer2.upstream.cache; import android.support.annotation.Nullable; import com.google.android.exoplayer2.C; -import java.io.DataInputStream; -import java.io.DataOutputStream; -import java.io.IOException; import java.nio.ByteBuffer; import java.nio.charset.Charset; import java.util.Arrays; @@ -28,6 +25,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.Set; /** Default implementation of {@link ContentMetadata}. Values are stored as byte arrays. */ public final class DefaultContentMetadata implements ContentMetadata { @@ -36,39 +34,16 @@ public final class DefaultContentMetadata implements ContentMetadata { public static final DefaultContentMetadata EMPTY = new DefaultContentMetadata(Collections.emptyMap()); - private static final int MAX_VALUE_LENGTH = 10 * 1024 * 1024; private int hashCode; - /** - * Deserializes a {@link DefaultContentMetadata} from the given input stream. - * - * @param input Input stream to read from. - * @return a {@link DefaultContentMetadata} instance. - * @throws IOException If an error occurs during reading from input. - */ - public static DefaultContentMetadata readFromStream(DataInputStream input) throws IOException { - int size = input.readInt(); - HashMap metadata = new HashMap<>(); - for (int i = 0; i < size; i++) { - String name = input.readUTF(); - int valueSize = input.readInt(); - if (valueSize < 0 || valueSize > MAX_VALUE_LENGTH) { - throw new IOException("Invalid value size: " + valueSize); - } - byte[] value = new byte[valueSize]; - input.readFully(value); - metadata.put(name, value); - } - return new DefaultContentMetadata(metadata); - } - private final Map metadata; public DefaultContentMetadata() { this(Collections.emptyMap()); } - private DefaultContentMetadata(Map metadata) { + /** @param metadata The metadata entries in their raw byte array form. */ + public DefaultContentMetadata(Map metadata) { this.metadata = Collections.unmodifiableMap(metadata); } @@ -84,20 +59,9 @@ public final class DefaultContentMetadata implements ContentMetadata { return new DefaultContentMetadata(mutatedMetadata); } - /** - * Serializes itself to a {@link DataOutputStream}. - * - * @param output Output stream to store the values. - * @throws IOException If an error occurs during writing values to output. - */ - public void writeToStream(DataOutputStream output) throws IOException { - output.writeInt(metadata.size()); - for (Entry entry : metadata.entrySet()) { - output.writeUTF(entry.getKey()); - byte[] value = entry.getValue(); - output.writeInt(value.length); - output.write(value); - } + /** Returns the set of metadata entries in their raw byte array form. */ + public Set> entrySet() { + return metadata.entrySet(); } @Override @@ -190,18 +154,7 @@ public final class DefaultContentMetadata implements ContentMetadata { private static void addValues(HashMap metadata, Map values) { for (String name : values.keySet()) { - Object value = values.get(name); - byte[] bytes = getBytes(value); - if (bytes.length > MAX_VALUE_LENGTH) { - throw new IllegalArgumentException( - "The size of " - + name - + " (" - + bytes.length - + ") is greater than maximum allowed: " - + MAX_VALUE_LENGTH); - } - metadata.put(name, bytes); + metadata.put(name, getBytes(values.get(name))); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java index 80eb779e39..f66471ba1f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java @@ -25,7 +25,9 @@ import java.io.File; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; +import java.util.Map; import java.util.NavigableSet; +import java.util.Random; import java.util.Set; import java.util.TreeSet; @@ -36,14 +38,24 @@ import java.util.TreeSet; public final class SimpleCache implements Cache { private static final String TAG = "SimpleCache"; + /** + * Cache files are distributed between a number of subdirectories. This helps to avoid poor + * performance in cases where the performance of the underlying file system (e.g. FAT32) scales + * badly with the number of files per directory. See + * https://github.com/google/ExoPlayer/issues/4253. + */ + private static final int SUBDIRECTORY_COUNT = 10; + private static final HashSet lockedCacheDirs = new HashSet<>(); private static boolean cacheFolderLockingDisabled; private final File cacheDir; private final CacheEvictor evictor; - private final CachedContentIndex index; + private final CachedContentIndex contentIndex; + @Nullable private final CacheFileMetadataIndex fileIndex; private final HashMap> listeners; + private final Random random; private long totalSpace; private boolean released; @@ -118,17 +130,19 @@ public final class SimpleCache implements Cache { * * @param cacheDir A dedicated cache directory. * @param evictor The evictor to be used. - * @param index The CachedContentIndex to be used. + * @param contentIndex The content index to be used. */ - /* package */ SimpleCache(File cacheDir, CacheEvictor evictor, CachedContentIndex index) { + /* package */ SimpleCache(File cacheDir, CacheEvictor evictor, CachedContentIndex contentIndex) { if (!lockFolder(cacheDir)) { throw new IllegalStateException("Another SimpleCache instance uses the folder: " + cacheDir); } this.cacheDir = cacheDir; this.evictor = evictor; - this.index = index; - this.listeners = new HashMap<>(); + this.contentIndex = contentIndex; + this.fileIndex = null; + listeners = new HashMap<>(); + random = new Random(); // Start cache initialization. final ConditionVariable conditionVariable = new ConditionVariable(); @@ -153,10 +167,11 @@ public final class SimpleCache implements Cache { listeners.clear(); removeStaleSpans(); try { - index.store(); + contentIndex.store(); } catch (CacheException e) { Log.e(TAG, "Storing index file failed", e); } finally { + contentIndex.release(); unlockFolder(cacheDir); released = true; } @@ -192,7 +207,7 @@ public final class SimpleCache implements Cache { @Override public synchronized NavigableSet getCachedSpans(String key) { Assertions.checkState(!released); - CachedContent cachedContent = index.get(key); + CachedContent cachedContent = contentIndex.get(key); return cachedContent == null || cachedContent.isEmpty() ? new TreeSet<>() : new TreeSet(cachedContent.getSpans()); @@ -201,7 +216,7 @@ public final class SimpleCache implements Cache { @Override public synchronized Set getKeys() { Assertions.checkState(!released); - return new HashSet<>(index.getKeys()); + return new HashSet<>(contentIndex.getKeys()); } @Override @@ -231,27 +246,32 @@ public final class SimpleCache implements Cache { public synchronized @Nullable SimpleCacheSpan startReadWriteNonBlocking(String key, long position) throws CacheException { Assertions.checkState(!released); - SimpleCacheSpan cacheSpan = getSpan(key, position); + SimpleCacheSpan span = getSpan(key, position); // Read case. - if (cacheSpan.isCached) { - try { - // Obtain a new span with updated last access timestamp. - SimpleCacheSpan newCacheSpan = index.get(key).touch(cacheSpan); - notifySpanTouched(cacheSpan, newCacheSpan); - return newCacheSpan; - } catch (CacheException e) { - // Ignore. In worst case the cache span is evicted early. - // This happens very rarely [Internal: b/38351639] - return cacheSpan; + if (span.isCached) { + String fileName = span.file.getName(); + long length = span.length; + long lastAccessTimestamp = System.currentTimeMillis(); + boolean updateFile = false; + if (fileIndex != null) { + fileIndex.set(fileName, length, lastAccessTimestamp); + } else { + // Updating the file itself to incorporate the new last access timestamp is much slower than + // updating the file index. Hence we only update the file if we don't have a file index. + updateFile = true; } + SimpleCacheSpan newSpan = + contentIndex.get(key).setLastAccessTimestamp(span, lastAccessTimestamp, updateFile); + notifySpanTouched(span, newSpan); + return newSpan; } - CachedContent cachedContent = index.getOrAdd(key); + CachedContent cachedContent = contentIndex.getOrAdd(key); if (!cachedContent.isLocked()) { // Write case, lock available. cachedContent.setLocked(true); - return cacheSpan; + return span; } // Write case, lock not available. @@ -261,7 +281,7 @@ public final class SimpleCache implements Cache { @Override public synchronized File startFile(String key, long position, long length) throws CacheException { Assertions.checkState(!released); - CachedContent cachedContent = index.get(key); + CachedContent cachedContent = contentIndex.get(key); Assertions.checkNotNull(cachedContent); Assertions.checkState(cachedContent.isLocked()); if (!cacheDir.exists()) { @@ -270,8 +290,13 @@ public final class SimpleCache implements Cache { removeStaleSpans(); } evictor.onStartFile(this, key, position, length); - return SimpleCacheSpan.getCacheFile( - cacheDir, cachedContent.id, position, System.currentTimeMillis()); + // Randomly distribute files into subdirectories with a uniform distribution. + File fileDir = new File(cacheDir, Integer.toString(random.nextInt(SUBDIRECTORY_COUNT))); + if (!fileDir.exists()) { + fileDir.mkdir(); + } + long lastAccessTimestamp = System.currentTimeMillis(); + return SimpleCacheSpan.getCacheFile(fileDir, cachedContent.id, position, lastAccessTimestamp); } @Override @@ -284,29 +309,35 @@ public final class SimpleCache implements Cache { file.delete(); return; } - SimpleCacheSpan span = SimpleCacheSpan.createCacheEntry(file, length, index); + + SimpleCacheSpan span = SimpleCacheSpan.createCacheEntry(file, length, contentIndex); Assertions.checkState(span != null); - CachedContent cachedContent = index.get(span.key); + CachedContent cachedContent = contentIndex.get(span.key); Assertions.checkNotNull(cachedContent); Assertions.checkState(cachedContent.isLocked()); + // Check if the span conflicts with the set content length long contentLength = ContentMetadata.getContentLength(cachedContent.getMetadata()); if (contentLength != C.LENGTH_UNSET) { Assertions.checkState((span.position + span.length) <= contentLength); } + + if (fileIndex != null) { + fileIndex.set(file.getName(), span.length, span.lastAccessTimestamp); + } addSpan(span); - index.store(); + contentIndex.store(); notifyAll(); } @Override public synchronized void releaseHoleSpan(CacheSpan holeSpan) { Assertions.checkState(!released); - CachedContent cachedContent = index.get(holeSpan.key); + CachedContent cachedContent = contentIndex.get(holeSpan.key); Assertions.checkNotNull(cachedContent); Assertions.checkState(cachedContent.isLocked()); cachedContent.setLocked(false); - index.maybeRemove(cachedContent.key); + contentIndex.maybeRemove(cachedContent.key); notifyAll(); } @@ -319,14 +350,14 @@ public final class SimpleCache implements Cache { @Override public synchronized boolean isCached(String key, long position, long length) { Assertions.checkState(!released); - CachedContent cachedContent = index.get(key); + CachedContent cachedContent = contentIndex.get(key); return cachedContent != null && cachedContent.getCachedBytesLength(position, length) >= length; } @Override public synchronized long getCachedLength(String key, long position, long length) { Assertions.checkState(!released); - CachedContent cachedContent = index.get(key); + CachedContent cachedContent = contentIndex.get(key); return cachedContent != null ? cachedContent.getCachedBytesLength(position, length) : -length; } @@ -334,14 +365,14 @@ public final class SimpleCache implements Cache { public synchronized void applyContentMetadataMutations( String key, ContentMetadataMutations mutations) throws CacheException { Assertions.checkState(!released); - index.applyContentMetadataMutations(key, mutations); - index.store(); + contentIndex.applyContentMetadataMutations(key, mutations); + contentIndex.store(); } @Override public synchronized ContentMetadata getContentMetadata(String key) { Assertions.checkState(!released); - return index.getContentMetadata(key); + return contentIndex.getContentMetadata(key); } /** @@ -358,7 +389,7 @@ public final class SimpleCache implements Cache { * @return The corresponding cache {@link SimpleCacheSpan}. */ private SimpleCacheSpan getSpan(String key, long position) throws CacheException { - CachedContent cachedContent = index.get(key); + CachedContent cachedContent = contentIndex.get(key); if (cachedContent == null) { return SimpleCacheSpan.createOpenHole(key, position); } @@ -381,40 +412,63 @@ public final class SimpleCache implements Cache { return; } - index.load(); - loadDirectory(cacheDir, /* isRootDirectory= */ true); - index.removeEmpty(); + contentIndex.load(); + if (fileIndex != null) { + Map fileMetadata = fileIndex.getAll(); + loadDirectory(cacheDir, /* isRoot= */ true, fileMetadata); + fileIndex.removeAll(fileMetadata.keySet()); + } else { + loadDirectory(cacheDir, /* isRoot= */ true, /* fileMetadata= */ null); + } + contentIndex.removeEmpty(); try { - index.store(); + contentIndex.store(); } catch (CacheException e) { Log.e(TAG, "Storing index file failed", e); } } - private void loadDirectory(File directory, boolean isRootDirectory) { + /** + * Loads a cache directory. If the root directory is passed, also loads any subdirectories. + * + * @param directory The directory to load. + * @param isRoot Whether the directory is the root directory. + * @param fileMetadata A mutable map containing cache file metadata, keyed by file name. The map + * is modified by removing entries for all loaded files. When the method call returns, the map + * will contain only metadata that was unused. May be null if no file metadata is available. + */ + private void loadDirectory( + File directory, boolean isRoot, @Nullable Map fileMetadata) { File[] files = directory.listFiles(); if (files == null) { // Not a directory. return; } - if (!isRootDirectory && files.length == 0) { + if (!isRoot && files.length == 0) { // Empty non-root directory. directory.delete(); return; } for (File file : files) { String fileName = file.getName(); - if (fileName.indexOf('.') == -1) { - loadDirectory(file, /* isRootDirectory= */ false); + if (isRoot && fileName.indexOf('.') == -1) { + loadDirectory(file, /* isRoot= */ false, fileMetadata); } else { - if (isRootDirectory && CachedContentIndex.FILE_NAME.equals(fileName)) { - // Skip the (expected) index file in the root directory. + if (isRoot && CachedContentIndex.isIndexFile(fileName)) { + // Skip the (expected) index files in the root directory. continue; } - long fileLength = file.length(); + CacheFileMetadata metadata = + fileMetadata != null ? fileMetadata.remove(file.getName()) : null; + long length = C.LENGTH_UNSET; + long lastAccessTimestamp = C.TIME_UNSET; + if (metadata != null) { + length = metadata.length; + lastAccessTimestamp = metadata.lastAccessTimestamp; + } SimpleCacheSpan span = - fileLength > 0 ? SimpleCacheSpan.createCacheEntry(file, fileLength, index) : null; + SimpleCacheSpan.createCacheEntry(file, length, lastAccessTimestamp, contentIndex); if (span != null) { addSpan(span); } else { @@ -430,18 +484,21 @@ public final class SimpleCache implements Cache { * @param span The span to be added. */ private void addSpan(SimpleCacheSpan span) { - index.getOrAdd(span.key).addSpan(span); + contentIndex.getOrAdd(span.key).addSpan(span); totalSpace += span.length; notifySpanAdded(span); } private void removeSpanInternal(CacheSpan span) { - CachedContent cachedContent = index.get(span.key); + CachedContent cachedContent = contentIndex.get(span.key); if (cachedContent == null || !cachedContent.removeSpan(span)) { return; } totalSpace -= span.length; - index.maybeRemove(cachedContent.key); + if (fileIndex != null) { + fileIndex.remove(span.file.getName()); + } + contentIndex.maybeRemove(cachedContent.key); notifySpanRemoved(span); } @@ -451,7 +508,7 @@ public final class SimpleCache implements Cache { */ private void removeStaleSpans() { ArrayList spansToBeRemoved = new ArrayList<>(); - for (CachedContent cachedContent : index.getAll()) { + for (CachedContent cachedContent : contentIndex.getAll()) { for (CacheSpan span : cachedContent.getSpans()) { if (!span.file.exists()) { spansToBeRemoved.add(span); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java index dfa553ffe4..82563af01c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java @@ -26,7 +26,9 @@ import java.util.regex.Pattern; /** This class stores span metadata in filename. */ /* package */ final class SimpleCacheSpan extends CacheSpan { - private static final String SUFFIX = ".v3.exo"; + /* package */ static final String COMMON_SUFFIX = ".exo"; + + private static final String SUFFIX = ".v3" + COMMON_SUFFIX; private static final Pattern CACHE_FILE_PATTERN_V1 = Pattern.compile( "^(.+)\\.(\\d+)\\.(\\d+)\\.v1\\.exo$", Pattern.DOTALL); private static final Pattern CACHE_FILE_PATTERN_V2 = Pattern.compile( @@ -36,16 +38,16 @@ import java.util.regex.Pattern; /** * Returns a new {@link File} instance from {@code cacheDir}, {@code id}, {@code position}, {@code - * lastAccessTimestamp}. + * timestamp}. * * @param cacheDir The parent abstract pathname. * @param id The cache file id. * @param position The position of the stored data in the original stream. - * @param lastAccessTimestamp The last access timestamp. + * @param timestamp The file timestamp. * @return The cache file. */ - public static File getCacheFile(File cacheDir, int id, long position, long lastAccessTimestamp) { - return new File(cacheDir, id + "." + position + "." + lastAccessTimestamp + SUFFIX); + public static File getCacheFile(File cacheDir, int id, long position, long timestamp) { + return new File(cacheDir, id + "." + position + "." + timestamp + SUFFIX); } /** @@ -82,22 +84,36 @@ import java.util.regex.Pattern; return new SimpleCacheSpan(key, position, length, C.TIME_UNSET, null); } - /* - * Note: {@code fileLength} is equivalent to {@code file.length()}, but passing it as an explicit - * argument can reduce the number of calls to this method if the calling code already knows the - * file length. This is preferable because calling {@code file.length()} can be expensive. See: - * https://github.com/google/ExoPlayer/issues/4253#issuecomment-451593889. - */ /** * Creates a cache span from an underlying cache file. Upgrades the file if necessary. * * @param file The cache file. - * @param length The length of the cache file in bytes. + * @param length The length of the cache file in bytes, or {@link C#LENGTH_UNSET} to query the + * underlying file system. Querying the underlying file system can be expensive, so callers + * that already know the length of the file should pass it explicitly. * @return The span, or null if the file name is not correctly formatted, or if the id is not - * present in the content index. + * present in the content index, or if the length is 0. */ @Nullable public static SimpleCacheSpan createCacheEntry(File file, long length, CachedContentIndex index) { + return createCacheEntry(file, length, /* lastAccessTimestamp= */ C.TIME_UNSET, index); + } + + /** + * Creates a cache span from an underlying cache file. Upgrades the file if necessary. + * + * @param file The cache file. + * @param length The length of the cache file in bytes, or {@link C#LENGTH_UNSET} to query the + * underlying file system. Querying the underlying file system can be expensive, so callers + * that already know the length of the file should pass it explicitly. + * @param lastAccessTimestamp The last access timestamp, or {@link C#TIME_UNSET} to use the file + * timestamp. + * @return The span, or null if the file name is not correctly formatted, or if the id is not + * present in the content index, or if the length is 0. + */ + @Nullable + public static SimpleCacheSpan createCacheEntry( + File file, long length, long lastAccessTimestamp, CachedContentIndex index) { String name = file.getName(); if (!name.endsWith(SUFFIX)) { file = upgradeFile(file, index); @@ -111,12 +127,25 @@ import java.util.regex.Pattern; if (!matcher.matches()) { return null; } + int id = Integer.parseInt(matcher.group(1)); String key = index.getKeyForId(id); - return key == null - ? null - : new SimpleCacheSpan( - key, Long.parseLong(matcher.group(2)), length, Long.parseLong(matcher.group(3)), file); + if (key == null) { + return null; + } + + if (length == C.LENGTH_UNSET) { + length = file.length(); + } + if (length == 0) { + return null; + } + + long position = Long.parseLong(matcher.group(2)); + if (lastAccessTimestamp == C.TIME_UNSET) { + lastAccessTimestamp = Long.parseLong(matcher.group(3)); + } + return new SimpleCacheSpan(key, position, length, lastAccessTimestamp, file); } /** @@ -168,18 +197,16 @@ import java.util.regex.Pattern; } /** - * Returns a copy of this CacheSpan whose last access time stamp is set to current time. This - * doesn't copy or change the underlying cache file. + * Returns a copy of this CacheSpan with a new file and last access timestamp. * - * @param id The cache file id. - * @return A {@link SimpleCacheSpan} with updated last access time stamp. + * @param file The new file. + * @param lastAccessTimestamp The new last access time. + * @return A copy with the new file and last access timestamp. * @throws IllegalStateException If called on a non-cached span (i.e. {@link #isCached} is false). */ - public SimpleCacheSpan copyWithUpdatedLastAccessTime(int id) { + public SimpleCacheSpan copyWithFileAndLastAccessTimestamp(File file, long lastAccessTimestamp) { Assertions.checkState(isCached); - long now = System.currentTimeMillis(); - File newCacheFile = getCacheFile(file.getParentFile(), id, position, now); - return new SimpleCacheSpan(key, position, length, now, newCacheFile); + return new SimpleCacheSpan(key, position, length, lastAccessTimestamp, file); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/AtomicFile.java b/library/core/src/main/java/com/google/android/exoplayer2/util/AtomicFile.java index 2466d5a049..b7b6c05d82 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/AtomicFile.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/AtomicFile.java @@ -52,6 +52,11 @@ public final class AtomicFile { backupName = new File(baseName.getPath() + ".bak"); } + /** Whether the file or its backup exists. */ + public boolean exists() { + return baseName.exists() || backupName.exists(); + } + /** Delete the atomic file. This deletes both the base and backup files. */ public void delete() { baseName.delete(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/GlUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/util/GlUtil.java index 9387392ec4..915e855d23 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/GlUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/GlUtil.java @@ -17,7 +17,6 @@ package com.google.android.exoplayer2.util; import static android.opengl.GLU.gluErrorString; -import android.annotation.TargetApi; import android.opengl.GLES11Ext; import android.opengl.GLES20; import android.text.TextUtils; @@ -114,7 +113,6 @@ public final class GlUtil { * Creates a GL_TEXTURE_EXTERNAL_OES with default configuration of GL_LINEAR filtering and * GL_CLAMP_TO_EDGE wrapping. */ - @TargetApi(15) public static int createExternalTexture() { int[] texId = new int[1]; GLES20.glGenTextures(1, IntBuffer.wrap(texId)); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java index 1e1153d367..b9d78f8af2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -418,9 +418,10 @@ public final class Util { } /** - * Returns a normalized RFC 639-2/T code for {@code language}. + * Returns a normalized ISO 639-2/T code for {@code language}. * - * @param language A case-insensitive ISO 639 alpha-2 or alpha-3 language code. + * @param language A case-insensitive ISO 639-1 two-letter or ISO 639-2 three-letter language + * code. * @return The all-lowercase normalized code, or null if the input was null, or {@code * language.toLowerCase()} if the language could not be normalized. */ @@ -1844,10 +1845,8 @@ public final class Util { getDisplaySizeV23(display, displaySize); } else if (Util.SDK_INT >= 17) { getDisplaySizeV17(display, displaySize); - } else if (Util.SDK_INT >= 16) { - getDisplaySizeV16(display, displaySize); } else { - getDisplaySizeV9(display, displaySize); + getDisplaySizeV16(display, displaySize); } return displaySize; } @@ -1903,17 +1902,10 @@ public final class Util { display.getRealSize(outSize); } - @TargetApi(16) private static void getDisplaySizeV16(Display display, Point outSize) { display.getSize(outSize); } - @SuppressWarnings("deprecation") - private static void getDisplaySizeV9(Display display, Point outSize) { - outSize.x = display.getWidth(); - outSize.y = display.getHeight(); - } - private static @C.NetworkType int getMobileNetworkType(NetworkInfo networkInfo) { switch (networkInfo.getSubtype()) { case TelephonyManager.NETWORK_TYPE_EDGE: diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java index 7c4287710d..68e98633d6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java @@ -68,7 +68,6 @@ import java.util.List; * a {@link android.view.SurfaceView}. * */ -@TargetApi(16) public class MediaCodecVideoRenderer extends MediaCodecRenderer { private static final String TAG = "MediaCodecVideoRenderer"; @@ -375,7 +374,6 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { try { super.onDisabled(); } finally { - decoderCounters.ensureUpdated(); eventDispatcher.disabled(decoderCounters); } } @@ -868,7 +866,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { // We dropped some buffers to catch up, so update the decoder counters and flush the codec, // which releases all pending buffers buffers including the current output buffer. updateDroppedBufferCounters(buffersInCodecCount + droppedSourceBufferCount); - flushOrReinitCodec(); + flushOrReinitializeCodec(); return true; } @@ -1096,6 +1094,10 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { throws DecoderQueryException { int maxWidth = format.width; int maxHeight = format.height; + if (codecNeedsMaxVideoSizeResetWorkaround(codecInfo.name)) { + maxWidth = Math.max(maxWidth, 1920); + maxHeight = Math.max(maxHeight, 1089); + } int maxInputSize = getMaxInputSize(codecInfo, format); if (streamFormats.length == 1) { // The single entry in streamFormats must correspond to the format for which the codec is @@ -1283,6 +1285,18 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { return "NVIDIA".equals(Util.MANUFACTURER); } + /** + * Returns whether the codec is known to have problems with the configuration for interlaced + * content and needs minimum values for the maximum video size to force reset the configuration. + * + *

    See https://github.com/google/ExoPlayer/issues/5003. + * + * @param name The name of the codec. + */ + private static boolean codecNeedsMaxVideoSizeResetWorkaround(String name) { + return "OMX.amlogic.avc.decoder.awesome".equals(name) && Util.SDK_INT <= 25; + } + /* * TODO: * @@ -1312,8 +1326,9 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } synchronized (MediaCodecVideoRenderer.class) { if (!evaluatedDeviceNeedsSetOutputSurfaceWorkaround) { - if (Util.SDK_INT <= 27 && "dangal".equals(Util.DEVICE)) { - // Dangal is affected on API level 27: https://github.com/google/ExoPlayer/issues/5169. + if (Util.SDK_INT <= 27 && ("dangal".equals(Util.DEVICE) || "HWEML".equals(Util.DEVICE))) { + // A small number of devices are affected on API level 27: + // https://github.com/google/ExoPlayer/issues/5169. deviceNeedsSetOutputSurfaceWorkaround = true; } else if (Util.SDK_INT >= 27) { // In general, devices running API level 27 or later should be unaffected. Do nothing. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoFrameReleaseTimeHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoFrameReleaseTimeHelper.java index 3c0fb92191..c7e34d00e9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoFrameReleaseTimeHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoFrameReleaseTimeHelper.java @@ -32,7 +32,6 @@ import com.google.android.exoplayer2.util.Util; /** * Makes a best effort to adjust frame release timestamps for a smoother visual result. */ -@TargetApi(16) public final class VideoFrameReleaseTimeHelper { private static final long CHOREOGRAPHER_SAMPLE_DELAY_MILLIS = 500; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoRendererEventListener.java b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoRendererEventListener.java index 7d78ba03c7..f2f451b3d0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoRendererEventListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoRendererEventListener.java @@ -179,6 +179,7 @@ public interface VideoRendererEventListener { /** Invokes {@link VideoRendererEventListener#onVideoDisabled(DecoderCounters)}. */ public void disabled(DecoderCounters counters) { + counters.ensureUpdated(); if (listener != null) { handler.post( () -> { diff --git a/library/core/src/test/assets/mp4/sample.mp4.0.dump b/library/core/src/test/assets/mp4/sample.mp4.0.dump index efc804d48b..b05d8250ab 100644 --- a/library/core/src/test/assets/mp4/sample.mp4.0.dump +++ b/library/core/src/test/assets/mp4/sample.mp4.0.dump @@ -147,7 +147,7 @@ track 0: data = length 530, hash C98BC6A8 sample 29: time = 934266 - flags = 0 + flags = 536870912 data = length 568, hash 4FE5C8EA track 1: format: @@ -352,6 +352,6 @@ track 1: data = length 229, hash FFF98DF0 sample 44: time = 1065678 - flags = 1 + flags = 536870913 data = length 6, hash 31B22286 tracksEnded = true diff --git a/library/core/src/test/assets/mp4/sample.mp4.1.dump b/library/core/src/test/assets/mp4/sample.mp4.1.dump index 10104b5e81..84d86f8ccf 100644 --- a/library/core/src/test/assets/mp4/sample.mp4.1.dump +++ b/library/core/src/test/assets/mp4/sample.mp4.1.dump @@ -147,7 +147,7 @@ track 0: data = length 530, hash C98BC6A8 sample 29: time = 934266 - flags = 0 + flags = 536870912 data = length 568, hash 4FE5C8EA track 1: format: @@ -304,6 +304,6 @@ track 1: data = length 229, hash FFF98DF0 sample 32: time = 1065678 - flags = 1 + flags = 536870913 data = length 6, hash 31B22286 tracksEnded = true diff --git a/library/core/src/test/assets/mp4/sample.mp4.2.dump b/library/core/src/test/assets/mp4/sample.mp4.2.dump index 8af96be673..9bbe8caa01 100644 --- a/library/core/src/test/assets/mp4/sample.mp4.2.dump +++ b/library/core/src/test/assets/mp4/sample.mp4.2.dump @@ -147,7 +147,7 @@ track 0: data = length 530, hash C98BC6A8 sample 29: time = 934266 - flags = 0 + flags = 536870912 data = length 568, hash 4FE5C8EA track 1: format: @@ -244,6 +244,6 @@ track 1: data = length 229, hash FFF98DF0 sample 17: time = 1065678 - flags = 1 + flags = 536870913 data = length 6, hash 31B22286 tracksEnded = true diff --git a/library/core/src/test/assets/mp4/sample.mp4.3.dump b/library/core/src/test/assets/mp4/sample.mp4.3.dump index f1259661ed..f210f277b3 100644 --- a/library/core/src/test/assets/mp4/sample.mp4.3.dump +++ b/library/core/src/test/assets/mp4/sample.mp4.3.dump @@ -147,7 +147,7 @@ track 0: data = length 530, hash C98BC6A8 sample 29: time = 934266 - flags = 0 + flags = 536870912 data = length 568, hash 4FE5C8EA track 1: format: @@ -184,6 +184,6 @@ track 1: data = length 229, hash FFF98DF0 sample 2: time = 1065678 - flags = 1 + flags = 536870913 data = length 6, hash 31B22286 tracksEnded = true diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index f957ac104c..7f01c02b49 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -2028,15 +2028,17 @@ public final class ExoPlayerTest { .blockUntilEnded(TIMEOUT_MS); testRunner.assertPlayedPeriodIndices(0, 1); // Assert that the second period was re-created from the new timeline. - assertThat(mediaSource.getCreatedMediaPeriods()) - .containsExactly( - new MediaPeriodId( - timeline1.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0), - new MediaPeriodId( - timeline1.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 1), - new MediaPeriodId( - timeline2.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 2)) - .inOrder(); + assertThat(mediaSource.getCreatedMediaPeriods()).hasSize(3); + assertThat(mediaSource.getCreatedMediaPeriods().get(0).periodUid) + .isEqualTo(timeline1.getUidOfPeriod(/* periodIndex= */ 0)); + assertThat(mediaSource.getCreatedMediaPeriods().get(1).periodUid) + .isEqualTo(timeline1.getUidOfPeriod(/* periodIndex= */ 1)); + assertThat(mediaSource.getCreatedMediaPeriods().get(2).periodUid) + .isEqualTo(timeline2.getUidOfPeriod(/* periodIndex= */ 1)); + assertThat(mediaSource.getCreatedMediaPeriods().get(1).windowSequenceNumber) + .isGreaterThan(mediaSource.getCreatedMediaPeriods().get(0).windowSequenceNumber); + assertThat(mediaSource.getCreatedMediaPeriods().get(2).windowSequenceNumber) + .isGreaterThan(mediaSource.getCreatedMediaPeriods().get(1).windowSequenceNumber); } @Test diff --git a/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java b/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java index e8f43e3fe6..37f8a05790 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java @@ -37,6 +37,7 @@ import org.robolectric.RobolectricTestRunner; public final class MediaPeriodQueueTest { private static final long CONTENT_DURATION_US = 30 * C.MICROS_PER_SECOND; + private static final long AD_DURATION_US = 10 * C.MICROS_PER_SECOND; private static final long FIRST_AD_START_TIME_US = 10 * C.MICROS_PER_SECOND; private static final long SECOND_AD_START_TIME_US = 20 * C.MICROS_PER_SECOND; @@ -65,18 +66,19 @@ public final class MediaPeriodQueueTest { } @Test - public void testGetNextMediaPeriodInfo_withoutAds_returnsLastMediaPeriodInfo() { - setupInitialTimeline(/* initialPositionUs= */ 0); + public void getNextMediaPeriodInfo_withoutAds_returnsLastMediaPeriodInfo() { + setupTimeline(/* initialPositionUs= */ 0); assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( /* startPositionUs= */ 0, /* endPositionUs= */ C.TIME_UNSET, /* durationUs= */ CONTENT_DURATION_US, - /* isLast= */ true); + /* isLast= */ true, + /* nextAdGroupIndex= */ C.INDEX_UNSET); } @Test - public void testGetNextMediaPeriodInfo_withPrerollAd_returnsCorrectMediaPeriodInfos() { - setupInitialTimeline(/* initialPositionUs= */ 0, /* adGroupTimesUs= */ 0); + public void getNextMediaPeriodInfo_withPrerollAd_returnsCorrectMediaPeriodInfos() { + setupTimeline(/* initialPositionUs= */ 0, /* adGroupTimesUs= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 0); assertNextMediaPeriodInfoIsAd(/* adGroupIndex= */ 0, /* contentPositionUs= */ 0); advance(); @@ -84,12 +86,13 @@ public final class MediaPeriodQueueTest { /* startPositionUs= */ 0, /* endPositionUs= */ C.TIME_UNSET, /* durationUs= */ CONTENT_DURATION_US, - /* isLast= */ true); + /* isLast= */ true, + /* nextAdGroupIndex= */ C.INDEX_UNSET); } @Test - public void testGetNextMediaPeriodInfo_withMidrollAds_returnsCorrectMediaPeriodInfos() { - setupInitialTimeline( + public void getNextMediaPeriodInfo_withMidrollAds_returnsCorrectMediaPeriodInfos() { + setupTimeline( /* initialPositionUs= */ 0, /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US); @@ -97,7 +100,8 @@ public final class MediaPeriodQueueTest { /* startPositionUs= */ 0, /* endPositionUs= */ FIRST_AD_START_TIME_US, /* durationUs= */ FIRST_AD_START_TIME_US, - /* isLast= */ false); + /* isLast= */ false, + /* nextAdGroupIndex= */ 0); // The next media period info should be null as we haven't loaded the ad yet. advance(); assertNull(getNextMediaPeriodInfo()); @@ -109,7 +113,8 @@ public final class MediaPeriodQueueTest { /* startPositionUs= */ FIRST_AD_START_TIME_US, /* endPositionUs= */ SECOND_AD_START_TIME_US, /* durationUs= */ SECOND_AD_START_TIME_US, - /* isLast= */ false); + /* isLast= */ false, + /* nextAdGroupIndex= */ 1); advance(); setAdGroupLoaded(/* adGroupIndex= */ 1); assertNextMediaPeriodInfoIsAd( @@ -119,12 +124,13 @@ public final class MediaPeriodQueueTest { /* startPositionUs= */ SECOND_AD_START_TIME_US, /* endPositionUs= */ C.TIME_UNSET, /* durationUs= */ CONTENT_DURATION_US, - /* isLast= */ true); + /* isLast= */ true, + /* nextAdGroupIndex= */ C.INDEX_UNSET); } @Test - public void testGetNextMediaPeriodInfo_withMidrollAndPostroll_returnsCorrectMediaPeriodInfos() { - setupInitialTimeline( + public void getNextMediaPeriodInfo_withMidrollAndPostroll_returnsCorrectMediaPeriodInfos() { + setupTimeline( /* initialPositionUs= */ 0, /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, C.TIME_END_OF_SOURCE); @@ -132,7 +138,8 @@ public final class MediaPeriodQueueTest { /* startPositionUs= */ 0, /* endPositionUs= */ FIRST_AD_START_TIME_US, /* durationUs= */ FIRST_AD_START_TIME_US, - /* isLast= */ false); + /* isLast= */ false, + /* nextAdGroupIndex= */ 0); advance(); setAdGroupLoaded(/* adGroupIndex= */ 0); assertNextMediaPeriodInfoIsAd( @@ -142,7 +149,8 @@ public final class MediaPeriodQueueTest { /* startPositionUs= */ FIRST_AD_START_TIME_US, /* endPositionUs= */ C.TIME_END_OF_SOURCE, /* durationUs= */ CONTENT_DURATION_US, - /* isLast= */ false); + /* isLast= */ false, + /* nextAdGroupIndex= */ 1); advance(); setAdGroupLoaded(/* adGroupIndex= */ 1); assertNextMediaPeriodInfoIsAd( @@ -152,27 +160,191 @@ public final class MediaPeriodQueueTest { /* startPositionUs= */ CONTENT_DURATION_US, /* endPositionUs= */ C.TIME_UNSET, /* durationUs= */ CONTENT_DURATION_US, - /* isLast= */ true); + /* isLast= */ true, + /* nextAdGroupIndex= */ C.INDEX_UNSET); } @Test - public void testGetNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaPeriodInfo() { - setupInitialTimeline(/* initialPositionUs= */ 0, /* adGroupTimesUs= */ C.TIME_END_OF_SOURCE); + public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaPeriodInfo() { + setupTimeline(/* initialPositionUs= */ 0, /* adGroupTimesUs= */ C.TIME_END_OF_SOURCE); assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( /* startPositionUs= */ 0, /* endPositionUs= */ C.TIME_END_OF_SOURCE, /* durationUs= */ CONTENT_DURATION_US, - /* isLast= */ false); + /* isLast= */ false, + /* nextAdGroupIndex= */ 0); advance(); setAdGroupFailedToLoad(/* adGroupIndex= */ 0); assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( /* startPositionUs= */ CONTENT_DURATION_US, /* endPositionUs= */ C.TIME_UNSET, /* durationUs= */ CONTENT_DURATION_US, - /* isLast= */ true); + /* isLast= */ true, + /* nextAdGroupIndex= */ C.INDEX_UNSET); } - private void setupInitialTimeline(long initialPositionUs, long... adGroupTimesUs) { + @Test + public void + updateQueuedPeriods_withDurationChangeAfterReadingPeriod_handlesChangeAndRemovesPeriodsAfterChangedPeriod() { + setupTimeline( + /* initialPositionUs= */ 0, + /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, + SECOND_AD_START_TIME_US); + setAdGroupLoaded(/* adGroupIndex= */ 0); + setAdGroupLoaded(/* adGroupIndex= */ 1); + enqueueNext(); // Content before first ad. + advancePlaying(); + enqueueNext(); // First ad. + enqueueNext(); // Content between ads. + enqueueNext(); // Second ad. + + // Change position of second ad (= change duration of content between ads). + setupTimeline( + /* initialPositionUs= */ 0, + /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, + SECOND_AD_START_TIME_US + 1); + setAdGroupLoaded(/* adGroupIndex= */ 0); + setAdGroupLoaded(/* adGroupIndex= */ 1); + boolean changeHandled = + mediaPeriodQueue.updateQueuedPeriods( + /* rendererPositionUs= */ 0, /* maxRendererReadPositionUs= */ 0); + + assertThat(changeHandled).isTrue(); + assertThat(getQueueLength()).isEqualTo(3); + } + + @Test + public void + updateQueuedPeriods_withDurationChangeBeforeReadingPeriod_doesntHandleChangeAndRemovesPeriodsAfterChangedPeriod() { + setupTimeline( + /* initialPositionUs= */ 0, + /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, + SECOND_AD_START_TIME_US); + setAdGroupLoaded(/* adGroupIndex= */ 0); + setAdGroupLoaded(/* adGroupIndex= */ 1); + enqueueNext(); // Content before first ad. + advancePlaying(); + enqueueNext(); // First ad. + enqueueNext(); // Content between ads. + enqueueNext(); // Second ad. + advanceReading(); // Reading first ad. + + // Change position of first ad (= change duration of content before first ad). + setupTimeline( + /* initialPositionUs= */ 0, + /* adGroupTimesUs= */ FIRST_AD_START_TIME_US + 1, + SECOND_AD_START_TIME_US); + setAdGroupLoaded(/* adGroupIndex= */ 0); + setAdGroupLoaded(/* adGroupIndex= */ 1); + boolean changeHandled = + mediaPeriodQueue.updateQueuedPeriods( + /* rendererPositionUs= */ 0, /* maxRendererReadPositionUs= */ FIRST_AD_START_TIME_US); + + assertThat(changeHandled).isFalse(); + assertThat(getQueueLength()).isEqualTo(1); + } + + @Test + public void + updateQueuedPeriods_withDurationChangeInReadingPeriodAfterReadingPosition_handlesChangeAndRemovesPeriodsAfterChangedPeriod() { + setupTimeline( + /* initialPositionUs= */ 0, + /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, + SECOND_AD_START_TIME_US); + setAdGroupLoaded(/* adGroupIndex= */ 0); + setAdGroupLoaded(/* adGroupIndex= */ 1); + enqueueNext(); // Content before first ad. + advancePlaying(); + enqueueNext(); // First ad. + enqueueNext(); // Content between ads. + enqueueNext(); // Second ad. + advanceReading(); // Reading first ad. + advanceReading(); // Reading content between ads. + + // Change position of second ad (= change duration of content between ads). + setupTimeline( + /* initialPositionUs= */ 0, + /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, + SECOND_AD_START_TIME_US - 1000); + setAdGroupLoaded(/* adGroupIndex= */ 0); + setAdGroupLoaded(/* adGroupIndex= */ 1); + long readingPositionAtStartOfContentBetweenAds = FIRST_AD_START_TIME_US + AD_DURATION_US; + boolean changeHandled = + mediaPeriodQueue.updateQueuedPeriods( + /* rendererPositionUs= */ 0, + /* maxRendererReadPositionUs= */ readingPositionAtStartOfContentBetweenAds); + + assertThat(changeHandled).isTrue(); + assertThat(getQueueLength()).isEqualTo(3); + } + + @Test + public void + updateQueuedPeriods_withDurationChangeInReadingPeriodBeforeReadingPosition_doesntHandleChangeAndRemovesPeriodsAfterChangedPeriod() { + setupTimeline( + /* initialPositionUs= */ 0, + /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, + SECOND_AD_START_TIME_US); + setAdGroupLoaded(/* adGroupIndex= */ 0); + setAdGroupLoaded(/* adGroupIndex= */ 1); + enqueueNext(); // Content before first ad. + advancePlaying(); + enqueueNext(); // First ad. + enqueueNext(); // Content between ads. + enqueueNext(); // Second ad. + advanceReading(); // Reading first ad. + advanceReading(); // Reading content between ads. + + // Change position of second ad (= change duration of content between ads). + setupTimeline( + /* initialPositionUs= */ 0, + /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, + SECOND_AD_START_TIME_US - 1000); + setAdGroupLoaded(/* adGroupIndex= */ 0); + setAdGroupLoaded(/* adGroupIndex= */ 1); + long readingPositionAtEndOfContentBetweenAds = SECOND_AD_START_TIME_US + AD_DURATION_US; + boolean changeHandled = + mediaPeriodQueue.updateQueuedPeriods( + /* rendererPositionUs= */ 0, + /* maxRendererReadPositionUs= */ readingPositionAtEndOfContentBetweenAds); + + assertThat(changeHandled).isFalse(); + assertThat(getQueueLength()).isEqualTo(3); + } + + @Test + public void + updateQueuedPeriods_withDurationChangeInReadingPeriodReadToEnd_doesntHandleChangeAndRemovesPeriodsAfterChangedPeriod() { + setupTimeline( + /* initialPositionUs= */ 0, + /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, + SECOND_AD_START_TIME_US); + setAdGroupLoaded(/* adGroupIndex= */ 0); + setAdGroupLoaded(/* adGroupIndex= */ 1); + enqueueNext(); // Content before first ad. + advancePlaying(); + enqueueNext(); // First ad. + enqueueNext(); // Content between ads. + enqueueNext(); // Second ad. + advanceReading(); // Reading first ad. + advanceReading(); // Reading content between ads. + + // Change position of second ad (= change duration of content between ads). + setupTimeline( + /* initialPositionUs= */ 0, + /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, + SECOND_AD_START_TIME_US - 1000); + setAdGroupLoaded(/* adGroupIndex= */ 0); + setAdGroupLoaded(/* adGroupIndex= */ 1); + boolean changeHandled = + mediaPeriodQueue.updateQueuedPeriods( + /* rendererPositionUs= */ 0, /* maxRendererReadPositionUs= */ C.TIME_END_OF_SOURCE); + + assertThat(changeHandled).isFalse(); + assertThat(getQueueLength()).isEqualTo(3); + } + + private void setupTimeline(long initialPositionUs, long... adGroupTimesUs) { adPlaybackState = new AdPlaybackState(adGroupTimesUs).withContentDurationUs(CONTENT_DURATION_US); timeline = new SinglePeriodAdTimeline(CONTENT_TIMELINE, adPlaybackState); @@ -196,9 +368,21 @@ public final class MediaPeriodQueueTest { } private void advance() { + enqueueNext(); + advancePlaying(); + } + + private void advancePlaying() { + mediaPeriodQueue.advancePlayingPeriod(); + } + + private void advanceReading() { + mediaPeriodQueue.advanceReadingPeriod(); + } + + private void enqueueNext() { mediaPeriodQueue.enqueueNextMediaPeriod( rendererCapabilities, trackSelector, allocator, mediaSource, getNextMediaPeriodInfo()); - mediaPeriodQueue.advancePlayingPeriod(); } private MediaPeriodInfo getNextMediaPeriodInfo() { @@ -206,10 +390,16 @@ public final class MediaPeriodQueueTest { } private void setAdGroupLoaded(int adGroupIndex) { + long[][] newDurations = new long[adPlaybackState.adGroupCount][]; + for (int i = 0; i < adPlaybackState.adGroupCount; i++) { + newDurations[i] = + i == adGroupIndex ? new long[] {AD_DURATION_US} : adPlaybackState.adGroups[i].durationsUs; + } adPlaybackState = adPlaybackState .withAdCount(adGroupIndex, /* adCount= */ 1) - .withAdUri(adGroupIndex, /* adIndexInAdGroup= */ 0, AD_URI); + .withAdUri(adGroupIndex, /* adIndexInAdGroup= */ 0, AD_URI) + .withAdDurationsUs(newDurations); updateTimeline(); } @@ -227,13 +417,18 @@ public final class MediaPeriodQueueTest { } private void assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( - long startPositionUs, long endPositionUs, long durationUs, boolean isLast) { + long startPositionUs, + long endPositionUs, + long durationUs, + boolean isLast, + int nextAdGroupIndex) { assertThat(getNextMediaPeriodInfo()) .isEqualTo( new MediaPeriodInfo( - new MediaPeriodId(periodUid, /* windowSequenceNumber= */ 0, endPositionUs), + new MediaPeriodId(periodUid, /* windowSequenceNumber= */ 0, nextAdGroupIndex), startPositionUs, /* contentPositionUs= */ C.TIME_UNSET, + endPositionUs, durationUs, /* isLastInTimelinePeriod= */ isLast, /* isFinal= */ isLast)); @@ -250,8 +445,19 @@ public final class MediaPeriodQueueTest { /* windowSequenceNumber= */ 0), /* startPositionUs= */ 0, contentPositionUs, - /* durationUs= */ C.TIME_UNSET, + /* endPositionUs= */ C.TIME_UNSET, + /* durationUs= */ AD_DURATION_US, /* isLastInTimelinePeriod= */ false, /* isFinal= */ false)); } + + private int getQueueLength() { + int length = 0; + MediaPeriodHolder periodHolder = mediaPeriodQueue.getFrontPeriod(); + while (periodHolder != null) { + length++; + periodHolder = periodHolder.getNext(); + } + return length; + } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/database/VersionTableTest.java b/library/core/src/test/java/com/google/android/exoplayer2/database/VersionTableTest.java new file mode 100644 index 0000000000..44961e8681 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/database/VersionTableTest.java @@ -0,0 +1,81 @@ +/* + * 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.database; + +import static com.google.android.exoplayer2.database.VersionTable.FEATURE_CACHE_CONTENT_METADATA; +import static com.google.android.exoplayer2.database.VersionTable.FEATURE_OFFLINE; +import static com.google.common.truth.Truth.assertThat; + +import android.database.sqlite.SQLiteDatabase; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +/** Unit tests for {@link VersionTable}. */ +@RunWith(RobolectricTestRunner.class) +public class VersionTableTest { + + private ExoDatabaseProvider databaseProvider; + private SQLiteDatabase readableDatabase; + private SQLiteDatabase writableDatabase; + + @Before + public void setUp() { + databaseProvider = new ExoDatabaseProvider(RuntimeEnvironment.application); + readableDatabase = databaseProvider.getReadableDatabase(); + writableDatabase = databaseProvider.getWritableDatabase(); + } + + @After + public void tearDown() { + databaseProvider.close(); + } + + @Test + public void getVersion_nonExistingTable_returnsVersionUnset() { + int version = VersionTable.getVersion(readableDatabase, FEATURE_OFFLINE); + assertThat(version).isEqualTo(VersionTable.VERSION_UNSET); + } + + @Test + public void getVersion_returnsSetVersion() { + VersionTable.setVersion(writableDatabase, FEATURE_OFFLINE, 1); + assertThat(VersionTable.getVersion(readableDatabase, FEATURE_OFFLINE)).isEqualTo(1); + + VersionTable.setVersion(writableDatabase, FEATURE_OFFLINE, 10); + assertThat(VersionTable.getVersion(readableDatabase, FEATURE_OFFLINE)).isEqualTo(10); + + VersionTable.setVersion(writableDatabase, FEATURE_CACHE_CONTENT_METADATA, 5); + assertThat(VersionTable.getVersion(readableDatabase, FEATURE_CACHE_CONTENT_METADATA)) + .isEqualTo(5); + assertThat(VersionTable.getVersion(readableDatabase, FEATURE_OFFLINE)).isEqualTo(10); + } + + @Test + public void doesTableExist_nonExistingTable_returnsFalse() { + assertThat(VersionTable.tableExists(readableDatabase, "NonExistingTable")).isFalse(); + } + + @Test + public void doesTableExist_existingTable_returnsTrue() { + String table = "TestTable"; + databaseProvider.getWritableDatabase().execSQL("CREATE TABLE " + table + " (dummy INTEGER)"); + assertThat(VersionTable.tableExists(readableDatabase, table)).isTrue(); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/extractor/mp4/Mp4ExtractorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/extractor/mp4/Mp4ExtractorTest.java index 8850a755be..3c84214686 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/extractor/mp4/Mp4ExtractorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/extractor/mp4/Mp4ExtractorTest.java @@ -15,14 +15,12 @@ */ package com.google.android.exoplayer2.extractor.mp4; -import android.annotation.TargetApi; import com.google.android.exoplayer2.testutil.ExtractorAsserts; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; /** Tests for {@link Mp4Extractor}. */ -@TargetApi(16) @RunWith(RobolectricTestRunner.class) public final class Mp4ExtractorTest { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractorTest.java index 62ad774fd3..9551c9df29 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractorTest.java @@ -15,7 +15,6 @@ */ package com.google.android.exoplayer2.extractor.rawcc; -import android.annotation.TargetApi; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.testutil.ExtractorAsserts; import com.google.android.exoplayer2.util.MimeTypes; @@ -24,7 +23,6 @@ import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; /** Tests for {@link RawCcExtractor}. */ -@TargetApi(16) @RunWith(RobolectricTestRunner.class) public final class RawCcExtractorTest { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloadIndexTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloadIndexTest.java new file mode 100644 index 0000000000..07254ef7f9 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloadIndexTest.java @@ -0,0 +1,211 @@ +/* + * 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.offline; + +import static com.google.common.truth.Truth.assertThat; + +import android.database.sqlite.SQLiteDatabase; +import com.google.android.exoplayer2.database.ExoDatabaseProvider; +import com.google.android.exoplayer2.database.VersionTable; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +/** Unit tests for {@link DefaultDownloadIndex}. */ +@RunWith(RobolectricTestRunner.class) +public class DefaultDownloadIndexTest { + + private ExoDatabaseProvider databaseProvider; + private DefaultDownloadIndex downloadIndex; + + @Before + public void setUp() { + databaseProvider = new ExoDatabaseProvider(RuntimeEnvironment.application); + downloadIndex = new DefaultDownloadIndex(databaseProvider); + } + + @After + public void tearDown() { + databaseProvider.close(); + } + + @Test + public void getDownloadState_nonExistingId_returnsNull() { + assertThat(downloadIndex.getDownloadState("non existing id")).isNull(); + } + + @Test + public void addAndGetDownloadState_nonExistingId_returnsTheSameDownloadState() { + String id = "id"; + DownloadState downloadState = new DownloadStateBuilder(id).build(); + + downloadIndex.putDownloadState(downloadState); + DownloadState readDownloadState = downloadIndex.getDownloadState(id); + + DownloadStateTest.assertEqual(readDownloadState, downloadState); + } + + @Test + public void addAndGetDownloadState_existingId_returnsUpdatedDownloadState() { + String id = "id"; + DownloadStateBuilder downloadStateBuilder = new DownloadStateBuilder(id); + downloadIndex.putDownloadState(downloadStateBuilder.build()); + + DownloadState downloadState = + downloadStateBuilder + .setType("different type") + .setUri("different uri") + .setCacheKey("different cacheKey") + .setState(DownloadState.STATE_FAILED) + .setDownloadPercentage(50) + .setDownloadedBytes(200) + .setTotalBytes(400) + .setFailureReason(DownloadState.FAILURE_REASON_UNKNOWN) + .setStopFlags( + DownloadState.STOP_FLAG_REQUIREMENTS_NOT_MET | DownloadState.STOP_FLAG_MANUAL) + .setNotMetRequirements(0x87654321) + .setManualStopReason(0x12345678) + .setStartTimeMs(10) + .setUpdateTimeMs(20) + .setStreamKeys( + new StreamKey(/* periodIndex= */ 0, /* groupIndex= */ 1, /* trackIndex= */ 2), + new StreamKey(/* periodIndex= */ 3, /* groupIndex= */ 4, /* trackIndex= */ 5)) + .setCustomMetadata(new byte[] {0, 1, 2, 3, 7, 8, 9, 10}) + .build(); + downloadIndex.putDownloadState(downloadState); + DownloadState readDownloadState = downloadIndex.getDownloadState(id); + + assertThat(readDownloadState).isNotNull(); + DownloadStateTest.assertEqual(readDownloadState, downloadState); + } + + @Test + public void releaseAndRecreateDownloadIndex_returnsTheSameDownloadState() { + String id = "id"; + DownloadState downloadState = new DownloadStateBuilder(id).build(); + downloadIndex.putDownloadState(downloadState); + + downloadIndex = new DefaultDownloadIndex(databaseProvider); + DownloadState readDownloadState = downloadIndex.getDownloadState(id); + assertThat(readDownloadState).isNotNull(); + DownloadStateTest.assertEqual(readDownloadState, downloadState); + } + + @Test + public void removeDownloadState_nonExistingId_doesNotFail() { + downloadIndex.removeDownloadState("non existing id"); + } + + @Test + public void removeDownloadState_existingId_getDownloadStateReturnsNull() { + String id = "id"; + DownloadState downloadState = new DownloadStateBuilder(id).build(); + downloadIndex.putDownloadState(downloadState); + downloadIndex.removeDownloadState(id); + + DownloadState readDownloadState = downloadIndex.getDownloadState(id); + assertThat(readDownloadState).isNull(); + } + + @Test + public void getDownloadStates_emptyDownloadIndex_returnsEmptyArray() { + assertThat(downloadIndex.getDownloadStates().getCount()).isEqualTo(0); + } + + @Test + public void getDownloadStates_noState_returnsAllDownloadStatusSortedByStartTime() { + DownloadState downloadState1 = new DownloadStateBuilder("id1").setStartTimeMs(1).build(); + downloadIndex.putDownloadState(downloadState1); + DownloadState downloadState2 = new DownloadStateBuilder("id2").setStartTimeMs(0).build(); + downloadIndex.putDownloadState(downloadState2); + + DownloadStateCursor cursor = downloadIndex.getDownloadStates(); + + assertThat(cursor.getCount()).isEqualTo(2); + cursor.moveToNext(); + DownloadStateTest.assertEqual(cursor.getDownloadState(), downloadState2); + cursor.moveToNext(); + DownloadStateTest.assertEqual(cursor.getDownloadState(), downloadState1); + cursor.close(); + } + + @Test + public void getDownloadStates_withStates_returnsAllDownloadStatusWithTheSameStates() { + DownloadState downloadState1 = + new DownloadStateBuilder("id1") + .setStartTimeMs(0) + .setState(DownloadState.STATE_REMOVED) + .build(); + downloadIndex.putDownloadState(downloadState1); + DownloadState downloadState2 = + new DownloadStateBuilder("id2") + .setStartTimeMs(1) + .setState(DownloadState.STATE_STOPPED) + .build(); + downloadIndex.putDownloadState(downloadState2); + DownloadState downloadState3 = + new DownloadStateBuilder("id3") + .setStartTimeMs(2) + .setState(DownloadState.STATE_COMPLETED) + .build(); + downloadIndex.putDownloadState(downloadState3); + + DownloadStateCursor cursor = + downloadIndex.getDownloadStates(DownloadState.STATE_REMOVED, DownloadState.STATE_COMPLETED); + + assertThat(cursor.getCount()).isEqualTo(2); + cursor.moveToNext(); + DownloadStateTest.assertEqual(cursor.getDownloadState(), downloadState1); + cursor.moveToNext(); + DownloadStateTest.assertEqual(cursor.getDownloadState(), downloadState3); + cursor.close(); + } + + @Test + public void putDownloadState_setsVersion() { + SQLiteDatabase readableDatabase = databaseProvider.getReadableDatabase(); + assertThat(VersionTable.getVersion(readableDatabase, VersionTable.FEATURE_OFFLINE)) + .isEqualTo(VersionTable.VERSION_UNSET); + + downloadIndex.putDownloadState(new DownloadStateBuilder("id1").build()); + + assertThat(VersionTable.getVersion(readableDatabase, VersionTable.FEATURE_OFFLINE)) + .isEqualTo(DefaultDownloadIndex.TABLE_VERSION); + } + + @Test + public void downloadIndex_versionDowngradeWipesData() { + DownloadState downloadState1 = new DownloadStateBuilder("id1").build(); + downloadIndex.putDownloadState(downloadState1); + DownloadStateCursor cursor = downloadIndex.getDownloadStates(); + assertThat(cursor.getCount()).isEqualTo(1); + cursor.close(); + + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + VersionTable.setVersion(writableDatabase, VersionTable.FEATURE_OFFLINE, Integer.MAX_VALUE); + + downloadIndex = new DefaultDownloadIndex(databaseProvider); + + cursor = downloadIndex.getDownloadStates(); + assertThat(cursor.getCount()).isEqualTo(0); + cursor.close(); + assertThat(VersionTable.getVersion(writableDatabase, VersionTable.FEATURE_OFFLINE)) + .isEqualTo(DefaultDownloadIndex.TABLE_VERSION); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java index 5f287d8685..6f41e10046 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java @@ -22,17 +22,29 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.RenderersFactory; +import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.offline.DownloadHelper.Callback; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.testutil.FakeMediaPeriod; +import com.google.android.exoplayer2.testutil.FakeMediaSource; import com.google.android.exoplayer2.testutil.FakeRenderer; +import com.google.android.exoplayer2.testutil.FakeTimeline; +import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; +import com.google.android.exoplayer2.testutil.RobolectricUtil; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.ParametersBuilder; import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.util.ConditionVariable; import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.Util; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.concurrent.atomic.AtomicReference; @@ -40,15 +52,19 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; import org.robolectric.shadows.ShadowLooper; /** Unit tests for {@link DownloadHelper}. */ @RunWith(RobolectricTestRunner.class) +@Config(shadows = {RobolectricUtil.CustomLooper.class, RobolectricUtil.CustomMessageQueue.class}) public class DownloadHelperTest { private static final String TEST_DOWNLOAD_TYPE = "downloadType"; private static final String TEST_CACHE_KEY = "cacheKey"; - private static final ManifestType TEST_MANIFEST = new ManifestType(); + private static final Timeline TEST_TIMELINE = + new FakeTimeline(new TimelineWindowDefinition(/* periodCount= */ 2, /* id= */ new Object())); + private static final Object TEST_MANIFEST = new Object(); private static final Format VIDEO_FORMAT_LOW = createVideoFormat(/* bitrate= */ 200_000); private static final Format VIDEO_FORMAT_HIGH = createVideoFormat(/* bitrate= */ 800_000); @@ -78,7 +94,7 @@ public class DownloadHelperTest { private Uri testUri; - private FakeDownloadHelper downloadHelper; + private DownloadHelper downloadHelper; @Before public void setUp() { @@ -91,14 +107,21 @@ public class DownloadHelperTest { (handler, videoListener, audioListener, metadata, text, drm) -> new Renderer[] {textRenderer, audioRenderer, videoRenderer}; - downloadHelper = new FakeDownloadHelper(testUri, renderersFactory); + downloadHelper = + new DownloadHelper( + TEST_DOWNLOAD_TYPE, + testUri, + TEST_CACHE_KEY, + new TestMediaSource(), + DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS, + Util.getRendererCapabilities(renderersFactory, /* drmSessionManager= */ null)); } @Test public void getManifest_returnsManifest() throws Exception { prepareDownloadHelper(downloadHelper); - ManifestType manifest = downloadHelper.getManifest(); + Object manifest = downloadHelper.getManifest(); assertThat(manifest).isEqualTo(TEST_MANIFEST); } @@ -289,6 +312,73 @@ public class DownloadHelperTest { assertSingleTrackSelectionEquals(selectedVideo1, TRACK_GROUP_VIDEO_SINGLE, 0); } + @Test + public void getTrackSelections_afterAddAudioLanguagesToSelection_returnsCombinedSelections() + throws Exception { + prepareDownloadHelper(downloadHelper); + downloadHelper.clearTrackSelections(/* periodIndex= */ 0); + downloadHelper.clearTrackSelections(/* periodIndex= */ 1); + + // Add a non-default language, and a non-existing language (which will select the default). + downloadHelper.addAudioLanguagesToSelection("ZH", "Klingonese"); + List selectedText0 = + downloadHelper.getTrackSelections(/* periodIndex= */ 0, /* rendererIndex= */ 0); + List selectedAudio0 = + downloadHelper.getTrackSelections(/* periodIndex= */ 0, /* rendererIndex= */ 1); + List selectedVideo0 = + downloadHelper.getTrackSelections(/* periodIndex= */ 0, /* rendererIndex= */ 2); + List selectedText1 = + downloadHelper.getTrackSelections(/* periodIndex= */ 1, /* rendererIndex= */ 0); + List selectedAudio1 = + downloadHelper.getTrackSelections(/* periodIndex= */ 1, /* rendererIndex= */ 1); + List selectedVideo1 = + downloadHelper.getTrackSelections(/* periodIndex= */ 1, /* rendererIndex= */ 2); + + assertThat(selectedVideo0).isEmpty(); + assertThat(selectedText0).isEmpty(); + assertThat(selectedAudio0).hasSize(2); + assertTrackSelectionEquals(selectedAudio0.get(0), TRACK_GROUP_AUDIO_ZH, 0); + assertTrackSelectionEquals(selectedAudio0.get(1), TRACK_GROUP_AUDIO_US, 0); + + assertThat(selectedVideo1).isEmpty(); + assertThat(selectedText1).isEmpty(); + assertSingleTrackSelectionEquals(selectedAudio1, TRACK_GROUP_AUDIO_US, 0); + } + + @Test + public void getTrackSelections_afterAddTextLanguagesToSelection_returnsCombinedSelections() + throws Exception { + prepareDownloadHelper(downloadHelper); + downloadHelper.clearTrackSelections(/* periodIndex= */ 0); + downloadHelper.clearTrackSelections(/* periodIndex= */ 1); + + // Add a non-default language, and a non-existing language (which will select the default). + downloadHelper.addTextLanguagesToSelection( + /* selectUndeterminedTextLanguage= */ true, "ZH", "Klingonese"); + List selectedText0 = + downloadHelper.getTrackSelections(/* periodIndex= */ 0, /* rendererIndex= */ 0); + List selectedAudio0 = + downloadHelper.getTrackSelections(/* periodIndex= */ 0, /* rendererIndex= */ 1); + List selectedVideo0 = + downloadHelper.getTrackSelections(/* periodIndex= */ 0, /* rendererIndex= */ 2); + List selectedText1 = + downloadHelper.getTrackSelections(/* periodIndex= */ 1, /* rendererIndex= */ 0); + List selectedAudio1 = + downloadHelper.getTrackSelections(/* periodIndex= */ 1, /* rendererIndex= */ 1); + List selectedVideo1 = + downloadHelper.getTrackSelections(/* periodIndex= */ 1, /* rendererIndex= */ 2); + + assertThat(selectedVideo0).isEmpty(); + assertThat(selectedAudio0).isEmpty(); + assertThat(selectedText0).hasSize(2); + assertTrackSelectionEquals(selectedText0.get(0), TRACK_GROUP_TEXT_ZH, 0); + assertTrackSelectionEquals(selectedText0.get(1), TRACK_GROUP_TEXT_US, 0); + + assertThat(selectedVideo1).isEmpty(); + assertThat(selectedAudio1).isEmpty(); + assertThat(selectedText1).isEmpty(); + } + @Test public void getDownloadAction_createsDownloadAction_withAllSelectedTracks() throws Exception { prepareDownloadHelper(downloadHelper); @@ -331,18 +421,18 @@ public class DownloadHelperTest { assertThat(removeAction.isRemoveAction).isTrue(); } - private static void prepareDownloadHelper(FakeDownloadHelper downloadHelper) throws Exception { + private static void prepareDownloadHelper(DownloadHelper downloadHelper) throws Exception { AtomicReference prepareException = new AtomicReference<>(null); ConditionVariable preparedCondition = new ConditionVariable(); downloadHelper.prepare( new Callback() { @Override - public void onPrepared(DownloadHelper helper) { + public void onPrepared(DownloadHelper helper) { preparedCondition.open(); } @Override - public void onPrepareError(DownloadHelper helper, IOException e) { + public void onPrepareError(DownloadHelper helper, IOException e) { prepareException.set(e); preparedCondition.open(); } @@ -411,35 +501,38 @@ public class DownloadHelperTest { assertThat(selectedTracksInGroup).isEqualTo(tracks); } - private static final class ManifestType {} + private static final class TestMediaSource extends FakeMediaSource { - private static final class FakeDownloadHelper extends DownloadHelper { - - public FakeDownloadHelper(Uri testUri, RenderersFactory renderersFactory) { - super( - TEST_DOWNLOAD_TYPE, - testUri, - TEST_CACHE_KEY, - DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS, - renderersFactory, - /* drmSessionManager= */ null); + public TestMediaSource() { + super(TEST_TIMELINE, TEST_MANIFEST); } @Override - protected ManifestType loadManifest(Uri uri) throws IOException { - return TEST_MANIFEST; + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + int periodIndex = TEST_TIMELINE.getIndexOfPeriod(id.periodUid); + return new FakeMediaPeriod( + TRACK_GROUP_ARRAYS[periodIndex], + new EventDispatcher() + .withParameters(/* windowIndex= */ 0, id, /* mediaTimeOffsetMs= */ 0)) { + @Override + public List getStreamKeys(List trackSelections) { + List result = new ArrayList<>(); + for (TrackSelection trackSelection : trackSelections) { + int groupIndex = + TRACK_GROUP_ARRAYS[periodIndex].indexOf(trackSelection.getTrackGroup()); + for (int i = 0; i < trackSelection.length(); i++) { + result.add( + new StreamKey(periodIndex, groupIndex, trackSelection.getIndexInTrackGroup(i))); + } + } + return result; + } + }; } @Override - protected TrackGroupArray[] getTrackGroupArrays(ManifestType manifest) { - assertThat(manifest).isEqualTo(TEST_MANIFEST); - return TRACK_GROUP_ARRAYS; - } - - @Override - protected StreamKey toStreamKey( - int periodIndex, int trackGroupIndex, int trackIndexInTrackGroup) { - return new StreamKey(periodIndex, trackGroupIndex, trackIndexInTrackGroup); + public void releasePeriod(MediaPeriod mediaPeriod) { + // Do nothing. } } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadIndexUtilTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadIndexUtilTest.java new file mode 100644 index 0000000000..a2b560cecd --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadIndexUtilTest.java @@ -0,0 +1,156 @@ +/* + * 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.offline; + +import static com.google.android.exoplayer2.offline.DownloadAction.TYPE_DASH; +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; +import com.google.android.exoplayer2.database.ExoDatabaseProvider; +import com.google.android.exoplayer2.util.Util; +import java.io.File; +import java.util.Arrays; +import java.util.List; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +/** Unit tests for {@link DownloadIndexUtil}. */ +@RunWith(RobolectricTestRunner.class) +public class DownloadIndexUtilTest { + + private File tempFile; + private ExoDatabaseProvider databaseProvider; + private DefaultDownloadIndex downloadIndex; + + @Before + public void setUp() throws Exception { + tempFile = Util.createTempFile(RuntimeEnvironment.application, "ExoPlayerTest"); + databaseProvider = new ExoDatabaseProvider(RuntimeEnvironment.application); + downloadIndex = new DefaultDownloadIndex(databaseProvider); + } + + @After + public void tearDown() { + databaseProvider.close(); + tempFile.delete(); + } + + @Test + public void addAction_nonExistingDownloadState_createsNewDownloadState() { + byte[] data = new byte[] {1, 2, 3, 4}; + DownloadAction action = + DownloadAction.createDownloadAction( + TYPE_DASH, + Uri.parse("https://www.test.com/download"), + asList( + new StreamKey(/* periodIndex= */ 0, /* groupIndex= */ 1, /* trackIndex= */ 2), + new StreamKey(/* periodIndex= */ 3, /* groupIndex= */ 4, /* trackIndex= */ 5)), + /* customCacheKey= */ "key123", + data); + + DownloadIndexUtil.addAction(downloadIndex, action.id, action); + + assertDownloadIndexContainsAction(action, DownloadState.STATE_QUEUED); + } + + @Test + public void addAction_existingDownloadState_createsMergedDownloadState() { + StreamKey streamKey1 = + new StreamKey(/* periodIndex= */ 3, /* groupIndex= */ 4, /* trackIndex= */ 5); + StreamKey streamKey2 = + new StreamKey(/* periodIndex= */ 0, /* groupIndex= */ 1, /* trackIndex= */ 2); + DownloadAction action1 = + DownloadAction.createDownloadAction( + TYPE_DASH, + Uri.parse("https://www.test.com/download1"), + asList(streamKey1), + /* customCacheKey= */ "key123", + new byte[] {1, 2, 3, 4}); + DownloadAction action2 = + DownloadAction.createDownloadAction( + TYPE_DASH, + Uri.parse("https://www.test.com/download2"), + asList(streamKey2), + /* customCacheKey= */ "key123", + new byte[] {5, 4, 3, 2, 1}); + DownloadIndexUtil.addAction(downloadIndex, action1.id, action1); + + DownloadIndexUtil.addAction(downloadIndex, action2.id, action2); + + DownloadState downloadState = downloadIndex.getDownloadState(action2.id); + assertThat(downloadState).isNotNull(); + assertThat(downloadState.type).isEqualTo(action2.type); + assertThat(downloadState.cacheKey).isEqualTo(action2.customCacheKey); + assertThat(downloadState.customMetadata).isEqualTo(action2.data); + assertThat(downloadState.uri).isEqualTo(action2.uri); + assertThat(Arrays.asList(downloadState.streamKeys)).containsExactly(streamKey1, streamKey2); + assertThat(downloadState.state).isEqualTo(DownloadState.STATE_QUEUED); + } + + @Test + public void upgradeActionFile_createsDownloadStates() throws Exception { + ActionFile actionFile = new ActionFile(tempFile); + StreamKey streamKey1 = + new StreamKey(/* periodIndex= */ 3, /* groupIndex= */ 4, /* trackIndex= */ 5); + StreamKey streamKey2 = + new StreamKey(/* periodIndex= */ 0, /* groupIndex= */ 1, /* trackIndex= */ 2); + DownloadAction action1 = + DownloadAction.createDownloadAction( + TYPE_DASH, + Uri.parse("https://www.test.com/download1"), + asList(streamKey1), + /* customCacheKey= */ "key123", + new byte[] {1, 2, 3, 4}); + DownloadAction action2 = + DownloadAction.createDownloadAction( + TYPE_DASH, + Uri.parse("https://www.test.com/download2"), + asList(streamKey2), + /* customCacheKey= */ "key234", + new byte[] {5, 4, 3, 2, 1}); + actionFile.store(action1, action2); + DownloadAction action3 = + DownloadAction.createRemoveAction( + TYPE_DASH, Uri.parse("https://www.test.com/download3"), /* customCacheKey= */ "key345"); + actionFile.store(action1, action2, action3); + + DownloadIndexUtil.upgradeActionFile(actionFile, downloadIndex, /* downloadIdProvider= */ null); + + assertDownloadIndexContainsAction(action1, DownloadState.STATE_QUEUED); + assertDownloadIndexContainsAction(action2, DownloadState.STATE_QUEUED); + assertDownloadIndexContainsAction(action3, DownloadState.STATE_REMOVING); + } + + private void assertDownloadIndexContainsAction(DownloadAction action, int state) { + DownloadState downloadState = downloadIndex.getDownloadState(action.id); + assertThat(downloadState).isNotNull(); + assertThat(downloadState.type).isEqualTo(action.type); + assertThat(downloadState.cacheKey).isEqualTo(action.customCacheKey); + assertThat(downloadState.customMetadata).isEqualTo(action.data); + assertThat(downloadState.uri).isEqualTo(action.uri); + assertThat(Arrays.asList(downloadState.streamKeys)).containsExactlyElementsIn(action.keys); + assertThat(downloadState.state).isEqualTo(state); + } + + @SuppressWarnings("unchecked") + private static List asList(StreamKey... streamKeys) { + return Arrays.asList(streamKeys); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java index 5902ac894a..283d343e63 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java @@ -21,6 +21,7 @@ import static org.junit.Assert.fail; import android.net.Uri; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.offline.DownloadState.State; +import com.google.android.exoplayer2.scheduler.Requirements; import com.google.android.exoplayer2.testutil.DummyMainThread; import com.google.android.exoplayer2.testutil.RobolectricUtil; import com.google.android.exoplayer2.testutil.TestDownloadManagerListener; @@ -426,7 +427,12 @@ public class DownloadManagerTest { () -> { downloadManager = new DownloadManager( - actionFile, downloaderFactory, maxActiveDownloadTasks, MIN_RETRY_COUNT); + RuntimeEnvironment.application, + actionFile, + downloaderFactory, + maxActiveDownloadTasks, + MIN_RETRY_COUNT, + new Requirements(0)); downloadManagerListener = new TestDownloadManagerListener(downloadManager, dummyMainThread); downloadManager.startDownloads(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadStateBuilder.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadStateBuilder.java new file mode 100644 index 0000000000..501042e69c --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadStateBuilder.java @@ -0,0 +1,188 @@ +/* + * 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.offline; + +import android.net.Uri; +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.C; + +/** + * Builder for DownloadState. + * + *

    Defines default values for each field (except {@code id}) to facilitate DownloadState creation + * for tests. Tests must avoid depending on the default values but explicitly set tested parameters + * during test initialization. + */ +class DownloadStateBuilder { + private String id; + private String type; + private Uri uri; + @Nullable private String cacheKey; + private int state; + private float downloadPercentage; + private long downloadedBytes; + private long totalBytes; + private int failureReason; + private int stopFlags; + private int notMetRequirements; + private int manualStopReason; + private long startTimeMs; + private long updateTimeMs; + private StreamKey[] streamKeys; + private byte[] customMetadata; + + DownloadStateBuilder(String id) { + this(id, "type", Uri.parse("uri"), /* cacheKey= */ null, new byte[0], new StreamKey[0]); + } + + DownloadStateBuilder(DownloadAction action) { + this( + action.id, + action.type, + action.uri, + action.customCacheKey, + action.data, + action.keys.toArray(new StreamKey[0])); + } + + DownloadStateBuilder( + String id, + String type, + Uri uri, + String cacheKey, + byte[] customMetadata, + StreamKey[] streamKeys) { + this.id = id; + this.type = type; + this.uri = uri; + this.cacheKey = cacheKey; + this.state = DownloadState.STATE_QUEUED; + this.downloadPercentage = (float) C.PERCENTAGE_UNSET; + this.downloadedBytes = (long) 0; + this.totalBytes = (long) C.LENGTH_UNSET; + this.failureReason = DownloadState.FAILURE_REASON_NONE; + this.stopFlags = 0; + this.startTimeMs = (long) 0; + this.updateTimeMs = (long) 0; + this.streamKeys = streamKeys; + this.customMetadata = customMetadata; + } + + public DownloadStateBuilder setId(String id) { + this.id = id; + return this; + } + + public DownloadStateBuilder setType(String type) { + this.type = type; + return this; + } + + public DownloadStateBuilder setUri(String uri) { + this.uri = Uri.parse(uri); + return this; + } + + public DownloadStateBuilder setUri(Uri uri) { + this.uri = uri; + return this; + } + + public DownloadStateBuilder setCacheKey(@Nullable String cacheKey) { + this.cacheKey = cacheKey; + return this; + } + + public DownloadStateBuilder setState(int state) { + this.state = state; + return this; + } + + public DownloadStateBuilder setDownloadPercentage(float downloadPercentage) { + this.downloadPercentage = downloadPercentage; + return this; + } + + public DownloadStateBuilder setDownloadedBytes(long downloadedBytes) { + this.downloadedBytes = downloadedBytes; + return this; + } + + public DownloadStateBuilder setTotalBytes(long totalBytes) { + this.totalBytes = totalBytes; + return this; + } + + public DownloadStateBuilder setFailureReason(int failureReason) { + this.failureReason = failureReason; + return this; + } + + public DownloadStateBuilder setStopFlags(int stopFlags) { + this.stopFlags = stopFlags; + return this; + } + + public DownloadStateBuilder setNotMetRequirements(int notMetRequirements) { + this.notMetRequirements = notMetRequirements; + return this; + } + + public DownloadStateBuilder setManualStopReason(int manualStopReason) { + this.manualStopReason = manualStopReason; + return this; + } + + public DownloadStateBuilder setStartTimeMs(long startTimeMs) { + this.startTimeMs = startTimeMs; + return this; + } + + public DownloadStateBuilder setUpdateTimeMs(long updateTimeMs) { + this.updateTimeMs = updateTimeMs; + return this; + } + + public DownloadStateBuilder setStreamKeys(StreamKey... streamKeys) { + this.streamKeys = streamKeys; + return this; + } + + public DownloadStateBuilder setCustomMetadata(byte[] customMetadata) { + this.customMetadata = customMetadata; + return this; + } + + public DownloadState build() { + return new DownloadState( + id, + type, + uri, + cacheKey, + state, + downloadPercentage, + downloadedBytes, + totalBytes, + failureReason, + stopFlags, + notMetRequirements, + manualStopReason, + startTimeMs, + updateTimeMs, + streamKeys, + customMetadata); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadStateTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadStateTest.java new file mode 100644 index 0000000000..982a6e8ef9 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadStateTest.java @@ -0,0 +1,344 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.offline; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import android.net.Uri; +import java.util.Arrays; +import java.util.Collections; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Unit tests for {@link DownloadState}. */ +@RunWith(RobolectricTestRunner.class) +public class DownloadStateTest { + + private Uri testUri; + + @Before + public void setUp() throws Exception { + testUri = Uri.parse("https://www.test.com/download1"); + } + + @Test + public void mergeAction_actionHaveDifferentType_throwsException() { + DownloadAction downloadAction = createDownloadAction(); + DownloadState downloadState = + new DownloadStateBuilder(downloadAction) + .setType(downloadAction.type + "_different") + .setState(DownloadState.STATE_QUEUED) + .build(); + + try { + downloadState.mergeAction(downloadAction); + fail(); + } catch (Exception e) { + // Expected. + } + } + + @Test + public void mergeAction_actionHaveDifferentId_throwsException() { + DownloadAction downloadAction = createDownloadAction(); + DownloadState downloadState = + new DownloadStateBuilder(downloadAction) + .setId(downloadAction.id + "_different") + .setState(DownloadState.STATE_QUEUED) + .build(); + + try { + downloadState.mergeAction(downloadAction); + fail(); + } catch (Exception e) { + // Expected. + } + } + + @Test + public void mergeAction_actionsWithSameIdAndType_doesNotFail() { + DownloadAction downloadAction = createDownloadAction(); + DownloadStateBuilder downloadStateBuilder = + new DownloadStateBuilder(downloadAction).setState(DownloadState.STATE_QUEUED); + DownloadState downloadState = downloadStateBuilder.build(); + + downloadState.mergeAction(downloadAction); + } + + @Test + public void mergeAction_actionHaveDifferentUri_downloadStateUriIsUpdated() { + DownloadAction downloadAction = createDownloadAction(); + DownloadStateBuilder downloadStateBuilder = + new DownloadStateBuilder(downloadAction) + .setUri(downloadAction.uri + "_different") + .setState(DownloadState.STATE_QUEUED); + DownloadState downloadState = downloadStateBuilder.build(); + + DownloadState mergedDownloadState = downloadState.mergeAction(downloadAction); + + DownloadState expectedDownloadState = downloadStateBuilder.setUri(downloadAction.uri).build(); + assertEqual(mergedDownloadState, expectedDownloadState); + } + + @Test + public void mergeAction_actionHaveDifferentData_downloadStateDataIsUpdated() { + DownloadAction downloadAction = + DownloadAction.createDownloadAction( + DownloadAction.TYPE_DASH, + testUri, + Collections.emptyList(), + /* customCacheKey= */ null, + /* data= */ new byte[] {1, 2, 3, 4}); + DownloadStateBuilder downloadStateBuilder = + new DownloadStateBuilder(downloadAction) + .setState(DownloadState.STATE_QUEUED) + .setCustomMetadata(new byte[0]); + DownloadState downloadState = downloadStateBuilder.build(); + + DownloadState mergedDownloadState = downloadState.mergeAction(downloadAction); + + DownloadState expectedDownloadState = + downloadStateBuilder.setCustomMetadata(downloadAction.data).build(); + assertEqual(mergedDownloadState, expectedDownloadState); + } + + @Test + public void mergeAction_queuedDownloadRemoveAction_stateBecomesRemoving() { + DownloadAction downloadAction = createRemoveAction(); + DownloadStateBuilder downloadStateBuilder = + new DownloadStateBuilder(downloadAction).setState(DownloadState.STATE_QUEUED); + DownloadState downloadState = downloadStateBuilder.build(); + + DownloadState mergedDownloadState = downloadState.mergeAction(downloadAction); + + DownloadState expectedDownloadState = + downloadStateBuilder.setState(DownloadState.STATE_REMOVING).build(); + assertEqual(mergedDownloadState, expectedDownloadState); + } + + @Test + public void mergeAction_removingDownloadDownloadAction_stateBecomesRestarting() { + DownloadAction downloadAction = createDownloadAction(); + DownloadStateBuilder downloadStateBuilder = + new DownloadStateBuilder(downloadAction).setState(DownloadState.STATE_REMOVING); + DownloadState downloadState = downloadStateBuilder.build(); + + DownloadState mergedDownloadState = downloadState.mergeAction(downloadAction); + + DownloadState expectedDownloadState = + downloadStateBuilder.setState(DownloadState.STATE_RESTARTING).build(); + assertEqual(mergedDownloadState, expectedDownloadState); + } + + @Test + public void mergeAction_failedDownloadDownloadAction_stateBecomesQueued() { + DownloadAction downloadAction = createDownloadAction(); + DownloadStateBuilder downloadStateBuilder = + new DownloadStateBuilder(downloadAction) + .setState(DownloadState.STATE_FAILED) + .setFailureReason(DownloadState.FAILURE_REASON_UNKNOWN); + DownloadState downloadState = downloadStateBuilder.build(); + + DownloadState mergedDownloadState = downloadState.mergeAction(downloadAction); + + DownloadState expectedDownloadState = + downloadStateBuilder + .setState(DownloadState.STATE_QUEUED) + .setFailureReason(DownloadState.FAILURE_REASON_NONE) + .build(); + assertEqual(mergedDownloadState, expectedDownloadState); + } + + @Test + public void mergeAction_stoppedDownloadDownloadAction_stateStaysStopped() { + DownloadAction downloadAction = createDownloadAction(); + DownloadStateBuilder downloadStateBuilder = + new DownloadStateBuilder(downloadAction) + .setState(DownloadState.STATE_STOPPED) + .setStopFlags(DownloadState.STOP_FLAG_MANUAL); + DownloadState downloadState = downloadStateBuilder.build(); + + DownloadState mergedDownloadState = downloadState.mergeAction(downloadAction); + + assertEqual(mergedDownloadState, downloadState); + } + + @Test + public void mergeAction_stoppedDownloadRemoveAction_stateBecomesRemoving() { + DownloadAction downloadAction = createRemoveAction(); + DownloadStateBuilder downloadStateBuilder = + new DownloadStateBuilder(downloadAction) + .setState(DownloadState.STATE_STOPPED) + .setStopFlags(DownloadState.STOP_FLAG_MANUAL); + DownloadState downloadState = downloadStateBuilder.build(); + + DownloadState mergedDownloadState = downloadState.mergeAction(downloadAction); + + DownloadState expectedDownloadState = + downloadStateBuilder.setState(DownloadState.STATE_REMOVING).build(); + assertEqual(mergedDownloadState, expectedDownloadState); + } + + @Test + public void mergeAction_restartingDownloadRemoveAction_stateBecomesRemoving() { + DownloadAction downloadAction = createRemoveAction(); + DownloadStateBuilder downloadStateBuilder = + new DownloadStateBuilder(downloadAction).setState(DownloadState.STATE_RESTARTING); + DownloadState downloadState = downloadStateBuilder.build(); + + DownloadState mergedDownloadState = downloadState.mergeAction(downloadAction); + + DownloadState expectedDownloadState = + downloadStateBuilder.setState(DownloadState.STATE_REMOVING).build(); + assertEqual(mergedDownloadState, expectedDownloadState); + } + + @Test + public void mergeAction_returnsMergedKeys() { + StreamKey streamKey1 = new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0); + StreamKey streamKey2 = new StreamKey(/* groupIndex= */ 1, /* trackIndex= */ 1); + StreamKey[] keys1 = new StreamKey[] {streamKey1}; + StreamKey[] keys2 = new StreamKey[] {streamKey2}; + StreamKey[] expectedKeys = new StreamKey[] {streamKey1, streamKey2}; + + doTestMergeActionReturnsMergedKeys(keys1, keys2, expectedKeys); + } + + @Test + public void mergeAction_returnsUniqueKeys() { + StreamKey streamKey1 = new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0); + StreamKey streamKey1Copy = new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0); + StreamKey streamKey2 = new StreamKey(/* groupIndex= */ 1, /* trackIndex= */ 1); + StreamKey[] keys1 = new StreamKey[] {streamKey1}; + StreamKey[] keys2 = new StreamKey[] {streamKey2, streamKey1Copy}; + StreamKey[] expectedKeys = new StreamKey[] {streamKey1, streamKey2}; + + doTestMergeActionReturnsMergedKeys(keys1, keys2, expectedKeys); + } + + @Test + public void mergeAction_ifFirstActionKeysEmpty_returnsEmptyKeys() { + StreamKey streamKey1 = new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0); + StreamKey streamKey2 = new StreamKey(/* groupIndex= */ 1, /* trackIndex= */ 1); + StreamKey[] keys1 = new StreamKey[] {}; + StreamKey[] keys2 = new StreamKey[] {streamKey2, streamKey1}; + StreamKey[] expectedKeys = new StreamKey[] {}; + + doTestMergeActionReturnsMergedKeys(keys1, keys2, expectedKeys); + } + + @Test + public void mergeAction_ifNotFirstActionKeysEmpty_returnsEmptyKeys() { + StreamKey streamKey1 = new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0); + StreamKey streamKey2 = new StreamKey(/* groupIndex= */ 1, /* trackIndex= */ 1); + StreamKey[] keys1 = new StreamKey[] {streamKey2, streamKey1}; + StreamKey[] keys2 = new StreamKey[] {}; + StreamKey[] expectedKeys = new StreamKey[] {}; + + doTestMergeActionReturnsMergedKeys(keys1, keys2, expectedKeys); + } + + private void doTestMergeActionReturnsMergedKeys( + StreamKey[] keys1, StreamKey[] keys2, StreamKey[] expectedKeys) { + DownloadAction downloadAction = + DownloadAction.createDownloadAction( + DownloadAction.TYPE_DASH, + testUri, + Arrays.asList(keys2), + /* customCacheKey= */ null, + /* data= */ null); + DownloadStateBuilder downloadStateBuilder = + new DownloadStateBuilder(downloadAction) + .setState(DownloadState.STATE_QUEUED) + .setStreamKeys(keys1); + DownloadState downloadState = downloadStateBuilder.build(); + + DownloadState mergedDownloadState = downloadState.mergeAction(downloadAction); + + DownloadState expectedDownloadState = downloadStateBuilder.setStreamKeys(expectedKeys).build(); + assertEqual(mergedDownloadState, expectedDownloadState); + } + + static void assertEqual(DownloadState downloadState, DownloadState expected) { + assertThat(areEqual(downloadState, expected)).isTrue(); + } + + private static boolean areEqual(DownloadState downloadState, DownloadState that) { + if (downloadState.state != that.state) { + return false; + } + if (Float.compare(that.downloadPercentage, downloadState.downloadPercentage) != 0) { + return false; + } + if (downloadState.downloadedBytes != that.downloadedBytes) { + return false; + } + if (downloadState.totalBytes != that.totalBytes) { + return false; + } + if (downloadState.startTimeMs != that.startTimeMs) { + return false; + } + if (downloadState.updateTimeMs != that.updateTimeMs) { + return false; + } + if (downloadState.failureReason != that.failureReason) { + return false; + } + if (downloadState.stopFlags != that.stopFlags) { + return false; + } + if (downloadState.notMetRequirements != that.notMetRequirements) { + return false; + } + if (!downloadState.id.equals(that.id)) { + return false; + } + if (!downloadState.type.equals(that.type)) { + return false; + } + if (!downloadState.uri.equals(that.uri)) { + return false; + } + if (downloadState.cacheKey != null + ? !downloadState.cacheKey.equals(that.cacheKey) + : that.cacheKey != null) { + return false; + } + if (downloadState.streamKeys.length != that.streamKeys.length + || !Arrays.asList(downloadState.streamKeys).containsAll(Arrays.asList(that.streamKeys))) { + return false; + } + return Arrays.equals(downloadState.customMetadata, that.customMetadata); + } + + private DownloadAction createDownloadAction() { + return DownloadAction.createDownloadAction( + DownloadAction.TYPE_DASH, + testUri, + Collections.emptyList(), + /* customCacheKey= */ null, + /* data= */ null); + } + + private DownloadAction createRemoveAction() { + return DownloadAction.createRemoveAction( + DownloadAction.TYPE_DASH, testUri, /* customCacheKey= */ null); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java index bb86c6186d..2665b61f8a 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.source; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import android.os.ConditionVariable; @@ -25,6 +26,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import com.google.android.exoplayer2.source.MediaSource.SourceInfoRefreshListener; import com.google.android.exoplayer2.source.ShuffleOrder.DefaultShuffleOrder; import com.google.android.exoplayer2.testutil.DummyMainThread; import com.google.android.exoplayer2.testutil.FakeMediaSource; @@ -42,7 +44,6 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.Mockito; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; @@ -416,7 +417,7 @@ public final class ConcatenatingMediaSourceTest { @Test public void testCustomCallbackBeforePreparationAddSingle() { - Runnable runnable = Mockito.mock(Runnable.class); + Runnable runnable = mock(Runnable.class); mediaSource.addMediaSource(createFakeMediaSource(), new Handler(), runnable); verify(runnable).run(); @@ -424,7 +425,7 @@ public final class ConcatenatingMediaSourceTest { @Test public void testCustomCallbackBeforePreparationAddMultiple() { - Runnable runnable = Mockito.mock(Runnable.class); + Runnable runnable = mock(Runnable.class); mediaSource.addMediaSources( Arrays.asList(new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()}), @@ -435,7 +436,7 @@ public final class ConcatenatingMediaSourceTest { @Test public void testCustomCallbackBeforePreparationAddSingleWithIndex() { - Runnable runnable = Mockito.mock(Runnable.class); + Runnable runnable = mock(Runnable.class); mediaSource.addMediaSource(/* index */ 0, createFakeMediaSource(), new Handler(), runnable); verify(runnable).run(); @@ -443,7 +444,7 @@ public final class ConcatenatingMediaSourceTest { @Test public void testCustomCallbackBeforePreparationAddMultipleWithIndex() { - Runnable runnable = Mockito.mock(Runnable.class); + Runnable runnable = mock(Runnable.class); mediaSource.addMediaSources( /* index */ 0, @@ -455,7 +456,7 @@ public final class ConcatenatingMediaSourceTest { @Test public void testCustomCallbackBeforePreparationRemove() { - Runnable runnable = Mockito.mock(Runnable.class); + Runnable runnable = mock(Runnable.class); mediaSource.addMediaSource(createFakeMediaSource()); mediaSource.removeMediaSource(/* index */ 0, new Handler(), runnable); @@ -464,7 +465,7 @@ public final class ConcatenatingMediaSourceTest { @Test public void testCustomCallbackBeforePreparationMove() { - Runnable runnable = Mockito.mock(Runnable.class); + Runnable runnable = mock(Runnable.class); mediaSource.addMediaSources( Arrays.asList(new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()})); @@ -588,6 +589,29 @@ public final class ConcatenatingMediaSourceTest { } } + @Test + public void testCustomCallbackIsCalledAfterRelease() throws IOException { + DummyMainThread dummyMainThread = new DummyMainThread(); + ConditionVariable callbackCalledCondition = new ConditionVariable(); + try { + dummyMainThread.runOnMainThread( + () -> { + SourceInfoRefreshListener listener = mock(SourceInfoRefreshListener.class); + mediaSource.addMediaSources(Arrays.asList(createMediaSources(2))); + mediaSource.prepareSource(listener, /* mediaTransferListener= */ null); + mediaSource.moveMediaSource( + /* currentIndex= */ 0, + /* newIndex= */ 1, + new Handler(), + callbackCalledCondition::open); + mediaSource.releaseSource(listener); + }); + assertThat(callbackCalledCondition.block(MediaSourceTestRunner.TIMEOUT_MS)).isTrue(); + } finally { + dummyMainThread.release(); + } + } + @Test public void testPeriodCreationWithAds() throws IOException, InterruptedException { // Create concatenated media source with ad child source. @@ -973,7 +997,7 @@ public final class ConcatenatingMediaSourceTest { @Test public void testCustomCallbackBeforePreparationSetShuffleOrder() throws Exception { - Runnable runnable = Mockito.mock(Runnable.class); + Runnable runnable = mock(Runnable.class); mediaSource.setShuffleOrder( new ShuffleOrder.UnshuffledShuffleOrder(/* length= */ 0), new Handler(), runnable); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelectionTest.java b/library/core/src/test/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelectionTest.java index c3836e63f6..bf6b935161 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelectionTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelectionTest.java @@ -244,7 +244,14 @@ public final class AdaptiveTrackSelectionTest { // But TrackBitrateEstimator returns 1500 for 3rd track so it should switch up. TrackBitrateEstimator estimator = mock(TrackBitrateEstimator.class); - when(estimator.getBitrates(any(), any(), any(), any())).thenReturn(new int[] {500, 1000, 1500}); + when(estimator.getBitrates(any(), any(), any(), any())) + .then( + (invocation) -> { + int[] returnValue = new int[] {500, 1000, 1500}; + int[] inputArray = (int[]) invocation.getArguments()[3]; + System.arraycopy(returnValue, 0, inputArray, 0, returnValue.length); + return returnValue; + }); adaptiveTrackSelection = adaptiveTrackSelection(trackGroup); adaptiveTrackSelection.experimental_setTrackBitrateEstimator(estimator); @@ -385,49 +392,64 @@ public final class AdaptiveTrackSelectionTest { private AdaptiveTrackSelection adaptiveTrackSelectionWithMinDurationForQualityIncreaseMs( TrackGroup trackGroup, long minDurationForQualityIncreaseMs) { - return new AdaptiveTrackSelection( - trackGroup, - selectedAllTracksInGroup(trackGroup), - mockBandwidthMeter, - minDurationForQualityIncreaseMs, - AdaptiveTrackSelection.DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, - AdaptiveTrackSelection.DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, - /* bandwidthFraction= */ 1.0f, - AdaptiveTrackSelection.DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, - AdaptiveTrackSelection.DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, - fakeClock); + return prepareTrackSelection( + new AdaptiveTrackSelection( + trackGroup, + selectedAllTracksInGroup(trackGroup), + mockBandwidthMeter, + minDurationForQualityIncreaseMs, + AdaptiveTrackSelection.DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, + AdaptiveTrackSelection.DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, + /* bandwidthFraction= */ 1.0f, + AdaptiveTrackSelection.DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, + AdaptiveTrackSelection.DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, + fakeClock)); } private AdaptiveTrackSelection adaptiveTrackSelectionWithMaxDurationForQualityDecreaseMs( TrackGroup trackGroup, long maxDurationForQualityDecreaseMs) { - return new AdaptiveTrackSelection( - trackGroup, - selectedAllTracksInGroup(trackGroup), - mockBandwidthMeter, - AdaptiveTrackSelection.DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS, - maxDurationForQualityDecreaseMs, - AdaptiveTrackSelection.DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, - /* bandwidthFraction= */ 1.0f, - AdaptiveTrackSelection.DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, - AdaptiveTrackSelection.DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, - fakeClock); + return prepareTrackSelection( + new AdaptiveTrackSelection( + trackGroup, + selectedAllTracksInGroup(trackGroup), + mockBandwidthMeter, + AdaptiveTrackSelection.DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS, + maxDurationForQualityDecreaseMs, + AdaptiveTrackSelection.DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, + /* bandwidthFraction= */ 1.0f, + AdaptiveTrackSelection.DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, + AdaptiveTrackSelection.DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, + fakeClock)); } private AdaptiveTrackSelection adaptiveTrackSelectionWithMinTimeBetweenBufferReevaluationMs( TrackGroup trackGroup, long durationToRetainAfterDiscardMs, long minTimeBetweenBufferReevaluationMs) { - return new AdaptiveTrackSelection( - trackGroup, - selectedAllTracksInGroup(trackGroup), - mockBandwidthMeter, - AdaptiveTrackSelection.DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS, - AdaptiveTrackSelection.DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, - durationToRetainAfterDiscardMs, - /* bandwidthFraction= */ 1.0f, - AdaptiveTrackSelection.DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, - minTimeBetweenBufferReevaluationMs, - fakeClock); + return prepareTrackSelection( + new AdaptiveTrackSelection( + trackGroup, + selectedAllTracksInGroup(trackGroup), + mockBandwidthMeter, + AdaptiveTrackSelection.DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS, + AdaptiveTrackSelection.DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, + durationToRetainAfterDiscardMs, + /* bandwidthFraction= */ 1.0f, + AdaptiveTrackSelection.DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, + minTimeBetweenBufferReevaluationMs, + fakeClock)); + } + + private AdaptiveTrackSelection prepareTrackSelection( + AdaptiveTrackSelection adaptiveTrackSelection) { + adaptiveTrackSelection.enable(); + adaptiveTrackSelection.updateSelectedTrack( + /* playbackPositionUs= */ 0, + /* bufferedDurationUs= */ 0, + /* availableDurationUs= */ C.TIME_UNSET, + /* queue= */ Collections.emptyList(), + /* mediaChunkIterators= */ THREE_EMPTY_MEDIA_CHUNK_ITERATORS); + return adaptiveTrackSelection; } private int[] selectedAllTracksInGroup(TrackGroup trackGroup) { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java index 666fa87e9e..f8c417499f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java @@ -47,7 +47,7 @@ import org.robolectric.RuntimeEnvironment; public final class CacheDataSourceTest { private static final byte[] TEST_DATA = new byte[] {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; - private static final int MAX_CACHE_FILE_SIZE = 3; + private static final int CACHE_FRAGMENT_SIZE = 3; private static final String DATASPEC_KEY = "dataSpecKey"; private Uri testDataUri; @@ -81,13 +81,13 @@ public final class CacheDataSourceTest { } @Test - public void testMaxCacheFileSize() throws Exception { + public void testFragmentSize() throws Exception { CacheDataSource cacheDataSource = createCacheDataSource(false, false); assertReadDataContentLength(cacheDataSource, boundedDataSpec, false, false); for (String key : cache.getKeys()) { for (CacheSpan cacheSpan : cache.getCachedSpans(key)) { - assertThat(cacheSpan.length <= MAX_CACHE_FILE_SIZE).isTrue(); - assertThat(cacheSpan.file.length() <= MAX_CACHE_FILE_SIZE).isTrue(); + assertThat(cacheSpan.length <= CACHE_FRAGMENT_SIZE).isTrue(); + assertThat(cacheSpan.file.length() <= CACHE_FRAGMENT_SIZE).isTrue(); } } } @@ -548,14 +548,14 @@ public final class CacheDataSourceTest { setReadException, unknownLength, CacheDataSource.FLAG_BLOCK_ON_CACHE, - new CacheDataSink(cache, MAX_CACHE_FILE_SIZE), + new CacheDataSink(cache, CACHE_FRAGMENT_SIZE), cacheKeyFactory); } private CacheDataSource createCacheDataSource( boolean setReadException, boolean unknownLength, @CacheDataSource.Flags int flags) { return createCacheDataSource( - setReadException, unknownLength, flags, new CacheDataSink(cache, MAX_CACHE_FILE_SIZE)); + setReadException, unknownLength, flags, new CacheDataSink(cache, CACHE_FRAGMENT_SIZE)); } private CacheDataSource createCacheDataSource( @@ -602,6 +602,7 @@ public final class CacheDataSourceTest { } private DataSpec buildDataSpec(long position, long length, @Nullable String key) { - return new DataSpec(testDataUri, position, length, key); + return new DataSpec( + testDataUri, position, length, key, DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION); } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java index d1bf734a98..228567a4bc 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java @@ -336,17 +336,17 @@ public final class CacheUtilTest { FakeDataSource dataSource = new FakeDataSource(fakeDataSet); Uri uri = Uri.parse("test_data"); - DataSpec dataSpec = new DataSpec(uri); + DataSpec dataSpec = new DataSpec(uri, DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION); CacheUtil.cache( dataSpec, cache, /* cacheKeyFactory= */ null, - // Set maxCacheFileSize to 10 to make sure there are multiple spans. + // Set fragmentSize to 10 to make sure there are multiple spans. new CacheDataSource( cache, dataSource, new FileDataSource(), - new CacheDataSink(cache, /* maxCacheFileSize= */ 10), + new CacheDataSink(cache, /* fragmentSize= */ 10), /* flags= */ 0, /* eventListener= */ null), new byte[CacheUtil.DEFAULT_BUFFER_SIZE_BYTES], diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndexTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndexTest.java index e7bdb0743e..d86e76d147 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndexTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndexTest.java @@ -151,7 +151,8 @@ public class CachedContentIndexTest { @Test public void testLoadV1() throws Exception { - FileOutputStream fos = new FileOutputStream(new File(cacheDir, CachedContentIndex.FILE_NAME)); + FileOutputStream fos = + new FileOutputStream(new File(cacheDir, CachedContentIndex.FILE_NAME_ATOMIC)); fos.write(testIndexV1File); fos.close(); @@ -169,7 +170,8 @@ public class CachedContentIndexTest { @Test public void testLoadV2() throws Exception { - FileOutputStream fos = new FileOutputStream(new File(cacheDir, CachedContentIndex.FILE_NAME)); + FileOutputStream fos = + new FileOutputStream(new File(cacheDir, CachedContentIndex.FILE_NAME_ATOMIC)); fos.write(testIndexV2File); fos.close(); @@ -220,7 +222,7 @@ public class CachedContentIndexTest { new CachedContentIndex(cacheDir, key), new CachedContentIndex(cacheDir, key)); // Rename the index file from the test above - File file1 = new File(cacheDir, CachedContentIndex.FILE_NAME); + File file1 = new File(cacheDir, CachedContentIndex.FILE_NAME_ATOMIC); File file2 = new File(cacheDir, "file2compare"); assertThat(file1.renameTo(file2)).isTrue(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadataTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadataTest.java index e1dc68eac6..e4ec278c22 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadataTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadataTest.java @@ -17,10 +17,6 @@ package com.google.android.exoplayer2.upstream.cache; import static com.google.common.truth.Truth.assertThat; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.DataInputStream; -import java.io.DataOutputStream; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -133,24 +129,6 @@ public class DefaultContentMetadataTest { assertThat(contentMetadata.get("metadata name", "default value")).isEqualTo("value"); } - @Test - public void testSerializeDeserialize() throws Exception { - byte[] metadata3 = {1, 2, 3}; - contentMetadata = - createContentMetadata( - "metadata1 name", "value", "metadata2 name", 12345, "metadata3 name", metadata3); - - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - contentMetadata.writeToStream(new DataOutputStream(outputStream)); - ByteArrayInputStream inputStream = new ByteArrayInputStream(outputStream.toByteArray()); - DefaultContentMetadata contentMetadata2 = - DefaultContentMetadata.readFromStream(new DataInputStream(inputStream)); - - assertThat(contentMetadata2.get("metadata1 name", "default value")).isEqualTo("value"); - assertThat(contentMetadata2.get("metadata2 name", 0)).isEqualTo(12345); - assertThat(contentMetadata2.get("metadata3 name", new byte[] {})).isEqualTo(metadata3); - } - @Test public void testEqualsStringValues() throws Exception { DefaultContentMetadata metadata1 = createContentMetadata("metadata1", "value"); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java index bdb9d4f9d9..6140d0ac82 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java @@ -75,7 +75,7 @@ public class SimpleCacheTest { NavigableSet cachedSpans = simpleCache.getCachedSpans(KEY_1); assertThat(cachedSpans.isEmpty()).isTrue(); assertThat(simpleCache.getCacheSpace()).isEqualTo(0); - assertThat(cacheDir.listFiles()).hasLength(0); + assertNoCacheFiles(cacheDir); addCache(simpleCache, KEY_1, 0, 15); @@ -233,7 +233,7 @@ public class SimpleCacheTest { // Cache should be cleared assertThat(simpleCache.getKeys()).isEmpty(); - assertThat(cacheDir.listFiles()).hasLength(0); + assertNoCacheFiles(cacheDir); } @Test @@ -252,7 +252,7 @@ public class SimpleCacheTest { // Cache should be cleared assertThat(simpleCache.getKeys()).isEmpty(); - assertThat(cacheDir.listFiles()).hasLength(0); + assertNoCacheFiles(cacheDir); } @Test @@ -391,6 +391,20 @@ public class SimpleCacheTest { } } + private static void assertNoCacheFiles(File dir) { + File[] files = dir.listFiles(); + if (files == null) { + return; + } + for (File file : files) { + if (file.isDirectory()) { + assertNoCacheFiles(file); + } else { + assertThat(file.getName().endsWith(SimpleCacheSpan.COMMON_SUFFIX)).isFalse(); + } + } + } + private static byte[] generateData(String key, int position, int length) { byte[] bytes = new byte[length]; new Random((long) (key.hashCode() ^ position)).nextBytes(bytes); diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java index d0a12b9688..fd0453e79e 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java @@ -22,6 +22,7 @@ import android.util.SparseIntArray; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.SeekParameters; +import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory; import com.google.android.exoplayer2.source.EmptySampleStream; import com.google.android.exoplayer2.source.MediaPeriod; @@ -46,6 +47,7 @@ import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.LoaderErrorThrower; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -191,6 +193,49 @@ import java.util.List; return trackGroups; } + @Override + public List getStreamKeys(List trackSelections) { + List manifestAdaptationSets = manifest.getPeriod(periodIndex).adaptationSets; + List streamKeys = new ArrayList<>(); + for (TrackSelection trackSelection : trackSelections) { + int trackGroupIndex = trackGroups.indexOf(trackSelection.getTrackGroup()); + TrackGroupInfo trackGroupInfo = trackGroupInfos[trackGroupIndex]; + if (trackGroupInfo.trackGroupCategory != TrackGroupInfo.CATEGORY_PRIMARY) { + // Ignore non-primary tracks. + continue; + } + int[] adaptationSetIndices = trackGroupInfo.adaptationSetIndices; + int[] trackIndices = new int[trackSelection.length()]; + for (int i = 0; i < trackSelection.length(); i++) { + trackIndices[i] = trackSelection.getIndexInTrackGroup(i); + } + Arrays.sort(trackIndices); + + int currentAdaptationSetIndex = 0; + int totalTracksInPreviousAdaptationSets = 0; + int tracksInCurrentAdaptationSet = + manifestAdaptationSets.get(adaptationSetIndices[0]).representations.size(); + for (int i = 0; i < trackIndices.length; i++) { + while (trackIndices[i] + >= totalTracksInPreviousAdaptationSets + tracksInCurrentAdaptationSet) { + currentAdaptationSetIndex++; + totalTracksInPreviousAdaptationSets += tracksInCurrentAdaptationSet; + tracksInCurrentAdaptationSet = + manifestAdaptationSets + .get(adaptationSetIndices[currentAdaptationSetIndex]) + .representations + .size(); + } + streamKeys.add( + new StreamKey( + periodIndex, + adaptationSetIndices[currentAdaptationSetIndex], + trackIndices[i] - totalTracksInPreviousAdaptationSets)); + } + } + return streamKeys; + } + @Override public long selectTracks(TrackSelection[] selections, boolean[] mayRetainStreamFlags, SampleStream[] streams, boolean[] streamResetFlags, long positionUs) { @@ -452,13 +497,22 @@ import java.util.List; if (adaptationSetSwitchingProperty == null) { groupedAdaptationSetIndices[groupCount++] = new int[] {i}; } else { - String[] extraAdaptationSetIds = adaptationSetSwitchingProperty.value.split(","); + String[] extraAdaptationSetIds = Util.split(adaptationSetSwitchingProperty.value, ","); int[] adaptationSetIndices = new int[1 + extraAdaptationSetIds.length]; adaptationSetIndices[0] = i; + int outputIndex = 1; for (int j = 0; j < extraAdaptationSetIds.length; j++) { - int extraIndex = idToIndexMap.get(Integer.parseInt(extraAdaptationSetIds[j])); - adaptationSetUsedFlags[extraIndex] = true; - adaptationSetIndices[1 + j] = extraIndex; + int extraIndex = + idToIndexMap.get( + Integer.parseInt(extraAdaptationSetIds[j]), /* valueIfKeyNotFound= */ -1); + if (extraIndex != -1) { + adaptationSetUsedFlags[extraIndex] = true; + adaptationSetIndices[outputIndex] = extraIndex; + outputIndex++; + } + } + if (outputIndex < adaptationSetIndices.length) { + adaptationSetIndices = Arrays.copyOf(adaptationSetIndices, outputIndex); } groupedAdaptationSetIndices[groupCount++] = adaptationSetIndices; } @@ -687,7 +741,7 @@ import java.util.List; public final int[] adaptationSetIndices; public final int trackType; - public @TrackGroupCategory final int trackGroupCategory; + @TrackGroupCategory public final int trackGroupCategory; public final int eventStreamGroupIndex; public final int primaryTrackGroupIndex; @@ -738,7 +792,7 @@ import java.util.List; return new TrackGroupInfo( C.TRACK_TYPE_METADATA, CATEGORY_MANIFEST_EVENTS, - null, + new int[0], -1, C.INDEX_UNSET, C.INDEX_UNSET, diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java index b31b770b03..cfdbdac1ea 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -654,7 +654,8 @@ public final class DashMediaSource extends BaseMediaSource { } @Override - public MediaPeriod createPeriod(MediaPeriodId periodId, Allocator allocator) { + public MediaPeriod createPeriod( + MediaPeriodId periodId, Allocator allocator, long startPositionUs) { int periodIndex = (Integer) periodId.periodUid - firstPeriodId; EventDispatcher periodEventDispatcher = createEventDispatcher(periodId, manifest.getPeriod(periodIndex).startMs); diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java index 33890a6767..a02b2f1ee7 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java @@ -457,10 +457,10 @@ public class DefaultDashChunkSource implements DashChunkSource { } private ArrayList getRepresentations() { - List manifestAdapationSets = manifest.getPeriod(periodIndex).adaptationSets; + List manifestAdaptationSets = manifest.getPeriod(periodIndex).adaptationSets; ArrayList representations = new ArrayList<>(); for (int adaptationSetIndex : adaptationSetIndices) { - representations.addAll(manifestAdapationSets.get(adaptationSetIndex).representations); + representations.addAll(manifestAdaptationSets.get(adaptationSetIndex).representations); } return representations; } diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloadHelper.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloadHelper.java deleted file mode 100644 index f86e47ed3d..0000000000 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloadHelper.java +++ /dev/null @@ -1,126 +0,0 @@ -/* - * 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.source.dash.offline; - -import android.net.Uri; -import android.support.annotation.Nullable; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.Format; -import com.google.android.exoplayer2.RenderersFactory; -import com.google.android.exoplayer2.drm.DrmSessionManager; -import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; -import com.google.android.exoplayer2.offline.DownloadAction; -import com.google.android.exoplayer2.offline.DownloadHelper; -import com.google.android.exoplayer2.offline.StreamKey; -import com.google.android.exoplayer2.source.TrackGroup; -import com.google.android.exoplayer2.source.TrackGroupArray; -import com.google.android.exoplayer2.source.dash.manifest.AdaptationSet; -import com.google.android.exoplayer2.source.dash.manifest.DashManifest; -import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; -import com.google.android.exoplayer2.source.dash.manifest.Representation; -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.ParsingLoadable; -import java.io.IOException; -import java.util.List; - -/** A {@link DownloadHelper} for DASH streams. */ -public final class DashDownloadHelper extends DownloadHelper { - - private final DataSource.Factory manifestDataSourceFactory; - - /** - * Creates a DASH download helper. - * - *

    The helper uses {@link DownloadHelper#DEFAULT_TRACK_SELECTOR_PARAMETERS} for track selection - * and does not support drm protected content. - * - * @param uri A manifest {@link Uri}. - * @param manifestDataSourceFactory A {@link DataSource.Factory} used to load the manifest. - * @param renderersFactory The {@link RenderersFactory} creating the renderers for which tracks - * are selected. - */ - public DashDownloadHelper( - Uri uri, DataSource.Factory manifestDataSourceFactory, RenderersFactory renderersFactory) { - this( - uri, - manifestDataSourceFactory, - DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS, - renderersFactory, - /* drmSessionManager= */ null); - } - - /** - * Creates a DASH download helper. - * - * @param uri A manifest {@link Uri}. - * @param manifestDataSourceFactory A {@link DataSource.Factory} used to load the manifest. - * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for - * downloading. - * @param renderersFactory The {@link RenderersFactory} creating the renderers for which tracks - * are selected. - * @param drmSessionManager An optional {@link DrmSessionManager} used by the renderers created by - * {@code renderersFactory}. - */ - public DashDownloadHelper( - Uri uri, - DataSource.Factory manifestDataSourceFactory, - DefaultTrackSelector.Parameters trackSelectorParameters, - RenderersFactory renderersFactory, - @Nullable DrmSessionManager drmSessionManager) { - super( - DownloadAction.TYPE_DASH, - uri, - /* cacheKey= */ null, - trackSelectorParameters, - renderersFactory, - drmSessionManager); - this.manifestDataSourceFactory = manifestDataSourceFactory; - } - - @Override - protected DashManifest loadManifest(Uri uri) throws IOException { - DataSource dataSource = manifestDataSourceFactory.createDataSource(); - return ParsingLoadable.load(dataSource, new DashManifestParser(), uri, C.DATA_TYPE_MANIFEST); - } - - @Override - public TrackGroupArray[] getTrackGroupArrays(DashManifest manifest) { - int periodCount = manifest.getPeriodCount(); - TrackGroupArray[] trackGroupArrays = new TrackGroupArray[periodCount]; - for (int periodIndex = 0; periodIndex < periodCount; periodIndex++) { - List adaptationSets = manifest.getPeriod(periodIndex).adaptationSets; - TrackGroup[] trackGroups = new TrackGroup[adaptationSets.size()]; - for (int i = 0; i < trackGroups.length; i++) { - List representations = adaptationSets.get(i).representations; - Format[] formats = new Format[representations.size()]; - int representationsCount = representations.size(); - for (int j = 0; j < representationsCount; j++) { - formats[j] = representations.get(j).format; - } - trackGroups[i] = new TrackGroup(formats); - } - trackGroupArrays[periodIndex] = new TrackGroupArray(trackGroups); - } - return trackGroupArrays; - } - - @Override - protected StreamKey toStreamKey( - int periodIndex, int trackGroupIndex, int trackIndexInTrackGroup) { - return new StreamKey(periodIndex, trackGroupIndex, trackIndexInTrackGroup); - } -} diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java new file mode 100644 index 0000000000..0d9fee282c --- /dev/null +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java @@ -0,0 +1,247 @@ +/* + * 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.source.dash; + +import static org.mockito.Mockito.mock; + +import android.net.Uri; +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import com.google.android.exoplayer2.source.dash.PlayerEmsgHandler.PlayerEmsgCallback; +import com.google.android.exoplayer2.source.dash.manifest.AdaptationSet; +import com.google.android.exoplayer2.source.dash.manifest.DashManifest; +import com.google.android.exoplayer2.source.dash.manifest.Descriptor; +import com.google.android.exoplayer2.source.dash.manifest.Period; +import com.google.android.exoplayer2.source.dash.manifest.Representation; +import com.google.android.exoplayer2.source.dash.manifest.SegmentBase.SingleSegmentBase; +import com.google.android.exoplayer2.source.dash.manifest.UtcTimingElement; +import com.google.android.exoplayer2.testutil.MediaPeriodAsserts; +import com.google.android.exoplayer2.testutil.MediaPeriodAsserts.FilterableManifestMediaPeriodFactory; +import com.google.android.exoplayer2.testutil.RobolectricUtil; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.LoaderErrorThrower; +import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.MimeTypes; +import java.util.Arrays; +import java.util.Collections; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** Unit tests for {@link DashMediaPeriod}. */ +@RunWith(RobolectricTestRunner.class) +@Config(shadows = {RobolectricUtil.CustomLooper.class, RobolectricUtil.CustomMessageQueue.class}) +public final class DashMediaPeriodTest { + + @Test + public void getSteamKeys_isCompatibleWithDashManifestFilter() { + // Test manifest which covers various edge cases: + // - Multiple periods. + // - Single and multiple representations per adaptation set. + // - Switch descriptors combining multiple adaptations sets. + // - Embedded track groups. + // All cases are deliberately combined in one test to catch potential indexing problems which + // only occur in combination. + DashManifest testManifest = + createDashManifest( + createPeriod( + createAdaptationSet( + /* id= */ 0, + /* trackType= */ C.TRACK_TYPE_VIDEO, + /* descriptor= */ null, + createVideoRepresentation(/* bitrate= */ 1000000))), + createPeriod( + createAdaptationSet( + /* id= */ 100, + /* trackType= */ C.TRACK_TYPE_VIDEO, + /* descriptor= */ createSwitchDescriptor(/* ids= */ 103, 104), + createVideoRepresentationWithInbandEventStream(/* bitrate= */ 200000), + createVideoRepresentationWithInbandEventStream(/* bitrate= */ 400000), + createVideoRepresentationWithInbandEventStream(/* bitrate= */ 600000)), + createAdaptationSet( + /* id= */ 101, + /* trackType= */ C.TRACK_TYPE_AUDIO, + /* descriptor= */ createSwitchDescriptor(/* ids= */ 102), + createAudioRepresentation(/* bitrate= */ 48000), + createAudioRepresentation(/* bitrate= */ 96000)), + createAdaptationSet( + /* id= */ 102, + /* trackType= */ C.TRACK_TYPE_AUDIO, + /* descriptor= */ createSwitchDescriptor(/* ids= */ 101), + createAudioRepresentation(/* bitrate= */ 256000)), + createAdaptationSet( + /* id= */ 103, + /* trackType= */ C.TRACK_TYPE_VIDEO, + /* descriptor= */ createSwitchDescriptor(/* ids= */ 100, 104), + createVideoRepresentationWithInbandEventStream(/* bitrate= */ 800000), + createVideoRepresentationWithInbandEventStream(/* bitrate= */ 1000000)), + createAdaptationSet( + /* id= */ 104, + /* trackType= */ C.TRACK_TYPE_VIDEO, + /* descriptor= */ createSwitchDescriptor(/* ids= */ 100, 103), + createVideoRepresentationWithInbandEventStream(/* bitrate= */ 2000000)), + createAdaptationSet( + /* id= */ 105, + /* trackType= */ C.TRACK_TYPE_TEXT, + /* descriptor= */ null, + createTextRepresentation(/* language= */ "eng")), + createAdaptationSet( + /* id= */ 105, + /* trackType= */ C.TRACK_TYPE_TEXT, + /* descriptor= */ null, + createTextRepresentation(/* language= */ "ger")))); + FilterableManifestMediaPeriodFactory mediaPeriodFactory = + (manifest, periodIndex) -> + new DashMediaPeriod( + /* id= */ periodIndex, + manifest, + periodIndex, + mock(DashChunkSource.Factory.class), + mock(TransferListener.class), + mock(LoadErrorHandlingPolicy.class), + new EventDispatcher() + .withParameters( + /* windowIndex= */ 0, + /* mediaPeriodId= */ new MediaPeriodId(/* periodUid= */ new Object()), + /* mediaTimeOffsetMs= */ 0), + /* elapsedRealtimeOffsetMs= */ 0, + mock(LoaderErrorThrower.class), + mock(Allocator.class), + mock(CompositeSequenceableLoaderFactory.class), + mock(PlayerEmsgCallback.class)); + + // Ignore embedded metadata as we don't want to select primary group just to get embedded track. + MediaPeriodAsserts.assertGetStreamKeysAndManifestFilterIntegration( + mediaPeriodFactory, + testManifest, + /* periodIndex= */ 1, + /* ignoredMimeType= */ "application/x-emsg"); + } + + private static DashManifest createDashManifest(Period... periods) { + return new DashManifest( + /* availabilityStartTimeMs= */ 0, + /* durationMs= */ 5000, + /* minBufferTimeMs= */ 1, + /* dynamic= */ false, + /* minUpdatePeriodMs= */ 2, + /* timeShiftBufferDepthMs= */ 3, + /* suggestedPresentationDelayMs= */ 4, + /* publishTimeMs= */ 12345, + /* programInformation= */ null, + new UtcTimingElement("", ""), + Uri.EMPTY, + Arrays.asList(periods)); + } + + private static Period createPeriod(AdaptationSet... adaptationSets) { + return new Period(/* id= */ null, /* startMs= */ 0, Arrays.asList(adaptationSets)); + } + + private static AdaptationSet createAdaptationSet( + int id, int trackType, @Nullable Descriptor descriptor, Representation... representations) { + return new AdaptationSet( + id, + trackType, + Arrays.asList(representations), + /* accessibilityDescriptors= */ Collections.emptyList(), + descriptor == null ? Collections.emptyList() : Collections.singletonList(descriptor)); + } + + private static Representation createVideoRepresentation(int bitrate) { + return Representation.newInstance( + /* revisionId= */ 0, + createVideoFormat(bitrate), + /* baseUrl= */ "", + new SingleSegmentBase()); + } + + private static Representation createVideoRepresentationWithInbandEventStream(int bitrate) { + return Representation.newInstance( + /* revisionId= */ 0, + createVideoFormat(bitrate), + /* baseUrl= */ "", + new SingleSegmentBase(), + Collections.singletonList(getInbandEventDescriptor())); + } + + private static Format createVideoFormat(int bitrate) { + return Format.createContainerFormat( + /* id= */ null, + /* label= */ null, + MimeTypes.VIDEO_MP4, + MimeTypes.VIDEO_H264, + /* codecs= */ null, + bitrate, + /* selectionFlags= */ 0, + /* language= */ null); + } + + private static Representation createAudioRepresentation(int bitrate) { + return Representation.newInstance( + /* revisionId= */ 0, + Format.createContainerFormat( + /* id= */ null, + /* label= */ null, + MimeTypes.AUDIO_MP4, + MimeTypes.AUDIO_AAC, + /* codecs= */ null, + bitrate, + /* selectionFlags= */ 0, + /* language= */ null), + /* baseUrl= */ "", + new SingleSegmentBase()); + } + + private static Representation createTextRepresentation(String language) { + return Representation.newInstance( + /* revisionId= */ 0, + Format.createContainerFormat( + /* id= */ null, + /* label= */ null, + MimeTypes.APPLICATION_MP4, + MimeTypes.TEXT_VTT, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + /* selectionFlags= */ 0, + language), + /* baseUrl= */ "", + new SingleSegmentBase()); + } + + private static Descriptor createSwitchDescriptor(int... ids) { + StringBuilder idString = new StringBuilder(); + idString.append(ids[0]); + for (int i = 1; i < ids.length; i++) { + idString.append(",").append(ids[i]); + } + return new Descriptor( + /* schemeIdUri= */ "urn:mpeg:dash:adaptation-set-switching:2016", + /* value= */ idString.toString(), + /* id= */ null); + } + + private static Descriptor getInbandEventDescriptor() { + return new Descriptor( + /* schemeIdUri= */ "inBandSchemeIdUri", /* value= */ "inBandValue", /* id= */ "inBandId"); + } +} diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadHelperTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadHelperTest.java new file mode 100644 index 0000000000..eb4af58675 --- /dev/null +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadHelperTest.java @@ -0,0 +1,43 @@ +/* + * 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.source.dash.offline; + +import android.net.Uri; +import com.google.android.exoplayer2.Renderer; +import com.google.android.exoplayer2.offline.DownloadHelper; +import com.google.android.exoplayer2.testutil.FakeDataSource; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Unit test to verify creation of a DASH {@link DownloadHelper}. */ +@RunWith(RobolectricTestRunner.class) +public final class DownloadHelperTest { + + @Test + public void staticDownloadHelperForDash_doesNotThrow() { + DownloadHelper.forDash( + Uri.parse("http://uri"), + new FakeDataSource.Factory(), + (handler, videoListener, audioListener, text, metadata, drm) -> new Renderer[0]); + DownloadHelper.forDash( + Uri.parse("http://uri"), + new FakeDataSource.Factory(), + (handler, videoListener, audioListener, text, metadata, drm) -> new Renderer[0], + /* drmSessionManager= */ null, + DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS); + } +} diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java index ccccb20ccb..8d36ea68a4 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java @@ -30,6 +30,7 @@ import com.google.android.exoplayer2.offline.DownloadAction; import com.google.android.exoplayer2.offline.DownloadManager; import com.google.android.exoplayer2.offline.DownloaderConstructorHelper; import com.google.android.exoplayer2.offline.StreamKey; +import com.google.android.exoplayer2.scheduler.Requirements; import com.google.android.exoplayer2.testutil.DummyMainThread; import com.google.android.exoplayer2.testutil.FakeDataSet; import com.google.android.exoplayer2.testutil.FakeDataSource; @@ -52,6 +53,7 @@ import org.mockito.MockitoAnnotations; import org.robolectric.RobolectricTestRunner; import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowLog; /** Tests {@link DownloadManager}. */ @RunWith(RobolectricTestRunner.class) @@ -72,6 +74,7 @@ public class DownloadManagerDashTest { @Before public void setUp() throws Exception { + ShadowLog.stream = System.out; dummyMainThread = new DummyMainThread(); Context context = RuntimeEnvironment.application; tempFolder = Util.createTempDirectory(context, "ExoPlayerTest"); @@ -241,11 +244,13 @@ public class DownloadManagerDashTest { Factory fakeDataSourceFactory = new FakeDataSource.Factory().setFakeDataSet(fakeDataSet); downloadManager = new DownloadManager( + RuntimeEnvironment.application, actionFile, new DefaultDownloaderFactory( new DownloaderConstructorHelper(cache, fakeDataSourceFactory)), /* maxSimultaneousDownloads= */ 1, - /* minRetryCount= */ 3); + /* minRetryCount= */ 3, + new Requirements(0)); downloadManagerListener = new TestDownloadManagerListener(downloadManager, dummyMainThread); diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java index adae0d7b04..bf32b65ba7 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java @@ -30,6 +30,7 @@ import com.google.android.exoplayer2.offline.DownloadManager; import com.google.android.exoplayer2.offline.DownloadService; import com.google.android.exoplayer2.offline.DownloaderConstructorHelper; import com.google.android.exoplayer2.offline.StreamKey; +import com.google.android.exoplayer2.scheduler.Requirements; import com.google.android.exoplayer2.scheduler.Scheduler; import com.google.android.exoplayer2.testutil.DummyMainThread; import com.google.android.exoplayer2.testutil.FakeDataSet; @@ -117,11 +118,13 @@ public class DownloadServiceDashTest { actionFile.delete(); final DownloadManager dashDownloadManager = new DownloadManager( + RuntimeEnvironment.application, actionFile, new DefaultDownloaderFactory( new DownloaderConstructorHelper(cache, fakeDataSourceFactory)), /* maxSimultaneousDownloads= */ 1, - /* minRetryCount= */ 3); + /* minRetryCount= */ 3, + new Requirements(0)); downloadManagerListener = new TestDownloadManagerListener(dashDownloadManager, dummyMainThread); dashDownloadManager.startDownloads(); diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java index da50d7cc93..0c4ebcb508 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java @@ -20,6 +20,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; @@ -68,6 +69,8 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper private TrackGroupArray trackGroups; private HlsSampleStreamWrapper[] sampleStreamWrappers; private HlsSampleStreamWrapper[] enabledSampleStreamWrappers; + // Maps sample stream wrappers to variant/rendition index by matching array positions. + private int[][] manifestUrlsIndicesPerWrapper; private SequenceableLoader compositeSequenceableLoader; private boolean notifiedReadingStarted; @@ -112,6 +115,7 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper timestampAdjusterProvider = new TimestampAdjusterProvider(); sampleStreamWrappers = new HlsSampleStreamWrapper[0]; enabledSampleStreamWrappers = new HlsSampleStreamWrapper[0]; + manifestUrlsIndicesPerWrapper = new int[0][]; eventDispatcher.mediaPeriodCreated(); } @@ -143,6 +147,79 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper return trackGroups; } + @Override + public List getStreamKeys(List trackSelections) { + // See HlsMasterPlaylist.copy for interpretation of StreamKeys. + HlsMasterPlaylist masterPlaylist = Assertions.checkNotNull(playlistTracker.getMasterPlaylist()); + boolean hasVariants = !masterPlaylist.variants.isEmpty(); + int audioWrapperOffset = hasVariants ? 1 : 0; + int subtitleWrapperOffset = audioWrapperOffset + masterPlaylist.audios.size(); + + TrackGroupArray mainWrapperTrackGroups; + int mainWrapperPrimaryGroupIndex; + int[] mainWrapperVariantIndices; + if (hasVariants) { + HlsSampleStreamWrapper mainWrapper = sampleStreamWrappers[0]; + mainWrapperVariantIndices = manifestUrlsIndicesPerWrapper[0]; + mainWrapperTrackGroups = mainWrapper.getTrackGroups(); + mainWrapperPrimaryGroupIndex = mainWrapper.getPrimaryTrackGroupIndex(); + } else { + mainWrapperVariantIndices = new int[0]; + mainWrapperTrackGroups = TrackGroupArray.EMPTY; + mainWrapperPrimaryGroupIndex = 0; + } + + List streamKeys = new ArrayList<>(); + boolean needsPrimaryTrackGroupSelection = false; + boolean hasPrimaryTrackGroupSelection = false; + for (TrackSelection trackSelection : trackSelections) { + TrackGroup trackSelectionGroup = trackSelection.getTrackGroup(); + int mainWrapperTrackGroupIndex = mainWrapperTrackGroups.indexOf(trackSelectionGroup); + if (mainWrapperTrackGroupIndex != C.INDEX_UNSET) { + if (mainWrapperTrackGroupIndex == mainWrapperPrimaryGroupIndex) { + // Primary group in main wrapper. + hasPrimaryTrackGroupSelection = true; + for (int i = 0; i < trackSelection.length(); i++) { + int variantIndex = mainWrapperVariantIndices[trackSelection.getIndexInTrackGroup(i)]; + streamKeys.add(new StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, variantIndex)); + } + } else { + // Embedded group in main wrapper. + needsPrimaryTrackGroupSelection = true; + } + } else { + // Audio or subtitle group. + for (int i = audioWrapperOffset; i < sampleStreamWrappers.length; i++) { + TrackGroupArray wrapperTrackGroups = sampleStreamWrappers[i].getTrackGroups(); + if (wrapperTrackGroups.indexOf(trackSelectionGroup) != C.INDEX_UNSET) { + int groupIndexType = + i < subtitleWrapperOffset + ? HlsMasterPlaylist.GROUP_INDEX_AUDIO + : HlsMasterPlaylist.GROUP_INDEX_SUBTITLE; + streamKeys.add(new StreamKey(groupIndexType, manifestUrlsIndicesPerWrapper[i][0])); + break; + } + } + } + } + if (needsPrimaryTrackGroupSelection && !hasPrimaryTrackGroupSelection) { + // A track selection includes a variant-embedded track, but no variant is added yet. We use + // the valid variant with the lowest bitrate to reduce overhead. + int lowestBitrateIndex = mainWrapperVariantIndices[0]; + int lowestBitrate = masterPlaylist.variants.get(mainWrapperVariantIndices[0]).format.bitrate; + for (int i = 1; i < mainWrapperVariantIndices.length; i++) { + int variantBitrate = + masterPlaylist.variants.get(mainWrapperVariantIndices[i]).format.bitrate; + if (variantBitrate < lowestBitrate) { + lowestBitrate = variantBitrate; + lowestBitrateIndex = mainWrapperVariantIndices[i]; + } + } + streamKeys.add(new StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, lowestBitrateIndex)); + } + return streamKeys; + } + @Override public long selectTracks(TrackSelection[] selections, boolean[] mayRetainStreamFlags, SampleStream[] streams, boolean[] streamResetFlags, long positionUs) { @@ -343,15 +420,20 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper private void buildAndPrepareSampleStreamWrappers(long positionUs) { HlsMasterPlaylist masterPlaylist = playlistTracker.getMasterPlaylist(); + boolean hasVariants = !masterPlaylist.variants.isEmpty(); List audioRenditions = masterPlaylist.audios; List subtitleRenditions = masterPlaylist.subtitles; - int wrapperCount = 1 /* variants */ + audioRenditions.size() + subtitleRenditions.size(); + int wrapperCount = (hasVariants ? 1 : 0) + audioRenditions.size() + subtitleRenditions.size(); sampleStreamWrappers = new HlsSampleStreamWrapper[wrapperCount]; + manifestUrlsIndicesPerWrapper = new int[wrapperCount][]; pendingPrepareCount = wrapperCount; - buildAndPrepareMainSampleStreamWrapper(masterPlaylist, positionUs); - int currentWrapperIndex = 1; + int currentWrapperIndex = 0; + if (hasVariants) { + buildAndPrepareMainSampleStreamWrapper(masterPlaylist, positionUs); + currentWrapperIndex++; + } // TODO: Build video stream wrappers here. @@ -365,13 +447,12 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper null, Collections.emptyList(), positionUs); + manifestUrlsIndicesPerWrapper[currentWrapperIndex] = new int[] {i}; sampleStreamWrappers[currentWrapperIndex++] = sampleStreamWrapper; Format renditionFormat = audioRendition.format; if (allowChunklessPreparation && renditionFormat.codecs != null) { sampleStreamWrapper.prepareWithMasterPlaylistInfo( new TrackGroupArray(new TrackGroup(audioRendition.format)), 0, TrackGroupArray.EMPTY); - } else { - sampleStreamWrapper.continuePreparing(); } } @@ -381,11 +462,18 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper HlsSampleStreamWrapper sampleStreamWrapper = buildSampleStreamWrapper( C.TRACK_TYPE_TEXT, new HlsUrl[] {url}, null, Collections.emptyList(), positionUs); + manifestUrlsIndicesPerWrapper[currentWrapperIndex] = new int[] {i}; sampleStreamWrappers[currentWrapperIndex++] = sampleStreamWrapper; sampleStreamWrapper.prepareWithMasterPlaylistInfo( new TrackGroupArray(new TrackGroup(url.format)), 0, TrackGroupArray.EMPTY); } + // Set timestamp master and trigger preparation (if not already prepared) + sampleStreamWrappers[0].setIsTimestampMaster(true); + for (HlsSampleStreamWrapper sampleStreamWrapper : sampleStreamWrappers) { + sampleStreamWrapper.continuePreparing(); + } + // All wrappers are enabled during preparation. enabledSampleStreamWrappers = sampleStreamWrappers; } @@ -416,44 +504,64 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper */ private void buildAndPrepareMainSampleStreamWrapper( HlsMasterPlaylist masterPlaylist, long positionUs) { - List selectedVariants = new ArrayList<>(masterPlaylist.variants); - ArrayList definiteVideoVariants = new ArrayList<>(); - ArrayList definiteAudioOnlyVariants = new ArrayList<>(); - for (int i = 0; i < selectedVariants.size(); i++) { - HlsUrl variant = selectedVariants.get(i); + int[] variantTypes = new int[masterPlaylist.variants.size()]; + int videoVariantCount = 0; + int audioVariantCount = 0; + for (int i = 0; i < masterPlaylist.variants.size(); i++) { + HlsUrl variant = masterPlaylist.variants.get(i); Format format = variant.format; if (format.height > 0 || Util.getCodecsOfType(format.codecs, C.TRACK_TYPE_VIDEO) != null) { - definiteVideoVariants.add(variant); + variantTypes[i] = C.TRACK_TYPE_VIDEO; + videoVariantCount++; } else if (Util.getCodecsOfType(format.codecs, C.TRACK_TYPE_AUDIO) != null) { - definiteAudioOnlyVariants.add(variant); + variantTypes[i] = C.TRACK_TYPE_AUDIO; + audioVariantCount++; + } else { + variantTypes[i] = C.TRACK_TYPE_UNKNOWN; } } - if (!definiteVideoVariants.isEmpty()) { + boolean useVideoVariantsOnly = false; + boolean useNonAudioVariantsOnly = false; + int selectedVariantsCount = variantTypes.length; + if (videoVariantCount > 0) { // We've identified some variants as definitely containing video. Assume variants within the // master playlist are marked consistently, and hence that we have the full set. Filter out // any other variants, which are likely to be audio only. - selectedVariants = definiteVideoVariants; - } else if (definiteAudioOnlyVariants.size() < selectedVariants.size()) { + useVideoVariantsOnly = true; + selectedVariantsCount = videoVariantCount; + } else if (audioVariantCount < variantTypes.length) { // We've identified some variants, but not all, as being audio only. Filter them out to leave // the remaining variants, which are likely to contain video. - selectedVariants.removeAll(definiteAudioOnlyVariants); - } else { - // Leave the enabled variants unchanged. They're likely either all video or all audio. + useNonAudioVariantsOnly = true; + selectedVariantsCount = variantTypes.length - audioVariantCount; } - Assertions.checkArgument(!selectedVariants.isEmpty()); - HlsUrl[] variants = selectedVariants.toArray(new HlsUrl[0]); - String codecs = variants[0].format.codecs; - HlsSampleStreamWrapper sampleStreamWrapper = buildSampleStreamWrapper(C.TRACK_TYPE_DEFAULT, - variants, masterPlaylist.muxedAudioFormat, masterPlaylist.muxedCaptionFormats, positionUs); + HlsUrl[] selectedVariants = new HlsUrl[selectedVariantsCount]; + manifestUrlsIndicesPerWrapper[0] = new int[selectedVariantsCount]; + int outIndex = 0; + for (int i = 0; i < masterPlaylist.variants.size(); i++) { + if ((!useVideoVariantsOnly || variantTypes[i] == C.TRACK_TYPE_VIDEO) + && (!useNonAudioVariantsOnly || variantTypes[i] != C.TRACK_TYPE_AUDIO)) { + selectedVariants[outIndex] = masterPlaylist.variants.get(i); + manifestUrlsIndicesPerWrapper[0][outIndex++] = i; + } + } + String codecs = selectedVariants[0].format.codecs; + HlsSampleStreamWrapper sampleStreamWrapper = + buildSampleStreamWrapper( + C.TRACK_TYPE_DEFAULT, + selectedVariants, + masterPlaylist.muxedAudioFormat, + masterPlaylist.muxedCaptionFormats, + positionUs); sampleStreamWrappers[0] = sampleStreamWrapper; if (allowChunklessPreparation && codecs != null) { boolean variantsContainVideoCodecs = Util.getCodecsOfType(codecs, C.TRACK_TYPE_VIDEO) != null; boolean variantsContainAudioCodecs = Util.getCodecsOfType(codecs, C.TRACK_TYPE_AUDIO) != null; List muxedTrackGroups = new ArrayList<>(); if (variantsContainVideoCodecs) { - Format[] videoFormats = new Format[selectedVariants.size()]; + Format[] videoFormats = new Format[selectedVariantsCount]; for (int i = 0; i < videoFormats.length; i++) { - videoFormats[i] = deriveVideoFormat(variants[i].format); + videoFormats[i] = deriveVideoFormat(selectedVariants[i].format); } muxedTrackGroups.add(new TrackGroup(videoFormats)); @@ -462,7 +570,7 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper muxedTrackGroups.add( new TrackGroup( deriveAudioFormat( - variants[0].format, + selectedVariants[0].format, masterPlaylist.muxedAudioFormat, /* isPrimaryTrackInVariant= */ false))); } @@ -474,9 +582,9 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper } } else if (variantsContainAudioCodecs) { // Variants only contain audio. - Format[] audioFormats = new Format[selectedVariants.size()]; + Format[] audioFormats = new Format[selectedVariantsCount]; for (int i = 0; i < audioFormats.length; i++) { - Format variantFormat = variants[i].format; + Format variantFormat = selectedVariants[i].format; audioFormats[i] = deriveAudioFormat( variantFormat, @@ -503,9 +611,6 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper new TrackGroupArray(muxedTrackGroups.toArray(new TrackGroup[0])), 0, new TrackGroupArray(id3TrackGroup)); - } else { - sampleStreamWrapper.setIsTimestampMaster(true); - sampleStreamWrapper.continuePreparing(); } } @@ -566,7 +671,7 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper if (isPrimaryTrackInVariant) { channelCount = variantFormat.channelCount; selectionFlags = variantFormat.selectionFlags; - language = variantFormat.label; + language = variantFormat.language; label = variantFormat.label; } } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java index 8058460b9f..f2b76dddc4 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java @@ -428,7 +428,7 @@ public final class HlsMediaSource extends BaseMediaSource } @Override - public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { EventDispatcher eventDispatcher = createEventDispatcher(id); return new HlsMediaPeriod( extractorFactory, diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index 39598c4cd8..4fd27ba2a0 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -214,6 +214,10 @@ import java.util.List; return trackGroups; } + public int getPrimaryTrackGroupIndex() { + return primaryTrackGroupIndex; + } + public int bindSampleQueueToSampleStream(int trackGroupIndex) { int sampleQueueIndex = trackGroupToSampleQueueIndex[trackGroupIndex]; if (sampleQueueIndex == C.INDEX_UNSET) { diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloadHelper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloadHelper.java deleted file mode 100644 index e0f55aa738..0000000000 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloadHelper.java +++ /dev/null @@ -1,144 +0,0 @@ -/* - * 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.source.hls.offline; - -import android.net.Uri; -import android.support.annotation.Nullable; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.Format; -import com.google.android.exoplayer2.RenderersFactory; -import com.google.android.exoplayer2.drm.DrmSessionManager; -import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; -import com.google.android.exoplayer2.offline.DownloadAction; -import com.google.android.exoplayer2.offline.DownloadHelper; -import com.google.android.exoplayer2.offline.StreamKey; -import com.google.android.exoplayer2.source.TrackGroup; -import com.google.android.exoplayer2.source.TrackGroupArray; -import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist; -import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; -import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist; -import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParser; -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.ParsingLoadable; -import com.google.android.exoplayer2.util.Assertions; -import java.io.IOException; -import java.util.Arrays; -import java.util.List; - -/** A {@link DownloadHelper} for HLS streams. */ -public final class HlsDownloadHelper extends DownloadHelper { - - private final DataSource.Factory manifestDataSourceFactory; - - private int[] renditionGroups; - - /** - * Creates a HLS download helper. - * - *

    The helper uses {@link DownloadHelper#DEFAULT_TRACK_SELECTOR_PARAMETERS} for track selection - * and does not support drm protected content. - * - * @param uri A manifest {@link Uri}. - * @param manifestDataSourceFactory A {@link DataSource.Factory} used to load the manifest. - * @param renderersFactory The {@link RenderersFactory} creating the renderers for which tracks - * are selected. - */ - public HlsDownloadHelper( - Uri uri, DataSource.Factory manifestDataSourceFactory, RenderersFactory renderersFactory) { - this( - uri, - manifestDataSourceFactory, - DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS, - renderersFactory, - /* drmSessionManager= */ null); - } - - /** - * Creates a HLS download helper. - * - * @param uri A manifest {@link Uri}. - * @param manifestDataSourceFactory A {@link DataSource.Factory} used to load the manifest. - * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for - * downloading. - * @param renderersFactory The {@link RenderersFactory} creating the renderers for which tracks - * are selected. - * @param drmSessionManager An optional {@link DrmSessionManager} used by the renderers created by - * {@code renderersFactory}. - */ - public HlsDownloadHelper( - Uri uri, - DataSource.Factory manifestDataSourceFactory, - DefaultTrackSelector.Parameters trackSelectorParameters, - RenderersFactory renderersFactory, - @Nullable DrmSessionManager drmSessionManager) { - super( - DownloadAction.TYPE_HLS, - uri, - /* cacheKey= */ null, - trackSelectorParameters, - renderersFactory, - drmSessionManager); - this.manifestDataSourceFactory = manifestDataSourceFactory; - } - - @Override - protected HlsPlaylist loadManifest(Uri uri) throws IOException { - DataSource dataSource = manifestDataSourceFactory.createDataSource(); - return ParsingLoadable.load(dataSource, new HlsPlaylistParser(), uri, C.DATA_TYPE_MANIFEST); - } - - @Override - protected TrackGroupArray[] getTrackGroupArrays(HlsPlaylist playlist) { - Assertions.checkNotNull(playlist); - if (playlist instanceof HlsMediaPlaylist) { - renditionGroups = new int[0]; - return new TrackGroupArray[] {TrackGroupArray.EMPTY}; - } - // TODO: Generate track groups as in playback. Reverse the mapping in toStreamKey. - HlsMasterPlaylist masterPlaylist = (HlsMasterPlaylist) playlist; - TrackGroup[] trackGroups = new TrackGroup[3]; - renditionGroups = new int[3]; - int trackGroupIndex = 0; - if (!masterPlaylist.variants.isEmpty()) { - renditionGroups[trackGroupIndex] = HlsMasterPlaylist.GROUP_INDEX_VARIANT; - trackGroups[trackGroupIndex++] = new TrackGroup(toFormats(masterPlaylist.variants)); - } - if (!masterPlaylist.audios.isEmpty()) { - renditionGroups[trackGroupIndex] = HlsMasterPlaylist.GROUP_INDEX_AUDIO; - trackGroups[trackGroupIndex++] = new TrackGroup(toFormats(masterPlaylist.audios)); - } - if (!masterPlaylist.subtitles.isEmpty()) { - renditionGroups[trackGroupIndex] = HlsMasterPlaylist.GROUP_INDEX_SUBTITLE; - trackGroups[trackGroupIndex++] = new TrackGroup(toFormats(masterPlaylist.subtitles)); - } - return new TrackGroupArray[] {new TrackGroupArray(Arrays.copyOf(trackGroups, trackGroupIndex))}; - } - - @Override - protected StreamKey toStreamKey( - int periodIndex, int trackGroupIndex, int trackIndexInTrackGroup) { - return new StreamKey(renditionGroups[trackGroupIndex], trackIndexInTrackGroup); - } - - private static Format[] toFormats(List hlsUrls) { - Format[] formats = new Format[hlsUrls.size()]; - for (int i = 0; i < hlsUrls.size(); i++) { - formats[i] = hlsUrls.get(i).format; - } - return formats; - } -} diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java index 242711431c..9e13d6fa0f 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java @@ -101,6 +101,7 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser variableDefinitions) { + String channelsString = parseOptionalStringAttr(line, REGEX_CHANNELS, variableDefinitions); + return channelsString != null + ? Integer.parseInt(Util.splitAtFirst(channelsString, "/")[0]) + : Format.NO_VALUE; + } + private static @Nullable SchemeData parsePlayReadySchemeData( String line, Map variableDefinitions) throws ParserException { String keyFormatVersions = diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriodTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriodTest.java new file mode 100644 index 0000000000..599e099b8c --- /dev/null +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriodTest.java @@ -0,0 +1,184 @@ +/* + * 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.source.hls; + +import static org.mockito.Matchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist; +import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.HlsUrl; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker; +import com.google.android.exoplayer2.testutil.MediaPeriodAsserts; +import com.google.android.exoplayer2.testutil.MediaPeriodAsserts.FilterableManifestMediaPeriodFactory; +import com.google.android.exoplayer2.testutil.RobolectricUtil; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.MimeTypes; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** Unit test for {@link HlsMediaPeriod}. */ +@RunWith(RobolectricTestRunner.class) +@Config(shadows = {RobolectricUtil.CustomLooper.class, RobolectricUtil.CustomMessageQueue.class}) +public final class HlsMediaPeriodTest { + + @Test + public void getSteamKeys_isCompatibleWithhHlsMasterPlaylistFilter() { + HlsMasterPlaylist testMasterPlaylist = + createMasterPlaylist( + /* variants= */ Arrays.asList( + createAudioOnlyVariantHlsUrl(/* bitrate= */ 10000), + createMuxedVideoAudioVariantHlsUrl(/* bitrate= */ 200000), + createAudioOnlyVariantHlsUrl(/* bitrate= */ 300000), + createMuxedVideoAudioVariantHlsUrl(/* bitrate= */ 400000), + createMuxedVideoAudioVariantHlsUrl(/* bitrate= */ 600000)), + /* audios= */ Arrays.asList( + createAudioHlsUrl(/* language= */ "spa"), + createAudioHlsUrl(/* language= */ "ger"), + createAudioHlsUrl(/* language= */ "tur")), + /* subtitles= */ Arrays.asList( + createSubtitleHlsUrl(/* language= */ "spa"), + createSubtitleHlsUrl(/* language= */ "ger"), + createSubtitleHlsUrl(/* language= */ "tur")), + /* muxedAudioFormat= */ createAudioFormat("eng"), + /* muxedCaptionFormats= */ Arrays.asList( + createSubtitleFormat("eng"), createSubtitleFormat("gsw"))); + FilterableManifestMediaPeriodFactory mediaPeriodFactory = + (playlist, periodIndex) -> { + HlsDataSourceFactory mockDataSourceFactory = mock(HlsDataSourceFactory.class); + when(mockDataSourceFactory.createDataSource(anyInt())).thenReturn(mock(DataSource.class)); + HlsPlaylistTracker mockPlaylistTracker = mock(HlsPlaylistTracker.class); + when(mockPlaylistTracker.getMasterPlaylist()).thenReturn((HlsMasterPlaylist) playlist); + return new HlsMediaPeriod( + mock(HlsExtractorFactory.class), + mockPlaylistTracker, + mockDataSourceFactory, + mock(TransferListener.class), + mock(LoadErrorHandlingPolicy.class), + new EventDispatcher() + .withParameters( + /* windowIndex= */ 0, + /* mediaPeriodId= */ new MediaPeriodId(/* periodUid= */ new Object()), + /* mediaTimeOffsetMs= */ 0), + mock(Allocator.class), + mock(CompositeSequenceableLoaderFactory.class), + /* allowChunklessPreparation =*/ true); + }; + + MediaPeriodAsserts.assertGetStreamKeysAndManifestFilterIntegration( + mediaPeriodFactory, testMasterPlaylist); + } + + private static HlsMasterPlaylist createMasterPlaylist( + List variants, + List audios, + List subtitles, + Format muxedAudioFormat, + List muxedCaptionFormats) { + return new HlsMasterPlaylist( + "http://baseUri", + /* tags= */ Collections.emptyList(), + variants, + audios, + subtitles, + muxedAudioFormat, + muxedCaptionFormats, + /* hasIndependentSegments= */ true, + /* variableDefinitions= */ Collections.emptyMap()); + } + + private static HlsUrl createMuxedVideoAudioVariantHlsUrl(int bitrate) { + return new HlsUrl( + "http://url", + Format.createVideoContainerFormat( + /* id= */ null, + /* label= */ null, + /* containerMimeType= */ MimeTypes.APPLICATION_M3U8, + /* sampleMimeType= */ null, + /* codecs= */ "avc1.100.41,mp4a.40.2", + bitrate, + /* width= */ Format.NO_VALUE, + /* height= */ Format.NO_VALUE, + /* frameRate= */ Format.NO_VALUE, + /* initializationData= */ null, + /* selectionFlags= */ 0)); + } + + private static HlsUrl createAudioOnlyVariantHlsUrl(int bitrate) { + return new HlsUrl( + "http://url", + Format.createVideoContainerFormat( + /* id= */ null, + /* label= */ null, + /* containerMimeType= */ MimeTypes.APPLICATION_M3U8, + /* sampleMimeType= */ null, + /* codecs= */ "mp4a.40.2", + bitrate, + /* width= */ Format.NO_VALUE, + /* height= */ Format.NO_VALUE, + /* frameRate= */ Format.NO_VALUE, + /* initializationData= */ null, + /* selectionFlags= */ 0)); + } + + private static HlsUrl createAudioHlsUrl(String language) { + return new HlsUrl("http://url", createAudioFormat(language)); + } + + private static HlsUrl createSubtitleHlsUrl(String language) { + return new HlsUrl("http://url", createSubtitleFormat(language)); + } + + private static Format createAudioFormat(String language) { + return Format.createAudioContainerFormat( + /* id= */ null, + /* label= */ null, + /* containerMimeType= */ MimeTypes.APPLICATION_M3U8, + MimeTypes.getMediaMimeType("mp4a.40.2"), + /* codecs= */ "mp4a.40.2", + /* bitrate= */ Format.NO_VALUE, + /* channelCount= */ Format.NO_VALUE, + /* sampleRate= */ Format.NO_VALUE, + /* initializationData= */ null, + /* selectionFlags= */ 0, + language); + } + + private static Format createSubtitleFormat(String language) { + return Format.createTextContainerFormat( + /* id= */ null, + /* label= */ null, + /* containerMimeType= */ MimeTypes.APPLICATION_M3U8, + /* sampleMimeType= */ MimeTypes.TEXT_VTT, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + /* selectionFlags= */ 0, + language); + } +} diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/DownloadHelperTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/DownloadHelperTest.java new file mode 100644 index 0000000000..dca8f9c3e8 --- /dev/null +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/DownloadHelperTest.java @@ -0,0 +1,43 @@ +/* + * 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.source.hls.offline; + +import android.net.Uri; +import com.google.android.exoplayer2.Renderer; +import com.google.android.exoplayer2.offline.DownloadHelper; +import com.google.android.exoplayer2.testutil.FakeDataSource; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Unit test to verify creation of a HLS {@link DownloadHelper}. */ +@RunWith(RobolectricTestRunner.class) +public final class DownloadHelperTest { + + @Test + public void staticDownloadHelperForHls_doesNotThrow() { + DownloadHelper.forHls( + Uri.parse("http://uri"), + new FakeDataSource.Factory(), + (handler, videoListener, audioListener, text, metadata, drm) -> new Renderer[0]); + DownloadHelper.forHls( + Uri.parse("http://uri"), + new FakeDataSource.Factory(), + (handler, videoListener, audioListener, text, metadata, drm) -> new Renderer[0], + /* drmSessionManager= */ null, + DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS); + } +} diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylistParserTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylistParserTest.java index 9701171ce9..80a9bd3eab 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylistParserTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylistParserTest.java @@ -81,6 +81,18 @@ public class HlsMasterPlaylistParserTest { + "CODECS=\"mp4a.40.2,avc1.66.30\",RESOLUTION=304x128\n" + "http://example.com/low.m3u8\n"; + private static final String PLAYLIST_WITH_CHANNELS_ATTRIBUTE = + " #EXTM3U \n" + + "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",CHANNELS=\"6\",NAME=\"Eng6\"," + + "URI=\"something.m3u8\"\n" + + "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",CHANNELS=\"2/6\",NAME=\"Eng26\"," + + "URI=\"something2.m3u8\"\n" + + "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",NAME=\"Eng\"," + + "URI=\"something3.m3u8\"\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=1280000," + + "CODECS=\"mp4a.40.2,avc1.66.30\",AUDIO=\"audio\",RESOLUTION=304x128\n" + + "http://example.com/low.m3u8\n"; + private static final String PLAYLIST_WITHOUT_CC = " #EXTM3U \n" + "#EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS," @@ -213,7 +225,18 @@ public class HlsMasterPlaylistParserTest { Format closedCaptionFormat = playlist.muxedCaptionFormats.get(0); assertThat(closedCaptionFormat.sampleMimeType).isEqualTo(MimeTypes.APPLICATION_CEA708); assertThat(closedCaptionFormat.accessibilityChannel).isEqualTo(4); - assertThat(closedCaptionFormat.language).isEqualTo("es"); + assertThat(closedCaptionFormat.language).isEqualTo("spa"); + } + + @Test + public void testPlaylistWithChannelsAttribute() throws IOException { + HlsMasterPlaylist playlist = + parseMasterPlaylist(PLAYLIST_URI, PLAYLIST_WITH_CHANNELS_ATTRIBUTE); + List audios = playlist.audios; + assertThat(audios).hasSize(3); + assertThat(audios.get(0).format.channelCount).isEqualTo(6); + assertThat(audios.get(1).format.channelCount).isEqualTo(2); + assertThat(audios.get(2).format.channelCount).isEqualTo(Format.NO_VALUE); } @Test diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java index d50375d4c9..45521726c0 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java @@ -61,14 +61,13 @@ public class DefaultSsChunkSource implements SsChunkSource { SsManifest manifest, int elementIndex, TrackSelection trackSelection, - TrackEncryptionBox[] trackEncryptionBoxes, @Nullable TransferListener transferListener) { DataSource dataSource = dataSourceFactory.createDataSource(); if (transferListener != null) { dataSource.addTransferListener(transferListener); } - return new DefaultSsChunkSource(manifestLoaderErrorThrower, manifest, elementIndex, - trackSelection, dataSource, trackEncryptionBoxes); + return new DefaultSsChunkSource( + manifestLoaderErrorThrower, manifest, elementIndex, trackSelection, dataSource); } } @@ -90,15 +89,13 @@ public class DefaultSsChunkSource implements SsChunkSource { * @param streamElementIndex The index of the stream element in the manifest. * @param trackSelection The track selection. * @param dataSource A {@link DataSource} suitable for loading the media data. - * @param trackEncryptionBoxes Track encryption boxes for the stream. */ public DefaultSsChunkSource( LoaderErrorThrower manifestLoaderErrorThrower, SsManifest manifest, int streamElementIndex, TrackSelection trackSelection, - DataSource dataSource, - TrackEncryptionBox[] trackEncryptionBoxes) { + DataSource dataSource) { this.manifestLoaderErrorThrower = manifestLoaderErrorThrower; this.manifest = manifest; this.streamElementIndex = streamElementIndex; @@ -110,6 +107,8 @@ public class DefaultSsChunkSource implements SsChunkSource { for (int i = 0; i < extractorWrappers.length; i++) { int manifestTrackIndex = trackSelection.getIndexInTrackGroup(i); Format format = streamElement.formats[manifestTrackIndex]; + TrackEncryptionBox[] trackEncryptionBoxes = + format.drmInitData != null ? manifest.protectionElement.trackEncryptionBoxes : null; int nalUnitLengthFieldLength = streamElement.type == C.TRACK_TYPE_VIDEO ? 4 : 0; Track track = new Track(manifestTrackIndex, streamElement.type, streamElement.timescale, C.TIME_UNSET, manifest.durationUs, format, Track.TRANSFORMATION_NONE, diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsChunkSource.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsChunkSource.java index f333a6f92c..4940f1592f 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsChunkSource.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsChunkSource.java @@ -16,7 +16,6 @@ package com.google.android.exoplayer2.source.smoothstreaming; import android.support.annotation.Nullable; -import com.google.android.exoplayer2.extractor.mp4.TrackEncryptionBox; import com.google.android.exoplayer2.source.chunk.ChunkSource; import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest; import com.google.android.exoplayer2.trackselection.TrackSelection; @@ -38,7 +37,6 @@ public interface SsChunkSource extends ChunkSource { * @param manifest The initial manifest. * @param streamElementIndex The index of the corresponding stream element in the manifest. * @param trackSelection The track selection. - * @param trackEncryptionBoxes Track encryption boxes for the stream. * @param transferListener The transfer listener which should be informed of any data transfers. * May be null if no listener is available. * @return The created {@link SsChunkSource}. @@ -48,7 +46,6 @@ public interface SsChunkSource extends ChunkSource { SsManifest manifest, int streamElementIndex, TrackSelection trackSelection, - TrackEncryptionBox[] trackEncryptionBoxes, @Nullable TransferListener transferListener); } diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java index fc22c45c5a..ae6b60183c 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java @@ -16,10 +16,8 @@ package com.google.android.exoplayer2.source.smoothstreaming; import android.support.annotation.Nullable; -import android.util.Base64; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.SeekParameters; -import com.google.android.exoplayer2.extractor.mp4.TrackEncryptionBox; import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory; import com.google.android.exoplayer2.source.MediaPeriod; @@ -30,7 +28,6 @@ import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.chunk.ChunkSampleStream; import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest; -import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest.ProtectionElement; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; @@ -44,8 +41,6 @@ import java.util.List; /* package */ final class SsMediaPeriod implements MediaPeriod, SequenceableLoader.Callback> { - private static final int INITIALIZATION_VECTOR_SIZE = 8; - private final SsChunkSource.Factory chunkSourceFactory; private final @Nullable TransferListener transferListener; private final LoaderErrorThrower manifestLoaderErrorThrower; @@ -53,7 +48,6 @@ import java.util.List; private final EventDispatcher eventDispatcher; private final Allocator allocator; private final TrackGroupArray trackGroups; - private final TrackEncryptionBox[] trackEncryptionBoxes; private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; private @Nullable Callback callback; @@ -71,6 +65,7 @@ import java.util.List; EventDispatcher eventDispatcher, LoaderErrorThrower manifestLoaderErrorThrower, Allocator allocator) { + this.manifest = manifest; this.chunkSourceFactory = chunkSourceFactory; this.transferListener = transferListener; this.manifestLoaderErrorThrower = manifestLoaderErrorThrower; @@ -78,18 +73,7 @@ import java.util.List; this.eventDispatcher = eventDispatcher; this.allocator = allocator; this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory; - trackGroups = buildTrackGroups(manifest); - ProtectionElement protectionElement = manifest.protectionElement; - if (protectionElement != null) { - byte[] keyId = getProtectionElementKeyId(protectionElement.data); - // We assume pattern encryption does not apply. - trackEncryptionBoxes = new TrackEncryptionBox[] { - new TrackEncryptionBox(true, null, INITIALIZATION_VECTOR_SIZE, keyId, 0, 0, null)}; - } else { - trackEncryptionBoxes = null; - } - this.manifest = manifest; sampleStreams = newSampleStreamArray(0); compositeSequenceableLoader = compositeSequenceableLoaderFactory.createCompositeSequenceableLoader(sampleStreams); @@ -160,11 +144,14 @@ import java.util.List; } @Override - public List getStreamKeys(TrackSelection trackSelection) { - List streamKeys = new ArrayList<>(trackSelection.length()); - int streamElementIndex = trackGroups.indexOf(trackSelection.getTrackGroup()); - for (int i = 0; i < trackSelection.length(); i++) { - streamKeys.add(new StreamKey(streamElementIndex, trackSelection.getIndexInTrackGroup(i))); + public List getStreamKeys(List trackSelections) { + List streamKeys = new ArrayList<>(); + for (int selectionIndex = 0; selectionIndex < trackSelections.size(); selectionIndex++) { + TrackSelection trackSelection = trackSelections.get(selectionIndex); + int streamElementIndex = trackGroups.indexOf(trackSelection.getTrackGroup()); + for (int i = 0; i < trackSelection.length(); i++) { + streamKeys.add(new StreamKey(streamElementIndex, trackSelection.getIndexInTrackGroup(i))); + } } return streamKeys; } @@ -241,7 +228,6 @@ import java.util.List; manifest, streamElementIndex, selection, - trackEncryptionBoxes, transferListener); return new ChunkSampleStream<>( manifest.streamElements[streamElementIndex].type, @@ -267,26 +253,4 @@ import java.util.List; private static ChunkSampleStream[] newSampleStreamArray(int length) { return new ChunkSampleStream[length]; } - - private static byte[] getProtectionElementKeyId(byte[] initData) { - StringBuilder initDataStringBuilder = new StringBuilder(); - for (int i = 0; i < initData.length; i += 2) { - initDataStringBuilder.append((char) initData[i]); - } - String initDataString = initDataStringBuilder.toString(); - String keyIdString = initDataString.substring( - initDataString.indexOf("") + 5, initDataString.indexOf("")); - byte[] keyId = Base64.decode(keyIdString, Base64.DEFAULT); - swap(keyId, 0, 3); - swap(keyId, 1, 2); - swap(keyId, 4, 5); - swap(keyId, 6, 7); - return keyId; - } - - private static void swap(byte[] data, int firstPosition, int secondPosition) { - byte temp = data[firstPosition]; - data[firstPosition] = data[secondPosition]; - data[secondPosition] = temp; - } } diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java index 39f707b09c..0f5544a993 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java @@ -552,7 +552,7 @@ public final class SsMediaSource extends BaseMediaSource } @Override - public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { EventDispatcher eventDispatcher = createEventDispatcher(id); SsMediaPeriod period = new SsMediaPeriod( diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/manifest/SsManifest.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/manifest/SsManifest.java index 2c508f0fde..cfb772a86b 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/manifest/SsManifest.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/manifest/SsManifest.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.source.smoothstreaming.manifest; import android.net.Uri; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.extractor.mp4.TrackEncryptionBox; import com.google.android.exoplayer2.offline.FilterableManifest; import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.util.Assertions; @@ -41,10 +42,12 @@ public class SsManifest implements FilterableManifest { public final UUID uuid; public final byte[] data; + public final TrackEncryptionBox[] trackEncryptionBoxes; - public ProtectionElement(UUID uuid, byte[] data) { + public ProtectionElement(UUID uuid, byte[] data, TrackEncryptionBox[] trackEncryptionBoxes) { this.uuid = uuid; this.data = data; + this.trackEncryptionBoxes = trackEncryptionBoxes; } } diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/manifest/SsManifestParser.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/manifest/SsManifestParser.java index 3d5ade403a..4c1c6ee0cc 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/manifest/SsManifestParser.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/manifest/SsManifestParser.java @@ -25,6 +25,7 @@ import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.drm.DrmInitData.SchemeData; import com.google.android.exoplayer2.extractor.mp4.PsshAtomUtil; +import com.google.android.exoplayer2.extractor.mp4.TrackEncryptionBox; import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest.ProtectionElement; import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest.StreamElement; import com.google.android.exoplayer2.upstream.ParsingLoadable; @@ -397,9 +398,10 @@ public class SsManifestParser implements ParsingLoadable.Parser { public static final String TAG = "Protection"; public static final String TAG_PROTECTION_HEADER = "ProtectionHeader"; - public static final String KEY_SYSTEM_ID = "SystemID"; + private static final int INITIALIZATION_VECTOR_SIZE = 8; + private boolean inProtectionHeader; private UUID uuid; private byte[] initData; @@ -439,7 +441,44 @@ public class SsManifestParser implements ParsingLoadable.Parser { @Override public Object build() { - return new ProtectionElement(uuid, PsshAtomUtil.buildPsshAtom(uuid, initData)); + return new ProtectionElement( + uuid, PsshAtomUtil.buildPsshAtom(uuid, initData), buildTrackEncryptionBoxes(initData)); + } + + private static TrackEncryptionBox[] buildTrackEncryptionBoxes(byte[] initData) { + return new TrackEncryptionBox[] { + new TrackEncryptionBox( + /* isEncrypted= */ true, + /* schemeType= */ null, + INITIALIZATION_VECTOR_SIZE, + getProtectionElementKeyId(initData), + /* defaultEncryptedBlocks= */ 0, + /* defaultClearBlocks= */ 0, + /* defaultInitializationVector= */ null) + }; + } + + private static byte[] getProtectionElementKeyId(byte[] initData) { + StringBuilder initDataStringBuilder = new StringBuilder(); + for (int i = 0; i < initData.length; i += 2) { + initDataStringBuilder.append((char) initData[i]); + } + String initDataString = initDataStringBuilder.toString(); + String keyIdString = + initDataString.substring( + initDataString.indexOf("") + 5, initDataString.indexOf("")); + byte[] keyId = Base64.decode(keyIdString, Base64.DEFAULT); + swap(keyId, 0, 3); + swap(keyId, 1, 2); + swap(keyId, 4, 5); + swap(keyId, 6, 7); + return keyId; + } + + private static void swap(byte[] data, int firstPosition, int secondPosition) { + byte temp = data[firstPosition]; + data[firstPosition] = data[secondPosition]; + data[secondPosition] = temp; } private static String stripCurlyBraces(String uuidString) { diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloadHelper.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloadHelper.java deleted file mode 100644 index b17768f202..0000000000 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloadHelper.java +++ /dev/null @@ -1,113 +0,0 @@ -/* - * 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.source.smoothstreaming.offline; - -import android.net.Uri; -import android.support.annotation.Nullable; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.RenderersFactory; -import com.google.android.exoplayer2.drm.DrmSessionManager; -import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; -import com.google.android.exoplayer2.offline.DownloadAction; -import com.google.android.exoplayer2.offline.DownloadHelper; -import com.google.android.exoplayer2.offline.StreamKey; -import com.google.android.exoplayer2.source.TrackGroup; -import com.google.android.exoplayer2.source.TrackGroupArray; -import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest; -import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifestParser; -import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsUtil; -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.ParsingLoadable; -import java.io.IOException; - -/** A {@link DownloadHelper} for SmoothStreaming streams. */ -public final class SsDownloadHelper extends DownloadHelper { - - private final DataSource.Factory manifestDataSourceFactory; - - /** - * Creates a SmoothStreaming download helper. - * - *

    The helper uses {@link DownloadHelper#DEFAULT_TRACK_SELECTOR_PARAMETERS} for track selection - * and does not support drm protected content. - * - * @param uri A manifest {@link Uri}. - * @param manifestDataSourceFactory A {@link DataSource.Factory} used to load the manifest. - * @param renderersFactory The {@link RenderersFactory} creating the renderers for which tracks - * are selected. - */ - public SsDownloadHelper( - Uri uri, DataSource.Factory manifestDataSourceFactory, RenderersFactory renderersFactory) { - this( - uri, - manifestDataSourceFactory, - DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS, - renderersFactory, - /* drmSessionManager= */ null); - } - - /** - * Creates a SmoothStreaming download helper. - * - * @param uri A manifest {@link Uri}. - * @param manifestDataSourceFactory A {@link DataSource.Factory} used to load the manifest. - * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for - * downloading. - * @param renderersFactory The {@link RenderersFactory} creating the renderers for which tracks - * are selected. - * @param drmSessionManager An optional {@link DrmSessionManager} used by the renderers created by - * {@code renderersFactory}. - */ - public SsDownloadHelper( - Uri uri, - DataSource.Factory manifestDataSourceFactory, - DefaultTrackSelector.Parameters trackSelectorParameters, - RenderersFactory renderersFactory, - @Nullable DrmSessionManager drmSessionManager) { - super( - DownloadAction.TYPE_SS, - uri, - /* cacheKey= */ null, - trackSelectorParameters, - renderersFactory, - drmSessionManager); - this.manifestDataSourceFactory = manifestDataSourceFactory; - } - - @Override - protected SsManifest loadManifest(Uri uri) throws IOException { - DataSource dataSource = manifestDataSourceFactory.createDataSource(); - Uri fixedUri = SsUtil.fixManifestUri(uri); - return ParsingLoadable.load(dataSource, new SsManifestParser(), fixedUri, C.DATA_TYPE_MANIFEST); - } - - @Override - protected TrackGroupArray[] getTrackGroupArrays(SsManifest manifest) { - SsManifest.StreamElement[] streamElements = manifest.streamElements; - TrackGroup[] trackGroups = new TrackGroup[streamElements.length]; - for (int i = 0; i < streamElements.length; i++) { - trackGroups[i] = new TrackGroup(streamElements[i].formats); - } - return new TrackGroupArray[] {new TrackGroupArray(trackGroups)}; - } - - @Override - protected StreamKey toStreamKey( - int periodIndex, int trackGroupIndex, int trackIndexInTrackGroup) { - return new StreamKey(trackGroupIndex, trackIndexInTrackGroup); - } -} diff --git a/library/smoothstreaming/src/test/assets/sample_ismc_1 b/library/smoothstreaming/src/test/assets/sample_ismc_1 index 25a37d65b4..1d279d0a67 100644 --- a/library/smoothstreaming/src/test/assets/sample_ismc_1 +++ b/library/smoothstreaming/src/test/assets/sample_ismc_1 @@ -3,7 +3,7 @@ Duration="2300000000" TimeScale="10000000"> - + fgMAAAEAAQB0AzwAVwBSAE0ASABFAEEARABFAFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAHIAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIAZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADAALgAwAC4AMAAiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsARQBZAEwARQBOAD4AMQA2ADwALwBLAEUAWQBMAEUATgA+ADwAQQBMAEcASQBEAD4AQQBFAFMAQwBUAFIAPAAvAEEATABHAEkARAA+ADwALwBQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAD4AQgBhAFUATQBPAEcAYwBzAGgAVQBDAEQAZAB3ADMANABZAGMAawBmAFoAQQA9AD0APAAvAEsASQBEAD4APABDAEgARQBDAEsAUwBVAE0APgBnADcATgBhAFIARABJAEkATwA5ADAAPQA8AC8AQwBIAEUAQwBLAFMAVQBNAD4APABMAEEAXwBVAFIATAA+AGgAdAB0AHAAcwA6AC8ALwBUAC0ATwBOAEwASQBOAEUALgBEAFUATQBNAFkALQBTAEUAUgBWAEUAUgAvAEEAcgB0AGUAbQBpAHMATABpAGMAZQBuAHMAZQBTAGUAcgB2AGUAcgAvAFAAbABhAHkAUgBlAGEAZAB5AE0AYQBuAGEAZwBlAHIALgBhAHMAbQB4ADwALwBMAEEAXwBVAFIATAA+ADwAQwBVAFMAVABPAE0AQQBUAFQAUgBJAEIAVQBUAEUAUwA+ADwAQwBJAEQAPgAxADcANQA4ADIANgA8AC8AQwBJAEQAPgA8AEkASQBTAF8ARABSAE0AXwBWAEUAUgBTAEkATwBOAD4ANwAuADEALgAxADUANgA1AC4ANAA8AC8ASQBJAFMAXwBEAFIATQBfAFYARQBSAFMASQBPAE4APgA8AC8AQwBVAFMAVABPAE0AQQBUAFQAUgBJAEIAVQBUAEUAUwA+ADwALwBEAEEAVABBAD4APAAvAFcAUgBNAEgARQBBAEQARQBSAD4A diff --git a/library/smoothstreaming/src/test/assets/sample_ismc_2 b/library/smoothstreaming/src/test/assets/sample_ismc_2 index 5875a18183..7f2a53036f 100644 --- a/library/smoothstreaming/src/test/assets/sample_ismc_2 +++ b/library/smoothstreaming/src/test/assets/sample_ismc_2 @@ -3,7 +3,7 @@ Duration="2300000000" TimeScale="10000000"> - + fgMAAAEAAQB0AzwAVwBSAE0ASABFAEEARABFAFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAHIAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIAZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADAALgAwAC4AMAAiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsARQBZAEwARQBOAD4AMQA2ADwALwBLAEUAWQBMAEUATgA+ADwAQQBMAEcASQBEAD4AQQBFAFMAQwBUAFIAPAAvAEEATABHAEkARAA+ADwALwBQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAD4AQgBhAFUATQBPAEcAYwBzAGgAVQBDAEQAZAB3ADMANABZAGMAawBmAFoAQQA9AD0APAAvAEsASQBEAD4APABDAEgARQBDAEsAUwBVAE0APgBnADcATgBhAFIARABJAEkATwA5ADAAPQA8AC8AQwBIAEUAQwBLAFMAVQBNAD4APABMAEEAXwBVAFIATAA+AGgAdAB0AHAAcwA6AC8ALwBUAC0ATwBOAEwASQBOAEUALgBEAFUATQBNAFkALQBTAEUAUgBWAEUAUgAvAEEAcgB0AGUAbQBpAHMATABpAGMAZQBuAHMAZQBTAGUAcgB2AGUAcgAvAFAAbABhAHkAUgBlAGEAZAB5AE0AYQBuAGEAZwBlAHIALgBhAHMAbQB4ADwALwBMAEEAXwBVAFIATAA+ADwAQwBVAFMAVABPAE0AQQBUAFQAUgBJAEIAVQBUAEUAUwA+ADwAQwBJAEQAPgAxADcANQA4ADIANgA8AC8AQwBJAEQAPgA8AEkASQBTAF8ARABSAE0AXwBWAEUAUgBTAEkATwBOAD4ANwAuADEALgAxADUANgA1AC4ANAA8AC8ASQBJAFMAXwBEAFIATQBfAFYARQBSAFMASQBPAE4APgA8AC8AQwBVAFMAVABPAE0AQQBUAFQAUgBJAEIAVQBUAEUAUwA+ADwALwBEAEEAVABBAD4APAAvAFcAUgBNAEgARQBBAEQARQBSAD4A diff --git a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriodTest.java b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriodTest.java index f4feef3949..bceaf8cdf2 100644 --- a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriodTest.java +++ b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriodTest.java @@ -61,7 +61,7 @@ public class SsMediaPeriodTest { createStreamElement( /* name= */ "text", C.TRACK_TYPE_TEXT, createTextFormat(/* language= */ "eng"))); FilterableManifestMediaPeriodFactory mediaPeriodFactory = - manifest -> + (manifest, periodIndex) -> new SsMediaPeriod( manifest, mock(SsChunkSource.Factory.class), diff --git a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsTestUtils.java b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsTestUtils.java index 4a2b23edc4..1e770756bc 100644 --- a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsTestUtils.java +++ b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsTestUtils.java @@ -15,13 +15,12 @@ */ package com.google.android.exoplayer2.source.smoothstreaming; -import android.util.Base64; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.extractor.mp4.TrackEncryptionBox; import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest; import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest.ProtectionElement; import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest.StreamElement; -import java.nio.charset.StandardCharsets; import java.util.Collections; /** Util methods for SmoothStreaming tests. */ @@ -41,12 +40,7 @@ public class SsTestUtils { private static final int TEST_MAX_HEIGHT = 768; private static final String TEST_LANGUAGE = "eng"; private static final ProtectionElement TEST_PROTECTION_ELEMENT = - new ProtectionElement( - C.WIDEVINE_UUID, - ("" - + Base64.encodeToString(new byte[] {0, 1, 2, 3, 4, 5, 6, 7}, Base64.DEFAULT) - + "") - .getBytes(StandardCharsets.UTF_16LE)); + new ProtectionElement(C.WIDEVINE_UUID, new byte[0], new TrackEncryptionBox[0]); private SsTestUtils() {} diff --git a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/DownloadHelperTest.java b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/DownloadHelperTest.java new file mode 100644 index 0000000000..071fc46313 --- /dev/null +++ b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/DownloadHelperTest.java @@ -0,0 +1,43 @@ +/* + * 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.source.smoothstreaming.offline; + +import android.net.Uri; +import com.google.android.exoplayer2.Renderer; +import com.google.android.exoplayer2.offline.DownloadHelper; +import com.google.android.exoplayer2.testutil.FakeDataSource; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Unit test to verify creation of a SmoothStreaming {@link DownloadHelper}. */ +@RunWith(RobolectricTestRunner.class) +public final class DownloadHelperTest { + + @Test + public void staticDownloadHelperForSmoothStreaming_doesNotThrow() { + DownloadHelper.forSmoothStreaming( + Uri.parse("http://uri"), + new FakeDataSource.Factory(), + (handler, videoListener, audioListener, text, metadata, drm) -> new Renderer[0]); + DownloadHelper.forSmoothStreaming( + Uri.parse("http://uri"), + new FakeDataSource.Factory(), + (handler, videoListener, audioListener, text, metadata, drm) -> new Renderer[0], + /* drmSessionManager= */ null, + DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS); + } +} diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DebugTextViewHelper.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DebugTextViewHelper.java index 8c7c507f92..da2081db31 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DebugTextViewHelper.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DebugTextViewHelper.java @@ -137,23 +137,40 @@ public class DebugTextViewHelper implements Player.EventListener, Runnable { /** Returns a string containing video debugging information. */ protected String getVideoString() { Format format = player.getVideoFormat(); - if (format == null) { + DecoderCounters decoderCounters = player.getVideoDecoderCounters(); + if (format == null || decoderCounters == null) { return ""; } - return "\n" + format.sampleMimeType + "(id:" + format.id + " r:" + format.width + "x" - + format.height + getPixelAspectRatioString(format.pixelWidthHeightRatio) - + getDecoderCountersBufferCountString(player.getVideoDecoderCounters()) + ")"; + return "\n" + + format.sampleMimeType + + "(id:" + + format.id + + " r:" + + format.width + + "x" + + format.height + + getPixelAspectRatioString(format.pixelWidthHeightRatio) + + getDecoderCountersBufferCountString(decoderCounters) + + ")"; } /** Returns a string containing audio debugging information. */ protected String getAudioString() { Format format = player.getAudioFormat(); - if (format == null) { + DecoderCounters decoderCounters = player.getAudioDecoderCounters(); + if (format == null || decoderCounters == null) { return ""; } - return "\n" + format.sampleMimeType + "(id:" + format.id + " hz:" + format.sampleRate + " ch:" + return "\n" + + format.sampleMimeType + + "(id:" + + format.id + + " hz:" + + format.sampleRate + + " ch:" + format.channelCount - + getDecoderCountersBufferCountString(player.getAudioDecoderCounters()) + ")"; + + getDecoderCountersBufferCountString(decoderCounters) + + ")"; } private static String getDecoderCountersBufferCountString(DecoderCounters counters) { diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java index 75c4f71b64..0c3d39a13a 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java @@ -319,8 +319,8 @@ public class DefaultTimeBar extends View implements TimeBar { keyTimeIncrement = C.TIME_UNSET; keyCountIncrement = DEFAULT_INCREMENT_COUNT; setFocusable(true); - if (Util.SDK_INT >= 16) { - maybeSetImportantForAccessibilityV16(); + if (getImportantForAccessibility() == View.IMPORTANT_FOR_ACCESSIBILITY_AUTO) { + setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); } } @@ -611,13 +611,12 @@ public class DefaultTimeBar extends View implements TimeBar { if (Util.SDK_INT >= 21) { info.addAction(AccessibilityAction.ACTION_SCROLL_FORWARD); info.addAction(AccessibilityAction.ACTION_SCROLL_BACKWARD); - } else if (Util.SDK_INT >= 16) { + } else { info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD); info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD); } } - @TargetApi(16) @Override public boolean performAccessibilityAction(int action, @Nullable Bundle args) { if (super.performAccessibilityAction(action, args)) { @@ -643,13 +642,6 @@ public class DefaultTimeBar extends View implements TimeBar { // Internal methods. - @TargetApi(16) - private void maybeSetImportantForAccessibilityV16() { - if (getImportantForAccessibility() == View.IMPORTANT_FOR_ACCESSIBILITY_AUTO) { - setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); - } - } - private void startScrubbing() { scrubbing = true; setPressed(true); diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationHelper.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationHelper.java new file mode 100644 index 0000000000..94bd0b81c5 --- /dev/null +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationHelper.java @@ -0,0 +1,174 @@ +/* + * 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.ui; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.support.annotation.DrawableRes; +import android.support.annotation.Nullable; +import android.support.annotation.StringRes; +import android.support.v4.app.NotificationCompat; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.offline.DownloadState; + +/** Helper for creating download notifications. */ +public final class DownloadNotificationHelper { + + private static final @StringRes int NULL_STRING_ID = 0; + + private final Context context; + private final NotificationCompat.Builder notificationBuilder; + + /** + * @param context A context. + * @param channelId The id of the notification channel to use. + */ + public DownloadNotificationHelper(Context context, String channelId) { + context = context.getApplicationContext(); + this.context = context; + this.notificationBuilder = new NotificationCompat.Builder(context, channelId); + } + + /** + * Returns a progress notification for the given download states. + * + * @param smallIcon A small icon for the notification. + * @param contentIntent An optional content intent to send when the notification is clicked. + * @param message An optional message to display on the notification. + * @param downloadStates The download states. + * @return The notification. + */ + public Notification buildProgressNotification( + @DrawableRes int smallIcon, + @Nullable PendingIntent contentIntent, + @Nullable String message, + DownloadState[] downloadStates) { + float totalPercentage = 0; + int downloadTaskCount = 0; + boolean allDownloadPercentagesUnknown = true; + boolean haveDownloadedBytes = false; + boolean haveDownloadTasks = false; + boolean haveRemoveTasks = false; + for (DownloadState downloadState : downloadStates) { + if (downloadState.state == DownloadState.STATE_REMOVING + || downloadState.state == DownloadState.STATE_RESTARTING + || downloadState.state == DownloadState.STATE_REMOVED) { + haveRemoveTasks = true; + continue; + } + if (downloadState.state != DownloadState.STATE_DOWNLOADING + && downloadState.state != DownloadState.STATE_COMPLETED) { + continue; + } + haveDownloadTasks = true; + if (downloadState.downloadPercentage != C.PERCENTAGE_UNSET) { + allDownloadPercentagesUnknown = false; + totalPercentage += downloadState.downloadPercentage; + } + haveDownloadedBytes |= downloadState.downloadedBytes > 0; + downloadTaskCount++; + } + + int titleStringId = + haveDownloadTasks + ? R.string.exo_download_downloading + : (haveRemoveTasks ? R.string.exo_download_removing : NULL_STRING_ID); + int progress = 0; + boolean indeterminate = true; + if (haveDownloadTasks) { + progress = (int) (totalPercentage / downloadTaskCount); + indeterminate = allDownloadPercentagesUnknown && haveDownloadedBytes; + } + return buildNotification( + smallIcon, + contentIntent, + message, + titleStringId, + /* maxProgress= */ 100, + progress, + indeterminate, + /* ongoing= */ true, + /* showWhen= */ false); + } + + /** + * Returns a notification for a completed download. + * + * @param smallIcon A small icon for the notifications. + * @param contentIntent An optional content intent to send when the notification is clicked. + * @param message An optional message to display on the notification. + * @return The notification. + */ + public Notification buildDownloadCompletedNotification( + @DrawableRes int smallIcon, @Nullable PendingIntent contentIntent, @Nullable String message) { + int titleStringId = R.string.exo_download_completed; + return buildEndStateNotification(smallIcon, contentIntent, message, titleStringId); + } + + /** + * Returns a notification for a failed download. + * + * @param smallIcon A small icon for the notifications. + * @param contentIntent An optional content intent to send when the notification is clicked. + * @param message An optional message to display on the notification. + * @return The notification. + */ + public Notification buildDownloadFailedNotification( + @DrawableRes int smallIcon, @Nullable PendingIntent contentIntent, @Nullable String message) { + @StringRes int titleStringId = R.string.exo_download_failed; + return buildEndStateNotification(smallIcon, contentIntent, message, titleStringId); + } + + private Notification buildEndStateNotification( + @DrawableRes int smallIcon, + @Nullable PendingIntent contentIntent, + @Nullable String message, + @StringRes int titleStringId) { + return buildNotification( + smallIcon, + contentIntent, + message, + titleStringId, + /* maxProgress= */ 0, + /* currentProgress= */ 0, + /* indeterminateProgress= */ false, + /* ongoing= */ false, + /* showWhen= */ true); + } + + private Notification buildNotification( + @DrawableRes int smallIcon, + @Nullable PendingIntent contentIntent, + @Nullable String message, + @StringRes int titleStringId, + int maxProgress, + int currentProgress, + boolean indeterminateProgress, + boolean ongoing, + boolean showWhen) { + notificationBuilder.setSmallIcon(smallIcon); + notificationBuilder.setContentTitle( + titleStringId == NULL_STRING_ID ? null : context.getResources().getString(titleStringId)); + notificationBuilder.setContentIntent(contentIntent); + notificationBuilder.setStyle( + message == null ? null : new NotificationCompat.BigTextStyle().bigText(message)); + notificationBuilder.setProgress(maxProgress, currentProgress, indeterminateProgress); + notificationBuilder.setOngoing(ongoing); + notificationBuilder.setShowWhen(showWhen); + return notificationBuilder.build(); + } +} diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationUtil.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationUtil.java index 2d1656af57..b9e952e62f 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationUtil.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationUtil.java @@ -20,16 +20,16 @@ import android.app.PendingIntent; import android.content.Context; import android.support.annotation.DrawableRes; import android.support.annotation.Nullable; -import android.support.annotation.StringRes; -import android.support.v4.app.NotificationCompat; -import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.offline.DownloadState; +import com.google.android.exoplayer2.util.Util; -/** Helper for creating download notifications. */ +/** + * @deprecated Using this class can cause notifications to flicker on devices with {@link + * Util#SDK_INT} < 21. Use {@link DownloadNotificationHelper} instead. + */ +@Deprecated public final class DownloadNotificationUtil { - private static final @StringRes int NULL_STRING_ID = 0; - private DownloadNotificationUtil() {} /** @@ -37,8 +37,7 @@ public final class DownloadNotificationUtil { * * @param context A context for accessing resources. * @param smallIcon A small icon for the notification. - * @param channelId The id of the notification channel to use. Only required for API level 26 and - * above. + * @param channelId The id of the notification channel to use. * @param contentIntent An optional content intent to send when the notification is clicked. * @param message An optional message to display on the notification. * @param downloadStates The download states. @@ -51,50 +50,8 @@ public final class DownloadNotificationUtil { @Nullable PendingIntent contentIntent, @Nullable String message, DownloadState[] downloadStates) { - float totalPercentage = 0; - int downloadTaskCount = 0; - boolean allDownloadPercentagesUnknown = true; - boolean haveDownloadedBytes = false; - boolean haveDownloadTasks = false; - boolean haveRemoveTasks = false; - for (DownloadState downloadState : downloadStates) { - if (downloadState.state == DownloadState.STATE_REMOVING - || downloadState.state == DownloadState.STATE_RESTARTING - || downloadState.state == DownloadState.STATE_REMOVED) { - haveRemoveTasks = true; - continue; - } - if (downloadState.state != DownloadState.STATE_DOWNLOADING - && downloadState.state != DownloadState.STATE_COMPLETED) { - continue; - } - haveDownloadTasks = true; - if (downloadState.downloadPercentage != C.PERCENTAGE_UNSET) { - allDownloadPercentagesUnknown = false; - totalPercentage += downloadState.downloadPercentage; - } - haveDownloadedBytes |= downloadState.downloadedBytes > 0; - downloadTaskCount++; - } - - int titleStringId = - haveDownloadTasks - ? R.string.exo_download_downloading - : (haveRemoveTasks ? R.string.exo_download_removing : NULL_STRING_ID); - NotificationCompat.Builder notificationBuilder = - newNotificationBuilder( - context, smallIcon, channelId, contentIntent, message, titleStringId); - - int progress = 0; - boolean indeterminate = true; - if (haveDownloadTasks) { - progress = (int) (totalPercentage / downloadTaskCount); - indeterminate = allDownloadPercentagesUnknown && haveDownloadedBytes; - } - notificationBuilder.setProgress(/* max= */ 100, progress, indeterminate); - notificationBuilder.setOngoing(true); - notificationBuilder.setShowWhen(false); - return notificationBuilder.build(); + return new DownloadNotificationHelper(context, channelId) + .buildProgressNotification(smallIcon, contentIntent, message, downloadStates); } /** @@ -102,8 +59,7 @@ public final class DownloadNotificationUtil { * * @param context A context for accessing resources. * @param smallIcon A small icon for the notifications. - * @param channelId The id of the notification channel to use. Only required for API level 26 and - * above. + * @param channelId The id of the notification channel to use. * @param contentIntent An optional content intent to send when the notification is clicked. * @param message An optional message to display on the notification. * @return The notification. @@ -114,10 +70,8 @@ public final class DownloadNotificationUtil { String channelId, @Nullable PendingIntent contentIntent, @Nullable String message) { - int titleStringId = R.string.exo_download_completed; - return newNotificationBuilder( - context, smallIcon, channelId, contentIntent, message, titleStringId) - .build(); + return new DownloadNotificationHelper(context, channelId) + .buildDownloadCompletedNotification(smallIcon, contentIntent, message); } /** @@ -125,8 +79,7 @@ public final class DownloadNotificationUtil { * * @param context A context for accessing resources. * @param smallIcon A small icon for the notifications. - * @param channelId The id of the notification channel to use. Only required for API level 26 and - * above. + * @param channelId The id of the notification channel to use. * @param contentIntent An optional content intent to send when the notification is clicked. * @param message An optional message to display on the notification. * @return The notification. @@ -137,30 +90,7 @@ public final class DownloadNotificationUtil { String channelId, @Nullable PendingIntent contentIntent, @Nullable String message) { - @StringRes int titleStringId = R.string.exo_download_failed; - return newNotificationBuilder( - context, smallIcon, channelId, contentIntent, message, titleStringId) - .build(); - } - - private static NotificationCompat.Builder newNotificationBuilder( - Context context, - @DrawableRes int smallIcon, - String channelId, - @Nullable PendingIntent contentIntent, - @Nullable String message, - @StringRes int titleStringId) { - NotificationCompat.Builder notificationBuilder = - new NotificationCompat.Builder(context, channelId).setSmallIcon(smallIcon); - if (titleStringId != NULL_STRING_ID) { - notificationBuilder.setContentTitle(context.getResources().getString(titleStringId)); - } - if (contentIntent != null) { - notificationBuilder.setContentIntent(contentIntent); - } - if (message != null) { - notificationBuilder.setStyle(new NotificationCompat.BigTextStyle().bigText(message)); - } - return notificationBuilder; + return new DownloadNotificationHelper(context, channelId) + .buildDownloadFailedNotification(smallIcon, contentIntent, message); } } diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java index 30c19d5f82..6634fdf820 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java @@ -58,7 +58,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; * player state. * *

    The notification is cancelled when {@code null} is passed to {@link #setPlayer(Player)} or - * when an intent with action {@link #ACTION_STOP} is received. + * when the notification is dismissed by the user. * *

    If the player is released it must be removed from the manager by calling {@code * setPlayer(null)} which will cancel the notification. @@ -72,11 +72,17 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; * are displayed. *

      *
    • Corresponding setter: {@link #setUseNavigationActions(boolean)} + *
    • Default: {@code true} *
    - *
  • {@code stopAction} - Sets which stop action should be used. If set to null, the stop - * action is not displayed. + *
  • {@code usePlayPauseActions} - Sets whether the play and pause actions are displayed. *
      - *
    • Corresponding setter: {@link #setStopAction(String)}} + *
    • Corresponding setter: {@link #setUsePlayPauseActions(boolean)} + *
    • Default: {@code true} + *
    + *
  • {@code useStopAction} - Sets whether the stop action is displayed. + *
      + *
    • Corresponding setter: {@link #setUseStopAction(boolean)} + *
    • Default: {@code false} *
    *
  • {@code rewindIncrementMs} - Sets the rewind increment. If set to zero the rewind * action is not displayed. @@ -87,7 +93,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; *
  • {@code fastForwardIncrementMs} - Sets the fast forward increment. If set to zero the * fast forward action is not included in the notification. *
      - *
    • Corresponding setter: {@link #setFastForwardIncrementMs(long)}} + *
    • Corresponding setter: {@link #setFastForwardIncrementMs(long)} *
    • Default: {@link #DEFAULT_FAST_FORWARD_MS} (5000) *
    * @@ -126,6 +132,18 @@ public class PlayerNotificationManager { @Nullable String getCurrentContentText(Player player); + /** + * Gets the content sub text for the current media item. + * + *

    See {@link NotificationCompat.Builder#setSubText(CharSequence)}. + * + * @param player The {@link Player} for which a notification is being built. + */ + @Nullable + default String getCurrentSubText(Player player) { + return null; + } + /** * Gets the large icon for the current media item. * @@ -183,7 +201,7 @@ public class PlayerNotificationManager { void onCustomAction(Player player, String action, Intent intent); } - /** A listener for start and cancellation of the notification. */ + /** A listener for changes to the notification. */ public interface NotificationListener { /** @@ -191,15 +209,41 @@ public class PlayerNotificationManager { * * @param notificationId The id with which the notification has been posted. * @param notification The {@link Notification}. + * @deprecated Use {@link #onNotificationPosted(int, Notification, boolean)} instead. */ - void onNotificationStarted(int notificationId, Notification notification); + @Deprecated + default void onNotificationStarted(int notificationId, Notification notification) {} /** * Called after the notification has been cancelled. * * @param notificationId The id of the notification which has been cancelled. + * @deprecated Use {@link #onNotificationCancelled(int, boolean)}. */ - void onNotificationCancelled(int notificationId); + @Deprecated + default void onNotificationCancelled(int notificationId) {} + + /** + * Called after the notification has been cancelled. + * + * @param notificationId The id of the notification which has been cancelled. + * @param dismissedByUser {@code true} if the notification is cancelled because the user + * dismissed the notification. + */ + default void onNotificationCancelled(int notificationId, boolean dismissedByUser) {} + + /** + * Called each time after the notification has been posted. + * + *

    For a service, the {@code ongoing} flag can be used as an indicator as to whether it + * should be in the foreground. + * + * @param notificationId The id of the notification which has been posted. + * @param notification The {@link Notification}. + * @param ongoing Whether the notification is ongoing. + */ + default void onNotificationPosted( + int notificationId, Notification notification, boolean ongoing) {} } /** Receives a {@link Bitmap}. */ @@ -223,7 +267,7 @@ public class PlayerNotificationManager { if (player != null && notificationTag == currentNotificationTag && isNotificationStarted) { - updateNotification(bitmap); + startOrUpdateNotification(bitmap); } }); } @@ -242,10 +286,15 @@ public class PlayerNotificationManager { public static final String ACTION_FAST_FORWARD = "com.google.android.exoplayer.ffwd"; /** The action which rewinds. */ public static final String ACTION_REWIND = "com.google.android.exoplayer.rewind"; - /** The action which cancels the notification and stops playback. */ + /** The action which stops playback. */ public static final String ACTION_STOP = "com.google.android.exoplayer.stop"; /** The extra key of the instance id of the player notification manager. */ public static final String EXTRA_INSTANCE_ID = "INSTANCE_ID"; + /** + * The action which is executed when the notification is dismissed. It cancels the notification + * and calls {@link NotificationListener#onNotificationCancelled(int, boolean)}. + */ + private static final String ACTION_DISMISS = "com.google.android.exoplayer.dismiss"; /** * Visibility of notification on the lock screen. One of {@link @@ -289,6 +338,7 @@ public class PlayerNotificationManager { private final Context context; private final String channelId; + private final NotificationCompat.Builder builder; private final int notificationId; private final MediaDescriptionAdapter mediaDescriptionAdapter; private final @Nullable CustomActionReceiver customActionReceiver; @@ -299,6 +349,7 @@ public class PlayerNotificationManager { private final NotificationBroadcastReceiver notificationBroadcastReceiver; private final Map playbackActions; private final Map customActions; + private final PendingIntent dismissPendingIntent; private final int instanceId; private final Timeline.Window window; @@ -311,8 +362,7 @@ public class PlayerNotificationManager { private @Nullable MediaSessionCompat.Token mediaSessionToken; private boolean useNavigationActions; private boolean usePlayPauseActions; - private @Nullable String stopAction; - private @Nullable PendingIntent stopPendingIntent; + private boolean useStopAction; private long fastForwardMs; private long rewindMs; private int badgeIconType; @@ -322,7 +372,6 @@ public class PlayerNotificationManager { private @DrawableRes int smallIconResourceId; private int visibility; private @Priority int priority; - private boolean ongoing; private boolean useChronometer; private boolean wasPlayWhenReady; private int lastPlaybackState; @@ -482,12 +531,14 @@ public class PlayerNotificationManager { MediaDescriptionAdapter mediaDescriptionAdapter, @Nullable NotificationListener notificationListener, @Nullable CustomActionReceiver customActionReceiver) { - this.context = context.getApplicationContext(); + context = context.getApplicationContext(); + this.context = context; this.channelId = channelId; this.notificationId = notificationId; this.mediaDescriptionAdapter = mediaDescriptionAdapter; this.notificationListener = notificationListener; this.customActionReceiver = customActionReceiver; + builder = new NotificationCompat.Builder(context, channelId); controlDispatcher = new DefaultControlDispatcher(); window = new Timeline.Window(); instanceId = instanceIdCounter++; @@ -498,7 +549,6 @@ public class PlayerNotificationManager { intentFilter = new IntentFilter(); useNavigationActions = true; usePlayPauseActions = true; - ongoing = true; colorized = true; useChronometer = true; color = Color.TRANSPARENT; @@ -507,7 +557,6 @@ public class PlayerNotificationManager { priority = NotificationCompat.PRIORITY_LOW; fastForwardMs = DEFAULT_FAST_FORWARD_MS; rewindMs = DEFAULT_REWIND_MS; - stopAction = ACTION_STOP; badgeIconType = NotificationCompat.BADGE_ICON_SMALL; visibility = NotificationCompat.VISIBILITY_PUBLIC; @@ -523,7 +572,8 @@ public class PlayerNotificationManager { for (String action : customActions.keySet()) { intentFilter.addAction(action); } - stopPendingIntent = Assertions.checkNotNull(playbackActions.get(ACTION_STOP)).actionIntent; + dismissPendingIntent = createBroadcastIntent(ACTION_DISMISS, context, instanceId); + intentFilter.addAction(ACTION_DISMISS); } /** @@ -550,7 +600,7 @@ public class PlayerNotificationManager { if (this.player != null) { this.player.removeListener(playerListener); if (player == null) { - stopNotification(); + stopNotification(/* dismissedByUser= */ false); } } this.player = player; @@ -558,9 +608,7 @@ public class PlayerNotificationManager { wasPlayWhenReady = player.getPlayWhenReady(); lastPlaybackState = player.getPlaybackState(); player.addListener(playerListener); - if (lastPlaybackState != Player.STATE_IDLE) { - startOrUpdateNotification(); - } + startOrUpdateNotification(); } } @@ -652,24 +700,15 @@ public class PlayerNotificationManager { } /** - * Sets the name of the action to be used as stop action to cancel the notification. If {@code - * null} is passed the stop action is not displayed. + * Sets whether the stop action should be used. * - * @param stopAction The name of the stop action which must be {@link #ACTION_STOP} or an action - * provided by the {@link CustomActionReceiver}. {@code null} to omit the stop action. + * @param useStopAction Whether to use the stop action. */ - public final void setStopAction(@Nullable String stopAction) { - if (Util.areEqual(stopAction, this.stopAction)) { + public final void setUseStopAction(boolean useStopAction) { + if (this.useStopAction == useStopAction) { return; } - this.stopAction = stopAction; - if (ACTION_STOP.equals(stopAction)) { - stopPendingIntent = Assertions.checkNotNull(playbackActions.get(ACTION_STOP)).actionIntent; - } else if (stopAction != null) { - stopPendingIntent = Assertions.checkNotNull(customActions.get(stopAction)).actionIntent; - } else { - stopPendingIntent = null; - } + this.useStopAction = useStopAction; invalidate(); } @@ -751,22 +790,6 @@ public class PlayerNotificationManager { } } - /** - * Sets whether the notification should be ongoing. If {@code false} the user can dismiss the - * notification by swiping. If in addition the stop action is enabled dismissing the notification - * triggers the stop action. - * - *

    See {@link NotificationCompat.Builder#setOngoing(boolean)}. - * - * @param ongoing Whether {@code true} the notification is ongoing and not dismissible. - */ - public final void setOngoing(boolean ongoing) { - if (this.ongoing != ongoing) { - this.ongoing = ongoing; - invalidate(); - } - } - /** * Sets the priority of the notification required for API 25 and lower. * @@ -852,36 +875,48 @@ public class PlayerNotificationManager { /** Forces an update of the notification if already started. */ public void invalidate() { if (isNotificationStarted && player != null) { - updateNotification(null); + startOrUpdateNotification(); } } + @Nullable + private Notification startOrUpdateNotification() { + Assertions.checkNotNull(this.player); + return startOrUpdateNotification(/* bitmap= */ null); + } + @RequiresNonNull("player") - private Notification updateNotification(@Nullable Bitmap bitmap) { - Notification notification = createNotification(player, bitmap); + @Nullable + private Notification startOrUpdateNotification(@Nullable Bitmap bitmap) { + Player player = this.player; + boolean ongoing = getOngoing(player); + Notification notification = createNotification(player, builder, ongoing, bitmap); + if (notification == null) { + stopNotification(/* dismissedByUser= */ false); + return null; + } notificationManager.notify(notificationId, notification); + if (!isNotificationStarted) { + isNotificationStarted = true; + context.registerReceiver(notificationBroadcastReceiver, intentFilter); + if (notificationListener != null) { + notificationListener.onNotificationStarted(notificationId, notification); + } + } + NotificationListener listener = notificationListener; + if (listener != null) { + listener.onNotificationPosted(notificationId, notification, ongoing); + } return notification; } - private void startOrUpdateNotification() { - if (player != null) { - Notification notification = updateNotification(null); - if (!isNotificationStarted) { - isNotificationStarted = true; - context.registerReceiver(notificationBroadcastReceiver, intentFilter); - if (notificationListener != null) { - notificationListener.onNotificationStarted(notificationId, notification); - } - } - } - } - - private void stopNotification() { + private void stopNotification(boolean dismissedByUser) { if (isNotificationStarted) { - notificationManager.cancel(notificationId); isNotificationStarted = false; + notificationManager.cancel(notificationId); context.unregisterReceiver(notificationBroadcastReceiver); if (notificationListener != null) { + notificationListener.onNotificationCancelled(notificationId, dismissedByUser); notificationListener.onNotificationCancelled(notificationId); } } @@ -891,11 +926,27 @@ public class PlayerNotificationManager { * Creates the notification given the current player state. * * @param player The player for which state to build a notification. + * @param builder A builder that can optionally be used for creating the notification. The same + * builder is passed each time this method is called, since reusing the same builder can + * prevent notification flicker when {@code Util#SDK_INT} < 21. This means implementations + * must take care to ensure anything set on the builder during a previous call is cleared, if + * no longer required. + * @param ongoing Whether the notification should be ongoing. * @param largeIcon The large icon to be used. - * @return The {@link Notification} which has been built. + * @return The {@link Notification} which has been built, or {@code null} if no notification + * should be displayed. */ - protected Notification createNotification(Player player, @Nullable Bitmap largeIcon) { - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId); + @Nullable + protected Notification createNotification( + Player player, + NotificationCompat.Builder builder, + boolean ongoing, + @Nullable Bitmap largeIcon) { + if (player.getPlaybackState() == Player.STATE_IDLE) { + return null; + } + + builder.mActions.clear(); List actionNames = getActions(player); for (int i = 0; i < actionNames.size(); i++) { String actionName = actionNames.get(i); @@ -907,20 +958,20 @@ public class PlayerNotificationManager { builder.addAction(action); } } - // Create a media style notification. + MediaStyle mediaStyle = new MediaStyle(); if (mediaSessionToken != null) { mediaStyle.setMediaSession(mediaSessionToken); } mediaStyle.setShowActionsInCompactView(getActionIndicesForCompactView(actionNames, player)); - // Configure stop action (eg. when user dismisses the notification when !isOngoing). - boolean useStopAction = stopAction != null; - mediaStyle.setShowCancelButton(useStopAction); - if (useStopAction && stopPendingIntent != null) { - builder.setDeleteIntent(stopPendingIntent); - mediaStyle.setCancelButtonIntent(stopPendingIntent); - } + // Configure dismiss action prior to API 21 ('x' button). + mediaStyle.setShowCancelButton(!ongoing); + mediaStyle.setCancelButtonIntent(dismissPendingIntent); builder.setStyle(mediaStyle); + + // Set intent which is sent if the user selects 'clear all' + builder.setDeleteIntent(dismissPendingIntent); + // Set notification properties from getters. builder .setBadgeIconType(badgeIconType) @@ -931,7 +982,10 @@ public class PlayerNotificationManager { .setVisibility(visibility) .setPriority(priority) .setDefaults(defaults); - if (useChronometer + + // Changing "showWhen" causes notification flicker if SDK_INT < 21. + if (Util.SDK_INT >= 21 + && useChronometer && !player.isPlayingAd() && !player.isCurrentWindowDynamic() && player.getPlayWhenReady() @@ -943,21 +997,19 @@ public class PlayerNotificationManager { } else { builder.setShowWhen(false).setUsesChronometer(false); } + // Set media specific notification properties from MediaDescriptionAdapter. builder.setContentTitle(mediaDescriptionAdapter.getCurrentContentTitle(player)); builder.setContentText(mediaDescriptionAdapter.getCurrentContentText(player)); + builder.setSubText(mediaDescriptionAdapter.getCurrentSubText(player)); if (largeIcon == null) { largeIcon = mediaDescriptionAdapter.getCurrentLargeIcon( player, new BitmapCallback(++currentNotificationTag)); } - if (largeIcon != null) { - builder.setLargeIcon(largeIcon); - } - PendingIntent contentIntent = mediaDescriptionAdapter.createCurrentContentIntent(player); - if (contentIntent != null) { - builder.setContentIntent(contentIntent); - } + setLargeIcon(builder, largeIcon); + builder.setContentIntent(mediaDescriptionAdapter.createCurrentContentIntent(player)); + return builder.build(); } @@ -1017,8 +1069,8 @@ public class PlayerNotificationManager { if (customActionReceiver != null) { stringActions.addAll(customActionReceiver.getCustomActions(player)); } - if (ACTION_STOP.equals(stopAction)) { - stringActions.add(stopAction); + if (useStopAction) { + stringActions.add(ACTION_STOP); } return stringActions; } @@ -1030,7 +1082,7 @@ public class PlayerNotificationManager { * first parameter. * * @param actionNames The names of the actions included in the notification. - * @param player The player for which state to build a notification. + * @param player The player for which a notification is being built. */ @SuppressWarnings("unused") protected int[] getActionIndicesForCompactView(List actionNames, Player player) { @@ -1041,52 +1093,11 @@ public class PlayerNotificationManager { : (playActionIndex != -1 ? new int[] {playActionIndex} : new int[0]); } - private static Map createPlaybackActions( - Context context, int instanceId) { - Map actions = new HashMap<>(); - actions.put( - ACTION_PLAY, - new NotificationCompat.Action( - R.drawable.exo_notification_play, - context.getString(R.string.exo_controls_play_description), - createBroadcastIntent(ACTION_PLAY, context, instanceId))); - actions.put( - ACTION_PAUSE, - new NotificationCompat.Action( - R.drawable.exo_notification_pause, - context.getString(R.string.exo_controls_pause_description), - createBroadcastIntent(ACTION_PAUSE, context, instanceId))); - actions.put( - ACTION_STOP, - new NotificationCompat.Action( - R.drawable.exo_notification_stop, - context.getString(R.string.exo_controls_stop_description), - createBroadcastIntent(ACTION_STOP, context, instanceId))); - actions.put( - ACTION_REWIND, - new NotificationCompat.Action( - R.drawable.exo_notification_rewind, - context.getString(R.string.exo_controls_rewind_description), - createBroadcastIntent(ACTION_REWIND, context, instanceId))); - actions.put( - ACTION_FAST_FORWARD, - new NotificationCompat.Action( - R.drawable.exo_notification_fastforward, - context.getString(R.string.exo_controls_fastforward_description), - createBroadcastIntent(ACTION_FAST_FORWARD, context, instanceId))); - actions.put( - ACTION_PREVIOUS, - new NotificationCompat.Action( - R.drawable.exo_notification_previous, - context.getString(R.string.exo_controls_previous_description), - createBroadcastIntent(ACTION_PREVIOUS, context, instanceId))); - actions.put( - ACTION_NEXT, - new NotificationCompat.Action( - R.drawable.exo_notification_next, - context.getString(R.string.exo_controls_next_description), - createBroadcastIntent(ACTION_NEXT, context, instanceId))); - return actions; + /** Returns whether the generated notification should be ongoing. */ + protected boolean getOngoing(Player player) { + int playbackState = player.getPlaybackState(); + return (playbackState == Player.STATE_BUFFERING || playbackState == Player.STATE_READY) + && player.getPlayWhenReady(); } private void previous(Player player) { @@ -1151,6 +1162,54 @@ public class PlayerNotificationManager { && player.getPlayWhenReady(); } + private static Map createPlaybackActions( + Context context, int instanceId) { + Map actions = new HashMap<>(); + actions.put( + ACTION_PLAY, + new NotificationCompat.Action( + R.drawable.exo_notification_play, + context.getString(R.string.exo_controls_play_description), + createBroadcastIntent(ACTION_PLAY, context, instanceId))); + actions.put( + ACTION_PAUSE, + new NotificationCompat.Action( + R.drawable.exo_notification_pause, + context.getString(R.string.exo_controls_pause_description), + createBroadcastIntent(ACTION_PAUSE, context, instanceId))); + actions.put( + ACTION_STOP, + new NotificationCompat.Action( + R.drawable.exo_notification_stop, + context.getString(R.string.exo_controls_stop_description), + createBroadcastIntent(ACTION_STOP, context, instanceId))); + actions.put( + ACTION_REWIND, + new NotificationCompat.Action( + R.drawable.exo_notification_rewind, + context.getString(R.string.exo_controls_rewind_description), + createBroadcastIntent(ACTION_REWIND, context, instanceId))); + actions.put( + ACTION_FAST_FORWARD, + new NotificationCompat.Action( + R.drawable.exo_notification_fastforward, + context.getString(R.string.exo_controls_fastforward_description), + createBroadcastIntent(ACTION_FAST_FORWARD, context, instanceId))); + actions.put( + ACTION_PREVIOUS, + new NotificationCompat.Action( + R.drawable.exo_notification_previous, + context.getString(R.string.exo_controls_previous_description), + createBroadcastIntent(ACTION_PREVIOUS, context, instanceId))); + actions.put( + ACTION_NEXT, + new NotificationCompat.Action( + R.drawable.exo_notification_next, + context.getString(R.string.exo_controls_next_description), + createBroadcastIntent(ACTION_NEXT, context, instanceId))); + return actions; + } + private static PendingIntent createBroadcastIntent( String action, Context context, int instanceId) { Intent intent = new Intent(action).setPackage(context.getPackageName()); @@ -1159,31 +1218,29 @@ public class PlayerNotificationManager { context, instanceId, intent, PendingIntent.FLAG_CANCEL_CURRENT); } + @SuppressWarnings("nullness:argument.type.incompatible") + private static void setLargeIcon(NotificationCompat.Builder builder, @Nullable Bitmap largeIcon) { + builder.setLargeIcon(largeIcon); + } + private class PlayerListener implements Player.EventListener { @Override public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { - if ((wasPlayWhenReady != playWhenReady && playbackState != Player.STATE_IDLE) - || lastPlaybackState != playbackState) { + if (wasPlayWhenReady != playWhenReady || lastPlaybackState != playbackState) { startOrUpdateNotification(); + wasPlayWhenReady = playWhenReady; + lastPlaybackState = playbackState; } - wasPlayWhenReady = playWhenReady; - lastPlaybackState = playbackState; } @Override public void onTimelineChanged(Timeline timeline, @Nullable Object manifest, int reason) { - if (player == null || player.getPlaybackState() == Player.STATE_IDLE) { - return; - } startOrUpdateNotification(); } @Override public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { - if (player == null || player.getPlaybackState() == Player.STATE_IDLE) { - return; - } startOrUpdateNotification(); } @@ -1194,9 +1251,6 @@ public class PlayerNotificationManager { @Override public void onRepeatModeChanged(int repeatMode) { - if (player == null || player.getPlaybackState() == Player.STATE_IDLE) { - return; - } startOrUpdateNotification(); } } @@ -1232,8 +1286,9 @@ public class PlayerNotificationManager { } else if (ACTION_NEXT.equals(action)) { next(player); } else if (ACTION_STOP.equals(action)) { - controlDispatcher.dispatchStop(player, true); - stopNotification(); + controlDispatcher.dispatchStop(player, /* reset= */ true); + } else if (ACTION_DISMISS.equals(action)) { + stopNotification(/* dismissedByUser= */ true); } else if (customActionReceiver != null && customActions.containsKey(action)) { customActionReceiver.onCustomAction(player, action, intent); } diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java index f7f509dfa7..9742d0005a 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java @@ -758,10 +758,6 @@ public class PlayerView extends FrameLayout { @Override public boolean dispatchKeyEvent(KeyEvent event) { if (player != null && player.isPlayingAd()) { - // Focus any overlay UI now, in case it's provided by a WebView whose contents may update - // dynamically. This is needed to make the "Skip ad" button focused on Android TV when using - // IMA [Internal: b/62371030]. - overlayFrameLayout.requestFocus(); return super.dispatchKeyEvent(event); } boolean isDpadWhenControlHidden = diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SimpleExoPlayerView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SimpleExoPlayerView.java index 55745a7cb5..b01e7a308c 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SimpleExoPlayerView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SimpleExoPlayerView.java @@ -15,7 +15,6 @@ */ package com.google.android.exoplayer2.ui; -import android.annotation.TargetApi; import android.content.Context; import android.support.annotation.NonNull; import android.support.annotation.Nullable; @@ -25,7 +24,6 @@ import com.google.android.exoplayer2.SimpleExoPlayer; /** @deprecated Use {@link PlayerView}. */ @Deprecated -@TargetApi(16) public final class SimpleExoPlayerView extends PlayerView { public SimpleExoPlayerView(Context context) { diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/CanvasRenderer.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/CanvasRenderer.java index 5538390d3b..955418acf7 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/CanvasRenderer.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/CanvasRenderer.java @@ -18,7 +18,6 @@ package com.google.android.exoplayer2.ui.spherical; import static com.google.android.exoplayer2.util.GlUtil.checkGlError; -import android.annotation.TargetApi; import android.graphics.Canvas; import android.graphics.PointF; import android.graphics.Rect; @@ -39,7 +38,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; *

    A CanvasRenderer can be created on any thread, but {@link #init()} needs to be called on the * GL thread before it can be rendered. */ -@TargetApi(15) public final class CanvasRenderer { private static final float WIDTH_UNIT = 0.8f; diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/ProjectionRenderer.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/ProjectionRenderer.java index 5e8a6d71d2..f24bcce3ce 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/ProjectionRenderer.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/ProjectionRenderer.java @@ -17,7 +17,6 @@ package com.google.android.exoplayer2.ui.spherical; import static com.google.android.exoplayer2.util.GlUtil.checkGlError; -import android.annotation.TargetApi; import android.opengl.GLES11Ext; import android.opengl.GLES20; import com.google.android.exoplayer2.C; @@ -30,7 +29,6 @@ import org.checkerframework.checker.nullness.qual.Nullable; * Utility class to render spherical meshes for video or images. Call {@link #init()} on the GL * thread when ready. */ -@TargetApi(15) /* package */ final class ProjectionRenderer { /** diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/SphericalSurfaceView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/SphericalSurfaceView.java index 36589c5e34..adbeb7773d 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/SphericalSurfaceView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/SphericalSurfaceView.java @@ -15,7 +15,6 @@ */ package com.google.android.exoplayer2.ui.spherical; -import android.annotation.TargetApi; import android.content.Context; import android.graphics.PointF; import android.graphics.SurfaceTexture; @@ -52,7 +51,6 @@ import javax.microedition.khronos.opengles.GL10; * apply the touch and sensor rotations in the correct order or the user's touch manipulations won't * match what they expect. */ -@TargetApi(15) public final class SphericalSurfaceView extends GLSurfaceView { /** diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashDownloadTest.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashDownloadTest.java index 0dd05e7fd3..7d000de4b0 100644 --- a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashDownloadTest.java +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashDownloadTest.java @@ -89,10 +89,6 @@ public final class DashDownloadTest { @Test public void testDownload() throws Exception { - if (Util.SDK_INT < 16) { - return; // Pass. - } - DashDownloader dashDownloader = downloadContent(); dashDownloader.download(); diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashStreamingTest.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashStreamingTest.java index 9a54ffd07c..56806183cc 100644 --- a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashStreamingTest.java +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashStreamingTest.java @@ -103,10 +103,6 @@ public final class DashStreamingTest { @Test public void testH264Fixed() { - if (Util.SDK_INT < 16) { - // Pass. - return; - } testRunner .setStreamName("test_h264_fixed") .setManifestUrl(DashTestData.H264_MANIFEST) @@ -118,7 +114,7 @@ public final class DashStreamingTest { @Test public void testH264Adaptive() throws DecoderQueryException { - if (Util.SDK_INT < 16 || shouldSkipAdaptiveTest(MimeTypes.VIDEO_H264)) { + if (shouldSkipAdaptiveTest(MimeTypes.VIDEO_H264)) { // Pass. return; } @@ -134,7 +130,7 @@ public final class DashStreamingTest { @Test public void testH264AdaptiveWithSeeking() throws DecoderQueryException { - if (Util.SDK_INT < 16 || shouldSkipAdaptiveTest(MimeTypes.VIDEO_H264)) { + if (shouldSkipAdaptiveTest(MimeTypes.VIDEO_H264)) { // Pass. return; } @@ -152,7 +148,7 @@ public final class DashStreamingTest { @Test public void testH264AdaptiveWithRendererDisabling() throws DecoderQueryException { - if (Util.SDK_INT < 16 || shouldSkipAdaptiveTest(MimeTypes.VIDEO_H264)) { + if (shouldSkipAdaptiveTest(MimeTypes.VIDEO_H264)) { // Pass. return; } @@ -633,10 +629,6 @@ public final class DashStreamingTest { @Test public void testDecoderInfoH264() throws DecoderQueryException { - if (Util.SDK_INT < 16) { - // Pass. - return; - } MediaCodecInfo decoderInfo = MediaCodecUtil.getDecoderInfo(MimeTypes.VIDEO_H264, false); assertThat(decoderInfo).isNotNull(); assertThat(Util.SDK_INT < 21 || decoderInfo.adaptive).isTrue(); diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTestRunner.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTestRunner.java index eb69cc88da..2446094136 100644 --- a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTestRunner.java +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashTestRunner.java @@ -205,7 +205,6 @@ public final class DashTestRunner { /** * A {@link HostedTest} for DASH playback tests. */ - @TargetApi(16) private static final class DashHostedTest extends ExoHostedTest { private final String streamName; diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/DebugRenderersFactory.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/DebugRenderersFactory.java index 39194d48fe..54e6088168 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/DebugRenderersFactory.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/DebugRenderersFactory.java @@ -20,6 +20,7 @@ import android.content.Context; import android.media.MediaCodec; import android.media.MediaCrypto; import android.os.Handler; +import android.support.annotation.Nullable; import com.google.android.exoplayer2.DefaultRenderersFactory; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; @@ -37,23 +38,37 @@ import java.util.ArrayList; /** * A debug extension of {@link DefaultRenderersFactory}. Provides a video renderer that performs - * video buffer timestamp assertions. + * video buffer timestamp assertions, and modifies the default value for {@link + * #setAllowedVideoJoiningTimeMs(long)} to be {@code 0}. */ -@TargetApi(16) public class DebugRenderersFactory extends DefaultRenderersFactory { public DebugRenderersFactory(Context context) { - super(context, DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF, 0); + super(context); + setAllowedVideoJoiningTimeMs(0); } @Override - protected void buildVideoRenderers(Context context, - DrmSessionManager drmSessionManager, long allowedVideoJoiningTimeMs, - Handler eventHandler, VideoRendererEventListener eventListener, - @ExtensionRendererMode int extensionRendererMode, ArrayList out) { - out.add(new DebugMediaCodecVideoRenderer(context, MediaCodecSelector.DEFAULT, - allowedVideoJoiningTimeMs, drmSessionManager, eventHandler, eventListener, - MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY)); + protected void buildVideoRenderers( + Context context, + @ExtensionRendererMode int extensionRendererMode, + MediaCodecSelector mediaCodecSelector, + @Nullable DrmSessionManager drmSessionManager, + boolean playClearSamplesWithoutKeys, + Handler eventHandler, + VideoRendererEventListener eventListener, + long allowedVideoJoiningTimeMs, + ArrayList out) { + out.add( + new DebugMediaCodecVideoRenderer( + context, + mediaCodecSelector, + allowedVideoJoiningTimeMs, + drmSessionManager, + playClearSamplesWithoutKeys, + eventHandler, + eventListener, + MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY)); } /** @@ -72,12 +87,24 @@ public class DebugRenderersFactory extends DefaultRenderersFactory { private int minimumInsertIndex; private boolean skipToPositionBeforeRenderingFirstFrame; - public DebugMediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector, - long allowedJoiningTimeMs, DrmSessionManager drmSessionManager, - Handler eventHandler, VideoRendererEventListener eventListener, + public DebugMediaCodecVideoRenderer( + Context context, + MediaCodecSelector mediaCodecSelector, + long allowedJoiningTimeMs, + DrmSessionManager drmSessionManager, + boolean playClearSamplesWithoutKeys, + Handler eventHandler, + VideoRendererEventListener eventListener, int maxDroppedFrameCountToNotify) { - super(context, mediaCodecSelector, allowedJoiningTimeMs, drmSessionManager, false, - eventHandler, eventListener, maxDroppedFrameCountToNotify); + super( + context, + mediaCodecSelector, + allowedJoiningTimeMs, + drmSessionManager, + playClearSamplesWithoutKeys, + eventHandler, + eventListener, + maxDroppedFrameCountToNotify); } @Override diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java index 8165eebaea..de4be82b38 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java @@ -112,7 +112,7 @@ public class FakeMediaSource extends BaseMediaSource { } @Override - public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { assertThat(preparedSource).isTrue(); assertThat(releasedSource).isFalse(); int periodIndex = timeline.getIndexOfPeriod(id.periodUid); diff --git a/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/MediaPeriodAsserts.java b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/MediaPeriodAsserts.java index 48b9128caf..b4137a41de 100644 --- a/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/MediaPeriodAsserts.java +++ b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/MediaPeriodAsserts.java @@ -30,6 +30,8 @@ import com.google.android.exoplayer2.trackselection.BaseTrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.util.ConditionVariable; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.concurrent.atomic.AtomicReference; @@ -45,14 +47,14 @@ public final class MediaPeriodAsserts { public interface FilterableManifestMediaPeriodFactory> { /** Returns media period based on the provided filterable manifest. */ - MediaPeriod createMediaPeriod(T manifest); + MediaPeriod createMediaPeriod(T manifest, int periodIndex); } private MediaPeriodAsserts() {} /** - * Asserts that the values returns by {@link MediaPeriod#getStreamKeys(TrackSelection)} are - * compatible with a {@link FilterableManifest} using these stream keys. + * Asserts that the values returns by {@link MediaPeriod#getStreamKeys(List)} are compatible with + * a {@link FilterableManifest} using these stream keys. * * @param mediaPeriodFactory A factory to create a {@link MediaPeriod} based on a manifest. * @param manifest The manifest which is to be tested. @@ -60,38 +62,97 @@ public final class MediaPeriodAsserts { public static > void assertGetStreamKeysAndManifestFilterIntegration( FilterableManifestMediaPeriodFactory mediaPeriodFactory, T manifest) { - MediaPeriod mediaPeriod = mediaPeriodFactory.createMediaPeriod(manifest); + assertGetStreamKeysAndManifestFilterIntegration( + mediaPeriodFactory, manifest, /* periodIndex= */ 0, /* ignoredMimeType= */ null); + } + + /** + * Asserts that the values returns by {@link MediaPeriod#getStreamKeys(List)} are compatible with + * a {@link FilterableManifest} using these stream keys. + * + * @param mediaPeriodFactory A factory to create a {@link MediaPeriod} based on a manifest. + * @param manifest The manifest which is to be tested. + * @param periodIndex The index of period in the manifest. + * @param ignoredMimeType Optional mime type whose existence in the filtered track groups is not + * asserted. + */ + public static > + void assertGetStreamKeysAndManifestFilterIntegration( + FilterableManifestMediaPeriodFactory mediaPeriodFactory, + T manifest, + int periodIndex, + @Nullable String ignoredMimeType) { + MediaPeriod mediaPeriod = mediaPeriodFactory.createMediaPeriod(manifest, periodIndex); TrackGroupArray trackGroupArray = getTrackGroups(mediaPeriod); + // Create test vector of query test selections: + // - One selection with one track per group, two tracks or all tracks. + // - Two selections with tracks from multiple groups, or tracks from a single group. + // - Multiple selections with tracks from all groups. + List> testSelections = new ArrayList<>(); for (int i = 0; i < trackGroupArray.length; i++) { TrackGroup trackGroup = trackGroupArray.get(i); - - // For each track group, create various test selections. - List testSelections = new ArrayList<>(); for (int j = 0; j < trackGroup.length; j++) { - testSelections.add(new TestTrackSelection(trackGroup, j)); + testSelections.add(Collections.singletonList(new TestTrackSelection(trackGroup, j))); } if (trackGroup.length > 1) { - testSelections.add(new TestTrackSelection(trackGroup, 0, 1)); + testSelections.add(Collections.singletonList(new TestTrackSelection(trackGroup, 0, 1))); + testSelections.add( + Arrays.asList( + new TrackSelection[] { + new TestTrackSelection(trackGroup, 0), new TestTrackSelection(trackGroup, 1) + })); } if (trackGroup.length > 2) { int[] allTracks = new int[trackGroup.length]; for (int j = 0; j < trackGroup.length; j++) { allTracks[j] = j; } - testSelections.add(new TestTrackSelection(trackGroup, allTracks)); + testSelections.add( + Collections.singletonList(new TestTrackSelection(trackGroup, allTracks))); } + } + if (trackGroupArray.length > 1) { + for (int i = 0; i < trackGroupArray.length - 1; i++) { + for (int j = i + 1; j < trackGroupArray.length; j++) { + testSelections.add( + Arrays.asList( + new TrackSelection[] { + new TestTrackSelection(trackGroupArray.get(i), 0), + new TestTrackSelection(trackGroupArray.get(j), 0) + })); + } + } + } + if (trackGroupArray.length > 2) { + List selectionsFromAllGroups = new ArrayList<>(); + for (int i = 0; i < trackGroupArray.length; i++) { + selectionsFromAllGroups.add(new TestTrackSelection(trackGroupArray.get(i), 0)); + } + testSelections.add(selectionsFromAllGroups); + } - // Get stream keys for each selection and check that the resulting filtered manifest includes - // at least the same subset of tracks. - for (TrackSelection testSelection : testSelections) { - List streamKeys = mediaPeriod.getStreamKeys(testSelection); - T filteredManifest = manifest.copy(streamKeys); - MediaPeriod filteredMediaPeriod = mediaPeriodFactory.createMediaPeriod(filteredManifest); - TrackGroupArray filteredTrackGroupArray = getTrackGroups(filteredMediaPeriod); - Format[] expectedFormats = new Format[testSelection.length()]; - for (int k = 0; k < testSelection.length(); k++) { - expectedFormats[k] = testSelection.getFormat(k); + // Verify for each case that stream keys can be used to create filtered tracks which still + // contain at least all requested formats. + for (List testSelection : testSelections) { + List streamKeys = mediaPeriod.getStreamKeys(testSelection); + if (streamKeys.isEmpty()) { + // Manifests won't be filtered if stream key is empty. + continue; + } + T filteredManifest = manifest.copy(streamKeys); + // The filtered manifest should only have one period left. + MediaPeriod filteredMediaPeriod = + mediaPeriodFactory.createMediaPeriod(filteredManifest, /* periodIndex= */ 0); + TrackGroupArray filteredTrackGroupArray = getTrackGroups(filteredMediaPeriod); + for (TrackSelection trackSelection : testSelection) { + if (ignoredMimeType != null + && ignoredMimeType.equals(trackSelection.getFormat(0).sampleMimeType)) { + continue; + } + Format[] expectedFormats = new Format[trackSelection.length()]; + for (int k = 0; k < trackSelection.length(); k++) { + expectedFormats[k] = trackSelection.getFormat(k); } assertOneTrackGroupContainsFormats(filteredTrackGroupArray, expectedFormats); } diff --git a/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/MediaSourceTestRunner.java b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/MediaSourceTestRunner.java index 5de1ab87b6..9514768416 100644 --- a/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/MediaSourceTestRunner.java +++ b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/MediaSourceTestRunner.java @@ -132,15 +132,28 @@ public class MediaSourceTestRunner { } /** - * Calls {@link MediaSource#createPeriod(MediaSource.MediaPeriodId, Allocator)} on the playback - * thread, asserting that a non-null {@link MediaPeriod} is returned. + * Calls {@link MediaSource#createPeriod(MediaSource.MediaPeriodId, Allocator, long)} with a zero + * start position on the playback thread, asserting that a non-null {@link MediaPeriod} is + * returned. * * @param periodId The id of the period to create. * @return The created {@link MediaPeriod}. */ public MediaPeriod createPeriod(final MediaPeriodId periodId) { + return createPeriod(periodId, /* startPositionUs= */ 0); + } + + /** + * Calls {@link MediaSource#createPeriod(MediaSource.MediaPeriodId, Allocator, long)} on the + * playback thread, asserting that a non-null {@link MediaPeriod} is returned. + * + * @param periodId The id of the period to create. + * @return The created {@link MediaPeriod}. + */ + public MediaPeriod createPeriod(final MediaPeriodId periodId, long startPositionUs) { final MediaPeriod[] holder = new MediaPeriod[1]; - runOnPlaybackThread(() -> holder[0] = mediaSource.createPeriod(periodId, allocator)); + runOnPlaybackThread( + () -> holder[0] = mediaSource.createPeriod(periodId, allocator, startPositionUs)); assertThat(holder[0]).isNotNull(); return holder[0]; } diff --git a/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/RobolectricUtil.java b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/RobolectricUtil.java index dc7781fd90..1e7f09bacf 100644 --- a/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/RobolectricUtil.java +++ b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/RobolectricUtil.java @@ -37,6 +37,7 @@ import org.robolectric.shadows.ShadowMessageQueue; public final class RobolectricUtil { private static final AtomicLong sequenceNumberGenerator = new AtomicLong(0); + private static final int ANY_MESSAGE = Integer.MIN_VALUE; private RobolectricUtil() {} @@ -110,7 +111,8 @@ public final class RobolectricUtil { boolean isRemoved = false; for (RemovedMessage removedMessage : removedMessages) { if (removedMessage.handler == target - && removedMessage.what == pendingMessage.message.what + && (removedMessage.what == ANY_MESSAGE + || removedMessage.what == pendingMessage.message.what) && (removedMessage.object == null || removedMessage.object == pendingMessage.message.obj) && pendingMessage.sequenceNumber < removedMessage.sequenceNumber) { @@ -179,6 +181,15 @@ public final class RobolectricUtil { ((CustomLooper) shadowOf(looper)).removeMessages(handler, what, object); } } + + @Implementation + public void removeCallbacksAndMessages(Handler handler, Object object) { + Looper looper = ShadowLooper.getLooperForThread(looperThread); + if (shadowOf(looper) instanceof CustomLooper + && shadowOf(looper) != ShadowLooper.getShadowMainLooper()) { + ((CustomLooper) shadowOf(looper)).removeMessages(handler, ANY_MESSAGE, object); + } + } } private static final class PendingMessage implements Comparable { diff --git a/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/TestDownloadManagerListener.java b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/TestDownloadManagerListener.java index 8216b881f3..2109cceda8 100644 --- a/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/TestDownloadManagerListener.java +++ b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/TestDownloadManagerListener.java @@ -20,6 +20,7 @@ import static com.google.common.truth.Truth.assertThat; import android.os.ConditionVariable; import com.google.android.exoplayer2.offline.DownloadManager; import com.google.android.exoplayer2.offline.DownloadState; +import com.google.android.exoplayer2.scheduler.Requirements; import java.util.HashMap; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.CountDownLatch; @@ -82,6 +83,12 @@ public final class TestDownloadManagerListener implements DownloadManager.Listen } } + @Override + public void onRequirementsStateChanged( + DownloadManager downloadManager, Requirements requirements, int notMetRequirements) { + // Do nothing. + } + /** * Blocks until all remove and download tasks are complete and throws an exception if there was an * error.