diff --git a/.gitignore b/.gitignore index 1146c064562..1a946e2ade5 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ proguard-project.txt # Other .DS_Store +cmake-build-debug dist tmp diff --git a/.hgignore b/.hgignore new file mode 100644 index 00000000000..f7c3656f650 --- /dev/null +++ b/.hgignore @@ -0,0 +1,71 @@ +# Mercurial's .hgignore files can only be used in the root directory. +# You can still apply these rules by adding +# include:path/to/this/directory/.hgignore to the top-level .hgignore file. + +# Ensure same syntax as in .gitignore can be used +syntax:glob + +# Android generated +bin +gen +libs +obj +lint.xml + +# IntelliJ IDEA +.idea +*.iml +*.ipr +*.iws +classes +gen-external-apklibs + +# Eclipse +.project +.classpath +.settings +.checkstyle +.cproject + +# Gradle +.gradle +build +buildout +out + +# Maven +target +release.properties +pom.xml.* + +# Ant +ant.properties +local.properties +proguard.cfg +proguard-project.txt + +# Other +.DS_Store +cmake-build-debug +dist +tmp + +# VP9 extension +extensions/vp9/src/main/jni/libvpx +extensions/vp9/src/main/jni/libvpx_android_configs +extensions/vp9/src/main/jni/libyuv + +# Opus extension +extensions/opus/src/main/jni/libopus + +# FLAC extension +extensions/flac/src/main/jni/flac + +# FFmpeg extension +extensions/ffmpeg/src/main/jni/ffmpeg + +# Cronet extension +extensions/cronet/jniLibs/* +!extensions/cronet/jniLibs/README.md +extensions/cronet/libs/* +!extensions/cronet/libs/README.md diff --git a/README.md b/README.md index 92b15d7c624..3902ec5cbd4 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ individually. In addition to library modules, ExoPlayer has multiple extension modules that depend on external libraries to provide additional functionality. Some extensions are available from JCenter, whereas others must be built manaully. -Browse the [extensions directory] and their individual READMEs for details. +Browse the [extensions directory][] and their individual READMEs for details. More information on the library and extension modules that are available from JCenter can be found on [Bintray][]. diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 90b3d15e083..579c2a92ac0 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,5 +1,75 @@ # Release notes # +### 2.6.0 ### + +* Removed "r" prefix from versions. This release is "2.6.0", not "r2.6.0". +* New `Player.DefaultEventListener` abstract class can be extended to avoid + having to implement all methods defined by `Player.EventListener`. +* Added a reason to `EventListener.onPositionDiscontinuity` + ([#3252](https://github.com/google/ExoPlayer/issues/3252)). +* New `setShuffleModeEnabled` method for enabling shuffled playback. +* SimpleExoPlayer: Support for multiple video, text and metadata outputs. +* Support for `Renderer`s that don't consume any media + ([#3212](https://github.com/google/ExoPlayer/issues/3212)). +* Fix reporting of internal position discontinuities via + `Player.onPositionDiscontinuity`. `DISCONTINUITY_REASON_SEEK_ADJUSTMENT` is + added to disambiguate position adjustments during seeks from other types of + internal position discontinuity. +* Fix potential `IndexOutOfBoundsException` when calling `ExoPlayer.getDuration` + ([#3362](https://github.com/google/ExoPlayer/issues/3362)). +* Fix playbacks involving looping, concatenation and ads getting stuck when + media contains tracks with uneven durations + ([#1874](https://github.com/google/ExoPlayer/issues/1874)). +* Fix issue with `ContentDataSource` when reading from certain `ContentProvider` + implementations ([#3426](https://github.com/google/ExoPlayer/issues/3426)). +* Better playback experience when the video decoder cannot keep up, by skipping + to key-frames. This is particularly relevant for variable speed playbacks. +* Allow `SingleSampleMediaSource` to suppress load errors + ([#3140](https://github.com/google/ExoPlayer/issues/3140)). +* `DynamicConcatenatingMediaSource`: Allow specifying a callback to be invoked + after a dynamic playlist modification has been applied + ([#3407](https://github.com/google/ExoPlayer/issues/3407)). +* Audio: New `AudioSink` interface allows customization of audio output path. +* Offline: Added `Downloader` implementations for DASH, HLS, SmoothStreaming + and progressive streams. +* Track selection: + * Fixed adaptive track selection logic for live playbacks + ([#3017](https://github.com/google/ExoPlayer/issues/3017)). + * Added ability to select the lowest bitrate tracks. +* DASH: + * Don't crash when a malformed or unexpected manifest update occurs + ([#2795](https://github.com/google/ExoPlayer/issues/2795)). +* HLS: + * Support for Widevine protected FMP4 variants. + * Support CEA-608 in FMP4 variants. + * Support extractor injection + ([#2748](https://github.com/google/ExoPlayer/issues/2748)). +* DRM: + * Improved compatibility with ClearKey content + ([#3138](https://github.com/google/ExoPlayer/issues/3138)). + * Support multiple PSSH boxes of the same type. + * Retry initial provisioning and key requests if they fail + * Fix incorrect parsing of non-CENC sinf boxes. +* IMA extension: + * Expose `AdsLoader` via getter + ([#3322](https://github.com/google/ExoPlayer/issues/3322)). + * Handle `setPlayWhenReady` calls during ad playbacks + ([#3303](https://github.com/google/ExoPlayer/issues/3303)). + * Ignore seeks if an ad is playing + ([#3309](https://github.com/google/ExoPlayer/issues/3309)). + * Improve robustness of `ImaAdsLoader` in case content is not paused between + content to ad transitions + ([#3430](https://github.com/google/ExoPlayer/issues/3430)). +* UI: + * Allow specifying a `Drawable` for the `TimeBar` scrubber + ([#3337](https://github.com/google/ExoPlayer/issues/3337)). + * Allow multiple listeners on `TimeBar` + ([#3406](https://github.com/google/ExoPlayer/issues/3406)). +* New Leanback extension: Simplifies binding Exoplayer to Leanback UI + components. +* Unit tests moved to Robolectric. +* Misc bugfixes. + ### r2.5.4 ### * Remove unnecessary media playlist fetches during playback of live HLS streams. diff --git a/build.gradle b/build.gradle index 8ec24a6e82e..2623db66fc2 100644 --- a/build.gradle +++ b/build.gradle @@ -14,12 +14,10 @@ buildscript { repositories { jcenter() - maven { - url "https://maven.google.com" - } + google() } dependencies { - classpath 'com.android.tools.build:gradle:3.0.0-beta4' + classpath 'com.android.tools.build:gradle:3.0.0' classpath 'com.novoda:bintray-release:0.5.0' } // Workaround for the following test coverage issue. Remove when fixed: @@ -34,9 +32,7 @@ buildscript { allprojects { repositories { jcenter() - maven { - url "https://maven.google.com" - } + google() } project.ext { exoplayerPublishEnabled = true diff --git a/constants.gradle b/constants.gradle index 3402375e878..2a7754d65cb 100644 --- a/constants.gradle +++ b/constants.gradle @@ -12,19 +12,23 @@ // See the License for the specific language governing permissions and // limitations under the License. project.ext { - // Important: ExoPlayer specifies a minSdkVersion of 9 because various + // 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 = 9 - compileSdkVersion = 25 - targetSdkVersion = 25 - buildToolsVersion = '25' + minSdkVersion = 14 + compileSdkVersion = 26 + targetSdkVersion = 26 + buildToolsVersion = '26.0.2' testSupportLibraryVersion = '0.5' - supportLibraryVersion = '25.4.0' + supportLibraryVersion = '27.0.0' + playServicesLibraryVersion = '11.4.2' dexmakerVersion = '1.2' mockitoVersion = '1.9.5' - releaseVersion = 'r2.5.4' + junitVersion = '4.12' + truthVersion = '0.35' + robolectricVersion = '3.4.2' + releaseVersion = '2.6.0' modulePrefix = ':' if (gradle.ext.has('exoplayerModulePrefix')) { modulePrefix += gradle.ext.exoplayerModulePrefix diff --git a/core_settings.gradle b/core_settings.gradle index 20e7b235a27..7a8320b1a12 100644 --- a/core_settings.gradle +++ b/core_settings.gradle @@ -33,6 +33,7 @@ include modulePrefix + 'extension-okhttp' include modulePrefix + 'extension-opus' include modulePrefix + 'extension-vp9' include modulePrefix + 'extension-rtmp' +include modulePrefix + 'extension-leanback' project(modulePrefix + 'library').projectDir = new File(rootDir, 'library/all') project(modulePrefix + 'library-core').projectDir = new File(rootDir, 'library/core') @@ -50,6 +51,7 @@ project(modulePrefix + 'extension-okhttp').projectDir = new File(rootDir, 'exten project(modulePrefix + 'extension-opus').projectDir = new File(rootDir, 'extensions/opus') project(modulePrefix + 'extension-vp9').projectDir = new File(rootDir, 'extensions/vp9') project(modulePrefix + 'extension-rtmp').projectDir = new File(rootDir, 'extensions/rtmp') +project(modulePrefix + 'extension-leanback').projectDir = new File(rootDir, 'extensions/leanback') if (gradle.ext.has('exoplayerIncludeCronetExtension') && gradle.ext.exoplayerIncludeCronetExtension) { diff --git a/demo/src/main/res/drawable-xhdpi/ic_banner.png b/demo/src/main/res/drawable-xhdpi/ic_banner.png deleted file mode 100644 index 520d83cc3b9..00000000000 Binary files a/demo/src/main/res/drawable-xhdpi/ic_banner.png and /dev/null differ diff --git a/demo/src/main/res/mipmap-hdpi/ic_launcher.png b/demo/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 6e8b5499deb..00000000000 Binary files a/demo/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/demo/src/main/res/mipmap-mdpi/ic_launcher.png b/demo/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 26fe2f0782a..00000000000 Binary files a/demo/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/demo/src/main/res/mipmap-xhdpi/ic_launcher.png b/demo/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index d3251491cea..00000000000 Binary files a/demo/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/demo/src/main/res/mipmap-xxhdpi/ic_launcher.png b/demo/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index b5a12d35f33..00000000000 Binary files a/demo/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/demo/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/demo/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 9c26192c32b..00000000000 Binary files a/demo/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/demos/README.md b/demos/README.md new file mode 100644 index 00000000000..7e62249db1b --- /dev/null +++ b/demos/README.md @@ -0,0 +1,4 @@ +# ExoPlayer demos # + +This directory contains applications that demonstrate how to use ExoPlayer. +Browse the individual demos and their READMEs to learn more. diff --git a/demos/ima/README.md b/demos/ima/README.md new file mode 100644 index 00000000000..8002b566678 --- /dev/null +++ b/demos/ima/README.md @@ -0,0 +1,4 @@ +# IMA demo application # + +This folder contains a demo application that showcases ExoPlayer integration +with the IMA SDK. diff --git a/demos/ima/build.gradle b/demos/ima/build.gradle new file mode 100644 index 00000000000..c32228de28b --- /dev/null +++ b/demos/ima/build.gradle @@ -0,0 +1,47 @@ +// Copyright (C) 2017 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +apply from: '../../constants.gradle' +apply plugin: 'com.android.application' + +android { + compileSdkVersion project.ext.compileSdkVersion + buildToolsVersion project.ext.buildToolsVersion + + defaultConfig { + minSdkVersion 16 + targetSdkVersion project.ext.targetSdkVersion + } + + buildTypes { + release { + shrinkResources true + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android.txt') + } + debug { + jniDebuggable = true + } + } + + lintOptions { + // The demo app does not have translations. + disable 'MissingTranslation' + } +} + +dependencies { + compile project(modulePrefix + 'library-core') + compile project(modulePrefix + 'library-ui') + compile project(modulePrefix + 'extension-ima') +} diff --git a/demos/ima/src/main/AndroidManifest.xml b/demos/ima/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..5252d2feeb0 --- /dev/null +++ b/demos/ima/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + diff --git a/demos/ima/src/main/java/com/google/android/exoplayer2/imademo/MainActivity.java b/demos/ima/src/main/java/com/google/android/exoplayer2/imademo/MainActivity.java new file mode 100644 index 00000000000..fb9cd05cd4a --- /dev/null +++ b/demos/ima/src/main/java/com/google/android/exoplayer2/imademo/MainActivity.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.imademo; + +import android.app.Activity; +import android.os.Bundle; +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.ui.SimpleExoPlayerView; + +/** + * Main Activity for the IMA plugin demo. {@link ExoPlayer} objects are created by + * {@link PlayerManager}, which this class instantiates. + */ +public final class MainActivity extends Activity { + + private SimpleExoPlayerView playerView; + private PlayerManager player; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.main_activity); + playerView = findViewById(R.id.player_view); + player = new PlayerManager(this); + } + + @Override + public void onResume() { + super.onResume(); + player.init(this, playerView); + } + + @Override + public void onPause() { + super.onPause(); + player.reset(); + } + + @Override + public void onDestroy() { + player.release(); + super.onDestroy(); + } + +} 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 new file mode 100644 index 00000000000..e11c840d123 --- /dev/null +++ b/demos/ima/src/main/java/com/google/android/exoplayer2/imademo/PlayerManager.java @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.imademo; + +import android.content.Context; +import android.net.Uri; +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.extractor.DefaultExtractorsFactory; +import com.google.android.exoplayer2.extractor.ExtractorsFactory; +import com.google.android.exoplayer2.source.ExtractorMediaSource; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ads.AdsMediaSource; +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.SimpleExoPlayerView; +import com.google.android.exoplayer2.upstream.BandwidthMeter; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.util.Util; + +/** + * Manages the {@link ExoPlayer}, the IMA plugin and all video playback. + */ +/* package */ final class PlayerManager { + + private final ImaAdsLoader adsLoader; + + private SimpleExoPlayer player; + private long contentPosition; + + public PlayerManager(Context context) { + String adTag = context.getString(R.string.ad_tag_url); + adsLoader = new ImaAdsLoader(context, Uri.parse(adTag)); + } + + public void init(Context context, SimpleExoPlayerView simpleExoPlayerView) { + // Create a default track selector. + BandwidthMeter bandwidthMeter = new DefaultBandwidthMeter(); + TrackSelection.Factory videoTrackSelectionFactory = + new AdaptiveTrackSelection.Factory(bandwidthMeter); + TrackSelector trackSelector = new DefaultTrackSelector(videoTrackSelectionFactory); + + // Create a player instance. + player = ExoPlayerFactory.newSimpleInstance(context, trackSelector); + + // Bind the player to the view. + simpleExoPlayerView.setPlayer(player); + + // Produces DataSource instances through which media data is loaded. + DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(context, + Util.getUserAgent(context, context.getString(R.string.application_name))); + + // Produces Extractor instances for parsing the content media (i.e. not the ad). + ExtractorsFactory extractorsFactory = new DefaultExtractorsFactory(); + + // This is the MediaSource representing the content media (i.e. not the ad). + String contentUrl = context.getString(R.string.content_url); + MediaSource contentMediaSource = new ExtractorMediaSource( + Uri.parse(contentUrl), dataSourceFactory, extractorsFactory, null, null); + + // Compose the content media source into a new AdsMediaSource with both ads and content. + MediaSource mediaSourceWithAds = new AdsMediaSource(contentMediaSource, dataSourceFactory, + adsLoader, simpleExoPlayerView.getOverlayFrameLayout()); + + // Prepare the player with the source. + player.seekTo(contentPosition); + player.prepare(mediaSourceWithAds); + player.setPlayWhenReady(true); + } + + public void reset() { + if (player != null) { + contentPosition = player.getContentPosition(); + player.release(); + player = null; + } + } + + public void release() { + if (player != null) { + player.release(); + player = null; + } + adsLoader.release(); + } + +} diff --git a/demos/ima/src/main/res/layout/main_activity.xml b/demos/ima/src/main/res/layout/main_activity.xml new file mode 100644 index 00000000000..ad5da62f47b --- /dev/null +++ b/demos/ima/src/main/res/layout/main_activity.xml @@ -0,0 +1,21 @@ + + + diff --git a/demos/ima/src/main/res/mipmap-hdpi/ic_launcher.png b/demos/ima/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000000..adaa93220eb Binary files /dev/null and b/demos/ima/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/demos/ima/src/main/res/mipmap-mdpi/ic_launcher.png b/demos/ima/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000000..9b6f7d5e806 Binary files /dev/null and b/demos/ima/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/demos/ima/src/main/res/mipmap-xhdpi/ic_launcher.png b/demos/ima/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000000..2101026c9fe Binary files /dev/null and b/demos/ima/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/demos/ima/src/main/res/mipmap-xxhdpi/ic_launcher.png b/demos/ima/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000000..223ec8bd113 Binary files /dev/null and b/demos/ima/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/demos/ima/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/demos/ima/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000000..698ed68c429 Binary files /dev/null and b/demos/ima/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/demos/ima/src/main/res/values/strings.xml b/demos/ima/src/main/res/values/strings.xml new file mode 100644 index 00000000000..67a7f06f8ba --- /dev/null +++ b/demos/ima/src/main/res/values/strings.xml @@ -0,0 +1,24 @@ + + + + + Exo IMA Demo + + + + + + diff --git a/library/ui/src/main/res/values-v11/styles.xml b/demos/ima/src/main/res/values/styles.xml similarity index 65% rename from library/ui/src/main/res/values-v11/styles.xml rename to demos/ima/src/main/res/values/styles.xml index 6f774402873..1c78ad58df8 100644 --- a/library/ui/src/main/res/values-v11/styles.xml +++ b/demos/ima/src/main/res/values/styles.xml @@ -1,5 +1,5 @@ - - + - diff --git a/demo/README.md b/demos/main/README.md similarity index 100% rename from demo/README.md rename to demos/main/README.md diff --git a/demo/build.gradle b/demos/main/build.gradle similarity index 98% rename from demo/build.gradle rename to demos/main/build.gradle index e0874e31473..adad8f0e58f 100644 --- a/demo/build.gradle +++ b/demos/main/build.gradle @@ -11,7 +11,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply from: '../constants.gradle' +apply from: '../../constants.gradle' apply plugin: 'com.android.application' android { diff --git a/demo/src/main/AndroidManifest.xml b/demos/main/src/main/AndroidManifest.xml similarity index 97% rename from demo/src/main/AndroidManifest.xml rename to demos/main/src/main/AndroidManifest.xml index 3fec020c3b2..d041e24d805 100644 --- a/demo/src/main/AndroidManifest.xml +++ b/demos/main/src/main/AndroidManifest.xml @@ -16,14 +16,14 @@ + android:versionCode="2600" + android:versionName="2.6.0"> - + buildDrmSessionManagerV18(UUID uuid, - String licenseUrl, String[] keyRequestPropertiesArray) throws UnsupportedDrmException { + String licenseUrl, String[] keyRequestPropertiesArray, boolean multiSession) + throws UnsupportedDrmException { HttpMediaDrmCallback drmCallback = new HttpMediaDrmCallback(licenseUrl, buildHttpDataSourceFactory(false)); if (keyRequestPropertiesArray != null) { @@ -389,7 +390,7 @@ private DrmSessionManager buildDrmSessionManagerV18(UUID u } } return new DefaultDrmSessionManager<>(uuid, FrameworkMediaDrm.newInstance(uuid), drmCallback, - null, mainHandler, eventLogger); + null, mainHandler, eventLogger, multiSession); } private void releasePlayer() { @@ -450,136 +451,25 @@ private MediaSource createAdsMediaSource(MediaSource mediaSource, Uri adTagUri) // Load the extension source using reflection so the demo app doesn't have to depend on it. // The ads loader is reused for multiple playbacks, so that ad playback can resume. Class loaderClass = Class.forName("com.google.android.exoplayer2.ext.ima.ImaAdsLoader"); - if (imaAdsLoader == null) { - imaAdsLoader = loaderClass.getConstructor(Context.class, Uri.class) + if (adsLoader == null) { + adsLoader = (AdsLoader) loaderClass.getConstructor(Context.class, Uri.class) .newInstance(this, adTagUri); - adOverlayViewGroup = new FrameLayout(this); + adUiViewGroup = new FrameLayout(this); // The demo app has a non-null overlay frame layout. - simpleExoPlayerView.getOverlayFrameLayout().addView(adOverlayViewGroup); + simpleExoPlayerView.getOverlayFrameLayout().addView(adUiViewGroup); } - Class sourceClass = - Class.forName("com.google.android.exoplayer2.ext.ima.ImaAdsMediaSource"); - Constructor constructor = sourceClass.getConstructor(MediaSource.class, - DataSource.Factory.class, loaderClass, ViewGroup.class); - return (MediaSource) constructor.newInstance(mediaSource, mediaDataSourceFactory, imaAdsLoader, - adOverlayViewGroup); + return new AdsMediaSource(mediaSource, mediaDataSourceFactory, adsLoader, adUiViewGroup); } private void releaseAdsLoader() { - if (imaAdsLoader != null) { - try { - Class loaderClass = Class.forName("com.google.android.exoplayer2.ext.ima.ImaAdsLoader"); - Method releaseMethod = loaderClass.getMethod("release"); - releaseMethod.invoke(imaAdsLoader); - } catch (Exception e) { - // Should never happen. - throw new IllegalStateException(e); - } - imaAdsLoader = null; + if (adsLoader != null) { + adsLoader.release(); + adsLoader = null; loadedAdTagUri = null; simpleExoPlayerView.getOverlayFrameLayout().removeAllViews(); } } - // Player.EventListener implementation - - @Override - public void onLoadingChanged(boolean isLoading) { - // Do nothing. - } - - @Override - public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { - if (playbackState == Player.STATE_ENDED) { - showControls(); - } - updateButtonVisibilities(); - } - - @Override - public void onRepeatModeChanged(int repeatMode) { - // Do nothing. - } - - @Override - public void onPositionDiscontinuity() { - if (inErrorState) { - // This will only occur if the user has performed a seek whilst in the error state. Update the - // resume position so that if the user then retries, playback will resume from the position to - // which they seeked. - updateResumePosition(); - } - } - - @Override - public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { - // Do nothing. - } - - @Override - public void onTimelineChanged(Timeline timeline, Object manifest) { - // Do nothing. - } - - @Override - public void onPlayerError(ExoPlaybackException e) { - String errorString = null; - if (e.type == ExoPlaybackException.TYPE_RENDERER) { - Exception cause = e.getRendererException(); - if (cause instanceof DecoderInitializationException) { - // Special case for decoder initialization failures. - DecoderInitializationException decoderInitializationException = - (DecoderInitializationException) cause; - if (decoderInitializationException.decoderName == null) { - if (decoderInitializationException.getCause() instanceof DecoderQueryException) { - errorString = getString(R.string.error_querying_decoders); - } else if (decoderInitializationException.secureDecoderRequired) { - errorString = getString(R.string.error_no_secure_decoder, - decoderInitializationException.mimeType); - } else { - errorString = getString(R.string.error_no_decoder, - decoderInitializationException.mimeType); - } - } else { - errorString = getString(R.string.error_instantiating_decoder, - decoderInitializationException.decoderName); - } - } - } - if (errorString != null) { - showToast(errorString); - } - inErrorState = true; - if (isBehindLiveWindow(e)) { - clearResumePosition(); - initializePlayer(); - } else { - updateResumePosition(); - updateButtonVisibilities(); - showControls(); - } - } - - @Override - @SuppressWarnings("ReferenceEquality") - public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { - updateButtonVisibilities(); - if (trackGroups != lastSeenTrackGroupArray) { - MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo(); - if (mappedTrackInfo != null) { - if (mappedTrackInfo.getTrackTypeRendererSupport(C.TRACK_TYPE_VIDEO) - == MappedTrackInfo.RENDERER_SUPPORT_UNSUPPORTED_TRACKS) { - showToast(R.string.error_unsupported_video); - } - if (mappedTrackInfo.getTrackTypeRendererSupport(C.TRACK_TYPE_AUDIO) - == MappedTrackInfo.RENDERER_SUPPORT_UNSUPPORTED_TRACKS) { - showToast(R.string.error_unsupported_audio); - } - } - lastSeenTrackGroupArray = trackGroups; - } - } - // User controls private void updateButtonVisibilities() { @@ -649,4 +539,85 @@ private static boolean isBehindLiveWindow(ExoPlaybackException e) { return false; } + private class PlayerEventListener extends Player.DefaultEventListener { + + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + if (playbackState == Player.STATE_ENDED) { + showControls(); + } + updateButtonVisibilities(); + } + + @Override + public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { + if (inErrorState) { + // This will only occur if the user has performed a seek whilst in the error state. Update + // the resume position so that if the user then retries, playback will resume from the + // position to which they seeked. + updateResumePosition(); + } + } + + @Override + public void onPlayerError(ExoPlaybackException e) { + String errorString = null; + if (e.type == ExoPlaybackException.TYPE_RENDERER) { + Exception cause = e.getRendererException(); + if (cause instanceof DecoderInitializationException) { + // Special case for decoder initialization failures. + DecoderInitializationException decoderInitializationException = + (DecoderInitializationException) cause; + if (decoderInitializationException.decoderName == null) { + if (decoderInitializationException.getCause() instanceof DecoderQueryException) { + errorString = getString(R.string.error_querying_decoders); + } else if (decoderInitializationException.secureDecoderRequired) { + errorString = getString(R.string.error_no_secure_decoder, + decoderInitializationException.mimeType); + } else { + errorString = getString(R.string.error_no_decoder, + decoderInitializationException.mimeType); + } + } else { + errorString = getString(R.string.error_instantiating_decoder, + decoderInitializationException.decoderName); + } + } + } + if (errorString != null) { + showToast(errorString); + } + inErrorState = true; + if (isBehindLiveWindow(e)) { + clearResumePosition(); + initializePlayer(); + } else { + updateResumePosition(); + updateButtonVisibilities(); + showControls(); + } + } + + @Override + @SuppressWarnings("ReferenceEquality") + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + updateButtonVisibilities(); + if (trackGroups != lastSeenTrackGroupArray) { + MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo(); + if (mappedTrackInfo != null) { + if (mappedTrackInfo.getTrackTypeRendererSupport(C.TRACK_TYPE_VIDEO) + == MappedTrackInfo.RENDERER_SUPPORT_UNSUPPORTED_TRACKS) { + showToast(R.string.error_unsupported_video); + } + if (mappedTrackInfo.getTrackTypeRendererSupport(C.TRACK_TYPE_AUDIO) + == MappedTrackInfo.RENDERER_SUPPORT_UNSUPPORTED_TRACKS) { + showToast(R.string.error_unsupported_audio); + } + } + lastSeenTrackGroupArray = trackGroups; + } + } + + } + } diff --git a/demo/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java similarity index 88% rename from demo/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java rename to demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java index 87b8e92e835..1f84b1f29c8 100644 --- a/demo/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java @@ -90,7 +90,7 @@ private void onSampleGroups(final List groups, boolean sawError) { Toast.makeText(getApplicationContext(), R.string.sample_list_load_error, Toast.LENGTH_LONG) .show(); } - ExpandableListView sampleList = (ExpandableListView) findViewById(R.id.sample_list); + ExpandableListView sampleList = findViewById(R.id.sample_list); sampleList.setAdapter(new SampleAdapter(this, groups)); sampleList.setOnChildClickListener(new OnChildClickListener() { @Override @@ -182,6 +182,7 @@ private Sample readEntry(JsonReader reader, boolean insidePlaylist) throws IOExc UUID drmUuid = null; String drmLicenseUrl = null; String[] drmKeyRequestProperties = null; + boolean drmMultiSession = false; boolean preferExtensionDecoders = false; ArrayList playlistSamples = null; String adTagUri = null; @@ -220,6 +221,9 @@ private Sample readEntry(JsonReader reader, boolean insidePlaylist) throws IOExc reader.endObject(); drmKeyRequestProperties = drmKeyRequestPropertiesList.toArray(new String[0]); break; + case "drm_multi_session": + drmMultiSession = reader.nextBoolean(); + break; case "prefer_extension_decoders": Assertions.checkState(!insidePlaylist, "Invalid attribute on nested item: prefer_extension_decoders"); @@ -242,15 +246,16 @@ private Sample readEntry(JsonReader reader, boolean insidePlaylist) throws IOExc } } reader.endObject(); - + DrmInfo drmInfo = drmUuid == null ? null : new DrmInfo(drmUuid, drmLicenseUrl, + drmKeyRequestProperties, drmMultiSession); if (playlistSamples != null) { UriSample[] playlistSamplesArray = playlistSamples.toArray( new UriSample[playlistSamples.size()]); - return new PlaylistSample(sampleName, drmUuid, drmLicenseUrl, drmKeyRequestProperties, - preferExtensionDecoders, playlistSamplesArray); + return new PlaylistSample(sampleName, preferExtensionDecoders, drmInfo, + playlistSamplesArray); } else { - return new UriSample(sampleName, drmUuid, drmLicenseUrl, drmKeyRequestProperties, - preferExtensionDecoders, uri, extension, adTagUri); + return new UriSample(sampleName, preferExtensionDecoders, drmInfo, uri, extension, + adTagUri); } } @@ -271,7 +276,7 @@ private UUID getDrmUuid(String typeString) throws ParserException { return C.WIDEVINE_UUID; case "playready": return C.PLAYREADY_UUID; - case "cenc": + case "clearkey": return C.CLEARKEY_UUID; default: try { @@ -372,31 +377,47 @@ public SampleGroup(String title) { } - private abstract static class Sample { - - public final String name; - public final boolean preferExtensionDecoders; + private static final class DrmInfo { public final UUID drmSchemeUuid; public final String drmLicenseUrl; public final String[] drmKeyRequestProperties; + public final boolean drmMultiSession; - public Sample(String name, UUID drmSchemeUuid, String drmLicenseUrl, - String[] drmKeyRequestProperties, boolean preferExtensionDecoders) { - this.name = name; + public DrmInfo(UUID drmSchemeUuid, String drmLicenseUrl, + String[] drmKeyRequestProperties, boolean drmMultiSession) { this.drmSchemeUuid = drmSchemeUuid; this.drmLicenseUrl = drmLicenseUrl; this.drmKeyRequestProperties = drmKeyRequestProperties; + this.drmMultiSession = drmMultiSession; + } + + public void updateIntent(Intent intent) { + Assertions.checkNotNull(intent); + intent.putExtra(PlayerActivity.DRM_SCHEME_UUID_EXTRA, drmSchemeUuid.toString()); + intent.putExtra(PlayerActivity.DRM_LICENSE_URL, drmLicenseUrl); + intent.putExtra(PlayerActivity.DRM_KEY_REQUEST_PROPERTIES, drmKeyRequestProperties); + intent.putExtra(PlayerActivity.DRM_MULTI_SESSION, drmMultiSession); + } + } + + private abstract static class Sample { + public final String name; + public final boolean preferExtensionDecoders; + public final DrmInfo drmInfo; + + public Sample(String name, boolean preferExtensionDecoders, DrmInfo drmInfo) { + this.name = name; this.preferExtensionDecoders = preferExtensionDecoders; + this.drmInfo = drmInfo; } public Intent buildIntent(Context context) { Intent intent = new Intent(context, PlayerActivity.class); intent.putExtra(PlayerActivity.PREFER_EXTENSION_DECODERS, preferExtensionDecoders); - if (drmSchemeUuid != null) { - intent.putExtra(PlayerActivity.DRM_SCHEME_UUID_EXTRA, drmSchemeUuid.toString()); - intent.putExtra(PlayerActivity.DRM_LICENSE_URL, drmLicenseUrl); - intent.putExtra(PlayerActivity.DRM_KEY_REQUEST_PROPERTIES, drmKeyRequestProperties); + if (drmInfo != null) { + drmInfo.updateIntent(intent); } + return intent; } @@ -408,10 +429,9 @@ private static final class UriSample extends Sample { public final String extension; public final String adTagUri; - public UriSample(String name, UUID drmSchemeUuid, String drmLicenseUrl, - String[] drmKeyRequestProperties, boolean preferExtensionDecoders, String uri, + public UriSample(String name, boolean preferExtensionDecoders, DrmInfo drmInfo, String uri, String extension, String adTagUri) { - super(name, drmSchemeUuid, drmLicenseUrl, drmKeyRequestProperties, preferExtensionDecoders); + super(name, preferExtensionDecoders, drmInfo); this.uri = uri; this.extension = extension; this.adTagUri = adTagUri; @@ -432,10 +452,9 @@ private static final class PlaylistSample extends Sample { public final UriSample[] children; - public PlaylistSample(String name, UUID drmSchemeUuid, String drmLicenseUrl, - String[] drmKeyRequestProperties, boolean preferExtensionDecoders, + public PlaylistSample(String name, boolean preferExtensionDecoders, DrmInfo drmInfo, UriSample... children) { - super(name, drmSchemeUuid, drmLicenseUrl, drmKeyRequestProperties, preferExtensionDecoders); + super(name, preferExtensionDecoders, drmInfo); this.children = children; } diff --git a/demo/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionHelper.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionHelper.java similarity index 99% rename from demo/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionHelper.java rename to demos/main/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionHelper.java index fb7217f8fdd..e033b91eef3 100644 --- a/demo/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionHelper.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionHelper.java @@ -109,7 +109,7 @@ public void showSelectionDialog(Activity activity, CharSequence title, MappedTra private View buildView(Context context) { LayoutInflater inflater = LayoutInflater.from(context); View view = inflater.inflate(R.layout.track_selection_dialog, null); - ViewGroup root = (ViewGroup) view.findViewById(R.id.root); + ViewGroup root = view.findViewById(R.id.root); TypedArray attributeArray = context.getTheme().obtainStyledAttributes( new int[] {android.R.attr.selectableItemBackground}); diff --git a/demos/main/src/main/res/drawable-xhdpi/ic_banner.png b/demos/main/src/main/res/drawable-xhdpi/ic_banner.png new file mode 100644 index 00000000000..09de177387a Binary files /dev/null and b/demos/main/src/main/res/drawable-xhdpi/ic_banner.png differ diff --git a/demo/src/main/res/layout/list_divider.xml b/demos/main/src/main/res/layout/list_divider.xml similarity index 100% rename from demo/src/main/res/layout/list_divider.xml rename to demos/main/src/main/res/layout/list_divider.xml diff --git a/demo/src/main/res/layout/player_activity.xml b/demos/main/src/main/res/layout/player_activity.xml similarity index 100% rename from demo/src/main/res/layout/player_activity.xml rename to demos/main/src/main/res/layout/player_activity.xml diff --git a/demo/src/main/res/layout/sample_chooser_activity.xml b/demos/main/src/main/res/layout/sample_chooser_activity.xml similarity index 100% rename from demo/src/main/res/layout/sample_chooser_activity.xml rename to demos/main/src/main/res/layout/sample_chooser_activity.xml diff --git a/demo/src/main/res/layout/track_selection_dialog.xml b/demos/main/src/main/res/layout/track_selection_dialog.xml similarity index 100% rename from demo/src/main/res/layout/track_selection_dialog.xml rename to demos/main/src/main/res/layout/track_selection_dialog.xml diff --git a/demos/main/src/main/res/mipmap-hdpi/ic_launcher.png b/demos/main/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000000..adaa93220eb Binary files /dev/null and b/demos/main/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/demos/main/src/main/res/mipmap-mdpi/ic_launcher.png b/demos/main/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000000..9b6f7d5e806 Binary files /dev/null and b/demos/main/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/demos/main/src/main/res/mipmap-xhdpi/ic_launcher.png b/demos/main/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000000..2101026c9fe Binary files /dev/null and b/demos/main/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/demos/main/src/main/res/mipmap-xxhdpi/ic_launcher.png b/demos/main/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000000..223ec8bd113 Binary files /dev/null and b/demos/main/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/demos/main/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/demos/main/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000000..698ed68c429 Binary files /dev/null and b/demos/main/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/demo/src/main/res/values/strings.xml b/demos/main/src/main/res/values/strings.xml similarity index 99% rename from demo/src/main/res/values/strings.xml rename to demos/main/src/main/res/values/strings.xml index cc6357c5743..b38ccf6e88e 100644 --- a/demo/src/main/res/values/strings.xml +++ b/demos/main/src/main/res/values/strings.xml @@ -13,7 +13,6 @@ See the License for the specific language governing permissions and limitations under the License. --> - ExoPlayer diff --git a/demo/src/main/res/values/styles.xml b/demos/main/src/main/res/values/styles.xml similarity index 99% rename from demo/src/main/res/values/styles.xml rename to demos/main/src/main/res/values/styles.xml index 751a224210b..5616bb9869c 100644 --- a/demo/src/main/res/values/styles.xml +++ b/demos/main/src/main/res/values/styles.xml @@ -13,7 +13,6 @@ See the License for the specific language governing permissions and limitations under the License. --> - @@ -51,4 +51,9 @@ @string/exo_controls_pause_description + + diff --git a/playbacktests/src/androidTest/AndroidManifest.xml b/playbacktests/src/androidTest/AndroidManifest.xml index 053fe4e61cd..1a660591d88 100644 --- a/playbacktests/src/androidTest/AndroidManifest.xml +++ b/playbacktests/src/androidTest/AndroidManifest.xml @@ -21,7 +21,7 @@ - + { + + private static final String TAG = "DashDownloadTest"; + + private DashTestRunner testRunner; + private File tempFolder; + private SimpleCache cache; + + public DashDownloadTest() { + super(HostActivity.class); + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + testRunner = new DashTestRunner(TAG, getActivity(), getInstrumentation()) + .setManifestUrl(DashTestData.H264_MANIFEST) + .setFullPlaybackNoSeeking(true) + .setCanIncludeAdditionalVideoFormats(false) + .setAudioVideoFormats(DashTestData.AAC_AUDIO_REPRESENTATION_ID, + DashTestData.H264_CDD_FIXED); + tempFolder = Util.createTempDirectory(getActivity(), "ExoPlayerTest"); + cache = new SimpleCache(tempFolder, new NoOpCacheEvictor()); + } + + @Override + protected void tearDown() throws Exception { + testRunner = null; + Util.recursiveDelete(tempFolder); + cache = null; + super.tearDown(); + } + + // Download tests + + public void testDownload() throws Exception { + if (Util.SDK_INT < 16) { + return; // Pass. + } + + // Download manifest only + createDashDownloader(false).getManifest(); + long manifestLength = cache.getCacheSpace(); + + // Download representations + DashDownloader dashDownloader = downloadContent(false, Float.NaN); + assertEquals(cache.getCacheSpace() - manifestLength, dashDownloader.getDownloadedBytes()); + + testRunner.setStreamName("test_h264_fixed_download"). + setDataSourceFactory(newOfflineCacheDataSourceFactory()).run(); + + dashDownloader.remove(); + + assertEquals("There should be no content left.", 0, cache.getKeys().size()); + assertEquals("There should be no content left.", 0, cache.getCacheSpace()); + } + + public void testPartialDownload() throws Exception { + if (Util.SDK_INT < 16) { + return; // Pass. + } + + // Just download the first half and manifest + downloadContent(false, 0.5f); + + // Download the rest + DashDownloader dashDownloader = downloadContent(false, Float.NaN); + long downloadedBytes = dashDownloader.getDownloadedBytes(); + + // Make sure it doesn't download any data + dashDownloader = downloadContent(true, Float.NaN); + assertEquals(downloadedBytes, dashDownloader.getDownloadedBytes()); + + testRunner.setStreamName("test_h264_fixed_partial_download") + .setDataSourceFactory(newOfflineCacheDataSourceFactory()).run(); + } + + private DashDownloader downloadContent(boolean offline, float stopAt) throws Exception { + DashDownloader dashDownloader = createDashDownloader(offline); + DashManifest dashManifest = dashDownloader.getManifest(); + try { + ArrayList keys = new ArrayList<>(); + for (int pIndex = 0; pIndex < dashManifest.getPeriodCount(); pIndex++) { + List adaptationSets = dashManifest.getPeriod(pIndex).adaptationSets; + for (int aIndex = 0; aIndex < adaptationSets.size(); aIndex++) { + AdaptationSet adaptationSet = adaptationSets.get(aIndex); + List representations = adaptationSet.representations; + for (int rIndex = 0; rIndex < representations.size(); rIndex++) { + String id = representations.get(rIndex).format.id; + if (DashTestData.AAC_AUDIO_REPRESENTATION_ID.equals(id) + || DashTestData.H264_CDD_FIXED.equals(id)) { + keys.add(new RepresentationKey(pIndex, aIndex, rIndex)); + } + } + } + dashDownloader.selectRepresentations(keys.toArray(new RepresentationKey[keys.size()])); + TestProgressListener listener = new TestProgressListener(stopAt); + dashDownloader.download(listener); + } + } catch (InterruptedException e) { + // do nothing + } catch (IOException e) { + Throwable exception = e; + while (!(exception instanceof InterruptedIOException)) { + if (exception == null) { + throw e; + } + exception = exception.getCause(); + } + // else do nothing + } + return dashDownloader; + } + + private DashDownloader createDashDownloader(boolean offline) { + DownloaderConstructorHelper constructorHelper = new DownloaderConstructorHelper(cache, + offline ? DummyDataSource.FACTORY : new DefaultHttpDataSourceFactory("ExoPlayer", null)); + return new DashDownloader(Uri.parse(DashTestData.H264_MANIFEST), constructorHelper); + } + + private CacheDataSourceFactory newOfflineCacheDataSourceFactory() { + return new CacheDataSourceFactory(cache, DummyDataSource.FACTORY, + CacheDataSource.FLAG_BLOCK_ON_CACHE); + } + + private static class TestProgressListener implements ProgressListener { + + private final float stopAt; + + private TestProgressListener(float stopAt) { + this.stopAt = stopAt; + } + + @Override + public void onDownloadProgress(Downloader downloader, float downloadPercentage, + long downloadedBytes) { + Log.d("DashDownloadTest", + String.format("onDownloadProgress downloadPercentage = [%g], downloadedData = [%d]%n", + downloadPercentage, downloadedBytes)); + if (downloadPercentage >= stopAt) { + Thread.currentThread().interrupt(); + } + } + + } + +} 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 529f57582e3..3748779b9d3 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 @@ -17,6 +17,7 @@ import android.test.ActivityInstrumentationTestCase2; import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException; @@ -33,12 +34,14 @@ public final class DashStreamingTest extends ActivityInstrumentationTestCase2= 18) { + try { + // Force L3 if secure decoder is not available. + if (MediaCodecUtil.getDecoderInfo(mimeType, true) == null) { + return false; + } + MediaDrm mediaDrm = MediaDrmBuilder.build(); + String securityProperty = mediaDrm.getPropertyString(SECURITY_LEVEL_PROPERTY); + mediaDrm.release(); + return WIDEVINE_SECURITY_LEVEL_1.equals(securityProperty); + } catch (MediaCodecUtil.DecoderQueryException e) { + throw new IllegalStateException(e); } - MediaDrm mediaDrm = new MediaDrm(WIDEVINE_UUID); - String securityProperty = mediaDrm.getPropertyString(SECURITY_LEVEL_PROPERTY); - mediaDrm.release(); - return WIDEVINE_SECURITY_LEVEL_1.equals(securityProperty); - } catch (MediaCodecUtil.DecoderQueryException | UnsupportedSchemeException e) { - throw new IllegalStateException(e); } + return false; } public DashTestRunner(String tag, HostActivity activity, Instrumentation instrumentation) { @@ -323,9 +325,9 @@ protected void logMetrics(DecoderCounters audioCounters, DecoderCounters videoCo metricsLogger.logMetric(MetricsLogger.KEY_TEST_NAME, streamName); metricsLogger.logMetric(MetricsLogger.KEY_IS_CDD_LIMITED_RETRY, isCddLimitedRetry); metricsLogger.logMetric(MetricsLogger.KEY_FRAMES_DROPPED_COUNT, - videoCounters.droppedOutputBufferCount); + videoCounters.droppedBufferCount); metricsLogger.logMetric(MetricsLogger.KEY_MAX_CONSECUTIVE_FRAMES_DROPPED_COUNT, - videoCounters.maxConsecutiveDroppedOutputBufferCount); + videoCounters.maxConsecutiveDroppedBufferCount); metricsLogger.logMetric(MetricsLogger.KEY_FRAMES_SKIPPED_COUNT, videoCounters.skippedOutputBufferCount); metricsLogger.logMetric(MetricsLogger.KEY_FRAMES_RENDERED_COUNT, @@ -343,20 +345,20 @@ protected void assertPassed(DecoderCounters audioCounters, DecoderCounters video .assertSkippedOutputBufferCount(tag + VIDEO_TAG_SUFFIX, videoCounters, 0); // We allow one fewer output buffer due to the way that MediaCodecRenderer and the // underlying decoders handle the end of stream. This should be tightened up in the future. - DecoderCountersUtil.assertTotalOutputBufferCount(tag + AUDIO_TAG_SUFFIX, audioCounters, + DecoderCountersUtil.assertTotalBufferCount(tag + AUDIO_TAG_SUFFIX, audioCounters, audioCounters.inputBufferCount - 1, audioCounters.inputBufferCount); - DecoderCountersUtil.assertTotalOutputBufferCount(tag + VIDEO_TAG_SUFFIX, videoCounters, + DecoderCountersUtil.assertTotalBufferCount(tag + VIDEO_TAG_SUFFIX, videoCounters, videoCounters.inputBufferCount - 1, videoCounters.inputBufferCount); } try { int droppedFrameLimit = (int) Math.ceil(MAX_DROPPED_VIDEO_FRAME_FRACTION - * DecoderCountersUtil.getTotalOutputBuffers(videoCounters)); + * DecoderCountersUtil.getTotalBufferCount(videoCounters)); // Assert that performance is acceptable. // Assert that total dropped frames were within limit. - DecoderCountersUtil.assertDroppedOutputBufferLimit(tag + VIDEO_TAG_SUFFIX, videoCounters, + DecoderCountersUtil.assertDroppedBufferLimit(tag + VIDEO_TAG_SUFFIX, videoCounters, droppedFrameLimit); // Assert that consecutive dropped frames were within limit. - DecoderCountersUtil.assertConsecutiveDroppedOutputBufferLimit(tag + VIDEO_TAG_SUFFIX, + DecoderCountersUtil.assertConsecutiveDroppedBufferLimit(tag + VIDEO_TAG_SUFFIX, videoCounters, MAX_CONSECUTIVE_DROPPED_VIDEO_FRAMES); } catch (AssertionFailedError e) { if (trackSelector.includedAdditionalVideoFormats) { @@ -457,4 +459,21 @@ private static boolean isFormatHandled(int formatSupport) { } + /** + * Creates a new {@code MediaDrm} object. The encapsulation ensures that the tests can be + * executed for API level < 18. + */ + @TargetApi(18) + private static final class MediaDrmBuilder { + + public static MediaDrm build () { + try { + return new MediaDrm(WIDEVINE_UUID); + } catch (UnsupportedSchemeException e) { + throw new IllegalStateException(e); + } + } + + } + } diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashWidevineOfflineTest.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashWidevineOfflineTest.java index c2b102d1ec2..a50c230a39a 100644 --- a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashWidevineOfflineTest.java +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashWidevineOfflineTest.java @@ -16,8 +16,10 @@ package com.google.android.exoplayer2.playbacktests.gts; import android.media.MediaDrm.MediaDrmStateException; +import android.net.Uri; import android.test.ActivityInstrumentationTestCase2; import android.util.Pair; +import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.drm.DrmSession.DrmSessionException; import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; @@ -65,8 +67,10 @@ protected void setUp() throws Exception { boolean useL1Widevine = DashTestRunner.isL1WidevineAvailable(MimeTypes.VIDEO_H264); String widevineLicenseUrl = DashTestData.getWidevineLicenseUrl(true, useL1Widevine); httpDataSourceFactory = new DefaultHttpDataSourceFactory(USER_AGENT); - offlineLicenseHelper = OfflineLicenseHelper.newWidevineInstance(widevineLicenseUrl, - httpDataSourceFactory); + if (Util.SDK_INT >= 18) { + offlineLicenseHelper = OfflineLicenseHelper.newWidevineInstance(widevineLicenseUrl, + httpDataSourceFactory); + } } @Override @@ -85,7 +89,7 @@ protected void tearDown() throws Exception { // Offline license tests - public void testWidevineOfflineLicense() throws Exception { + public void testWidevineOfflineLicenseV22() throws Exception { if (Util.SDK_INT < 22) { return; // Pass. } @@ -97,7 +101,7 @@ public void testWidevineOfflineLicense() throws Exception { Assert.assertNotNull(offlineLicenseKeySetId); } - public void testWidevineOfflineReleasedLicense() throws Throwable { + public void testWidevineOfflineReleasedLicenseV22() throws Throwable { if (Util.SDK_INT < 22) { return; // Pass. } @@ -123,7 +127,7 @@ public void testWidevineOfflineReleasedLicense() throws Throwable { } } - public void testWidevineOfflineExpiredLicense() throws Exception { + public void testWidevineOfflineExpiredLicenseV22() throws Exception { if (Util.SDK_INT < 22) { return; // Pass. } @@ -148,7 +152,7 @@ public void testWidevineOfflineExpiredLicense() throws Exception { testRunner.run(); } - public void testWidevineOfflineLicenseExpiresOnPause() throws Exception { + public void testWidevineOfflineLicenseExpiresOnPauseV22() throws Exception { if (Util.SDK_INT < 22) { return; // Pass. } @@ -161,6 +165,7 @@ public void testWidevineOfflineLicenseExpiresOnPause() throws Exception { assertTrue("License duration should be less than 30 sec. " + "Server settings might have changed.", licenseDuration < 30); ActionSchedule schedule = new ActionSchedule.Builder(TAG) + .waitForPlaybackState(Player.STATE_READY) .delay(3000).pause().delay(licenseDuration * 1000 + 2000).play().build(); // DefaultDrmSessionManager should renew the license and stream play fine @@ -170,7 +175,7 @@ public void testWidevineOfflineLicenseExpiresOnPause() throws Exception { private void downloadLicense() throws InterruptedException, DrmSessionException, IOException { DataSource dataSource = httpDataSourceFactory.createDataSource(); DashManifest dashManifest = DashUtil.loadManifest(dataSource, - DashTestData.WIDEVINE_H264_MANIFEST); + Uri.parse(DashTestData.WIDEVINE_H264_MANIFEST)); DrmInitData drmInitData = DashUtil.loadDrmInitData(dataSource, dashManifest.getPeriod(0)); offlineLicenseKeySetId = offlineLicenseHelper.downloadLicense(drmInitData); Assert.assertNotNull(offlineLicenseKeySetId); diff --git a/settings.gradle b/settings.gradle index fb31055f5ef..0a404aad730 100644 --- a/settings.gradle +++ b/settings.gradle @@ -19,8 +19,10 @@ if (gradle.ext.has('exoplayerModulePrefix')) { } include modulePrefix + 'demo' +include modulePrefix + 'demo-ima' include modulePrefix + 'playbacktests' -project(modulePrefix + 'demo').projectDir = new File(rootDir, 'demo') +project(modulePrefix + 'demo').projectDir = new File(rootDir, 'demos/main') +project(modulePrefix + 'demo-ima').projectDir = new File(rootDir, 'demos/ima') project(modulePrefix + 'playbacktests').projectDir = new File(rootDir, 'playbacktests') apply from: 'core_settings.gradle' diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java index b1c6f081cf3..2abe521883d 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java @@ -15,10 +15,15 @@ */ package com.google.android.exoplayer2.testutil; +import android.os.Handler; import android.util.Log; import android.view.Surface; import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.testutil.ActionSchedule.ActionNode; import com.google.android.exoplayer2.trackselection.MappingTrackSelector; /** @@ -39,21 +44,41 @@ public Action(String tag, String description) { } /** - * Executes the action. + * Executes the action and schedules the next. * * @param player The player to which the action should be applied. * @param trackSelector The track selector to which the action should be applied. * @param surface The surface to use when applying actions. + * @param handler The handler to use to pass to the next action. + * @param nextAction The next action to schedule immediately after this action finished. */ - public final void doAction(SimpleExoPlayer player, MappingTrackSelector trackSelector, - Surface surface) { + public final void doActionAndScheduleNext(SimpleExoPlayer player, + MappingTrackSelector trackSelector, Surface surface, Handler handler, ActionNode nextAction) { Log.i(tag, description); + doActionAndScheduleNextImpl(player, trackSelector, surface, handler, nextAction); + } + + /** + * Called by {@link #doActionAndScheduleNext(SimpleExoPlayer, MappingTrackSelector, Surface, + * Handler, ActionNode)} to perform the action and to schedule the next action node. + * + * @param player The player to which the action should be applied. + * @param trackSelector The track selector to which the action should be applied. + * @param surface The surface to use when applying actions. + * @param handler The handler to use to pass to the next action. + * @param nextAction The next action to schedule immediately after this action finished. + */ + protected void doActionAndScheduleNextImpl(SimpleExoPlayer player, + MappingTrackSelector trackSelector, Surface surface, Handler handler, ActionNode nextAction) { doActionImpl(player, trackSelector, surface); + if (nextAction != null) { + nextAction.schedule(player, trackSelector, surface, handler); + } } /** - * Called by {@link #doAction(SimpleExoPlayer, MappingTrackSelector, Surface)} do perform the - * action. + * Called by {@link #doActionAndScheduleNextImpl(SimpleExoPlayer, MappingTrackSelector, Surface, + * Handler, ActionNode)} to perform the action. * * @param player The player to which the action should be applied. * @param trackSelector The track selector to which the action should be applied. @@ -63,7 +88,7 @@ protected abstract void doActionImpl(SimpleExoPlayer player, MappingTrackSelecto Surface surface); /** - * Calls {@link ExoPlayer#seekTo(long)}. + * Calls {@link Player#seekTo(long)}. */ public static final class Seek extends Action { @@ -87,7 +112,7 @@ protected void doActionImpl(SimpleExoPlayer player, MappingTrackSelector trackSe } /** - * Calls {@link ExoPlayer#stop()}. + * Calls {@link Player#stop()}. */ public static final class Stop extends Action { @@ -107,7 +132,7 @@ protected void doActionImpl(SimpleExoPlayer player, MappingTrackSelector trackSe } /** - * Calls {@link ExoPlayer#setPlayWhenReady(boolean)}. + * Calls {@link Player#setPlayWhenReady(boolean)}. */ public static final class SetPlayWhenReady extends Action { @@ -197,5 +222,272 @@ protected void doActionImpl(SimpleExoPlayer player, MappingTrackSelector trackSe } + /** + * Calls {@link ExoPlayer#prepare(MediaSource)}. + */ + public static final class PrepareSource extends Action { + + private final MediaSource mediaSource; + private final boolean resetPosition; + private final boolean resetState; + + /** + * @param tag A tag to use for logging. + */ + public PrepareSource(String tag, MediaSource mediaSource) { + this(tag, mediaSource, true, true); + } + + /** + * @param tag A tag to use for logging. + */ + public PrepareSource(String tag, MediaSource mediaSource, boolean resetPosition, + boolean resetState) { + super(tag, "PrepareSource"); + this.mediaSource = mediaSource; + this.resetPosition = resetPosition; + this.resetState = resetState; + } + + @Override + protected void doActionImpl(SimpleExoPlayer player, MappingTrackSelector trackSelector, + Surface surface) { + player.prepare(mediaSource, resetPosition, resetState); + } + + } + + /** + * Calls {@link Player#setRepeatMode(int)}. + */ + public static final class SetRepeatMode extends Action { + + private final @Player.RepeatMode int repeatMode; + + /** + * @param tag A tag to use for logging. + */ + public SetRepeatMode(String tag, @Player.RepeatMode int repeatMode) { + super(tag, "SetRepeatMode:" + repeatMode); + this.repeatMode = repeatMode; + } + + @Override + protected void doActionImpl(SimpleExoPlayer player, MappingTrackSelector trackSelector, + Surface surface) { + player.setRepeatMode(repeatMode); + } + + } + + /** + * Calls {@link Player#setShuffleModeEnabled(boolean)}. + */ + public static final class SetShuffleModeEnabled extends Action { + + private final boolean shuffleModeEnabled; + + /** + * @param tag A tag to use for logging. + */ + public SetShuffleModeEnabled(String tag, boolean shuffleModeEnabled) { + super(tag, "SetShuffleModeEnabled:" + shuffleModeEnabled); + this.shuffleModeEnabled = shuffleModeEnabled; + } + + @Override + protected void doActionImpl(SimpleExoPlayer player, MappingTrackSelector trackSelector, + Surface surface) { + player.setShuffleModeEnabled(shuffleModeEnabled); + } + + } + + /** + * Waits for {@link Player.EventListener#onTimelineChanged(Timeline, Object)}. + */ + public static final class WaitForTimelineChanged extends Action { + + private final Timeline expectedTimeline; + + /** + * @param tag A tag to use for logging. + */ + public WaitForTimelineChanged(String tag, Timeline expectedTimeline) { + super(tag, "WaitForTimelineChanged"); + this.expectedTimeline = expectedTimeline; + } + + @Override + protected void doActionAndScheduleNextImpl(final SimpleExoPlayer player, + final MappingTrackSelector trackSelector, final Surface surface, final Handler handler, + final ActionNode nextAction) { + if (nextAction == null) { + return; + } + Player.EventListener listener = new Player.DefaultEventListener() { + @Override + public void onTimelineChanged(Timeline timeline, Object manifest) { + if (timeline.equals(expectedTimeline)) { + player.removeListener(this); + nextAction.schedule(player, trackSelector, surface, handler); + } + } + }; + player.addListener(listener); + if (player.getCurrentTimeline().equals(expectedTimeline)) { + player.removeListener(listener); + nextAction.schedule(player, trackSelector, surface, handler); + } + } + + @Override + protected void doActionImpl(SimpleExoPlayer player, MappingTrackSelector trackSelector, + Surface surface) { + // Not triggered. + } + + } + + /** + * Waits for {@link Player.EventListener#onPositionDiscontinuity(int)}. + */ + public static final class WaitForPositionDiscontinuity extends Action { + + /** + * @param tag A tag to use for logging. + */ + public WaitForPositionDiscontinuity(String tag) { + super(tag, "WaitForPositionDiscontinuity"); + } + + @Override + protected void doActionAndScheduleNextImpl(final SimpleExoPlayer player, + final MappingTrackSelector trackSelector, final Surface surface, final Handler handler, + final ActionNode nextAction) { + if (nextAction == null) { + return; + } + player.addListener(new Player.DefaultEventListener() { + @Override + public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { + player.removeListener(this); + nextAction.schedule(player, trackSelector, surface, handler); + } + }); + } + + @Override + protected void doActionImpl(SimpleExoPlayer player, MappingTrackSelector trackSelector, + Surface surface) { + // Not triggered. + } + + } + + /** + * Waits for a specified playback state, returning either immediately or after a call to + * {@link Player.EventListener#onPlayerStateChanged(boolean, int)}. + */ + public static final class WaitForPlaybackState extends Action { + + private final int targetPlaybackState; + + /** + * @param tag A tag to use for logging. + */ + public WaitForPlaybackState(String tag, int targetPlaybackState) { + super(tag, "WaitForPlaybackState"); + this.targetPlaybackState = targetPlaybackState; + } + + @Override + protected void doActionAndScheduleNextImpl(final SimpleExoPlayer player, + final MappingTrackSelector trackSelector, final Surface surface, final Handler handler, + final ActionNode nextAction) { + if (nextAction == null) { + return; + } + if (targetPlaybackState == player.getPlaybackState()) { + nextAction.schedule(player, trackSelector, surface, handler); + } else { + player.addListener(new Player.DefaultEventListener() { + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + if (targetPlaybackState == playbackState) { + player.removeListener(this); + nextAction.schedule(player, trackSelector, surface, handler); + } + } + }); + } + } + + @Override + protected void doActionImpl(SimpleExoPlayer player, MappingTrackSelector trackSelector, + Surface surface) { + // Not triggered. + } + + } + + /** + * Waits for {@link Player.EventListener#onSeekProcessed()}. + */ + public static final class WaitForSeekProcessed extends Action { + + /** + * @param tag A tag to use for logging. + */ + public WaitForSeekProcessed(String tag) { + super(tag, "WaitForSeekProcessed"); + } + + @Override + protected void doActionAndScheduleNextImpl(final SimpleExoPlayer player, + final MappingTrackSelector trackSelector, final Surface surface, final Handler handler, + final ActionNode nextAction) { + if (nextAction == null) { + return; + } + player.addListener(new Player.DefaultEventListener() { + @Override + public void onSeekProcessed() { + player.removeListener(this); + nextAction.schedule(player, trackSelector, surface, handler); + } + }); + } + + @Override + protected void doActionImpl(SimpleExoPlayer player, MappingTrackSelector trackSelector, + Surface surface) { + // Not triggered. + } + + } + + /** + * Calls {@link Runnable#run()}. + */ + public static final class ExecuteRunnable extends Action { + + private final Runnable runnable; + + /** + * @param tag A tag to use for logging. + */ + public ExecuteRunnable(String tag, Runnable runnable) { + super(tag, "ExecuteRunnable"); + this.runnable = runnable; + } + + @Override + protected void doActionImpl(SimpleExoPlayer player, MappingTrackSelector trackSelector, + Surface surface) { + runnable.run(); + } + + } } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ActionSchedule.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ActionSchedule.java index 66f7ebca959..ddfa2345ee5 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ActionSchedule.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ActionSchedule.java @@ -16,16 +16,29 @@ package com.google.android.exoplayer2.testutil; import android.os.Handler; +import android.os.Looper; import android.view.Surface; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.testutil.Action.ClearVideoSurface; +import com.google.android.exoplayer2.testutil.Action.ExecuteRunnable; +import com.google.android.exoplayer2.testutil.Action.PrepareSource; import com.google.android.exoplayer2.testutil.Action.Seek; import com.google.android.exoplayer2.testutil.Action.SetPlayWhenReady; import com.google.android.exoplayer2.testutil.Action.SetRendererDisabled; +import com.google.android.exoplayer2.testutil.Action.SetRepeatMode; +import com.google.android.exoplayer2.testutil.Action.SetShuffleModeEnabled; import com.google.android.exoplayer2.testutil.Action.SetVideoSurface; import com.google.android.exoplayer2.testutil.Action.Stop; +import com.google.android.exoplayer2.testutil.Action.WaitForPlaybackState; +import com.google.android.exoplayer2.testutil.Action.WaitForPositionDiscontinuity; +import com.google.android.exoplayer2.testutil.Action.WaitForSeekProcessed; +import com.google.android.exoplayer2.testutil.Action.WaitForTimelineChanged; import com.google.android.exoplayer2.trackselection.MappingTrackSelector; +import com.google.android.exoplayer2.util.Clock; /** * Schedules a sequence of {@link Action}s for execution during a test. @@ -60,17 +73,27 @@ private ActionSchedule(ActionNode rootNode) { public static final class Builder { private final String tag; + private final Clock clock; private final ActionNode rootNode; - private long currentDelayMs; + private long currentDelayMs; private ActionNode previousNode; /** * @param tag A tag to use for logging. */ public Builder(String tag) { + this(tag, Clock.DEFAULT); + } + + /** + * @param tag A tag to use for logging. + * @param clock A clock to use for measuring delays. + */ + public Builder(String tag, Clock clock) { this.tag = tag; - rootNode = new ActionNode(new RootAction(tag), 0); + this.clock = clock; + rootNode = new ActionNode(new RootAction(tag), clock, 0); previousNode = rootNode; } @@ -92,7 +115,7 @@ public Builder delay(long delayMs) { * @return The builder, for convenience. */ public Builder apply(Action action) { - return appendActionNode(new ActionNode(action, currentDelayMs)); + return appendActionNode(new ActionNode(action, clock, currentDelayMs)); } /** @@ -103,7 +126,7 @@ public Builder apply(Action action) { * @return The builder, for convenience. */ public Builder repeat(Action action, long intervalMs) { - return appendActionNode(new ActionNode(action, currentDelayMs, intervalMs)); + return appendActionNode(new ActionNode(action, clock, currentDelayMs, intervalMs)); } /** @@ -116,6 +139,18 @@ public Builder seek(long positionMs) { return apply(new Seek(tag, positionMs)); } + /** + * Schedules a seek action to be executed and waits until playback resumes after the seek. + * + * @param positionMs The seek position. + * @return The builder, for convenience. + */ + public Builder seekAndWait(long positionMs) { + return apply(new Seek(tag, positionMs)) + .apply(new WaitForSeekProcessed(tag)) + .apply(new WaitForPlaybackState(tag, Player.STATE_READY)); + } + /** * Schedules a stop action to be executed. * @@ -179,6 +214,82 @@ public Builder setVideoSurface() { return apply(new SetVideoSurface(tag)); } + /** + * Schedules a new source preparation action to be executed. + * + * @return The builder, for convenience. + */ + public Builder prepareSource(MediaSource mediaSource) { + return apply(new PrepareSource(tag, mediaSource)); + } + + /** + * Schedules a new source preparation action to be executed. + * @see com.google.android.exoplayer2.ExoPlayer#prepare(MediaSource, boolean, boolean). + * + * @return The builder, for convenience. + */ + public Builder prepareSource(MediaSource mediaSource, boolean resetPosition, + boolean resetState) { + return apply(new PrepareSource(tag, mediaSource, resetPosition, resetState)); + } + + /** + * Schedules a repeat mode setting action to be executed. + * + * @return The builder, for convenience. + */ + public Builder setRepeatMode(@Player.RepeatMode int repeatMode) { + return apply(new SetRepeatMode(tag, repeatMode)); + } + + /** + * Schedules a shuffle setting action to be executed. + * + * @return The builder, for convenience. + */ + public Builder setShuffleModeEnabled(boolean shuffleModeEnabled) { + return apply(new SetShuffleModeEnabled(tag, shuffleModeEnabled)); + } + + /** + * Schedules a delay until the timeline changed to a specified expected timeline. + * + * @param expectedTimeline The expected timeline to wait for. + * @return The builder, for convenience. + */ + public Builder waitForTimelineChanged(Timeline expectedTimeline) { + return apply(new WaitForTimelineChanged(tag, expectedTimeline)); + } + + /** + * Schedules a delay until the next position discontinuity. + * + * @return The builder, for convenience. + */ + public Builder waitForPositionDiscontinuity() { + return apply(new WaitForPositionDiscontinuity(tag)); + } + + /** + * Schedules a delay until the playback state changed to the specified state. + * + * @param targetPlaybackState The target playback state. + * @return The builder, for convenience. + */ + public Builder waitForPlaybackState(int targetPlaybackState) { + return apply(new WaitForPlaybackState(tag, targetPlaybackState)); + } + + /** + * Schedules a {@link Runnable} to be executed. + * + * @return The builder, for convenience. + */ + public Builder executeRunnable(Runnable runnable) { + return apply(new ExecuteRunnable(tag, runnable)); + } + public ActionSchedule build() { return new ActionSchedule(rootNode); } @@ -195,9 +306,10 @@ private Builder appendActionNode(ActionNode actionNode) { /** * Wraps an {@link Action}, allowing a delay and a next {@link Action} to be specified. */ - private static final class ActionNode implements Runnable { + /* package */ static final class ActionNode implements Runnable { private final Action action; + private final Clock clock; private final long delayMs; private final long repeatIntervalMs; @@ -210,20 +322,23 @@ private static final class ActionNode implements Runnable { /** * @param action The wrapped action. + * @param clock The clock to use for measuring the delay. * @param delayMs The delay between the node being scheduled and the action being executed. */ - public ActionNode(Action action, long delayMs) { - this(action, delayMs, C.TIME_UNSET); + public ActionNode(Action action, Clock clock, long delayMs) { + this(action, clock, delayMs, C.TIME_UNSET); } /** * @param action The wrapped action. + * @param clock The clock to use for measuring the delay. * @param delayMs The delay between the node being scheduled and the action being executed. * @param repeatIntervalMs The interval between one execution and the next repetition. If set to * {@link C#TIME_UNSET}, the action is executed once only. */ - public ActionNode(Action action, long delayMs, long repeatIntervalMs) { + public ActionNode(Action action, Clock clock, long delayMs, long repeatIntervalMs) { this.action = action; + this.clock = clock; this.delayMs = delayMs; this.repeatIntervalMs = repeatIntervalMs; } @@ -252,17 +367,24 @@ public void schedule(SimpleExoPlayer player, MappingTrackSelector trackSelector, this.trackSelector = trackSelector; this.surface = surface; this.mainHandler = mainHandler; - mainHandler.postDelayed(this, delayMs); + if (delayMs == 0 && Looper.myLooper() == mainHandler.getLooper()) { + run(); + } else { + clock.postDelayed(mainHandler, this, delayMs); + } } @Override public void run() { - action.doAction(player, trackSelector, surface); - if (next != null) { - next.schedule(player, trackSelector, surface, mainHandler); - } + action.doActionAndScheduleNext(player, trackSelector, surface, mainHandler, next); if (repeatIntervalMs != C.TIME_UNSET) { - mainHandler.postDelayed(this, repeatIntervalMs); + clock.postDelayed(mainHandler, new Runnable() { + @Override + public void run() { + action.doActionAndScheduleNext(player, trackSelector, surface, mainHandler, null); + clock.postDelayed(mainHandler, this, repeatIntervalMs); + } + }, repeatIntervalMs); } } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/CacheAsserts.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/CacheAsserts.java index c8ead5dcba3..82fff0d4fe5 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/CacheAsserts.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/CacheAsserts.java @@ -39,11 +39,11 @@ public final class CacheAsserts { /** Asserts that the cache content is equal to the data in the {@code fakeDataSet}. */ public static void assertCachedData(Cache cache, FakeDataSet fakeDataSet) throws IOException { ArrayList allData = fakeDataSet.getAllData(); - String[] uriStrings = new String[allData.size()]; + Uri[] uris = new Uri[allData.size()]; for (int i = 0; i < allData.size(); i++) { - uriStrings[i] = allData.get(i).uri; + uris[i] = allData.get(i).uri; } - assertCachedData(cache, fakeDataSet, uriStrings); + assertCachedData(cache, fakeDataSet, uris); } /** @@ -51,30 +51,41 @@ public static void assertCachedData(Cache cache, FakeDataSet fakeDataSet) throws */ public static void assertCachedData(Cache cache, FakeDataSet fakeDataSet, String... uriStrings) throws IOException { + Uri[] uris = new Uri[uriStrings.length]; + for (int i = 0; i < uriStrings.length; i++) { + uris[i] = Uri.parse(uriStrings[i]); + } + assertCachedData(cache, fakeDataSet, uris); + } + + /** + * Asserts that the cache content is equal to the given subset of data in the {@code fakeDataSet}. + */ + public static void assertCachedData(Cache cache, FakeDataSet fakeDataSet, Uri... uris) + throws IOException { int totalLength = 0; - for (String uriString : uriStrings) { - byte[] data = fakeDataSet.getData(uriString).getData(); - assertDataCached(cache, uriString, data); + for (Uri uri : uris) { + byte[] data = fakeDataSet.getData(uri).getData(); + assertDataCached(cache, uri, data); totalLength += data.length; } assertEquals(totalLength, cache.getCacheSpace()); } /** Asserts that the cache contains the given subset of data in the {@code fakeDataSet}. */ - public static void assertDataCached(Cache cache, FakeDataSet fakeDataSet, String... uriStrings) + public static void assertDataCached(Cache cache, FakeDataSet fakeDataSet, Uri... uris) throws IOException { - for (String uriString : uriStrings) { - assertDataCached(cache, uriString, fakeDataSet.getData(uriString).getData()); + for (Uri uri : uris) { + assertDataCached(cache, uri, fakeDataSet.getData(uri).getData()); } } /** Asserts that the cache contains the given data for {@code uriString}. */ - public static void assertDataCached(Cache cache, String uriString, byte[] expected) - throws IOException { + public static void assertDataCached(Cache cache, Uri uri, byte[] expected) throws IOException { CacheDataSource dataSource = new CacheDataSource(cache, DummyDataSource.INSTANCE, 0); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); DataSourceInputStream inputStream = new DataSourceInputStream(dataSource, - new DataSpec(Uri.parse(uriString), DataSpec.FLAG_ALLOW_CACHING_UNKNOWN_LENGTH)); + new DataSpec(uri, DataSpec.FLAG_ALLOW_CACHING_UNKNOWN_LENGTH)); try { inputStream.open(); byte[] buffer = new byte[1024]; @@ -87,7 +98,7 @@ public static void assertDataCached(Cache cache, String uriString, byte[] expect } finally { inputStream.close(); } - MoreAsserts.assertEquals("Cached data doesn't match expected for '" + uriString + "',", + MoreAsserts.assertEquals("Cached data doesn't match expected for '" + uri + "',", expected, outputStream.toByteArray()); } 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 af7c1a3e2a2..392a4907d42 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 @@ -17,6 +17,8 @@ import android.annotation.TargetApi; import android.content.Context; +import android.media.MediaCodec; +import android.media.MediaCrypto; import android.os.Handler; import com.google.android.exoplayer2.DefaultRenderersFactory; import com.google.android.exoplayer2.ExoPlaybackException; @@ -25,9 +27,12 @@ import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; +import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; +import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException; import com.google.android.exoplayer2.video.MediaCodecVideoRenderer; import com.google.android.exoplayer2.video.VideoRendererEventListener; +import java.nio.ByteBuffer; import java.util.ArrayList; /** @@ -66,6 +71,7 @@ private static class DebugMediaCodecVideoRenderer extends MediaCodecVideoRendere private int queueSize; private int bufferCount; private int minimumInsertIndex; + private boolean skipToPositionBeforeRenderingFirstFrame; public DebugMediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector, long allowedJoiningTimeMs, DrmSessionManager drmSessionManager, @@ -75,10 +81,23 @@ public DebugMediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCod eventHandler, eventListener, maxDroppedFrameCountToNotify); } + @Override + protected void configureCodec(MediaCodecInfo codecInfo, MediaCodec codec, Format format, + MediaCrypto crypto) throws DecoderQueryException { + // If the codec is being initialized whilst the renderer is started, default behavior is to + // render the first frame (i.e. the keyframe before the current position), then drop frames up + // to the current playback position. For test runs that place a maximum limit on the number of + // dropped frames allowed, this is not desired behavior. Hence we skip (rather than drop) + // frames up to the current playback position [Internal: b/66494991]. + skipToPositionBeforeRenderingFirstFrame = getState() == Renderer.STATE_STARTED; + super.configureCodec(codecInfo, codec, format, crypto); + } + @Override protected void releaseCodec() { super.releaseCodec(); clearTimestamps(); + skipToPositionBeforeRenderingFirstFrame = false; } @Override @@ -102,6 +121,34 @@ protected void onQueueInputBuffer(DecoderInputBuffer buffer) { maybeShiftTimestampsList(); } + @Override + protected boolean processOutputBuffer(long positionUs, long elapsedRealtimeUs, MediaCodec codec, + ByteBuffer buffer, int bufferIndex, int bufferFlags, long bufferPresentationTimeUs, + boolean shouldSkip) throws ExoPlaybackException { + if (skipToPositionBeforeRenderingFirstFrame && bufferPresentationTimeUs < positionUs) { + // After the codec has been initialized, don't render the first frame until we've caught up + // to the playback position. Else test runs on devices that do not support dummy surface + // will drop frames between rendering the first one and catching up [Internal: b/66494991]. + shouldSkip = true; + } + return super.processOutputBuffer(positionUs, elapsedRealtimeUs, codec, buffer, bufferIndex, + bufferFlags, bufferPresentationTimeUs, shouldSkip); + } + + @Override + protected void renderOutputBuffer(MediaCodec codec, int index, long presentationTimeUs) { + skipToPositionBeforeRenderingFirstFrame = false; + super.renderOutputBuffer(codec, index, presentationTimeUs); + } + + @TargetApi(21) + @Override + protected void renderOutputBufferV21(MediaCodec codec, int index, long presentationTimeUs, + long releaseTimeNs) { + skipToPositionBeforeRenderingFirstFrame = false; + super.renderOutputBufferV21(codec, index, presentationTimeUs, releaseTimeNs); + } + @Override protected void onProcessedOutputBuffer(long presentationTimeUs) { super.onProcessedOutputBuffer(presentationTimeUs); diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/DecoderCountersUtil.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/DecoderCountersUtil.java index 448ec79c2da..16af394cdf1 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/DecoderCountersUtil.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/DecoderCountersUtil.java @@ -31,8 +31,9 @@ private DecoderCountersUtil() {} * @param counters The counters for which the total should be calculated. * @return The sum of the skipped, dropped and rendered buffers. */ - public static int getTotalOutputBuffers(DecoderCounters counters) { - return counters.skippedOutputBufferCount + counters.droppedOutputBufferCount + public static int getTotalBufferCount(DecoderCounters counters) { + counters.ensureUpdated(); + return counters.skippedOutputBufferCount + counters.droppedBufferCount + counters.renderedOutputBufferCount; } @@ -44,26 +45,24 @@ public static void assertSkippedOutputBufferCount(String name, DecoderCounters c + expected + ".", expected, actual); } - public static void assertTotalOutputBufferCount(String name, DecoderCounters counters, - int minCount, int maxCount) { - counters.ensureUpdated(); - int actual = getTotalOutputBuffers(counters); + public static void assertTotalBufferCount(String name, DecoderCounters counters, int minCount, + int maxCount) { + int actual = getTotalBufferCount(counters); TestCase.assertTrue("Codec(" + name + ") output " + actual + " buffers. Expected in range [" + minCount + ", " + maxCount + "].", minCount <= actual && actual <= maxCount); } - public static void assertDroppedOutputBufferLimit(String name, DecoderCounters counters, - int limit) { + public static void assertDroppedBufferLimit(String name, DecoderCounters counters, int limit) { counters.ensureUpdated(); - int actual = counters.droppedOutputBufferCount; + int actual = counters.droppedBufferCount; TestCase.assertTrue("Codec(" + name + ") was late decoding: " + actual + " buffers. " + "Limit: " + limit + ".", actual <= limit); } - public static void assertConsecutiveDroppedOutputBufferLimit(String name, - DecoderCounters counters, int limit) { + public static void assertConsecutiveDroppedBufferLimit(String name, DecoderCounters counters, + int limit) { counters.ensureUpdated(); - int actual = counters.maxConsecutiveDroppedOutputBufferCount; + int actual = counters.maxConsecutiveDroppedBufferCount; TestCase.assertTrue("Codec(" + name + ") was late decoding: " + actual + " buffers consecutively. " + "Limit: " + limit + ".", actual <= limit); } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java index b61b484e323..ee4018ba0e8 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.testutil; +import android.os.ConditionVariable; import android.os.Handler; import android.os.SystemClock; import android.util.Log; @@ -24,23 +25,19 @@ import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.ExoPlayerFactory; import com.google.android.exoplayer2.Format; -import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.SimpleExoPlayer; -import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.audio.AudioRendererEventListener; -import com.google.android.exoplayer2.audio.AudioTrack; +import com.google.android.exoplayer2.audio.DefaultAudioSink; import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.testutil.HostActivity.HostedTest; import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.MappingTrackSelector; -import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.upstream.BandwidthMeter; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; @@ -52,14 +49,14 @@ /** * A {@link HostedTest} for {@link ExoPlayer} playback tests. */ -public abstract class ExoHostedTest implements HostedTest, Player.EventListener, +public abstract class ExoHostedTest extends Player.DefaultEventListener implements HostedTest, AudioRendererEventListener, VideoRendererEventListener { static { - // ExoPlayer's AudioTrack class is able to work around spurious timestamps reported by the - // platform (by ignoring them). Disable this workaround, since we're interested in testing - // that the underlying platform is behaving correctly. - AudioTrack.failOnSpuriousAudioTimestamp = true; + // DefaultAudioSink is able to work around spurious timestamps reported by the platform (by + // ignoring them). Disable this workaround, since we're interested in testing that the + // underlying platform is behaving correctly. + DefaultAudioSink.failOnSpuriousAudioTimestamp = true; } public static final long MAX_PLAYING_TIME_DISCREPANCY_MS = 2000; @@ -72,6 +69,7 @@ public abstract class ExoHostedTest implements HostedTest, Player.EventListener, private final long expectedPlayingTimeMs; private final DecoderCounters videoDecoderCounters; private final DecoderCounters audioDecoderCounters; + private final ConditionVariable testFinished; private ActionSchedule pendingSchedule; private Handler actionHandler; @@ -81,7 +79,7 @@ public abstract class ExoHostedTest implements HostedTest, Player.EventListener, private ExoPlaybackException playerError; private Player.EventListener playerEventListener; private boolean playerWasPrepared; - private boolean playerFinished; + private boolean playing; private long totalPlayingTimeMs; private long lastPlayingStartTimeMs; @@ -114,8 +112,9 @@ public ExoHostedTest(String tag, long expectedPlayingTimeMs, boolean failOnPlaye this.tag = tag; this.expectedPlayingTimeMs = expectedPlayingTimeMs; this.failOnPlayerError = failOnPlayerError; - videoDecoderCounters = new DecoderCounters(); - audioDecoderCounters = new DecoderCounters(); + this.testFinished = new ConditionVariable(); + this.videoDecoderCounters = new DecoderCounters(); + this.audioDecoderCounters = new DecoderCounters(); } /** @@ -169,16 +168,13 @@ public final void onStart(HostActivity host, Surface surface) { } @Override - public final boolean canStop() { - return playerFinished; + public final boolean blockUntilStopped(long timeoutMs) { + return testFinished.block(timeoutMs); } @Override - public final void onStop() { - actionHandler.removeCallbacksAndMessages(null); - sourceDurationMs = player.getDuration(); - player.release(); - player = null; + public final boolean forceStop() { + return stopTest(); } @Override @@ -203,23 +199,13 @@ public final void onFinished() { // Player.EventListener - @Override - public void onLoadingChanged(boolean isLoading) { - // Do nothing. - } - - @Override - public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { - // Do nothing. - } - @Override public final void onPlayerStateChanged(boolean playWhenReady, int playbackState) { Log.d(tag, "state [" + playWhenReady + ", " + playbackState + "]"); playerWasPrepared |= playbackState != Player.STATE_IDLE; if (playbackState == Player.STATE_ENDED || (playbackState == Player.STATE_IDLE && playerWasPrepared)) { - playerFinished = true; + stopTest(); } boolean playing = playWhenReady && playbackState == Player.STATE_READY; if (!this.playing && playing) { @@ -230,11 +216,6 @@ public final void onPlayerStateChanged(boolean playWhenReady, int playbackState) this.playing = playing; } - @Override - public void onRepeatModeChanged(int repeatMode) { - // Do nothing. - } - @Override public final void onPlayerError(ExoPlaybackException error) { playerWasPrepared = true; @@ -242,21 +223,6 @@ public final void onPlayerError(ExoPlaybackException error) { onPlayerErrorInternal(error); } - @Override - public final void onPositionDiscontinuity() { - // Do nothing. - } - - @Override - public final void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { - // Do nothing. - } - - @Override - public final void onTimelineChanged(Timeline timeline, Object manifest) { - // Do nothing. - } - // AudioRendererEventListener @Override @@ -287,7 +253,7 @@ public void onAudioDisabled(DecoderCounters counters) { } @Override - public void onAudioTrackUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { + public void onAudioSinkUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { Log.e(tag, "audioTrackUnderrun [" + bufferSize + ", " + bufferSizeMs + ", " + elapsedSinceLastFeedMs + "]", null); } @@ -334,6 +300,25 @@ public void onRenderedFirstFrame(Surface surface) { // Internal logic + private boolean stopTest() { + if (player == null) { + return false; + } + actionHandler.removeCallbacksAndMessages(null); + sourceDurationMs = player.getDuration(); + player.release(); + player = null; + // We post opening of the finished condition so that any events posted to the main thread as a + // result of player.release() are guaranteed to be handled before the test returns. + actionHandler.post(new Runnable() { + @Override + public void run() { + testFinished.open(); + } + }); + return true; + } + protected DrmSessionManager buildDrmSessionManager(String userAgent) { // Do nothing. Interested subclasses may override. return null; diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoPlayerTestRunner.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoPlayerTestRunner.java new file mode 100644 index 00000000000..a87066415d4 --- /dev/null +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoPlayerTestRunner.java @@ -0,0 +1,366 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.testutil; + +import android.os.Handler; +import android.os.HandlerThread; +import com.google.android.exoplayer2.DefaultLoadControl; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.ExoPlayerFactory; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.LoadControl; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Renderer; +import com.google.android.exoplayer2.RenderersFactory; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.audio.AudioRendererEventListener; +import com.google.android.exoplayer2.metadata.MetadataOutput; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.testutil.ExoPlayerTestRunner.Builder.PlayerFactory; +import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; +import com.google.android.exoplayer2.text.TextOutput; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.MappingTrackSelector; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.video.VideoRendererEventListener; +import java.util.LinkedList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import junit.framework.Assert; + +/** + * Helper class to run an ExoPlayer test. + */ +public final class ExoPlayerTestRunner extends Player.DefaultEventListener { + + /** + * Builder to set-up a {@link ExoPlayerTestRunner}. Default fake implementations will be used for + * unset test properties. + */ + public static final class Builder { + + /** + * Factory to create an {@link SimpleExoPlayer} instance. The player will be created on its own + * {@link HandlerThread}. + */ + public interface PlayerFactory { + + SimpleExoPlayer createExoPlayer(RenderersFactory renderersFactory, + MappingTrackSelector trackSelector, LoadControl loadControl); + + } + + public static final Format VIDEO_FORMAT = Format.createVideoSampleFormat(null, + MimeTypes.VIDEO_H264, null, Format.NO_VALUE, Format.NO_VALUE, 1280, 720, Format.NO_VALUE, + null, null); + public static final Format AUDIO_FORMAT = Format.createAudioSampleFormat(null, + MimeTypes.AUDIO_AAC, null, Format.NO_VALUE, Format.NO_VALUE, 2, 44100, null, null, 0, null); + + private PlayerFactory playerFactory; + private Timeline timeline; + private Object manifest; + private MediaSource mediaSource; + private MappingTrackSelector trackSelector; + private LoadControl loadControl; + private Format[] supportedFormats; + private Renderer[] renderers; + private RenderersFactory renderersFactory; + private ActionSchedule actionSchedule; + private Player.EventListener eventListener; + + public Builder setTimeline(Timeline timeline) { + Assert.assertNull(mediaSource); + this.timeline = timeline; + return this; + } + + public Builder setManifest(Object manifest) { + Assert.assertNull(mediaSource); + this.manifest = manifest; + return this; + } + + /** Replaces {@link #setTimeline(Timeline)} and {@link #setManifest(Object)}. */ + public Builder setMediaSource(MediaSource mediaSource) { + Assert.assertNull(timeline); + Assert.assertNull(manifest); + this.mediaSource = mediaSource; + return this; + } + + public Builder setTrackSelector(MappingTrackSelector trackSelector) { + this.trackSelector = trackSelector; + return this; + } + + public Builder setLoadControl(LoadControl loadControl) { + this.loadControl = loadControl; + return this; + } + + public Builder setSupportedFormats(Format... supportedFormats) { + this.supportedFormats = supportedFormats; + return this; + } + + public Builder setRenderers(Renderer... renderers) { + Assert.assertNull(renderersFactory); + this.renderers = renderers; + return this; + } + + /** Replaces {@link #setRenderers(Renderer...)}. */ + public Builder setRenderersFactory(RenderersFactory renderersFactory) { + Assert.assertNull(renderers); + this.renderersFactory = renderersFactory; + return this; + } + + public Builder setExoPlayer(PlayerFactory playerFactory) { + this.playerFactory = playerFactory; + return this; + } + + public Builder setActionSchedule(ActionSchedule actionSchedule) { + this.actionSchedule = actionSchedule; + return this; + } + + public Builder setEventListener(Player.EventListener eventListener) { + this.eventListener = eventListener; + return this; + } + + public ExoPlayerTestRunner build() { + if (supportedFormats == null) { + supportedFormats = new Format[] { VIDEO_FORMAT }; + } + if (trackSelector == null) { + trackSelector = new DefaultTrackSelector(); + } + if (renderersFactory == null) { + if (renderers == null) { + renderers = new Renderer[] { new FakeRenderer(supportedFormats) }; + } + renderersFactory = new RenderersFactory() { + @Override + public Renderer[] createRenderers(Handler eventHandler, + VideoRendererEventListener videoRendererEventListener, + AudioRendererEventListener audioRendererEventListener, TextOutput textRendererOutput, + MetadataOutput metadataRendererOutput) { + return renderers; + } + }; + } + if (loadControl == null) { + loadControl = new DefaultLoadControl(); + } + if (playerFactory == null) { + playerFactory = new PlayerFactory() { + @Override + public SimpleExoPlayer createExoPlayer(RenderersFactory renderersFactory, + MappingTrackSelector trackSelector, LoadControl loadControl) { + return ExoPlayerFactory.newSimpleInstance(renderersFactory, trackSelector, loadControl); + } + }; + } + if (mediaSource == null) { + if (timeline == null) { + timeline = new FakeTimeline(new TimelineWindowDefinition(false, false, 0)); + } + mediaSource = new FakeMediaSource(timeline, manifest, supportedFormats); + } + return new ExoPlayerTestRunner(playerFactory, mediaSource, renderersFactory, trackSelector, + loadControl, actionSchedule, eventListener); + } + } + + private final PlayerFactory playerFactory; + private final MediaSource mediaSource; + private final RenderersFactory renderersFactory; + private final MappingTrackSelector trackSelector; + private final LoadControl loadControl; + private final ActionSchedule actionSchedule; + private final Player.EventListener eventListener; + + private final HandlerThread playerThread; + private final Handler handler; + private final CountDownLatch endedCountDownLatch; + private final LinkedList timelines; + private final LinkedList manifests; + private final LinkedList periodIndices; + + private SimpleExoPlayer player; + private Exception exception; + private TrackGroupArray trackGroups; + private int positionDiscontinuityCount; + private boolean playerWasPrepared; + + private ExoPlayerTestRunner(PlayerFactory playerFactory, MediaSource mediaSource, + RenderersFactory renderersFactory, MappingTrackSelector trackSelector, + LoadControl loadControl, ActionSchedule actionSchedule, Player.EventListener eventListener) { + this.playerFactory = playerFactory; + this.mediaSource = mediaSource; + this.renderersFactory = renderersFactory; + this.trackSelector = trackSelector; + this.loadControl = loadControl; + this.actionSchedule = actionSchedule; + this.eventListener = eventListener; + this.timelines = new LinkedList<>(); + this.manifests = new LinkedList<>(); + this.periodIndices = new LinkedList<>(); + this.endedCountDownLatch = new CountDownLatch(1); + this.playerThread = new HandlerThread("ExoPlayerTest thread"); + playerThread.start(); + this.handler = new Handler(playerThread.getLooper()); + } + + // Called on the test thread to run the test. + + public ExoPlayerTestRunner start() { + handler.post(new Runnable() { + @Override + public void run() { + try { + player = playerFactory.createExoPlayer(renderersFactory, trackSelector, loadControl); + player.addListener(ExoPlayerTestRunner.this); + if (eventListener != null) { + player.addListener(eventListener); + } + player.setPlayWhenReady(true); + if (actionSchedule != null) { + actionSchedule.start(player, trackSelector, null, handler); + } + player.prepare(mediaSource); + } catch (Exception e) { + handleException(e); + } + } + }); + return this; + } + + public ExoPlayerTestRunner blockUntilEnded(long timeoutMs) throws Exception { + if (!endedCountDownLatch.await(timeoutMs, TimeUnit.MILLISECONDS)) { + exception = new TimeoutException("Test playback timed out waiting for playback to end."); + } + release(); + // Throw any pending exception (from playback, timing out or releasing). + if (exception != null) { + throw exception; + } + return this; + } + + // Assertions called on the test thread after test finished. + + public void assertTimelinesEqual(Timeline... timelines) { + Assert.assertEquals(timelines.length, this.timelines.size()); + for (Timeline timeline : timelines) { + Assert.assertEquals(timeline, this.timelines.remove()); + } + } + + public void assertManifestsEqual(Object... manifests) { + Assert.assertEquals(manifests.length, this.manifests.size()); + for (Object manifest : manifests) { + Assert.assertEquals(manifest, this.manifests.remove()); + } + } + + public void assertTrackGroupsEqual(TrackGroupArray trackGroupArray) { + Assert.assertEquals(trackGroupArray, this.trackGroups); + } + + public void assertPositionDiscontinuityCount(int expectedCount) { + Assert.assertEquals(expectedCount, positionDiscontinuityCount); + } + + public void assertPlayedPeriodIndices(int... periodIndices) { + Assert.assertEquals(periodIndices.length, this.periodIndices.size()); + for (int periodIndex : periodIndices) { + Assert.assertEquals(periodIndex, (int) this.periodIndices.remove()); + } + } + + // Private implementation details. + + private void release() throws InterruptedException { + handler.post(new Runnable() { + @Override + public void run() { + try { + if (player != null) { + player.release(); + } + } catch (Exception e) { + handleException(e); + } finally { + playerThread.quit(); + } + } + }); + playerThread.join(); + } + + private void handleException(Exception exception) { + if (this.exception == null) { + this.exception = exception; + } + endedCountDownLatch.countDown(); + } + + // Player.EventListener + + @Override + public void onTimelineChanged(Timeline timeline, Object manifest) { + timelines.add(timeline); + manifests.add(manifest); + } + + @Override + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + this.trackGroups = trackGroups; + } + + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + if (periodIndices.isEmpty() && playbackState == Player.STATE_READY) { + periodIndices.add(player.getCurrentPeriodIndex()); + } + playerWasPrepared |= playbackState != Player.STATE_IDLE; + if (playbackState == Player.STATE_ENDED + || (playbackState == Player.STATE_IDLE && playerWasPrepared)) { + endedCountDownLatch.countDown(); + } + } + + @Override + public void onPlayerError(ExoPlaybackException error) { + handleException(exception); + } + + @Override + public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { + positionDiscontinuityCount++; + periodIndices.add(player.getCurrentPeriodIndex()); + } + +} diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoPlayerWrapper.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoPlayerWrapper.java deleted file mode 100644 index ab247283e67..00000000000 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoPlayerWrapper.java +++ /dev/null @@ -1,192 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.testutil; - -import android.os.Handler; -import android.os.HandlerThread; -import android.util.Pair; -import com.google.android.exoplayer2.ExoPlaybackException; -import com.google.android.exoplayer2.ExoPlayer; -import com.google.android.exoplayer2.ExoPlayerFactory; -import com.google.android.exoplayer2.PlaybackParameters; -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.Renderer; -import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.TrackGroupArray; -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; -import com.google.android.exoplayer2.trackselection.TrackSelectionArray; -import java.util.LinkedList; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import junit.framework.Assert; - -/** - * Wraps a player with its own handler thread. - */ -public class ExoPlayerWrapper implements Player.EventListener { - - private final CountDownLatch sourceInfoCountDownLatch; - private final CountDownLatch endedCountDownLatch; - private final HandlerThread playerThread; - private final Handler handler; - private final LinkedList> sourceInfos; - - public ExoPlayer player; - public TrackGroupArray trackGroups; - public Exception exception; - - // Written only on the main thread. - public volatile int positionDiscontinuityCount; - - public ExoPlayerWrapper() { - sourceInfoCountDownLatch = new CountDownLatch(1); - endedCountDownLatch = new CountDownLatch(1); - playerThread = new HandlerThread("ExoPlayerTest thread"); - playerThread.start(); - handler = new Handler(playerThread.getLooper()); - sourceInfos = new LinkedList<>(); - } - - // Called on the test thread. - - public void blockUntilEnded(long timeoutMs) throws Exception { - if (!endedCountDownLatch.await(timeoutMs, TimeUnit.MILLISECONDS)) { - exception = new TimeoutException("Test playback timed out waiting for playback to end."); - } - release(); - // Throw any pending exception (from playback, timing out or releasing). - if (exception != null) { - throw exception; - } - } - - public void blockUntilSourceInfoRefreshed(long timeoutMs) throws Exception { - if (!sourceInfoCountDownLatch.await(timeoutMs, TimeUnit.MILLISECONDS)) { - throw new TimeoutException("Test playback timed out waiting for source info."); - } - } - - public void setup(final MediaSource mediaSource, final Renderer... renderers) { - handler.post(new Runnable() { - @Override - public void run() { - try { - player = ExoPlayerFactory.newInstance(renderers, new DefaultTrackSelector()); - player.addListener(ExoPlayerWrapper.this); - player.setPlayWhenReady(true); - player.prepare(mediaSource); - } catch (Exception e) { - handleError(e); - } - } - }); - } - - public void prepare(final MediaSource mediaSource) { - handler.post(new Runnable() { - @Override - public void run() { - try { - player.prepare(mediaSource); - } catch (Exception e) { - handleError(e); - } - } - }); - } - - public void release() throws InterruptedException { - handler.post(new Runnable() { - @Override - public void run() { - try { - if (player != null) { - player.release(); - } - } catch (Exception e) { - handleError(e); - } finally { - playerThread.quit(); - } - } - }); - playerThread.join(); - } - - private void handleError(Exception exception) { - if (this.exception == null) { - this.exception = exception; - } - endedCountDownLatch.countDown(); - } - - @SafeVarargs - public final void assertSourceInfosEquals(Pair... sourceInfos) { - Assert.assertEquals(sourceInfos.length, this.sourceInfos.size()); - for (Pair sourceInfo : sourceInfos) { - Assert.assertEquals(sourceInfo, this.sourceInfos.remove()); - } - } - - // Player.EventListener implementation. - - @Override - public void onLoadingChanged(boolean isLoading) { - // Do nothing. - } - - @Override - public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { - if (playbackState == Player.STATE_ENDED) { - endedCountDownLatch.countDown(); - } - } - - @Override - public void onRepeatModeChanged(int repeatMode) { - // Do nothing. - } - - @Override - public void onTimelineChanged(Timeline timeline, Object manifest) { - sourceInfos.add(Pair.create(timeline, manifest)); - sourceInfoCountDownLatch.countDown(); - } - - @Override - public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { - this.trackGroups = trackGroups; - } - - @Override - public void onPlayerError(ExoPlaybackException exception) { - handleError(exception); - } - - @SuppressWarnings("NonAtomicVolatileUpdate") - @Override - public void onPositionDiscontinuity() { - positionDiscontinuityCount++; - } - - @Override - public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { - // Do nothing. - } - -} diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveDataSet.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveDataSet.java index f4476ddf934..82c14a5b328 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveDataSet.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveDataSet.java @@ -17,11 +17,12 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; -import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.source.TrackGroup; +import java.util.Random; /** * Fake data set emulating the data of an adaptive media source. - * It provides chunk data for all {@link Format}s in the given {@link TrackSelection}. + * It provides chunk data for all {@link Format}s in the given {@link TrackGroup}. */ public final class FakeAdaptiveDataSet extends FakeDataSet { @@ -30,52 +31,86 @@ public final class FakeAdaptiveDataSet extends FakeDataSet { */ public static final class Factory { + private static final Random random = new Random(); + private final long chunkDurationUs; + private final double bitratePercentStdDev; - public Factory(long chunkDurationUs) { + /** + * Set up factory for {@link FakeAdaptiveDataSet}s with a chunk duration and the standard + * deviation of the chunk size. + * + * @param chunkDurationUs The chunk duration to use in microseconds. + * @param bitratePercentStdDev The standard deviation used to generate the chunk sizes centered + * around the average bitrate of the {@link Format}s. The standard deviation is given in + * percent (of the average size). + */ + public Factory(long chunkDurationUs, double bitratePercentStdDev) { this.chunkDurationUs = chunkDurationUs; + this.bitratePercentStdDev = bitratePercentStdDev; } - public FakeAdaptiveDataSet createDataSet(TrackSelection trackSelection, long mediaDurationUs) { - return new FakeAdaptiveDataSet(trackSelection, mediaDurationUs, chunkDurationUs); + /** + * Returns a new {@link FakeAdaptiveDataSet} for the given {@link TrackGroup}. + * + * @param trackGroup The {@link TrackGroup} for which the data set is to be created. + * @param mediaDurationUs The total duration of the fake data set in microseconds. + */ + public FakeAdaptiveDataSet createDataSet(TrackGroup trackGroup, long mediaDurationUs) { + return new FakeAdaptiveDataSet(trackGroup, mediaDurationUs, chunkDurationUs, + bitratePercentStdDev, random); } } - private final long chunkCount; + private final int chunkCount; private final long chunkDurationUs; private final long lastChunkDurationUs; - public FakeAdaptiveDataSet(TrackSelection trackSelection, long mediaDurationUs, - long chunkDurationUs) { + /** + * Create {@link FakeAdaptiveDataSet} using a {@link TrackGroup} and meta data about the media. + * + * @param trackGroup The {@link TrackGroup} for which the data set is to be created. + * @param mediaDurationUs The total duration of the fake data set in microseconds. + * @param chunkDurationUs The chunk duration to use in microseconds. + * @param bitratePercentStdDev The standard deviation used to generate the chunk sizes centered + * around the average bitrate of the {@link Format}s in the {@link TrackGroup}. The standard + * deviation is given in percent (of the average size). + * @param random A {@link Random} instance used to generate random chunk sizes. + */ + /* package */ FakeAdaptiveDataSet(TrackGroup trackGroup, long mediaDurationUs, + long chunkDurationUs, double bitratePercentStdDev, Random random) { this.chunkDurationUs = chunkDurationUs; - int selectionCount = trackSelection.length(); long lastChunkDurationUs = mediaDurationUs % chunkDurationUs; int fullChunks = (int) (mediaDurationUs / chunkDurationUs); - for (int i = 0; i < selectionCount; i++) { + this.lastChunkDurationUs = lastChunkDurationUs == 0 ? chunkDurationUs : lastChunkDurationUs; + this.chunkCount = lastChunkDurationUs == 0 ? fullChunks : fullChunks + 1; + double[] bitrateFactors = new double[chunkCount]; + for (int i = 0; i < chunkCount; i++) { + bitrateFactors[i] = 1.0 + random.nextGaussian() * bitratePercentStdDev / 100.0; + } + for (int i = 0; i < trackGroup.length; i++) { String uri = getUri(i); - Format format = trackSelection.getFormat(i); - int chunkLength = (int) (format.bitrate * chunkDurationUs / (8 * C.MICROS_PER_SECOND)); + Format format = trackGroup.getFormat(i); + double avgChunkLength = format.bitrate * chunkDurationUs / (8 * C.MICROS_PER_SECOND); FakeData newData = this.newData(uri); for (int j = 0; j < fullChunks; j++) { - newData.appendReadData(chunkLength); + newData.appendReadData((int) (avgChunkLength * bitrateFactors[j])); } if (lastChunkDurationUs > 0) { - int lastChunkLength = (int) (format.bitrate * (mediaDurationUs % chunkDurationUs) - / (8 * C.MICROS_PER_SECOND)); + int lastChunkLength = (int) (format.bitrate * bitrateFactors[bitrateFactors.length - 1] + * (mediaDurationUs % chunkDurationUs) / (8 * C.MICROS_PER_SECOND)); newData.appendReadData(lastChunkLength); } } - this.lastChunkDurationUs = lastChunkDurationUs == 0 ? chunkDurationUs : lastChunkDurationUs; - this.chunkCount = lastChunkDurationUs == 0 ? fullChunks : fullChunks + 1; } - public long getChunkCount() { + public int getChunkCount() { return chunkCount; } - public String getUri(int trackSelectionIndex) { - return "fake://adaptive.media/" + Integer.toString(trackSelectionIndex); + public String getUri(int trackIndex) { + return "fake://adaptive.media/" + trackIndex; } public long getChunkDuration(int chunkIndex) { diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaPeriod.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaPeriod.java new file mode 100644 index 00000000000..3dcf5519439 --- /dev/null +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaPeriod.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.testutil; + +import com.google.android.exoplayer2.source.AdaptiveMediaSourceEventListener.EventDispatcher; +import com.google.android.exoplayer2.source.CompositeSequenceableLoader; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.SampleStream; +import com.google.android.exoplayer2.source.SequenceableLoader; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.source.chunk.ChunkSampleStream; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.util.MimeTypes; +import java.util.ArrayList; +import java.util.List; + +/** + * Fake {@link MediaPeriod} that provides tracks from the given {@link TrackGroupArray}. Selecting a + * track will give the player a {@link ChunkSampleStream}. + */ +public class FakeAdaptiveMediaPeriod extends FakeMediaPeriod + implements SequenceableLoader.Callback> { + + private final EventDispatcher eventDispatcher; + private final Allocator allocator; + private final FakeChunkSource.Factory chunkSourceFactory; + private final long durationUs; + + private Callback callback; + private ChunkSampleStream[] sampleStreams; + private SequenceableLoader sequenceableLoader; + + public FakeAdaptiveMediaPeriod(TrackGroupArray trackGroupArray, EventDispatcher eventDispatcher, + Allocator allocator, FakeChunkSource.Factory chunkSourceFactory, long durationUs) { + super(trackGroupArray); + this.eventDispatcher = eventDispatcher; + this.allocator = allocator; + this.chunkSourceFactory = chunkSourceFactory; + this.durationUs = durationUs; + } + + @Override + public void release() { + super.release(); + for (ChunkSampleStream sampleStream : sampleStreams) { + sampleStream.release(); + } + } + + @Override + public void prepare(Callback callback, long positionUs) { + super.prepare(callback, positionUs); + this.callback = callback; + } + + @Override + @SuppressWarnings("unchecked") + public long selectTracks(TrackSelection[] selections, boolean[] mayRetainStreamFlags, + SampleStream[] streams, boolean[] streamResetFlags, long positionUs) { + long returnPositionUs = super.selectTracks(selections, mayRetainStreamFlags, streams, + streamResetFlags, positionUs); + List> validStreams = new ArrayList<>(); + for (SampleStream stream : streams) { + if (stream != null) { + validStreams.add((ChunkSampleStream) stream); + } + } + this.sampleStreams = validStreams.toArray(new ChunkSampleStream[validStreams.size()]); + this.sequenceableLoader = new CompositeSequenceableLoader(sampleStreams); + return returnPositionUs; + } + + @Override + public long getBufferedPositionUs() { + super.getBufferedPositionUs(); + return sequenceableLoader.getBufferedPositionUs(); + } + + @Override + public long seekToUs(long positionUs) { + for (ChunkSampleStream sampleStream : sampleStreams) { + sampleStream.seekToUs(positionUs); + } + return super.seekToUs(positionUs); + } + + @Override + public long getNextLoadPositionUs() { + super.getNextLoadPositionUs(); + return sequenceableLoader.getNextLoadPositionUs(); + } + + @Override + public boolean continueLoading(long positionUs) { + super.continueLoading(positionUs); + return sequenceableLoader.continueLoading(positionUs); + } + + @Override + protected SampleStream createSampleStream(TrackSelection trackSelection) { + FakeChunkSource chunkSource = chunkSourceFactory.createChunkSource(trackSelection, durationUs); + return new ChunkSampleStream<>( + MimeTypes.getTrackType(trackSelection.getSelectedFormat().sampleMimeType), null, + chunkSource, this, allocator, 0, 3, eventDispatcher); + } + + @Override + public void onContinueLoadingRequested(ChunkSampleStream source) { + callback.onContinueLoadingRequested(this); + } + +} diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaSource.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaSource.java new file mode 100644 index 00000000000..59bcaf3e7c8 --- /dev/null +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaSource.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.testutil; + +import android.os.Handler; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.Timeline.Period; +import com.google.android.exoplayer2.source.AdaptiveMediaSourceEventListener; +import com.google.android.exoplayer2.source.AdaptiveMediaSourceEventListener.EventDispatcher; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.upstream.Allocator; + +/** + * Fake {@link MediaSource} that provides a given timeline. Creating the period returns a + * {@link FakeAdaptiveMediaPeriod} from the given {@link TrackGroupArray}. + */ +public class FakeAdaptiveMediaSource extends FakeMediaSource { + + private final EventDispatcher eventDispatcher; + private final FakeChunkSource.Factory chunkSourceFactory; + + public FakeAdaptiveMediaSource(Timeline timeline, Object manifest, + TrackGroupArray trackGroupArray, Handler eventHandler, + AdaptiveMediaSourceEventListener eventListener, FakeChunkSource.Factory chunkSourceFactory) { + super(timeline, manifest, trackGroupArray); + this.eventDispatcher = new EventDispatcher(eventHandler, eventListener); + this.chunkSourceFactory = chunkSourceFactory; + } + + @Override + protected FakeMediaPeriod createFakeMediaPeriod(MediaPeriodId id, TrackGroupArray trackGroupArray, + Allocator allocator) { + Period period = timeline.getPeriod(id.periodIndex, new Period()); + return new FakeAdaptiveMediaPeriod(trackGroupArray, eventDispatcher, allocator, + chunkSourceFactory, period.durationUs); + } + +} diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeChunkSource.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeChunkSource.java index 0c970caa15e..28f5926bfae 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeChunkSource.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeChunkSource.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.testutil; import android.net.Uri; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.source.chunk.Chunk; import com.google.android.exoplayer2.source.chunk.ChunkHolder; @@ -50,7 +51,8 @@ public Factory(FakeAdaptiveDataSet.Factory dataSetFactory, } public FakeChunkSource createChunkSource(TrackSelection trackSelection, long durationUs) { - FakeAdaptiveDataSet dataSet = dataSetFactory.createDataSet(trackSelection, durationUs); + FakeAdaptiveDataSet dataSet = + dataSetFactory.createDataSet(trackSelection.getTrackGroup(), durationUs); dataSourceFactory.setFakeDataSet(dataSet); DataSource dataSource = dataSourceFactory.createDataSource(); return new FakeChunkSource(trackSelection, dataSource, dataSet); @@ -80,9 +82,10 @@ public int getPreferredQueueSize(long playbackPositionUs, List= dataSet.getChunkCount()) { @@ -91,7 +94,8 @@ public void getNextChunk(MediaChunk previous, long playbackPositionUs, ChunkHold Format selectedFormat = trackSelection.getSelectedFormat(); long startTimeUs = dataSet.getStartTime(chunkIndex); long endTimeUs = startTimeUs + dataSet.getChunkDuration(chunkIndex); - String uri = dataSet.getUri(trackSelection.getSelectedIndex()); + int trackGroupIndex = trackSelection.getIndexInTrackGroup(trackSelection.getSelectedIndex()); + String uri = dataSet.getUri(trackGroupIndex); Segment fakeDataChunk = dataSet.getData(uri).getSegments().get(chunkIndex); DataSpec dataSpec = new DataSpec(Uri.parse(uri), fakeDataChunk.byteOffset, fakeDataChunk.length, null); diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeClock.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeClock.java index 36ce4b5c3e3..843e5858d85 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeClock.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeClock.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.testutil; +import android.os.Handler; import com.google.android.exoplayer2.util.Clock; import java.util.ArrayList; import java.util.List; @@ -26,6 +27,7 @@ public final class FakeClock implements Clock { private long currentTimeMs; private final List wakeUpTimes; + private final List handlerPosts; /** * Create {@link FakeClock} with an arbitrary initial timestamp. @@ -35,6 +37,7 @@ public final class FakeClock implements Clock { public FakeClock(long initialTimeMs) { this.currentTimeMs = initialTimeMs; this.wakeUpTimes = new ArrayList<>(); + this.handlerPosts = new ArrayList<>(); } /** @@ -50,10 +53,16 @@ public synchronized void advanceTime(long timeDiffMs) { break; } } + for (int i = handlerPosts.size() - 1; i >= 0; i--) { + if (handlerPosts.get(i).postTime <= currentTimeMs) { + HandlerPostData postData = handlerPosts.remove(i); + postData.handler.post(postData.runnable); + } + } } @Override - public long elapsedRealtime() { + public synchronized long elapsedRealtime() { return currentTimeMs; } @@ -74,5 +83,28 @@ public synchronized void sleep(long sleepTimeMs) { wakeUpTimes.remove(wakeUpTimeMs); } + @Override + public synchronized void postDelayed(Handler handler, Runnable runnable, long delayMs) { + if (delayMs <= 0) { + handler.post(runnable); + } else { + handlerPosts.add(new HandlerPostData(currentTimeMs + delayMs, handler, runnable)); + } + } + + private static final class HandlerPostData { + + public final long postTime; + public final Handler handler; + public final Runnable runnable; + + public HandlerPostData(long postTime, Handler handler, Runnable runnable) { + this.postTime = postTime; + this.handler = handler; + this.runnable = runnable; + } + + } + } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeDataSet.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeDataSet.java index 25802053614..e77e0714e7e 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeDataSet.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeDataSet.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.testutil; +import android.net.Uri; import android.support.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.upstream.DataSpec; @@ -28,11 +29,11 @@ /** * Collection of {@link FakeData} to be served by a {@link FakeDataSource}. * - *

Multiple fake data can be defined by {@link FakeDataSet#setData(String, byte[])} and {@link - * FakeDataSet#newData(String)} methods. It's also possible to define a default data by {@link + *

Multiple fake data can be defined by {@link FakeDataSet#setData(Uri, byte[])} and {@link + * FakeDataSet#newData(Uri)} methods. It's also possible to define a default data by {@link * FakeDataSet#newDefaultData()}. * - *

{@link FakeDataSet#newData(String)} and {@link FakeDataSet#newDefaultData()} return a {@link + *

{@link FakeDataSet#newData(Uri)} and {@link FakeDataSet#newDefaultData()} return a {@link * FakeData} instance which can be used to define specific results during * {@link FakeDataSource#read(byte[], int, int)} calls. * @@ -104,8 +105,8 @@ private Segment(Runnable action, Segment previousSegment) { this(null, 0, null, action, previousSegment); } - private Segment(byte[] data, int length, IOException exception, Runnable action, - Segment previousSegment) { + private Segment(@Nullable byte[] data, int length, @Nullable IOException exception, + @Nullable Runnable action, Segment previousSegment) { this.exception = exception; this.action = action; this.data = data; @@ -125,12 +126,12 @@ public boolean isActionSegment() { } /** Uri of the data or null if this is the default FakeData. */ - public final String uri; + public final Uri uri; private final ArrayList segments; private final FakeDataSet dataSet; private boolean simulateUnknownLength; - private FakeData(FakeDataSet dataSet, String uri) { + private FakeData(FakeDataSet dataSet, Uri uri) { this.uri = uri; this.segments = new ArrayList<>(); this.dataSet = dataSet; @@ -162,8 +163,8 @@ public FakeData appendReadData(byte[] data) { } /** - * Appends data of the specified length. No actual data is available and this data should not - * be read. + * Appends a data segment of the specified length. No actual data is available and the + * {@link FakeDataSource} will perform no copy operations when this data is read. */ public FakeData appendReadData(int length) { Assertions.checkState(length > 0); @@ -219,7 +220,7 @@ private Segment getLastSegment() { } - private final HashMap dataMap; + private final HashMap dataMap; private FakeData defaultData; public FakeDataSet() { @@ -234,16 +235,31 @@ public FakeData newDefaultData() { /** Sets random data with the given {@code length} for the given {@code uri}. */ public FakeDataSet setRandomData(String uri, int length) { + return setRandomData(Uri.parse(uri), length); + } + + /** Sets random data with the given {@code length} for the given {@code uri}. */ + public FakeDataSet setRandomData(Uri uri, int length) { return setData(uri, TestUtil.buildTestData(length)); } /** Sets the given {@code data} for the given {@code uri}. */ public FakeDataSet setData(String uri, byte[] data) { + return setData(Uri.parse(uri), data); + } + + /** Sets the given {@code data} for the given {@code uri}. */ + public FakeDataSet setData(Uri uri, byte[] data) { return newData(uri).appendReadData(data).endData(); } /** Returns a new {@link FakeData} with the given {@code uri}. */ public FakeData newData(String uri) { + return newData(Uri.parse(uri)); + } + + /** Returns a new {@link FakeData} with the given {@code uri}. */ + public FakeData newData(Uri uri) { FakeData data = new FakeData(this, uri); dataMap.put(uri, data); return data; @@ -251,6 +267,11 @@ public FakeData newData(String uri) { /** Returns the data for the given {@code uri}, or {@code defaultData} if no data is set. */ public FakeData getData(String uri) { + return getData(Uri.parse(uri)); + } + + /** Returns the data for the given {@code uri}, or {@code defaultData} if no data is set. */ + public FakeData getData(Uri uri) { FakeData data = dataMap.get(uri); return data != null ? data : defaultData; } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeDataSource.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeDataSource.java index 6180a8aa77a..2675e1f0d7d 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeDataSource.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeDataSource.java @@ -166,6 +166,7 @@ public final int read(byte[] buffer, int offset, int readLength) throws IOExcept // Do not allow crossing of the segment boundary. readLength = Math.min(readLength, current.length - current.bytesRead); // Perform the read and return. + Assertions.checkArgument(buffer.length - offset >= readLength); if (current.data != null) { System.arraycopy(current.data, current.bytesRead, buffer, offset, readLength); } @@ -216,7 +217,7 @@ public final DataSpec[] getAndClearOpenedDataSpecs() { return dataSpecs; } - protected void onDataRead(int bytesRead) { + protected void onDataRead(int bytesRead) throws IOException { // Do nothing. Can be overridden. } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java index d8e501a2984..38a5e37fa53 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java @@ -16,7 +16,6 @@ package com.google.android.exoplayer2.testutil; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.SampleStream; import com.google.android.exoplayer2.source.TrackGroup; @@ -26,10 +25,10 @@ import junit.framework.Assert; /** - * Fake {@link MediaPeriod} that provides one track with a given {@link Format}. Selecting that - * track will give the player a {@link FakeSampleStream}. + * Fake {@link MediaPeriod} that provides tracks from the given {@link TrackGroupArray}. Selecting + * tracks will give the player {@link FakeSampleStream}s. */ -public final class FakeMediaPeriod implements MediaPeriod { +public class FakeMediaPeriod implements MediaPeriod { private final TrackGroupArray trackGroupArray; @@ -46,7 +45,6 @@ public void release() { @Override public void prepare(Callback callback, long positionUs) { Assert.assertFalse(preparedPeriod); - Assert.assertEquals(0, positionUs); preparedPeriod = true; callback.onPrepared(this); } @@ -71,8 +69,6 @@ public long selectTracks(TrackSelection[] selections, boolean[] mayRetainStreamF if (streams[i] != null && (selections[i] == null || !mayRetainStreamFlags[i])) { streams[i] = null; } - } - for (int i = 0; i < rendererCount; i++) { if (streams[i] == null && selections[i] != null) { TrackSelection selection = selections[i]; Assert.assertTrue(1 <= selection.length()); @@ -81,11 +77,11 @@ public long selectTracks(TrackSelection[] selections, boolean[] mayRetainStreamF int indexInTrackGroup = selection.getIndexInTrackGroup(selection.getSelectedIndex()); Assert.assertTrue(0 <= indexInTrackGroup); Assert.assertTrue(indexInTrackGroup < trackGroup.length); - streams[i] = new FakeSampleStream(selection.getSelectedFormat()); + streams[i] = createSampleStream(selection); streamResetFlags[i] = true; } } - return 0; + return positionUs; } @Override @@ -123,4 +119,8 @@ public boolean continueLoading(long positionUs) { return false; } + protected SampleStream createSampleStream(TrackSelection selection) { + return new FakeSampleStream(selection.getSelectedFormat()); + } + } 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 93134bf3122..1f2524110a9 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 @@ -34,10 +34,11 @@ */ public class FakeMediaSource implements MediaSource { - private final Timeline timeline; + protected final Timeline timeline; private final Object manifest; private final TrackGroupArray trackGroupArray; private final ArrayList activeMediaPeriods; + private final ArrayList createdMediaPeriods; private boolean preparedSource; private boolean releasedSource; @@ -58,18 +59,15 @@ public FakeMediaSource(Timeline timeline, Object manifest, TrackGroupArray track this.timeline = timeline; this.manifest = manifest; this.activeMediaPeriods = new ArrayList<>(); + this.createdMediaPeriods = new ArrayList<>(); this.trackGroupArray = trackGroupArray; } - public void assertReleased() { - Assert.assertTrue(releasedSource); - } - @Override public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) { Assert.assertFalse(preparedSource); preparedSource = true; - listener.onSourceInfoRefreshed(timeline, manifest); + listener.onSourceInfoRefreshed(this, timeline, manifest); } @Override @@ -82,8 +80,9 @@ public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { Assertions.checkIndex(id.periodIndex, 0, timeline.getPeriodCount()); Assert.assertTrue(preparedSource); Assert.assertFalse(releasedSource); - FakeMediaPeriod mediaPeriod = new FakeMediaPeriod(trackGroupArray); + FakeMediaPeriod mediaPeriod = createFakeMediaPeriod(id, trackGroupArray, allocator); activeMediaPeriods.add(mediaPeriod); + createdMediaPeriods.add(id); return mediaPeriod; } @@ -104,6 +103,25 @@ public void releaseSource() { releasedSource = true; } + /** + * Assert that the source and all periods have been released. + */ + public void assertReleased() { + Assert.assertTrue(releasedSource); + } + + /** + * Assert that a media period for the given id has been created. + */ + public void assertMediaPeriodCreated(MediaPeriodId mediaPeriodId) { + Assert.assertTrue(createdMediaPeriods.contains(mediaPeriodId)); + } + + protected FakeMediaPeriod createFakeMediaPeriod(MediaPeriodId id, TrackGroupArray trackGroupArray, + Allocator allocator) { + return new FakeMediaPeriod(trackGroupArray); + } + private static TrackGroupArray buildTrackGroupArray(Format... formats) { TrackGroup[] trackGroups = new TrackGroup[formats.length]; for (int i = 0; i < formats.length; i++) { diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeRenderer.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeRenderer.java index a66043b77ff..c4270eb9c47 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeRenderer.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeRenderer.java @@ -59,6 +59,7 @@ protected void onPositionReset(long positionUs, boolean joining) throws ExoPlayb @Override public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { if (!isEnded) { + buffer.clear(); // Verify the format matches the expected format. FormatHolder formatHolder = new FormatHolder(); int result = readSource(formatHolder, buffer, false); diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeSampleStream.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeSampleStream.java index 4e1e32980fe..699b850f73f 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeSampleStream.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeSampleStream.java @@ -60,8 +60,8 @@ public void maybeThrowError() throws IOException { } @Override - public void skipData(long positionUs) { - // Do nothing. + public int skipData(long positionUs) { + return 0; } } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeShuffleOrder.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeShuffleOrder.java new file mode 100644 index 00000000000..0664f470231 --- /dev/null +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeShuffleOrder.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.testutil; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.source.ShuffleOrder; + +/** + * Fake {@link ShuffleOrder} which returns a reverse order. This order is thus deterministic but + * different from the original order. + */ +public final class FakeShuffleOrder implements ShuffleOrder { + + private final int length; + + public FakeShuffleOrder(int length) { + this.length = length; + } + + @Override + public int getLength() { + return length; + } + + @Override + public int getNextIndex(int index) { + return index > 0 ? index - 1 : C.INDEX_UNSET; + } + + @Override + public int getPreviousIndex(int index) { + return index < length - 1 ? index + 1 : C.INDEX_UNSET; + } + + @Override + public int getLastIndex() { + return length > 0 ? 0 : C.INDEX_UNSET; + } + + @Override + public int getFirstIndex() { + return length > 0 ? length - 1 : C.INDEX_UNSET; + } + + @Override + public ShuffleOrder cloneAndInsert(int insertionIndex, int insertionCount) { + return new FakeShuffleOrder(length + insertionCount); + } + + @Override + public ShuffleOrder cloneAndRemove(int removalIndex) { + return new FakeShuffleOrder(length - 1); + } + +} diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeSimpleExoPlayer.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeSimpleExoPlayer.java new file mode 100644 index 00000000000..4d53a6c89de --- /dev/null +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeSimpleExoPlayer.java @@ -0,0 +1,575 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.testutil; + +import android.os.ConditionVariable; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.LoadControl; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Renderer; +import com.google.android.exoplayer2.RendererCapabilities; +import com.google.android.exoplayer2.RenderersFactory; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.Timeline.Period; +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.SampleStream; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.trackselection.TrackSelector; +import com.google.android.exoplayer2.trackselection.TrackSelector.InvalidationListener; +import com.google.android.exoplayer2.trackselection.TrackSelectorResult; +import com.google.android.exoplayer2.util.Assertions; +import java.util.Arrays; +import java.util.concurrent.CopyOnWriteArraySet; + +/** + * Fake {@link SimpleExoPlayer} which runs a simplified copy of the playback loop as fast as + * possible without waiting. It does only support single period timelines and does not support + * updates during playback (like seek, timeline changes, repeat mode changes). + */ +public class FakeSimpleExoPlayer extends SimpleExoPlayer { + + private FakeExoPlayer player; + + public FakeSimpleExoPlayer(RenderersFactory renderersFactory, TrackSelector trackSelector, + LoadControl loadControl, FakeClock clock) { + super (renderersFactory, trackSelector, loadControl); + player.setFakeClock(clock); + } + + @Override + protected ExoPlayer createExoPlayerImpl(Renderer[] renderers, TrackSelector trackSelector, + LoadControl loadControl) { + this.player = new FakeExoPlayer(renderers, trackSelector, loadControl); + return player; + } + + private static class FakeExoPlayer implements ExoPlayer, MediaSource.Listener, + MediaPeriod.Callback, Runnable { + + private final Renderer[] renderers; + private final TrackSelector trackSelector; + private final LoadControl loadControl; + private final CopyOnWriteArraySet eventListeners; + private final HandlerThread playbackThread; + private final Handler playbackHandler; + private final Handler eventListenerHandler; + + private FakeClock clock; + private MediaSource mediaSource; + private Timeline timeline; + private Object manifest; + private MediaPeriod mediaPeriod; + private TrackSelectorResult selectorResult; + + private boolean isStartingUp; + private boolean isLoading; + private int playbackState; + private long rendererPositionUs; + private long durationUs; + private volatile long currentPositionMs; + private volatile long bufferedPositionMs; + + public FakeExoPlayer(Renderer[] renderers, TrackSelector trackSelector, + LoadControl loadControl) { + this.renderers = renderers; + this.trackSelector = trackSelector; + this.loadControl = loadControl; + this.eventListeners = new CopyOnWriteArraySet<>(); + Looper eventListenerLooper = Looper.myLooper(); + this.eventListenerHandler = new Handler(eventListenerLooper != null ? eventListenerLooper + : Looper.getMainLooper()); + this.playbackThread = new HandlerThread("FakeExoPlayer Thread"); + playbackThread.start(); + this.playbackHandler = new Handler(playbackThread.getLooper()); + this.isStartingUp = true; + this.isLoading = false; + this.playbackState = Player.STATE_IDLE; + this.durationUs = C.TIME_UNSET; + } + + public void setFakeClock(FakeClock clock) { + this.clock = clock; + } + + @Override + public void addListener(Player.EventListener listener) { + eventListeners.add(listener); + } + + @Override + public void removeListener(Player.EventListener listener) { + eventListeners.remove(listener); + } + + @Override + public int getPlaybackState() { + return playbackState; + } + + @Override + public void setPlayWhenReady(boolean playWhenReady) { + if (!playWhenReady) { + throw new UnsupportedOperationException(); + } + } + + @Override + public boolean getPlayWhenReady() { + return true; + } + + @Override + public void setRepeatMode(@RepeatMode int repeatMode) { + throw new UnsupportedOperationException(); + } + + @Override + public int getRepeatMode() { + return Player.REPEAT_MODE_OFF; + } + + @Override + public void setShuffleModeEnabled(boolean shuffleModeEnabled) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean getShuffleModeEnabled() { + return false; + } + + @Override + public boolean isLoading() { + return isLoading; + } + + @Override + public void seekToDefaultPosition() { + throw new UnsupportedOperationException(); + } + + @Override + public void seekToDefaultPosition(int windowIndex) { + throw new UnsupportedOperationException(); + } + + @Override + public void seekTo(long positionMs) { + throw new UnsupportedOperationException(); + } + + @Override + public void seekTo(int windowIndex, long positionMs) { + throw new UnsupportedOperationException(); + } + + @Override + public void setPlaybackParameters(@Nullable PlaybackParameters playbackParameters) { + throw new UnsupportedOperationException(); + } + + @Override + public PlaybackParameters getPlaybackParameters() { + return PlaybackParameters.DEFAULT; + } + + @Override + public void stop() { + playbackHandler.post(new Runnable() { + @Override + public void run () { + playbackHandler.removeCallbacksAndMessages(null); + releaseMedia(); + changePlaybackState(Player.STATE_IDLE); + } + }); + } + + @Override + @SuppressWarnings("ThreadJoinLoop") + public void release() { + stop(); + playbackHandler.post(new Runnable() { + @Override + public void run () { + playbackHandler.removeCallbacksAndMessages(null); + playbackThread.quit(); + } + }); + while (playbackThread.isAlive()) { + try { + playbackThread.join(); + } catch (InterruptedException e) { + // Ignore interrupt. + } + } + } + + @Override + public int getRendererCount() { + return renderers.length; + } + + @Override + public int getRendererType(int index) { + return renderers[index].getTrackType(); + } + + @Override + public TrackGroupArray getCurrentTrackGroups() { + return selectorResult != null ? selectorResult.groups : null; + } + + @Override + public TrackSelectionArray getCurrentTrackSelections() { + return selectorResult != null ? selectorResult.selections : null; + } + + @Nullable + @Override + public Object getCurrentManifest() { + return manifest; + } + + @Override + public Timeline getCurrentTimeline() { + return timeline; + } + + @Override + public int getCurrentPeriodIndex() { + return 0; + } + + @Override + public int getCurrentWindowIndex() { + return 0; + } + + @Override + public int getNextWindowIndex() { + return C.INDEX_UNSET; + } + + @Override + public int getPreviousWindowIndex() { + return C.INDEX_UNSET; + } + + @Override + public long getDuration() { + return C.usToMs(durationUs); + } + + @Override + public long getCurrentPosition() { + return currentPositionMs; + } + + @Override + public long getBufferedPosition() { + return bufferedPositionMs == C.TIME_END_OF_SOURCE ? getDuration() : bufferedPositionMs; + } + + @Override + public int getBufferedPercentage() { + long duration = getDuration(); + return duration == C.TIME_UNSET ? 0 : (int) (getBufferedPosition() * 100 / duration); + } + + @Override + public boolean isCurrentWindowDynamic() { + return false; + } + + @Override + public boolean isCurrentWindowSeekable() { + return false; + } + + @Override + public boolean isPlayingAd() { + return false; + } + + @Override + public int getCurrentAdGroupIndex() { + return 0; + } + + @Override + public int getCurrentAdIndexInAdGroup() { + return 0; + } + + @Override + public long getContentPosition() { + return getCurrentPosition(); + } + + @Override + public Looper getPlaybackLooper() { + return playbackThread.getLooper(); + } + + @Override + public void prepare(MediaSource mediaSource) { + prepare(mediaSource, true, true); + } + + @Override + public void prepare(final MediaSource mediaSource, boolean resetPosition, boolean resetState) { + if (!resetPosition || !resetState) { + throw new UnsupportedOperationException(); + } + this.mediaSource = mediaSource; + playbackHandler.post(new Runnable() { + @Override + public void run() { + mediaSource.prepareSource(FakeExoPlayer.this, true, FakeExoPlayer.this); + } + }); + } + + @Override + public void sendMessages(ExoPlayerMessage... messages) { + throw new UnsupportedOperationException(); + } + + @Override + public void blockingSendMessages(ExoPlayerMessage... messages) { + throw new UnsupportedOperationException(); + } + + // MediaSource.Listener + + @Override + public void onSourceInfoRefreshed(MediaSource source, final Timeline timeline, + final @Nullable Object manifest) { + if (this.timeline != null) { + throw new UnsupportedOperationException(); + } + Assertions.checkArgument(timeline.getPeriodCount() == 1); + Assertions.checkArgument(timeline.getWindowCount() == 1); + final ConditionVariable waitForNotification = new ConditionVariable(); + eventListenerHandler.post(new Runnable() { + @Override + public void run() { + for (Player.EventListener eventListener : eventListeners) { + FakeExoPlayer.this.durationUs = timeline.getPeriod(0, new Period()).durationUs; + FakeExoPlayer.this.timeline = timeline; + FakeExoPlayer.this.manifest = manifest; + eventListener.onTimelineChanged(timeline, manifest); + waitForNotification.open(); + } + } + }); + waitForNotification.block(); + this.mediaPeriod = mediaSource.createPeriod(new MediaPeriodId(0), loadControl.getAllocator()); + mediaPeriod.prepare(this, 0); + } + + // MediaPeriod.Callback + + @Override + public void onContinueLoadingRequested(MediaPeriod source) { + maybeContinueLoading(); + } + + @Override + public void onPrepared(MediaPeriod mediaPeriod) { + try { + initializePlaybackLoop(); + } catch (ExoPlaybackException e) { + handlePlayerError(e); + } + } + + // Runnable (Playback loop). + + @Override + public void run() { + try { + maybeContinueLoading(); + boolean allRenderersEnded = true; + boolean allRenderersReadyOrEnded = true; + if (playbackState == Player.STATE_READY) { + for (Renderer renderer : renderers) { + renderer.render(rendererPositionUs, C.msToUs(clock.elapsedRealtime())); + if (!renderer.isEnded()) { + allRenderersEnded = false; + } + if (!(renderer.isReady() || renderer.isEnded())) { + allRenderersReadyOrEnded = false; + } + } + } + if (rendererPositionUs >= durationUs && allRenderersEnded) { + changePlaybackState(Player.STATE_ENDED); + return; + } + long bufferedPositionUs = mediaPeriod.getBufferedPositionUs(); + if (playbackState == Player.STATE_BUFFERING && allRenderersReadyOrEnded + && haveSufficientBuffer(!isStartingUp, rendererPositionUs, bufferedPositionUs)) { + changePlaybackState(Player.STATE_READY); + isStartingUp = false; + } else if (playbackState == Player.STATE_READY && !allRenderersReadyOrEnded) { + changePlaybackState(Player.STATE_BUFFERING); + } + // Advance simulated time by 10ms. + clock.advanceTime(10); + if (playbackState == Player.STATE_READY) { + rendererPositionUs += 10000; + } + this.currentPositionMs = C.usToMs(rendererPositionUs); + this.bufferedPositionMs = C.usToMs(bufferedPositionUs); + playbackHandler.post(this); + } catch (ExoPlaybackException e) { + handlePlayerError(e); + } + } + + // Internal logic + + private void initializePlaybackLoop() throws ExoPlaybackException { + Assertions.checkNotNull(clock); + trackSelector.init(new InvalidationListener() { + @Override + public void onTrackSelectionsInvalidated() { + throw new IllegalStateException(); + } + }); + RendererCapabilities[] rendererCapabilities = new RendererCapabilities[renderers.length]; + for (int i = 0; i < renderers.length; i++) { + rendererCapabilities[i] = renderers[i].getCapabilities(); + } + selectorResult = trackSelector.selectTracks(rendererCapabilities, + mediaPeriod.getTrackGroups()); + SampleStream[] sampleStreams = new SampleStream[renderers.length]; + boolean[] mayRetainStreamFlags = new boolean[renderers.length]; + Arrays.fill(mayRetainStreamFlags, true); + mediaPeriod.selectTracks(selectorResult.selections.getAll(), mayRetainStreamFlags, + sampleStreams, new boolean[renderers.length], 0); + eventListenerHandler.post(new Runnable() { + @Override + public void run() { + for (Player.EventListener eventListener : eventListeners) { + eventListener.onTracksChanged(selectorResult.groups, selectorResult.selections); + } + } + }); + + loadControl.onPrepared(); + loadControl.onTracksSelected(renderers, selectorResult.groups, selectorResult.selections); + + for (int i = 0; i < renderers.length; i++) { + TrackSelection selection = selectorResult.selections.get(i); + Format[] formats = new Format[selection.length()]; + for (int j = 0; j < formats.length; j++) { + formats[j] = selection.getFormat(j); + } + renderers[i].enable(selectorResult.rendererConfigurations[i], formats, sampleStreams[i], 0, + false, 0); + renderers[i].setCurrentStreamFinal(); + } + + rendererPositionUs = 0; + changePlaybackState(Player.STATE_BUFFERING); + playbackHandler.post(this); + } + + private void maybeContinueLoading() { + boolean newIsLoading = false; + long nextLoadPositionUs = mediaPeriod.getNextLoadPositionUs(); + if (nextLoadPositionUs != C.TIME_END_OF_SOURCE) { + long bufferedDurationUs = nextLoadPositionUs - rendererPositionUs; + if (loadControl.shouldContinueLoading(bufferedDurationUs)) { + newIsLoading = true; + mediaPeriod.continueLoading(rendererPositionUs); + } + } + if (newIsLoading != isLoading) { + isLoading = newIsLoading; + eventListenerHandler.post(new Runnable() { + @Override + public void run() { + for (Player.EventListener eventListener : eventListeners) { + eventListener.onLoadingChanged(isLoading); + } + } + }); + } + } + + private boolean haveSufficientBuffer(boolean rebuffering, long rendererPositionUs, + long bufferedPositionUs) { + if (bufferedPositionUs == C.TIME_END_OF_SOURCE) { + return true; + } + return loadControl.shouldStartPlayback(bufferedPositionUs - rendererPositionUs, rebuffering); + } + + private void handlePlayerError(final ExoPlaybackException e) { + eventListenerHandler.post(new Runnable() { + @Override + public void run() { + for (Player.EventListener listener : eventListeners) { + listener.onPlayerError(e); + } + } + }); + changePlaybackState(Player.STATE_ENDED); + } + + private void changePlaybackState(final int playbackState) { + this.playbackState = playbackState; + eventListenerHandler.post(new Runnable() { + @Override + public void run() { + for (Player.EventListener listener : eventListeners) { + listener.onPlayerStateChanged(true, playbackState); + } + } + }); + } + + private void releaseMedia() { + if (mediaSource != null) { + if (mediaPeriod != null) { + mediaSource.releasePeriod(mediaPeriod); + mediaPeriod = null; + } + mediaSource.releaseSource(); + mediaSource = null; + } + } + + } + +} diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java index 040782264b4..2937ee27708 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java @@ -18,6 +18,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.util.Util; +import java.util.Arrays; /** * Fake {@link Timeline} which can be setup to return custom {@link TimelineWindowDefinition}s. @@ -36,6 +37,8 @@ public static final class TimelineWindowDefinition { public final boolean isSeekable; public final boolean isDynamic; public final long durationUs; + public final int adGroupsPerPeriodCount; + public final int adsPerAdGroupCount; public TimelineWindowDefinition(int periodCount, Object id) { this(periodCount, id, true, false, WINDOW_DURATION_US); @@ -47,15 +50,24 @@ public TimelineWindowDefinition(boolean isSeekable, boolean isDynamic, long dura public TimelineWindowDefinition(int periodCount, Object id, boolean isSeekable, boolean isDynamic, long durationUs) { + this(periodCount, id, isSeekable, isDynamic, durationUs, 0, 0); + } + + public TimelineWindowDefinition(int periodCount, Object id, boolean isSeekable, + boolean isDynamic, long durationUs, int adGroupsCountPerPeriod, int adsPerAdGroupCount) { this.periodCount = periodCount; this.id = id; this.isSeekable = isSeekable; this.isDynamic = isDynamic; this.durationUs = durationUs; + this.adGroupsPerPeriodCount = adGroupsCountPerPeriod; + this.adsPerAdGroupCount = adsPerAdGroupCount; } } + private static final long AD_DURATION_US = 10 * C.MICROS_PER_SECOND; + private final TimelineWindowDefinition[] windowDefinitions; private final int[] periodOffsets; @@ -96,7 +108,28 @@ public Period getPeriod(int periodIndex, Period period, boolean setIds) { Object id = setIds ? windowPeriodIndex : null; Object uid = setIds ? periodIndex : null; long periodDurationUs = windowDefinition.durationUs / windowDefinition.periodCount; - return period.set(id, uid, windowIndex, periodDurationUs, periodDurationUs * windowPeriodIndex); + long positionInWindowUs = periodDurationUs * windowPeriodIndex; + if (windowDefinition.adGroupsPerPeriodCount == 0) { + return period.set(id, uid, windowIndex, periodDurationUs, positionInWindowUs); + } else { + int adGroups = windowDefinition.adGroupsPerPeriodCount; + long[] adGroupTimesUs = new long[adGroups]; + int[] adCounts = new int[adGroups]; + int[] adLoadedAndPlayedCounts = new int[adGroups]; + long[][] adDurationsUs = new long[adGroups][]; + long adResumePositionUs = 0; + long adGroupOffset = adGroups > 1 ? periodDurationUs / (adGroups - 1) : 0; + for (int i = 0; i < adGroups; i++) { + adGroupTimesUs[i] = i * adGroupOffset; + adCounts[i] = windowDefinition.adsPerAdGroupCount; + adLoadedAndPlayedCounts[i] = 0; + adDurationsUs[i] = new long[adCounts[i]]; + Arrays.fill(adDurationsUs[i], AD_DURATION_US); + } + return period.set(id, uid, windowIndex, periodDurationUs, positionInWindowUs, adGroupTimesUs, + adCounts, adLoadedAndPlayedCounts, adLoadedAndPlayedCounts, adDurationsUs, + adResumePositionUs); + } } @Override diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/HostActivity.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/HostActivity.java index 831344aa8be..1ef1acd80bf 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/HostActivity.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/HostActivity.java @@ -17,14 +17,12 @@ import static junit.framework.Assert.fail; -import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; import android.net.wifi.WifiManager; import android.net.wifi.WifiManager.WifiLock; import android.os.Bundle; import android.os.ConditionVariable; -import android.os.Handler; import android.os.PowerManager; import android.os.PowerManager.WakeLock; import android.util.Log; @@ -57,19 +55,20 @@ public interface HostedTest { void onStart(HostActivity host, Surface surface); /** - * Called on the main thread to check whether the test is ready to be stopped. + * Called on the main thread to block until the test has stopped or {@link #forceStop()} is + * called. * - * @return Whether the test is ready to be stopped. + * @param timeoutMs The maximum time to block in milliseconds. + * @return Whether the test has stopped successful. */ - boolean canStop(); + boolean blockUntilStopped(long timeoutMs); /** - * Called on the main thread when the test is stopped. - *

- * The test will be stopped if {@link #canStop()} returns true, if the {@link HostActivity} has - * been paused, or if the {@link HostActivity}'s {@link Surface} has been destroyed. + * Called on the main thread to force stop the test (if it is not stopped already). + * + * @return Whether the test was forced stopped. */ - void onStop(); + boolean forceStop(); /** * Called on the test thread after the test has finished and been stopped. @@ -85,13 +84,11 @@ public interface HostedTest { private WakeLock wakeLock; private WifiLock wifiLock; private SurfaceView surfaceView; - private Handler mainHandler; - private CheckCanStopRunnable checkCanStopRunnable; private HostedTest hostedTest; - private ConditionVariable hostedTestStoppedCondition; private boolean hostedTestStarted; - private boolean hostedTestFinished; + private ConditionVariable hostedTestStartedCondition; + private boolean forcedStopped; /** * Executes a {@link HostedTest} inside the host. @@ -100,7 +97,7 @@ public interface HostedTest { * @param timeoutMs The number of milliseconds to wait for the test to finish. If the timeout * is exceeded then the test will fail. */ - public void runTest(final HostedTest hostedTest, long timeoutMs) { + public void runTest(HostedTest hostedTest, long timeoutMs) { runTest(hostedTest, timeoutMs, true); } @@ -114,40 +111,46 @@ public void runTest(final HostedTest hostedTest, long timeoutMs) { public void runTest(final HostedTest hostedTest, long timeoutMs, boolean failOnTimeout) { Assertions.checkArgument(timeoutMs > 0); Assertions.checkState(Thread.currentThread() != getMainLooper().getThread()); - Assertions.checkState(this.hostedTest == null); - this.hostedTest = Assertions.checkNotNull(hostedTest); - hostedTestStoppedCondition = new ConditionVariable(); + Assertions.checkNotNull(hostedTest); + hostedTestStartedCondition = new ConditionVariable(); + forcedStopped = false; hostedTestStarted = false; - hostedTestFinished = false; runOnUiThread(new Runnable() { @Override public void run() { + HostActivity.this.hostedTest = hostedTest; maybeStartHostedTest(); } }); + hostedTestStartedCondition.block(); - if (hostedTestStoppedCondition.block(timeoutMs)) { - if (hostedTestFinished) { - Log.d(TAG, "Test finished. Checking pass conditions."); + if (hostedTest.blockUntilStopped(timeoutMs)) { + if (!forcedStopped) { + Log.d(TAG, "Checking test pass conditions."); hostedTest.onFinished(); Log.d(TAG, "Pass conditions checked."); } else { - String message = "Test released before it finished. Activity may have been paused whilst " + String message = "Test force stopped. Activity may have been paused whilst " + "test was in progress."; Log.e(TAG, message); fail(message); } } else { + runOnUiThread(new Runnable() { + @Override + public void run() { + hostedTest.forceStop(); + } + }); String message = "Test timed out after " + timeoutMs + " ms."; Log.e(TAG, message); if (failOnTimeout) { fail(message); } - maybeStopHostedTest(); - hostedTestStoppedCondition.block(); } + this.hostedTest = null; } // Activity lifecycle @@ -157,18 +160,16 @@ public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); requestWindowFeature(Window.FEATURE_NO_TITLE); setContentView(getResources().getIdentifier("host_activity", "layout", getPackageName())); - surfaceView = (SurfaceView) findViewById( + surfaceView = findViewById( getResources().getIdentifier("surface_view", "id", getPackageName())); surfaceView.getHolder().addCallback(this); - mainHandler = new Handler(); - checkCanStopRunnable = new CheckCanStopRunnable(); } @Override public void onStart() { Context appContext = getApplicationContext(); WifiManager wifiManager = (WifiManager) appContext.getSystemService(Context.WIFI_SERVICE); - wifiLock = wifiManager.createWifiLock(getWifiLockMode(), TAG); + wifiLock = wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, TAG); wifiLock.acquire(); PowerManager powerManager = (PowerManager) appContext.getSystemService(Context.POWER_SERVICE); wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG); @@ -176,21 +177,20 @@ public void onStart() { super.onStart(); } - @Override - public void onResume() { - super.onResume(); - maybeStartHostedTest(); - } - @Override public void onPause() { super.onPause(); - maybeStopHostedTest(); + if (Util.SDK_INT <= 23) { + maybeStopHostedTest(); + } } @Override public void onStop() { super.onStop(); + if (Util.SDK_INT > 23) { + maybeStopHostedTest(); + } wakeLock.release(); wakeLock = null; wifiLock.release(); @@ -225,50 +225,14 @@ private void maybeStartHostedTest() { hostedTestStarted = true; Log.d(TAG, "Starting test."); hostedTest.onStart(this, surface); - checkCanStopRunnable.startChecking(); + hostedTestStartedCondition.open(); } } private void maybeStopHostedTest() { - if (hostedTest != null && hostedTestStarted) { - hostedTest.onStop(); - hostedTest = null; - mainHandler.removeCallbacks(checkCanStopRunnable); - // We post opening of the stopped condition so that any events posted to the main thread as a - // result of hostedTest.onStop() are guaranteed to be handled before hostedTest.onFinished() - // is called from runTest. - mainHandler.post(new Runnable() { - @Override - public void run() { - hostedTestStoppedCondition.open(); - } - }); - } - } - - @SuppressLint("InlinedApi") - private static int getWifiLockMode() { - return Util.SDK_INT < 12 ? WifiManager.WIFI_MODE_FULL : WifiManager.WIFI_MODE_FULL_HIGH_PERF; - } - - private final class CheckCanStopRunnable implements Runnable { - - private static final long CHECK_INTERVAL_MS = 1000; - - private void startChecking() { - mainHandler.post(this); - } - - @Override - public void run() { - if (hostedTest.canStop()) { - hostedTestFinished = true; - maybeStopHostedTest(); - } else { - mainHandler.postDelayed(this, CHECK_INTERVAL_MS); - } + if (hostedTest != null && hostedTestStarted && !forcedStopped) { + forcedStopped = hostedTest.forceStop(); } - } } diff --git a/library/core/src/androidTest/java/com/google/android/exoplayer2/extractor/ogg/TestData.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/OggTestData.java similarity index 99% rename from library/core/src/androidTest/java/com/google/android/exoplayer2/extractor/ogg/TestData.java rename to testutils/src/main/java/com/google/android/exoplayer2/testutil/OggTestData.java index cef033bf17e..88b5de7f65d 100644 --- a/library/core/src/androidTest/java/com/google/android/exoplayer2/extractor/ogg/TestData.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/OggTestData.java @@ -13,19 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer2.extractor.ogg; +package com.google.android.exoplayer2.testutil; -import com.google.android.exoplayer2.testutil.FakeExtractorInput; -import com.google.android.exoplayer2.testutil.TestUtil; /** * Provides ogg/vorbis test data in bytes for unit tests. */ -/* package */ final class TestData { +public final class OggTestData { - /* package */ static FakeExtractorInput createInput(byte[] data, boolean simulateUnkownLength) { + public static FakeExtractorInput createInput(byte[] data, boolean simulateUnknownLength) { return new FakeExtractorInput.Builder().setData(data).setSimulateIOErrors(true) - .setSimulateUnknownLength(simulateUnkownLength).setSimulatePartialReads(true).build(); + .setSimulateUnknownLength(simulateUnknownLength).setSimulatePartialReads(true).build(); } public static byte[] buildOggHeader(int headerType, long granule, int pageSequenceCounter, diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java index 2e59b33c0b6..61d1ecaeea7 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java @@ -16,7 +16,7 @@ package com.google.android.exoplayer2.testutil; import android.app.Instrumentation; -import android.test.InstrumentationTestCase; +import android.content.Context; import android.test.MoreAsserts; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Timeline; @@ -33,7 +33,6 @@ import java.util.Arrays; import java.util.Random; import junit.framework.Assert; -import org.mockito.MockitoAnnotations; /** * Utility methods for tests. @@ -121,21 +120,22 @@ public static byte[] joinByteArrays(byte[]... byteArrays) { return joined; } - public static void setUpMockito(InstrumentationTestCase instrumentationTestCase) { - // Workaround for https://code.google.com/p/dexmaker/issues/detail?id=2. - System.setProperty("dexmaker.dexcache", - instrumentationTestCase.getInstrumentation().getTargetContext().getCacheDir().getPath()); - MockitoAnnotations.initMocks(instrumentationTestCase); - } - public static byte[] getByteArray(Instrumentation instrumentation, String fileName) throws IOException { - return Util.toByteArray(getInputStream(instrumentation, fileName)); + return getByteArray(instrumentation.getContext(), fileName); + } + + public static byte[] getByteArray(Context context, String fileName) throws IOException { + return Util.toByteArray(getInputStream(context, fileName)); } public static InputStream getInputStream(Instrumentation instrumentation, String fileName) throws IOException { - return instrumentation.getContext().getResources().getAssets().open(fileName); + return getInputStream(instrumentation.getContext(), fileName); + } + + public static InputStream getInputStream(Context context, String fileName) throws IOException { + return context.getResources().getAssets().open(fileName); } public static String getString(Instrumentation instrumentation, String fileName) @@ -150,7 +150,8 @@ public static Timeline extractTimelineFromMediaSource(MediaSource mediaSource) { class TimelineListener implements Listener { private Timeline timeline; @Override - public synchronized void onSourceInfoRefreshed(Timeline timeline, Object manifest) { + public synchronized void onSourceInfoRefreshed(MediaSource source, Timeline timeline, + Object manifest) { this.timeline = timeline; this.notify(); } @@ -175,13 +176,15 @@ public synchronized void onSourceInfoRefreshed(Timeline timeline, Object manifes * @param dataSource The {@link DataSource} through which to read. * @param dataSpec The {@link DataSpec} to use when opening the {@link DataSource}. * @param expectedData The expected data. + * @param expectKnownLength Whether to assert that {@link DataSource#open} returns the expected + * data length. If false then it's asserted that {@link C#LENGTH_UNSET} is returned. * @throws IOException If an error occurs reading fom the {@link DataSource}. */ public static void assertDataSourceContent(DataSource dataSource, DataSpec dataSpec, - byte[] expectedData) throws IOException { + byte[] expectedData, boolean expectKnownLength) throws IOException { try { long length = dataSource.open(dataSpec); - Assert.assertEquals(expectedData.length, length); + Assert.assertEquals(expectKnownLength ? expectedData.length : C.LENGTH_UNSET, length); byte[] readData = TestUtil.readToEnd(dataSource); MoreAsserts.assertEquals(expectedData, readData); } finally { diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TimelineAsserts.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TimelineAsserts.java index 8357ce70c7f..b1df8f62e15 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TimelineAsserts.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TimelineAsserts.java @@ -16,12 +16,19 @@ package com.google.android.exoplayer2.testutil; import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertTrue; +import android.os.ConditionVariable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Period; import com.google.android.exoplayer2.Timeline.Window; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaPeriod.Callback; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; /** * Unit test for {@link Timeline}. @@ -36,6 +43,10 @@ private TimelineAsserts() {} public static void assertEmpty(Timeline timeline) { assertWindowIds(timeline); assertPeriodCounts(timeline); + for (boolean shuffled : new boolean[] {false, true}) { + assertEquals(C.INDEX_UNSET, timeline.getFirstWindowIndex(shuffled)); + assertEquals(C.INDEX_UNSET, timeline.getLastWindowIndex(shuffled)); + } } /** @@ -56,7 +67,7 @@ public static void assertWindowIds(Timeline timeline, Object... expectedWindowId } /** - * Asserts that window properties {@link Window}.isDynamic are set correctly.. + * Asserts that window properties {@link Window}.isDynamic are set correctly. */ public static void assertWindowIsDynamic(Timeline timeline, boolean... windowIsDynamic) { Window window = new Window(); @@ -67,33 +78,34 @@ public static void assertWindowIsDynamic(Timeline timeline, boolean... windowIsD } /** - * Asserts that previous window indices for each window are set correctly depending on the repeat - * mode. + * Asserts that previous window indices for each window depending on the repeat mode and the + * shuffle mode are equal to the given sequence. */ public static void assertPreviousWindowIndices(Timeline timeline, - @Player.RepeatMode int repeatMode, int... expectedPreviousWindowIndices) { + @Player.RepeatMode int repeatMode, boolean shuffleModeEnabled, + int... expectedPreviousWindowIndices) { for (int i = 0; i < timeline.getWindowCount(); i++) { assertEquals(expectedPreviousWindowIndices[i], - timeline.getPreviousWindowIndex(i, repeatMode)); + timeline.getPreviousWindowIndex(i, repeatMode, shuffleModeEnabled)); } } /** - * Asserts that next window indices for each window are set correctly depending on the repeat - * mode. + * Asserts that next window indices for each window depending on the repeat mode and the + * shuffle mode are equal to the given sequence. */ public static void assertNextWindowIndices(Timeline timeline, @Player.RepeatMode int repeatMode, - int... expectedNextWindowIndices) { + boolean shuffleModeEnabled, int... expectedNextWindowIndices) { for (int i = 0; i < timeline.getWindowCount(); i++) { assertEquals(expectedNextWindowIndices[i], - timeline.getNextWindowIndex(i, repeatMode)); + timeline.getNextWindowIndex(i, repeatMode, shuffleModeEnabled)); } } /** * Asserts that period counts for each window are set correctly. Also asserts that * {@link Window#firstPeriodIndex} and {@link Window#lastPeriodIndex} are set correctly, and it - * asserts the correct behavior of {@link Timeline#getNextWindowIndex(int, int)}. + * asserts the correct behavior of {@link Timeline#getNextWindowIndex(int, int, boolean)}. */ public static void assertPeriodCounts(Timeline timeline, int... expectedPeriodCounts) { int windowCount = timeline.getWindowCount(); @@ -118,31 +130,73 @@ public static void assertPeriodCounts(Timeline timeline, int... expectedPeriodCo expectedWindowIndex++; } assertEquals(expectedWindowIndex, period.windowIndex); - if (i < accumulatedPeriodCounts[expectedWindowIndex + 1] - 1) { - assertEquals(i + 1, timeline.getNextPeriodIndex(i, period, window, Player.REPEAT_MODE_OFF)); - assertEquals(i + 1, timeline.getNextPeriodIndex(i, period, window, Player.REPEAT_MODE_ONE)); - assertEquals(i + 1, timeline.getNextPeriodIndex(i, period, window, Player.REPEAT_MODE_ALL)); - } else { - int nextWindowOff = timeline.getNextWindowIndex(expectedWindowIndex, - Player.REPEAT_MODE_OFF); - int nextWindowOne = timeline.getNextWindowIndex(expectedWindowIndex, - Player.REPEAT_MODE_ONE); - int nextWindowAll = timeline.getNextWindowIndex(expectedWindowIndex, - Player.REPEAT_MODE_ALL); - int nextPeriodOff = nextWindowOff == C.INDEX_UNSET ? C.INDEX_UNSET - : accumulatedPeriodCounts[nextWindowOff]; - int nextPeriodOne = nextWindowOne == C.INDEX_UNSET ? C.INDEX_UNSET - : accumulatedPeriodCounts[nextWindowOne]; - int nextPeriodAll = nextWindowAll == C.INDEX_UNSET ? C.INDEX_UNSET - : accumulatedPeriodCounts[nextWindowAll]; - assertEquals(nextPeriodOff, timeline.getNextPeriodIndex(i, period, window, - Player.REPEAT_MODE_OFF)); - assertEquals(nextPeriodOne, timeline.getNextPeriodIndex(i, period, window, - Player.REPEAT_MODE_ONE)); - assertEquals(nextPeriodAll, timeline.getNextPeriodIndex(i, period, window, - Player.REPEAT_MODE_ALL)); + assertEquals(i, timeline.getIndexOfPeriod(period.uid)); + for (@Player.RepeatMode int repeatMode + : new int[] {Player.REPEAT_MODE_OFF, Player.REPEAT_MODE_ONE, Player.REPEAT_MODE_ALL}) { + if (i < accumulatedPeriodCounts[expectedWindowIndex + 1] - 1) { + assertEquals(i + 1, timeline.getNextPeriodIndex(i, period, window, repeatMode, false)); + } else { + int nextWindow = timeline.getNextWindowIndex(expectedWindowIndex, repeatMode, false); + int nextPeriod = nextWindow == C.INDEX_UNSET ? C.INDEX_UNSET + : accumulatedPeriodCounts[nextWindow]; + assertEquals(nextPeriod, timeline.getNextPeriodIndex(i, period, window, repeatMode, + false)); + } + } + } + } + + /** + * Asserts that periods' {@link Period#getAdGroupCount()} are set correctly. + */ + public static void assertAdGroupCounts(Timeline timeline, int... expectedAdGroupCounts) { + Period period = new Period(); + for (int i = 0; i < timeline.getPeriodCount(); i++) { + timeline.getPeriod(i, period); + assertEquals(expectedAdGroupCounts[i], period.getAdGroupCount()); + } + } + + /** + * Asserts that all period (including ad periods) can be created from the source, prepared, and + * released without exception and within timeout. + */ + public static void assertAllPeriodsCanBeCreatedPreparedAndReleased(MediaSource mediaSource, + Timeline timeline, long timeoutMs) { + Period period = new Period(); + for (int i = 0; i < timeline.getPeriodCount(); i++) { + assertPeriodCanBeCreatedPreparedAndReleased(mediaSource, new MediaPeriodId(i), timeoutMs); + timeline.getPeriod(i, period); + for (int adGroupIndex = 0; adGroupIndex < period.getAdGroupCount(); adGroupIndex++) { + for (int adIndex = 0; adIndex < period.getAdCountInAdGroup(adGroupIndex); adIndex++) { + assertPeriodCanBeCreatedPreparedAndReleased(mediaSource, + new MediaPeriodId(i, adGroupIndex, adIndex), timeoutMs); + } } } } + private static void assertPeriodCanBeCreatedPreparedAndReleased(MediaSource mediaSource, + MediaPeriodId mediaPeriodId, long timeoutMs) { + MediaPeriod mediaPeriod = mediaSource.createPeriod(mediaPeriodId, null); + assertNotNull(mediaPeriod); + final ConditionVariable mediaPeriodPrepared = new ConditionVariable(); + mediaPeriod.prepare(new Callback() { + @Override + public void onPrepared(MediaPeriod mediaPeriod) { + mediaPeriodPrepared.open(); + } + @Override + public void onContinueLoadingRequested(MediaPeriod source) {} + }, /* positionUs= */ 0); + assertTrue(mediaPeriodPrepared.block(timeoutMs)); + // MediaSource is supposed to support multiple calls to createPeriod with the same id without an + // intervening call to releasePeriod. + MediaPeriod secondMediaPeriod = mediaSource.createPeriod(mediaPeriodId, null); + assertNotNull(secondMediaPeriod); + mediaSource.releasePeriod(secondMediaPeriod); + mediaSource.releasePeriod(mediaPeriod); + } + } +