diff --git a/.gitignore b/.gitignore index 1146c06456..db5a8c4305 100644 --- a/.gitignore +++ b/.gitignore @@ -39,9 +39,13 @@ proguard-project.txt # Other .DS_Store +cmake-build-debug dist tmp +# External native builds +.externalNativeBuild + # VP9 extension extensions/vp9/src/main/jni/libvpx extensions/vp9/src/main/jni/libvpx_android_configs @@ -61,3 +65,4 @@ extensions/cronet/jniLibs/* !extensions/cronet/jniLibs/README.md extensions/cronet/libs/* !extensions/cronet/libs/README.md + diff --git a/.hgignore b/.hgignore new file mode 100644 index 0000000000..f7c3656f65 --- /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/.idea/codeStyleSettings.xml b/.idea/codeStyleSettings.xml new file mode 100644 index 0000000000..056b47a1e8 --- /dev/null +++ b/.idea/codeStyleSettings.xml @@ -0,0 +1,495 @@ + + + + + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 43c4809480..94b349b217 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,9 +16,8 @@ all of the information requested in the issue template. ## Pull requests ## We will also consider high quality pull requests. These should normally merge -into the `dev-vX` branch with the highest major version number. Bug fixes may -be suitable for merging into older `dev-vX` branches. Before a pull request can -be accepted you must submit a Contributor License Agreement, as described below. +into the `dev-v2` branch. Before a pull request can be accepted you must submit +a Contributor License Agreement, as described below. [dev]: https://github.com/google/ExoPlayer/tree/dev diff --git a/ISSUE_TEMPLATE b/ISSUE_TEMPLATE index 1b912312d1..8d2f66093d 100644 --- a/ISSUE_TEMPLATE +++ b/ISSUE_TEMPLATE @@ -1,5 +1,3 @@ -*** ISSUES THAT IGNORE THIS TEMPLATE WILL BE CLOSED WITHOUT INVESTIGATION *** - Before filing an issue: ----------------------- - Search existing issues, including issues that are closed. @@ -26,7 +24,7 @@ Describe how the issue can be reproduced, ideally using the ExoPlayer demo app. ### Link to test content Provide a link to media that reproduces the issue. If you don't wish to post it publicly, please submit the issue, then email the link to -dev.exoplayer@gmail.com including the issue number in the subject line. +dev.exoplayer@gmail.com using a subject in the format "Issue #1234". ### Version of ExoPlayer being used Specify the absolute version number. Avoid using terms such as "latest". @@ -40,5 +38,6 @@ devices and Android versions. Capture a full bug report using "adb bugreport". Output from "adb logcat" or a log snippet is NOT sufficient. Please attach the captured bug report as a file. If you don't wish to post it publicly, please submit the issue, then email the -bug report to dev.exoplayer@gmail.com including the issue number in the subject -line. +bug report to dev.exoplayer@gmail.com using a subject in the format +"Issue #1234". + diff --git a/README.md b/README.md index d7bc23f700..37967dd527 100644 --- a/README.md +++ b/README.md @@ -9,52 +9,65 @@ and extend, and can be updated through Play Store application updates. ## Documentation ## -* The [developer guide][] provides a wealth of information to help you get - started. -* The [class reference][] documents the ExoPlayer library classes. +* The [developer guide][] provides a wealth of information. +* The [class reference][] documents ExoPlayer classes. * The [release notes][] document the major changes in each release. +* Follow our [developer blog][] to keep up to date with the latest ExoPlayer + developments! [developer guide]: https://google.github.io/ExoPlayer/guide.html [class reference]: https://google.github.io/ExoPlayer/doc/reference -[release notes]: https://github.com/google/ExoPlayer/blob/dev-v2/RELEASENOTES.md +[release notes]: https://github.com/google/ExoPlayer/blob/release-v2/RELEASENOTES.md +[developer blog]: https://medium.com/google-exoplayer ## Using ExoPlayer ## -ExoPlayer modules can be obtained via jCenter. It's also possible to clone the +ExoPlayer modules can be obtained from JCenter. It's also possible to clone the repository and depend on the modules locally. -### Via jCenter ### +### From JCenter ### The easiest way to get started using ExoPlayer is to add it as a gradle -dependency. You need to make sure you have the jcenter repository included in -the `build.gradle` file in the root of your project: +dependency. You need to make sure you have the Google and JCenter repositories +included in the `build.gradle` file in the root of your project: ```gradle repositories { + google() jcenter() } ``` -Next add a gradle compile dependency to the `build.gradle` file of your app -module. The following will add a dependency to the full ExoPlayer library: +Next add a dependency in the `build.gradle` file of your app module. The +following will add a dependency to the full library: ```gradle -compile 'com.google.android.exoplayer:exoplayer:r2.X.X' +implementation 'com.google.android.exoplayer:exoplayer:2.X.X' ``` -where `r2.X.X` is your preferred version. Alternatively, you can depend on only -the library modules that you actually need. For example the following will add -dependencies on the Core, DASH and UI library modules, as might be required for -an app that plays DASH content: +where `2.X.X` is your preferred version. If not enabled already, you also need +to turn on Java 8 support in all `build.gradle` files depending on ExoPlayer, by +adding the following to the `android` section: ```gradle -compile 'com.google.android.exoplayer:exoplayer-core:r2.X.X' -compile 'com.google.android.exoplayer:exoplayer-dash:r2.X.X' -compile 'com.google.android.exoplayer:exoplayer-ui:r2.X.X' +compileOptions { + targetCompatibility JavaVersion.VERSION_1_8 +} ``` -The available modules are listed below. Adding a dependency to the full -ExoPlayer library is equivalent to adding dependencies on all of the modules +As an alternative to the full library, you can depend on only the library +modules that you actually need. For example the following will add dependencies +on the Core, DASH and UI library modules, as might be required for an app that +plays DASH content: + +```gradle +implementation 'com.google.android.exoplayer:exoplayer-core:2.X.X' +implementation 'com.google.android.exoplayer:exoplayer-dash:2.X.X' +implementation 'com.google.android.exoplayer:exoplayer-ui:2.X.X' +``` + +The available library modules are listed below. Adding a dependency to the full +library is equivalent to adding dependencies on all of the library modules individually. * `exoplayer-core`: Core functionality (required). @@ -63,11 +76,16 @@ individually. * `exoplayer-smoothstreaming`: Support for SmoothStreaming content. * `exoplayer-ui`: UI components and resources for use with ExoPlayer. -For more details, see the project on [Bintray][]. For information about the -latest versions, see the [Release notes][]. +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 manually. +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][]. + +[extensions directory]: https://github.com/google/ExoPlayer/tree/release-v2/extensions/ [Bintray]: https://bintray.com/google/exoplayer -[Release notes]: https://github.com/google/ExoPlayer/blob/release-v2/RELEASENOTES.md ### Locally ### @@ -97,24 +115,18 @@ You should now see the ExoPlayer modules appear as part of your project. You can depend on them as you would on any other local module, for example: ```gradle -compile project(':exoplayer-library-core') -compile project(':exoplayer-library-dash') -compile project(':exoplayer-library-ui) +implementation project(':exoplayer-library-core') +implementation project(':exoplayer-library-dash') +implementation project(':exoplayer-library-ui') ``` ## Developing ExoPlayer ## #### Project branches #### - * The project has `dev-vX` and `release-vX` branches, where `X` is the major - version number. - * Most development work happens on the `dev-vX` branch with the highest major - version number. Pull requests should normally be made to this branch. - * Bug fixes may be submitted to older `dev-vX` branches. When doing this, the - same (or an equivalent) fix should also be submitted to all subsequent - `dev-vX` branches. - * A `release-vX` branch holds the most recent stable release for major version - `X`. +* Development work happens on the `dev-v2` branch. Pull requests should + normally be made to this branch. +* The `release-v2` branch holds the most recent release. #### Using Android Studio #### diff --git a/RELEASENOTES.md b/RELEASENOTES.md index ff1bd42fde..d7bd90055e 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,5 +1,878 @@ # Release notes # +### dev-v2 (not yet released) ### + +* Support for playing spherical videos on Daydream. +* Improve decoder re-use between playbacks. TODO: Write and link a blog post + here ([#2826](https://github.com/google/ExoPlayer/issues/2826)). +* Add options for controlling audio track selections to `DefaultTrackSelector` + ([#3314](https://github.com/google/ExoPlayer/issues/3314)). +* Do not retry failed loads whose error is `FileNotFoundException`. +* Prevent Cea608Decoder from generating Subtitles with null Cues list + +### 2.9.2 ### + +* HLS: + * Fix issue causing unnecessary media playlist requests when playing live + streams ([#5059](https://github.com/google/ExoPlayer/issues/5059)). + * Fix decoder re-instantiation issue for packed audio streams + ([#5063](https://github.com/google/ExoPlayer/issues/5063)). +* MP4: Support Opus and FLAC in the MP4 container, and in DASH + ([#4883](https://github.com/google/ExoPlayer/issues/4883)). +* DASH: Fix detecting the end of live events + ([#4780](https://github.com/google/ExoPlayer/issues/4780)). +* Spherical video: Fall back to `TYPE_ROTATION_VECTOR` if + `TYPE_GAME_ROTATION_VECTOR` is unavailable + ([#5119](https://github.com/google/ExoPlayer/issues/5119)). +* Support seeking for a wider range of MPEG-TS streams + ([#5097](https://github.com/google/ExoPlayer/issues/5097)). +* Include channel count in audio capabilities check + ([#4690](https://github.com/google/ExoPlayer/issues/4690)). +* Fix issue with applying the `show_buffering` attribute in `PlayerView` + ([#5139](https://github.com/google/ExoPlayer/issues/5139)). +* Fix issue where null `Metadata` was output when it failed to decode + ([#5149](https://github.com/google/ExoPlayer/issues/5149)). +* Fix playback of some invalid but playable MP4 streams by replacing assertions + with logged warnings in sample table parsing code + ([#5162](https://github.com/google/ExoPlayer/issues/5162)). +* Fix UUID passed to `MediaCrypto` when using `C.CLEARKEY_UUID` before API 27. + +### 2.9.1 ### + +* Add convenience methods `Player.next`, `Player.previous`, `Player.hasNext` + and `Player.hasPrevious` + ([#4863](https://github.com/google/ExoPlayer/issues/4863)). +* Improve initial bandwidth meter estimates using the current country and + network type. +* IMA extension: + * For preroll to live stream transitions, project forward the loading position + to avoid being behind the live window. + * Let apps specify whether to focus the skip button on ATV + ([#5019](https://github.com/google/ExoPlayer/issues/5019)). +* MP3: + * Support seeking based on MLLT metadata + ([#3241](https://github.com/google/ExoPlayer/issues/3241)). + * Fix handling of streams with appended data + ([#4954](https://github.com/google/ExoPlayer/issues/4954)). +* DASH: Parse ProgramInformation element if present in the manifest. +* HLS: + * Add constructor to `DefaultHlsExtractorFactory` for adding TS payload + reader factory flags. + * Fix bug in segment sniffing + ([#5039](https://github.com/google/ExoPlayer/issues/5039)). + ([#4861](https://github.com/google/ExoPlayer/issues/4861)). +* SubRip: Add support for alignment tags, and remove tags from the displayed + captions ([#4306](https://github.com/google/ExoPlayer/issues/4306)). +* Fix issue with blind seeking to windows with non-zero offset in a + `ConcatenatingMediaSource` + ([#4873](https://github.com/google/ExoPlayer/issues/4873)). +* Fix logic for enabling next and previous actions in `TimelineQueueNavigator` + ([#5065](https://github.com/google/ExoPlayer/issues/5065)). +* Fix issue where audio focus handling could not be disabled after enabling it + ([#5055](https://github.com/google/ExoPlayer/issues/5055)). +* Fix issue where subtitles were positioned incorrectly if `SubtitleView` had a + non-zero position offset to its parent + ([#4788](https://github.com/google/ExoPlayer/issues/4788)). +* Fix issue where the buffered position was not updated correctly when + transitioning between periods + ([#4899](https://github.com/google/ExoPlayer/issues/4899)). +* Fix issue where a `NullPointerException` is thrown when removing an unprepared + media source from a `ConcatenatingMediaSource` with the `useLazyPreparation` + option enabled ([#4986](https://github.com/google/ExoPlayer/issues/4986)). +* Work around an issue where a non-empty end-of-stream audio buffer would be + output with timestamp zero, causing the player position to jump backwards + ([#5045](https://github.com/google/ExoPlayer/issues/5045)). +* Suppress a spurious assertion failure on some Samsung devices + ([#4532](https://github.com/google/ExoPlayer/issues/4532)). +* Suppress spurious "references unknown class member" shrinking warning + ([#4890](https://github.com/google/ExoPlayer/issues/4890)). +* Swap recommended order for google() and jcenter() in gradle config + ([#4997](https://github.com/google/ExoPlayer/issues/4997)). + +### 2.9.0 ### + +* Turn on Java 8 compiler support for the ExoPlayer library. Apps may need to + add `compileOptions { targetCompatibility JavaVersion.VERSION_1_8 }` to their + gradle settings to ensure bytecode compatibility. +* Set `compileSdkVersion` and `targetSdkVersion` to 28. +* Support for automatic audio focus handling via + `SimpleExoPlayer.setAudioAttributes`. +* Add `ExoPlayer.retry` convenience method. +* Add `AudioListener` for listening to changes in audio configuration during + playback ([#3994](https://github.com/google/ExoPlayer/issues/3994)). +* Add `LoadErrorHandlingPolicy` to allow configuration of load error handling + across `MediaSource` implementations + ([#3370](https://github.com/google/ExoPlayer/issues/3370)). +* Allow passing a `Looper`, which specifies the thread that must be used to + access the player, when instantiating player instances using + `ExoPlayerFactory` ([#4278](https://github.com/google/ExoPlayer/issues/4278)). +* Allow setting log level for ExoPlayer logcat output + ([#4665](https://github.com/google/ExoPlayer/issues/4665)). +* Simplify `BandwidthMeter` injection: The `BandwidthMeter` should now be + passed directly to `ExoPlayerFactory`, instead of to `TrackSelection.Factory` + and `DataSource.Factory`. The `BandwidthMeter` is passed to the components + that need it internally. The `BandwidthMeter` may also be omitted, in which + case a default instance will be used. +* Spherical video: + * Support for spherical video by setting `surface_type="spherical_view"` on + `PlayerView`. + * Support for + [VR180](https://github.com/google/spatial-media/blob/master/docs/vr180.md). +* HLS: + * Support PlayReady. + * Add container format sniffing + ([#2025](https://github.com/google/ExoPlayer/issues/2025)). + * Support alternative `EXT-X-KEY` tags. + * Support `EXT-X-INDEPENDENT-SEGMENTS` in the master playlist. + * Support variable substitution + ([#4422](https://github.com/google/ExoPlayer/issues/4422)). + * Fix the bitrate being unset on primary track sample formats + ([#3297](https://github.com/google/ExoPlayer/issues/3297)). + * Make `HlsMediaSource.Factory` take a factory of trackers instead of a + tracker instance ([#4814](https://github.com/google/ExoPlayer/issues/4814)). +* DASH: + * Support `messageData` attribute for in-manifest event streams. + * Clip periods to their specified durations + ([#4185](https://github.com/google/ExoPlayer/issues/4185)). +* Improve seeking support for progressive streams: + * Support seeking in MPEG-TS + ([#966](https://github.com/google/ExoPlayer/issues/966)). + * Support seeking in MPEG-PS + ([#4476](https://github.com/google/ExoPlayer/issues/4476)). + * Support approximate seeking in ADTS using a constant bitrate assumption + ([#4548](https://github.com/google/ExoPlayer/issues/4548)). The + `FLAG_ENABLE_CONSTANT_BITRATE_SEEKING` flag must be set on the extractor to + enable this functionality. + * Support approximate seeking in AMR using a constant bitrate assumption. + The `FLAG_ENABLE_CONSTANT_BITRATE_SEEKING` flag must be set on the extractor + to enable this functionality. + * Add `DefaultExtractorsFactory.setConstantBitrateSeekingEnabled` to enable + approximate seeking using a constant bitrate assumption on all extractors + that support it. +* Video: + * Add callback to `VideoListener` to notify of surface size changes. + * Improve performance when playing high frame-rate content, and when playing + at greater than 1x speed + ([#2777](https://github.com/google/ExoPlayer/issues/2777)). + * Scale up the initial video decoder maximum input size so playlist + transitions with small increases in maximum sample size do not require + reinitialization ([#4510](https://github.com/google/ExoPlayer/issues/4510)). + * Fix a bug where the player would not transition to the ended state when + playing video in tunneled mode. +* Audio: + * Support attaching auxiliary audio effects to the `AudioTrack` via + `Player.setAuxEffectInfo` and `Player.clearAuxEffectInfo`. + * Support seamless adaptation while playing xHE-AAC streams. + ([#4360](https://github.com/google/ExoPlayer/issues/4360)). + * Increase `AudioTrack` buffer sizes to the theoretical maximum required for + each encoding for passthrough playbacks + ([#3803](https://github.com/google/ExoPlayer/issues/3803)). + * WAV: Fix issue where white noise would be output at the end of playback + ([#4724](https://github.com/google/ExoPlayer/issues/4724)). + * MP3: Fix issue where streams would play twice on the SM-T530 + ([#4519](https://github.com/google/ExoPlayer/issues/4519)). +* Analytics: + * Add callbacks to `DefaultDrmSessionEventListener` and `AnalyticsListener` to + be notified of acquired and released DRM sessions. + * Add uri field to `LoadEventInfo` in `MediaSourceEventListener` and + `AnalyticsListener` callbacks. This uri is the redirected uri if redirection + occurred ([#2054](https://github.com/google/ExoPlayer/issues/2054)). + * Add response headers field to `LoadEventInfo` in `MediaSourceEventListener` + and `AnalyticsListener` callbacks + ([#4361](https://github.com/google/ExoPlayer/issues/4361) and + [#4615](https://github.com/google/ExoPlayer/issues/4615)). +* UI components: + * Add option to `PlayerView` to show buffering view when playWhenReady is + false ([#4304](https://github.com/google/ExoPlayer/issues/4304)). + * Allow any `Drawable` to be used as `PlayerView` default artwork. +* ConcatenatingMediaSource: + * Support lazy preparation of playlist media sources + ([#3972](https://github.com/google/ExoPlayer/issues/3972)). + * Support range removal with `removeMediaSourceRange` methods + ([#4542](https://github.com/google/ExoPlayer/issues/4542)). + * Support setting a new shuffle order with `setShuffleOrder` + ([#4791](https://github.com/google/ExoPlayer/issues/4791)). +* MPEG-TS: Support CEA-608/708 in H262 + ([#2565](https://github.com/google/ExoPlayer/issues/2565)). +* Allow configuration of the back buffer in `DefaultLoadControl.Builder` + ([#4857](https://github.com/google/ExoPlayer/issues/4857)). +* Allow apps to pass a `CacheKeyFactory` for setting custom cache keys when + creating a `CacheDataSource`. +* Provide additional information for adaptive track selection. + `TrackSelection.updateSelectedTrack` has two new parameters for the current + queue of media chunks and iterators for information about upcoming chunks. +* Allow `MediaCodecSelector`s to return multiple compatible decoders for + `MediaCodecRenderer`, and provide an (optional) `MediaCodecSelector` that + falls back to less preferred decoders like `MediaCodec.createDecoderByType` + ([#273](https://github.com/google/ExoPlayer/issues/273)). +* Enable gzip for requests made by `SingleSampleMediaSource` + ([#4771](https://github.com/google/ExoPlayer/issues/4771)). +* Fix bug reporting buffered position for multi-period windows, and add + convenience methods `Player.getTotalBufferedDuration` and + `Player.getContentBufferedDuration` + ([#4023](https://github.com/google/ExoPlayer/issues/4023)). +* Fix bug where transitions to clipped media sources would happen too early + ([#4583](https://github.com/google/ExoPlayer/issues/4583)). +* Fix bugs reporting events for multi-period media sources + ([#4492](https://github.com/google/ExoPlayer/issues/4492) and + [#4634](https://github.com/google/ExoPlayer/issues/4634)). +* Fix issue where removing looping media from a playlist throws an exception + ([#4871](https://github.com/google/ExoPlayer/issues/4871). +* Fix issue where the preferred audio or text track would not be selected if + mapped onto a secondary renderer of the corresponding type + ([#4711](http://github.com/google/ExoPlayer/issues/4711)). +* Fix issue where errors of upcoming playlist items are thrown too early + ([#4661](https://github.com/google/ExoPlayer/issues/4661)). +* Allow edit lists which do not start with a sync sample. + ([#4774](https://github.com/google/ExoPlayer/issues/4774)). +* Fix issue with audio discontinuities at period transitions, e.g. when + looping ([#3829](https://github.com/google/ExoPlayer/issues/3829)). +* Fix issue where `player.getCurrentTag()` throws an `IndexOutOfBoundsException` + ([#4822](https://github.com/google/ExoPlayer/issues/4822)). +* Fix bug preventing use of multiple key session support (`multiSession=true`) + for non-Widevine `DefaultDrmSessionManager` instances + ([#4834](https://github.com/google/ExoPlayer/issues/4834)). +* Fix issue where audio and video would desynchronize when playing + concatenations of gapless content + ([#4559](https://github.com/google/ExoPlayer/issues/4559)). +* IMA extension: + * Refine the previous fix for empty ad groups to avoid discarding ad breaks + unnecessarily ([#4030](https://github.com/google/ExoPlayer/issues/4030) and + [#4280](https://github.com/google/ExoPlayer/issues/4280)). + * Fix handling of empty postrolls + ([#4681](https://github.com/google/ExoPlayer/issues/4681)). + * Fix handling of postrolls with multiple ads + ([#4710](https://github.com/google/ExoPlayer/issues/4710)). +* MediaSession extension: + * Add `MediaSessionConnector.setCustomErrorMessage` to support setting custom + error messages. + * Add `MediaMetadataProvider` to support setting custom metadata + ([#3497](https://github.com/google/ExoPlayer/issues/3497)). +* Cronet extension: Now distributed via jCenter. +* FFmpeg extension: Support mu-law and A-law PCM. + +### 2.8.4 ### + +* IMA extension: Improve handling of consecutive empty ad groups + ([#4030](https://github.com/google/ExoPlayer/issues/4030)), + ([#4280](https://github.com/google/ExoPlayer/issues/4280)). + +### 2.8.3 ### + +* IMA extension: + * Fix behavior when creating/releasing the player then releasing + `ImaAdsLoader` ([#3879](https://github.com/google/ExoPlayer/issues/3879)). + * Add support for setting slots for companion ads. +* Captions: + * TTML: Fix an issue with TTML using font size as % of cell resolution that + makes `SubtitleView.setApplyEmbeddedFontSizes()` not work correctly. + ([#4491](https://github.com/google/ExoPlayer/issues/4491)). + * CEA-608: Improve handling of embedded styles + ([#4321](https://github.com/google/ExoPlayer/issues/4321)). +* DASH: + * Exclude text streams from duration calculations + ([#4029](https://github.com/google/ExoPlayer/issues/4029)). + * Fix freezing when playing multi-period manifests with `EventStream`s + ([#4492](https://github.com/google/ExoPlayer/issues/4492)). +* DRM: Allow DrmInitData to carry a license server URL + ([#3393](https://github.com/google/ExoPlayer/issues/3393)). +* MPEG-TS: Fix bug preventing SCTE-35 cues from being output + ([#4573](https://github.com/google/ExoPlayer/issues/4573)). +* Expose all internal ID3 data stored in MP4 udta boxes, and switch from using + CommentFrame to InternalFrame for frames with gapless metadata in MP4. +* Add `PlayerView.isControllerVisible` + ([#4385](https://github.com/google/ExoPlayer/issues/4385)). +* Fix issue playing DRM protected streams on Asus Zenfone 2 + ([#4403](https://github.com/google/ExoPlayer/issues/4413)). +* Add support for multiple audio and video tracks in MPEG-PS streams + ([#4406](https://github.com/google/ExoPlayer/issues/4406)). +* Add workaround for track index mismatches between trex and tkhd boxes in + fragmented MP4 files + ([#4477](https://github.com/google/ExoPlayer/issues/4477)). +* Add workaround for track index mismatches between tfhd and tkhd boxes in + fragmented MP4 files + ([#4083](https://github.com/google/ExoPlayer/issues/4083)). +* Ignore all MP4 edit lists if one edit list couldn't be handled + ([#4348](https://github.com/google/ExoPlayer/issues/4348)). +* Fix issue when switching track selection from an embedded track to a primary + track in DASH ([#4477](https://github.com/google/ExoPlayer/issues/4477)). +* Fix accessibility class name for `DefaultTimeBar` + ([#4611](https://github.com/google/ExoPlayer/issues/4611)). +* Improved compatibility with FireOS devices. + +### 2.8.2 ### + +* IMA extension: Don't advertise support for video/mpeg ad media, as we don't + have an extractor for this + ([#4297](https://github.com/google/ExoPlayer/issues/4297)). +* DASH: Fix playback getting stuck when playing representations that have both + sidx atoms and non-zero presentationTimeOffset values. +* HLS: + * Allow injection of custom playlist trackers. + * Fix adaptation in live playlists with EXT-X-PROGRAM-DATE-TIME tags. +* Mitigate memory leaks when `MediaSource` loads are slow to cancel + ([#4249](https://github.com/google/ExoPlayer/issues/4249)). +* Fix inconsistent `Player.EventListener` invocations for recursive player state + changes ([#4276](https://github.com/google/ExoPlayer/issues/4276)). +* Fix `MediaCodec.native_setSurface` crash on Moto C + ([#4315](https://github.com/google/ExoPlayer/issues/4315)). +* Fix missing whitespace in CEA-608 + ([#3906](https://github.com/google/ExoPlayer/issues/3906)). +* Fix crash downloading HLS media playlists + ([#4396](https://github.com/google/ExoPlayer/issues/4396)). +* Fix a bug where download cancellation was ignored + ([#4403](https://github.com/google/ExoPlayer/issues/4403)). +* Set `METADATA_KEY_TITLE` on media descriptions + ([#4292](https://github.com/google/ExoPlayer/issues/4292)). +* Allow apps to register custom MIME types + ([#4264](https://github.com/google/ExoPlayer/issues/4264)). + +### 2.8.1 ### + +* HLS: + * Fix playback of livestreams with EXT-X-PROGRAM-DATE-TIME tags + ([#4239](https://github.com/google/ExoPlayer/issues/4239)). + * Fix playback of clipped streams starting from non-keyframe positions + ([#4241](https://github.com/google/ExoPlayer/issues/4241)). +* OkHttp extension: Fix to correctly include response headers in thrown + `InvalidResponseCodeException`s. +* Add possibility to cancel `PlayerMessage`s. +* UI components: + * Add `PlayerView.setKeepContentOnPlayerReset` to keep the currently displayed + video frame or media artwork visible when the player is reset + ([#2843](https://github.com/google/ExoPlayer/issues/2843)). +* Fix crash when switching surface on Moto E(4) + ([#4134](https://github.com/google/ExoPlayer/issues/4134)). +* Fix a bug that could cause event listeners to be called with inconsistent + information if an event listener interacted with the player + ([#4262](https://github.com/google/ExoPlayer/issues/4262)). +* Audio: + * Fix extraction of PCM in MP4/MOV + ([#4228](https://github.com/google/ExoPlayer/issues/4228)). + * FLAC: Supports seeking for FLAC files without SEEKTABLE + ([#1808](https://github.com/google/ExoPlayer/issues/1808)). +* Captions: + * TTML: + * Fix a styling issue when there are multiple regions displayed at the same + time that can make text size of each region much smaller than defined. + * Fix an issue when the caption line has no text (empty line or only line + break), and the line's background is still displayed. + * Support TTML font size using % correctly (as percentage of document cell + resolution). + +### 2.8.0 ### + +* Downloading: + * Add `DownloadService`, `DownloadManager` and related classes + ([#2643](https://github.com/google/ExoPlayer/issues/2643)). Information on + using these components to download progressive formats can be found + [here](https://medium.com/google-exoplayer/downloading-streams-6d259eec7f95). + To see how to download DASH, HLS and SmoothStreaming media, take a look at + the app. + * Updated main demo app to support downloading DASH, HLS, SmoothStreaming and + progressive media. +* MediaSources: + * Allow reusing media sources after they have been released and + also in parallel to allow adding them multiple times to a concatenation. + ([#3498](https://github.com/google/ExoPlayer/issues/3498)). + * Merged `DynamicConcatenatingMediaSource` into `ConcatenatingMediaSource` and + deprecated `DynamicConcatenatingMediaSource`. + * Allow clipping of child media sources where the period and window have a + non-zero offset with `ClippingMediaSource`. + * Allow adding and removing `MediaSourceEventListener`s to MediaSources after + they have been created. Listening to events is now supported for all + media sources including composite sources. + * Added callbacks to `MediaSourceEventListener` to get notified when media + periods are created, released and being read from. + * Support live stream clipping with `ClippingMediaSource`. + * Allow setting tags for all media sources in their factories. The tag of the + current window can be retrieved with `Player.getCurrentTag`. +* UI components: + * Add support for displaying error messages and a buffering spinner in + `PlayerView`. + * Add support for listening to `AspectRatioFrameLayout`'s aspect ratio update + ([#3736](https://github.com/google/ExoPlayer/issues/3736)). + * Add `PlayerNotificationManager` for displaying notifications reflecting the + player state. + * Add `TrackSelectionView` for selecting tracks with `DefaultTrackSelector`. + * Add `TrackNameProvider` for converting track `Format`s to textual + descriptions, and `DefaultTrackNameProvider` as a default implementation. +* Track selection: + * Reworked `MappingTrackSelector` and `DefaultTrackSelector`. + * `DefaultTrackSelector.Parameters` now implements `Parcelable`. + * Added UI components for track selection (see above). +* Audio: + * Support extracting data from AMR container formats, including both narrow + and wide band ([#2527](https://github.com/google/ExoPlayer/issues/2527)). + * FLAC: + * Sniff FLAC files correctly if they have ID3 headers + ([#4055](https://github.com/google/ExoPlayer/issues/4055)). + * Supports FLAC files with high sample rate (176400 and 192000) + ([#3769](https://github.com/google/ExoPlayer/issues/3769)). + * Factor out `AudioTrack` position tracking from `DefaultAudioSink`. + * Fix an issue where the playback position would pause just after playback + begins, and poll the audio timestamp less frequently once it starts + advancing ([#3841](https://github.com/google/ExoPlayer/issues/3841)). + * Add an option to skip silent audio in `PlaybackParameters` + ([#2635](https://github.com/google/ExoPlayer/issues/2635)). + * Fix an issue where playback of TrueHD streams would get stuck after seeking + due to not finding a syncframe + ([#3845](https://github.com/google/ExoPlayer/issues/3845)). + * Fix an issue with eac3-joc playback where a codec would fail to configure + ([#4165](https://github.com/google/ExoPlayer/issues/4165)). + * Handle non-empty end-of-stream buffers, to fix gapless playback of streams + with encoder padding when the decoder returns a non-empty final buffer. + * Allow trimming more than one sample when applying an elst audio edit via + gapless playback info. + * Allow overriding skipping/scaling with custom `AudioProcessor`s + ([#3142](https://github.com/google/ExoPlayer/issues/3142)). +* Caching: + * Add release method to the `Cache` interface, and prevent multiple instances + of `SimpleCache` using the same folder at the same time. + * Cache redirect URLs + ([#2360](https://github.com/google/ExoPlayer/issues/2360)). +* DRM: + * Allow multiple listeners for `DefaultDrmSessionManager`. + * Pass `DrmSessionManager` to `ExoPlayerFactory` instead of `RendererFactory`. + * Change minimum API requirement for CBC and pattern encryption from 24 to 25 + ([#4022](https://github.com/google/ExoPlayer/issues/4022)). + * Fix handling of 307/308 redirects when making license requests + ([#4108](https://github.com/google/ExoPlayer/issues/4108)). +* HLS: + * Fix playlist loading error propagation when the current selection does + not include all of the playlist's variants. + * Fix SAMPLE-AES-CENC and SAMPLE-AES-CTR EXT-X-KEY methods + ([#4145](https://github.com/google/ExoPlayer/issues/4145)). + * Preeptively declare an ID3 track in chunkless preparation + ([#4016](https://github.com/google/ExoPlayer/issues/4016)). + * Add support for multiple #EXT-X-MAP tags in a media playlist + ([#4164](https://github.com/google/ExoPlayer/issues/4182)). + * Fix seeking in live streams + ([#4187](https://github.com/google/ExoPlayer/issues/4187)). +* IMA extension: + * Allow setting the ad media load timeout + ([#3691](https://github.com/google/ExoPlayer/issues/3691)). + * Expose ad load errors via `MediaSourceEventListener` on `AdsMediaSource`, + and allow setting an ad event listener on `ImaAdsLoader`. Deprecate the + `AdsMediaSource.EventListener`. +* Add `AnalyticsListener` interface which can be registered in + `SimpleExoPlayer` to receive detailed metadata for each ExoPlayer event. +* Optimize seeking in FMP4 by enabling seeking to the nearest sync sample within + a fragment. This benefits standalone FMP4 playbacks, DASH and SmoothStreaming. +* Updated default max buffer length in `DefaultLoadControl`. +* Fix ClearKey decryption error if the key contains a forward slash + ([#4075](https://github.com/google/ExoPlayer/issues/4075)). +* Fix crash when switching surface on Huawei P9 Lite + ([#4084](https://github.com/google/ExoPlayer/issues/4084)), and Philips QM163E + ([#4104](https://github.com/google/ExoPlayer/issues/4104)). +* Support ZLIB compressed PGS subtitles. +* Added `getPlaybackError` to `Player` interface. +* Moved initial bitrate estimate from `AdaptiveTrackSelection` to + `DefaultBandwidthMeter`. +* Removed default renderer time offset of 60000000 from internal player. The + actual renderer timestamp offset can be obtained by listening to + `BaseRenderer.onStreamChanged`. +* Added dependencies on checkerframework annotations for static code analysis. + +### 2.7.3 ### + +* Fix ProGuard configuration for Cast, IMA and OkHttp extensions. +* Update OkHttp extension to depend on OkHttp 3.10.0. + +### 2.7.2 ### + +* Gradle: Upgrade Gradle version from 4.1 to 4.4 so it can work with Android + Studio 3.1 ([#3708](https://github.com/google/ExoPlayer/issues/3708)). +* Match codecs starting with "mp4a" to different Audio MimeTypes + ([#3779](https://github.com/google/ExoPlayer/issues/3779)). +* Fix ANR issue on Redmi 4X and Redmi Note 4 + ([#4006](https://github.com/google/ExoPlayer/issues/4006)). +* Fix handling of zero padded strings when parsing Matroska streams + ([#4010](https://github.com/google/ExoPlayer/issues/4010)). +* Fix "Decoder input buffer too small" error when playing some FLAC streams. +* MediaSession extension: Omit fast forward and rewind actions when media is not + seekable ([#4001](https://github.com/google/ExoPlayer/issues/4001)). + +### 2.7.1 ### + +* Gradle: Replaced 'compile' (deprecated) with 'implementation' and + 'api'. This may lead to build breakage for applications upgrading from + previous version that rely on indirect dependencies of certain modules. In + such cases, application developers need to add the missing dependency to + their gradle file. You can read more about the new dependency configurations + [here](https://developer.android.com/studio/build/gradle-plugin-3-0-0-migration.html#new_configurations). +* HlsMediaSource: Make HLS periods start at zero instead of the epoch. + Applications that rely on HLS timelines having a period starting at + the epoch will need to update their handling of HLS timelines. The program + date time is still available via the informational + `Timeline.Window.windowStartTimeMs` field + ([#3865](https://github.com/google/ExoPlayer/issues/3865), + [#3888](https://github.com/google/ExoPlayer/issues/3888)). +* Enable seeking in MP4 streams where duration is set incorrectly in the track + header ([#3926](https://github.com/google/ExoPlayer/issues/3926)). +* Video: Force rendering a frame periodically in `MediaCodecVideoRenderer` and + `LibvpxVideoRenderer`, even if it is late. + +### 2.7.0 ### + +* Player interface: + * Add optional parameter to `stop` to reset the player when stopping. + * Add a reason to `EventListener.onTimelineChanged` to distinguish between + initial preparation, reset and dynamic updates. + * Add `Player.DISCONTINUITY_REASON_AD_INSERTION` to the possible reasons + reported in `Eventlistener.onPositionDiscontinuity` to distinguish + transitions to and from ads within one period from transitions between + periods. + * Replaced `ExoPlayer.sendMessages` with `ExoPlayer.createMessage` to allow + more customization of the message. Now supports setting a message delivery + playback position and/or a delivery handler + ([#2189](https://github.com/google/ExoPlayer/issues/2189)). + * Add `Player.VideoComponent`, `Player.TextComponent` and + `Player.MetadataComponent` interfaces that define optional video, text and + metadata output functionality. New `getVideoComponent`, `getTextComponent` + and `getMetadataComponent` methods provide access to this functionality. +* Add `ExoPlayer.setSeekParameters` for controlling how seek operations are + performed. The `SeekParameters` class contains defaults for exact seeking and + seeking to the closest sync points before, either side or after specified seek + positions. `SeekParameters` are not currently supported when playing HLS + streams. +* DefaultTrackSelector: + * Replace `DefaultTrackSelector.Parameters` copy methods with a builder. + * Support disabling of individual text track selection flags. +* Buffering: + * Allow a back-buffer of media to be retained behind the current playback + position, for fast backward seeking. The back-buffer can be configured by + custom `LoadControl` implementations. + * Add ability for `SequenceableLoader` to re-evaluate its buffer and discard + buffered media so that it can be re-buffered in a different quality. + * Allow more flexible loading strategy when playing media containing multiple + sub-streams, by allowing injection of custom `CompositeSequenceableLoader` + factories through `DashMediaSource.Factory`, `HlsMediaSource.Factory`, + `SsMediaSource.Factory`, and `MergingMediaSource`. + * Play out existing buffer before retrying for progressive live streams + ([#1606](https://github.com/google/ExoPlayer/issues/1606)). +* UI components: + * Generalized player and control views to allow them to bind with any + `Player`, and renamed them to `PlayerView` and `PlayerControlView` + respectively. + * Made `PlayerView` automatically apply video rotation when configured to use + `TextureView` ([#91](https://github.com/google/ExoPlayer/issues/91)). + * Made `PlayerView` play button behave correctly when the player is ended + ([#3689](https://github.com/google/ExoPlayer/issues/3689)), and call a + `PlaybackPreparer` when the player is idle. +* DRM: Optimistically attempt playback of DRM protected content that does not + declare scheme specific init data in the manifest. If playback of clear + samples without keys is allowed, delay DRM session error propagation until + keys are actually needed + ([#3630](https://github.com/google/ExoPlayer/issues/3630)). +* DASH: + * Support in-band Emsg events targeting the player with scheme id + `urn:mpeg:dash:event:2012` and scheme values "1", "2" and "3". + * Support EventStream elements in DASH manifests. +* HLS: + * Add opt-in support for chunkless preparation in HLS. This allows an + HLS source to finish preparation without downloading any chunks, which can + significantly reduce initial buffering time + ([#3149](https://github.com/google/ExoPlayer/issues/3149)). More details + can be found + [here](https://medium.com/google-exoplayer/faster-hls-preparation-f6611aa15ea6). + * Fail if unable to sync with the Transport Stream, rather than entering + stuck in an indefinite buffering state. + * Fix mime type propagation + ([#3653](https://github.com/google/ExoPlayer/issues/3653)). + * Fix ID3 context reuse across segment format changes + ([#3622](https://github.com/google/ExoPlayer/issues/3622)). + * Use long for media sequence numbers + ([#3747](https://github.com/google/ExoPlayer/issues/3747)) + * Add initial support for the EXT-X-GAP tag. +* Audio: + * Support TrueHD passthrough for rechunked samples in Matroska files + ([#2147](https://github.com/google/ExoPlayer/issues/2147)). + * Support resampling 24-bit and 32-bit integer to 32-bit float for high + resolution output in `DefaultAudioSink` + ([#3635](https://github.com/google/ExoPlayer/pull/3635)). +* Captions: + * Basic support for PGS subtitles + ([#3008](https://github.com/google/ExoPlayer/issues/3008)). + * Fix handling of CEA-608 captions where multiple buffers have the same + presentation timestamp + ([#3782](https://github.com/google/ExoPlayer/issues/3782)). +* Caching: + * Fix cache corruption issue + ([#3762](https://github.com/google/ExoPlayer/issues/3762)). + * Implement periodic check in `CacheDataSource` to see whether it's possible + to switch to reading/writing the cache having initially bypassed it. +* IMA extension: + * Fix the player getting stuck when an ad group fails to load + ([#3584](https://github.com/google/ExoPlayer/issues/3584)). + * Work around loadAd not being called beore the LOADED AdEvent arrives + ([#3552](https://github.com/google/ExoPlayer/issues/3552)). + * Handle asset mismatch errors + ([#3801](https://github.com/google/ExoPlayer/issues/3801)). + * Add support for playing non-Extractor content MediaSources in + the IMA demo app + ([#3676](https://github.com/google/ExoPlayer/issues/3676)). + * Fix handling of ad tags where ad groups are out of order + ([#3716](https://github.com/google/ExoPlayer/issues/3716)). + * Fix handling of ad tags with only preroll/postroll ad groups + ([#3715](https://github.com/google/ExoPlayer/issues/3715)). + * Propagate ad media preparation errors to IMA so that the ads can be + skipped. + * Handle exceptions in IMA callbacks so that can be logged less verbosely. +* New Cast extension. Simplifies toggling between local and Cast playbacks. +* `EventLogger` moved from the demo app into the core library. +* Fix ANR issue on the Huawei P8 Lite, Huawei Y6II, Moto C+, Meizu M5C, + Lenovo K4 Note and Sony Xperia E5. + ([#3724](https://github.com/google/ExoPlayer/issues/3724), + [#3835](https://github.com/google/ExoPlayer/issues/3835)). +* Fix potential NPE when removing media sources from a + DynamicConcatenatingMediaSource + ([#3796](https://github.com/google/ExoPlayer/issues/3796)). +* Check `sys.display-size` on Philips ATVs + ([#3807](https://github.com/google/ExoPlayer/issues/3807)). +* Release `Extractor`s on the loading thread to avoid potentially leaking + resources when the playback thread has quit by the time the loading task has + completed. +* ID3: Better handle malformed ID3 data + ([#3792](https://github.com/google/ExoPlayer/issues/3792). +* Support 14-bit mode and little endianness in DTS PES packets + ([#3340](https://github.com/google/ExoPlayer/issues/3340)). +* Demo app: Add ability to download not DRM protected content. + +### 2.6.1 ### + +* Add factories to `ExtractorMediaSource`, `HlsMediaSource`, `SsMediaSource`, + `DashMediaSource` and `SingleSampleMediaSource`. +* Use the same listener `MediaSourceEventListener` for all MediaSource + implementations. +* IMA extension: + * Support non-ExtractorMediaSource ads + ([#3302](https://github.com/google/ExoPlayer/issues/3302)). + * Skip ads before the ad preceding the player's initial seek position + ([#3527](https://github.com/google/ExoPlayer/issues/3527)). + * Fix ad loading when there is no preroll. + * Add an option to turn off hiding controls during ad playback + ([#3532](https://github.com/google/ExoPlayer/issues/3532)). + * Support specifying an ads response instead of an ad tag + ([#3548](https://github.com/google/ExoPlayer/issues/3548)). + * Support overriding the ad load timeout + ([#3556](https://github.com/google/ExoPlayer/issues/3556)). +* DASH: Support time zone designators in ISO8601 UTCTiming elements + ([#3524](https://github.com/google/ExoPlayer/issues/3524)). +* Audio: + * Support 32-bit PCM float output from `DefaultAudioSink`, and add an option + to use this with `FfmpegAudioRenderer`. + * Add support for extracting 32-bit WAVE files + ([#3379](https://github.com/google/ExoPlayer/issues/3379)). + * Support extraction and decoding of Dolby Atmos + ([#2465](https://github.com/google/ExoPlayer/issues/2465)). + * Fix handling of playback parameter changes while paused when followed by a + seek. +* SimpleExoPlayer: Allow multiple audio and video debug listeners. +* DefaultTrackSelector: Support undefined language text track selection when the + preferred language is not available + ([#2980](https://github.com/google/ExoPlayer/issues/2980)). +* Add options to `DefaultLoadControl` to set maximum buffer size in bytes and + to choose whether size or time constraints are prioritized. +* Use surfaceless context for secure `DummySurface`, if available + ([#3558](https://github.com/google/ExoPlayer/issues/3558)). +* FLV: Fix playback of live streams that do not contain an audio track + ([#3188](https://github.com/google/ExoPlayer/issues/3188)). +* CEA-608: Fix handling of row count changes in roll-up mode + ([#3513](https://github.com/google/ExoPlayer/issues/3513)). +* Prevent period transitions when seeking to the end of a period when paused + ([#2439](https://github.com/google/ExoPlayer/issues/2439)). + +### 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. +* Add the ability to inject a HLS playlist parser through `HlsMediaSource`. +* Fix potential `IndexOutOfBoundsException` when using `ImaMediaSource` + ([#3334](https://github.com/google/ExoPlayer/issues/3334)). +* Fix an issue parsing MP4 content containing non-CENC sinf boxes. +* Fix memory leak when seeking with repeated periods. +* Fix playback position when `ExoPlayer.prepare` is called with `resetPosition` + set to false. +* Ignore MP4 edit lists that seem invalid + ([#3351](https://github.com/google/ExoPlayer/issues/3351)). +* Add extractor flag for ignoring all MP4 edit lists + ([#3358](https://github.com/google/ExoPlayer/issues/3358)). +* Improve extensibility by exposing public constructors for + `FrameworkMediaCrypto` and by making `DefaultDashChunkSource.getNextChunk` + non-final. + +### r2.5.3 ### + +* IMA extension: Support skipping of skippable ads on AndroidTV and other + non-touch devices ([#3258](https://github.com/google/ExoPlayer/issues/3258)). +* HLS: Fix broken WebVTT captions when PTS wraps around + ([#2928](https://github.com/google/ExoPlayer/issues/2928)). +* Captions: Fix issues rendering CEA-608 captions + ([#3250](https://github.com/google/ExoPlayer/issues/3250)). +* Workaround broken AAC decoders on Galaxy S6 + ([#3249](https://github.com/google/ExoPlayer/issues/3249)). +* Caching: Fix infinite loop when cache eviction fails + ([#3260](https://github.com/google/ExoPlayer/issues/3260)). +* Caching: Force use of BouncyCastle on JellyBean to fix decryption issue + ([#2755](https://github.com/google/ExoPlayer/issues/2755)). + +### r2.5.2 ### + +* IMA extension: Fix issue where ad playback could end prematurely for some + content types ([#3180](https://github.com/google/ExoPlayer/issues/3180)). +* RTMP extension: Fix SIGABRT on fast RTMP stream restart + ([#3156](https://github.com/google/ExoPlayer/issues/3156)). +* UI: Allow app to manually specify ad markers + ([#3184](https://github.com/google/ExoPlayer/issues/3184)). +* DASH: Expose segment indices to subclasses of DefaultDashChunkSource + ([#3037](https://github.com/google/ExoPlayer/issues/3037)). +* Captions: Added robustness against malformed WebVTT captions + ([#3228](https://github.com/google/ExoPlayer/issues/3228)). +* DRM: Support forcing a specific license URL. +* Fix playback error when seeking in media loaded through content:// URIs + ([#3216](https://github.com/google/ExoPlayer/issues/3216)). +* Fix issue playing MP4s in which the last atom specifies a size of zero + ([#3191](https://github.com/google/ExoPlayer/issues/3191)). +* Workaround playback failures on some Xiaomi devices + ([#3171](https://github.com/google/ExoPlayer/issues/3171)). +* Workaround SIGSEGV issue on some devices when setting and swapping surface for + secure playbacks ([#3215](https://github.com/google/ExoPlayer/issues/3215)). +* Workaround for Nexus 7 issue when swapping output surface + ([#3236](https://github.com/google/ExoPlayer/issues/3236)). +* Workaround for SimpleExoPlayerView's surface not being hidden properly + ([#3160](https://github.com/google/ExoPlayer/issues/3160)). + +### r2.5.1 ### + +* Fix an issue that could cause the reported playback position to stop advancing + in some cases. +* Fix an issue where a Surface could be released whilst still in use by the + player. + +### r2.5.0 ### + +* IMA extension: Wraps the Google Interactive Media Ads (IMA) SDK to provide an + easy and seamless way of incorporating display ads into ExoPlayer playbacks. + You can read more about the IMA extension + [here](https://medium.com/google-exoplayer/playing-ads-with-exoplayer-and-ima-868dfd767ea). +* MediaSession extension: Provides an easy way to connect ExoPlayer with + MediaSessionCompat in the Android Support Library. +* RTMP extension: An extension for playing streams over RTMP. +* Build: Made it easier for application developers to depend on a local checkout + of ExoPlayer. You can learn how to do this + [here](https://medium.com/google-exoplayer/howto-2-depend-on-a-local-checkout-of-exoplayer-bcd7f8531720). +* Core playback improvements: + * Eliminated re-buffering when changing audio and text track selections during + playback of progressive streams + ([#2926](https://github.com/google/ExoPlayer/issues/2926)). + * New DynamicConcatenatingMediaSource class to support playback of dynamic + playlists. + * New ExoPlayer.setRepeatMode method for dynamic toggling of repeat mode + during playback. Use of setRepeatMode should be preferred to + LoopingMediaSource for most looping use cases. You can read more about + setRepeatMode + [here](https://medium.com/google-exoplayer/repeat-modes-in-exoplayer-19dd85f036d3). + * Eliminated jank when switching video playback from one Surface to another on + API level 23+ for unencrypted content, and on devices that support the + EGL_EXT_protected_content OpenGL extension for protected content + ([#677](https://github.com/google/ExoPlayer/issues/677)). + * Enabled ExoPlayer instantiation on background threads without Loopers. + Events from such players are delivered on the application's main thread. +* HLS improvements: + * Optimized adaptive switches for playlists that specify the + EXT-X-INDEPENDENT-SEGMENTS tag. + * Optimized in-buffer seeking + ([#551](https://github.com/google/ExoPlayer/issues/551)). + * Eliminated re-buffering when changing audio and text track selections during + playback, provided the new selection does not require switching to different + renditions ([#2718](https://github.com/google/ExoPlayer/issues/2718)). + * Exposed all media playlist tags in ExoPlayer's MediaPlaylist object. +* DASH: Support for seamless switching across streams in different AdaptationSet + elements ([#2431](https://github.com/google/ExoPlayer/issues/2431)). +* DRM: Support for additional crypto schemes (cbc1, cbcs and cens) on + API level 24+ ([#1989](https://github.com/google/ExoPlayer/issues/1989)). +* Captions: Initial support for SSA/ASS subtitles + ([#889](https://github.com/google/ExoPlayer/issues/889)). +* AndroidTV: Fixed issue where tunneled video playback would not start on some + devices ([#2985](https://github.com/google/ExoPlayer/issues/2985)). +* MPEG-TS: Fixed segmentation issue when parsing H262 + ([#2891](https://github.com/google/ExoPlayer/issues/2891)). +* Cronet extension: Support for a user-defined fallback if Cronet library is not + present. +* Fix buffer too small IllegalStateException issue affecting some composite + media playbacks ([#2900](https://github.com/google/ExoPlayer/issues/2900)). +* Misc bugfixes. + ### r2.4.4 ### * HLS/MPEG-TS: Some initial optimizations of MPEG-TS extractor performance diff --git a/build.gradle b/build.gradle index dbc8a41eb0..96eade1aa3 100644 --- a/build.gradle +++ b/build.gradle @@ -13,11 +13,13 @@ // limitations under the License. buildscript { repositories { + google() jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:2.3.3' - classpath 'com.novoda:bintray-release:0.5.0' + classpath 'com.android.tools.build:gradle:3.1.4' + classpath 'com.novoda:bintray-release:0.8.1' + classpath 'com.google.android.gms:strict-version-matcher-plugin:1.0.3' } // Workaround for the following test coverage issue. Remove when fixed: // https://code.google.com/p/android/issues/detail?id=226070 @@ -30,10 +32,8 @@ buildscript { } allprojects { repositories { + google() jcenter() - maven { - url "https://maven.google.com" - } } project.ext { exoplayerPublishEnabled = true diff --git a/constants.gradle b/constants.gradle index df36a01d55..cac4f6d78b 100644 --- a/constants.gradle +++ b/constants.gradle @@ -12,19 +12,27 @@ // 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 + // ExoPlayer version and version code. + releaseVersion = '2.9.2' + releaseVersionCode = 2009002 + // 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 + targetSdkVersion = 28 + compileSdkVersion = 28 + buildToolsVersion = '28.0.2' testSupportLibraryVersion = '0.5' - supportLibraryVersion = '25.4.0' + supportLibraryVersion = '27.1.1' dexmakerVersion = '1.2' mockitoVersion = '1.9.5' - releaseVersion = 'r2.4.4' + junitVersion = '4.12' + truthVersion = '0.39' + robolectricVersion = '3.7.1' + autoValueVersion = '1.6' + checkerframeworkVersion = '2.5.0' + testRunnerVersion = '1.1.0-alpha3' modulePrefix = ':' if (gradle.ext.has('exoplayerModulePrefix')) { modulePrefix += gradle.ext.exoplayerModulePrefix diff --git a/core_settings.gradle b/core_settings.gradle index 20e7b235a2..4d90fa962a 100644 --- a/core_settings.gradle +++ b/core_settings.gradle @@ -24,15 +24,20 @@ include modulePrefix + 'library-hls' include modulePrefix + 'library-smoothstreaming' include modulePrefix + 'library-ui' include modulePrefix + 'testutils' +include modulePrefix + 'testutils-robolectric' include modulePrefix + 'extension-ffmpeg' include modulePrefix + 'extension-flac' include modulePrefix + 'extension-gvr' include modulePrefix + 'extension-ima' +include modulePrefix + 'extension-cast' +include modulePrefix + 'extension-cronet' include modulePrefix + 'extension-mediasession' include modulePrefix + 'extension-okhttp' include modulePrefix + 'extension-opus' include modulePrefix + 'extension-vp9' include modulePrefix + 'extension-rtmp' +include modulePrefix + 'extension-leanback' +include modulePrefix + 'extension-jobdispatcher' project(modulePrefix + 'library').projectDir = new File(rootDir, 'library/all') project(modulePrefix + 'library-core').projectDir = new File(rootDir, 'library/core') @@ -41,18 +46,17 @@ project(modulePrefix + 'library-hls').projectDir = new File(rootDir, 'library/hl project(modulePrefix + 'library-smoothstreaming').projectDir = new File(rootDir, 'library/smoothstreaming') project(modulePrefix + 'library-ui').projectDir = new File(rootDir, 'library/ui') project(modulePrefix + 'testutils').projectDir = new File(rootDir, 'testutils') +project(modulePrefix + 'testutils-robolectric').projectDir = new File(rootDir, 'testutils_robolectric') project(modulePrefix + 'extension-ffmpeg').projectDir = new File(rootDir, 'extensions/ffmpeg') project(modulePrefix + 'extension-flac').projectDir = new File(rootDir, 'extensions/flac') project(modulePrefix + 'extension-gvr').projectDir = new File(rootDir, 'extensions/gvr') project(modulePrefix + 'extension-ima').projectDir = new File(rootDir, 'extensions/ima') +project(modulePrefix + 'extension-cast').projectDir = new File(rootDir, 'extensions/cast') +project(modulePrefix + 'extension-cronet').projectDir = new File(rootDir, 'extensions/cronet') project(modulePrefix + 'extension-mediasession').projectDir = new File(rootDir, 'extensions/mediasession') project(modulePrefix + 'extension-okhttp').projectDir = new File(rootDir, 'extensions/okhttp') 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') - -if (gradle.ext.has('exoplayerIncludeCronetExtension') - && gradle.ext.exoplayerIncludeCronetExtension) { - include modulePrefix + 'extension-cronet' - project(modulePrefix + 'extension-cronet').projectDir = new File(rootDir, 'extensions/cronet') -} +project(modulePrefix + 'extension-leanback').projectDir = new File(rootDir, 'extensions/leanback') +project(modulePrefix + 'extension-jobdispatcher').projectDir = new File(rootDir, 'extensions/jobdispatcher') diff --git a/demo/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java b/demo/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java deleted file mode 100644 index b5db4c018d..0000000000 --- a/demo/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.demo; - -import android.app.Application; -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.upstream.DefaultHttpDataSourceFactory; -import com.google.android.exoplayer2.upstream.HttpDataSource; -import com.google.android.exoplayer2.util.Util; - -/** - * Placeholder application to facilitate overriding Application methods for debugging and testing. - */ -public class DemoApplication extends Application { - - protected String userAgent; - - @Override - public void onCreate() { - super.onCreate(); - userAgent = Util.getUserAgent(this, "ExoPlayerDemo"); - } - - public DataSource.Factory buildDataSourceFactory(DefaultBandwidthMeter bandwidthMeter) { - return new DefaultDataSourceFactory(this, bandwidthMeter, - buildHttpDataSourceFactory(bandwidthMeter)); - } - - public HttpDataSource.Factory buildHttpDataSourceFactory(DefaultBandwidthMeter bandwidthMeter) { - return new DefaultHttpDataSourceFactory(userAgent, bandwidthMeter); - } - - public boolean useExtensionRenderers() { - return BuildConfig.FLAVOR.equals("withExtensions"); - } - -} diff --git a/demo/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java b/demo/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java deleted file mode 100644 index f9e9c34158..0000000000 --- a/demo/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java +++ /dev/null @@ -1,86 +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.demo; - -import android.text.TextUtils; -import com.google.android.exoplayer2.Format; -import com.google.android.exoplayer2.util.MimeTypes; -import java.util.Locale; - -/** - * Utility methods for demo application. - */ -/*package*/ final class DemoUtil { - - /** - * Builds a track name for display. - * - * @param format {@link Format} of the track. - * @return a generated name specific to the track. - */ - public static String buildTrackName(Format format) { - String trackName; - if (MimeTypes.isVideo(format.sampleMimeType)) { - trackName = joinWithSeparator(joinWithSeparator(joinWithSeparator( - buildResolutionString(format), buildBitrateString(format)), buildTrackIdString(format)), - buildSampleMimeTypeString(format)); - } else if (MimeTypes.isAudio(format.sampleMimeType)) { - trackName = joinWithSeparator(joinWithSeparator(joinWithSeparator(joinWithSeparator( - buildLanguageString(format), buildAudioPropertyString(format)), - buildBitrateString(format)), buildTrackIdString(format)), - buildSampleMimeTypeString(format)); - } else { - trackName = joinWithSeparator(joinWithSeparator(joinWithSeparator(buildLanguageString(format), - buildBitrateString(format)), buildTrackIdString(format)), - buildSampleMimeTypeString(format)); - } - return trackName.length() == 0 ? "unknown" : trackName; - } - - private static String buildResolutionString(Format format) { - return format.width == Format.NO_VALUE || format.height == Format.NO_VALUE - ? "" : format.width + "x" + format.height; - } - - private static String buildAudioPropertyString(Format format) { - return format.channelCount == Format.NO_VALUE || format.sampleRate == Format.NO_VALUE - ? "" : format.channelCount + "ch, " + format.sampleRate + "Hz"; - } - - private static String buildLanguageString(Format format) { - return TextUtils.isEmpty(format.language) || "und".equals(format.language) ? "" - : format.language; - } - - private static String buildBitrateString(Format format) { - return format.bitrate == Format.NO_VALUE ? "" - : String.format(Locale.US, "%.2fMbit", format.bitrate / 1000000f); - } - - private static String joinWithSeparator(String first, String second) { - return first.length() == 0 ? second : (second.length() == 0 ? first : first + ", " + second); - } - - private static String buildTrackIdString(Format format) { - return format.id == null ? "" : ("id:" + format.id); - } - - private static String buildSampleMimeTypeString(Format format) { - return format.sampleMimeType == null ? "" : format.sampleMimeType; - } - - private DemoUtil() {} -} diff --git a/demo/src/main/java/com/google/android/exoplayer2/demo/EventLogger.java b/demo/src/main/java/com/google/android/exoplayer2/demo/EventLogger.java deleted file mode 100644 index 87c85f6800..0000000000 --- a/demo/src/main/java/com/google/android/exoplayer2/demo/EventLogger.java +++ /dev/null @@ -1,481 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.demo; - -import android.os.SystemClock; -import android.util.Log; -import android.view.Surface; -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.PlaybackParameters; -import com.google.android.exoplayer2.RendererCapabilities; -import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.audio.AudioRendererEventListener; -import com.google.android.exoplayer2.decoder.DecoderCounters; -import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; -import com.google.android.exoplayer2.metadata.Metadata; -import com.google.android.exoplayer2.metadata.MetadataRenderer; -import com.google.android.exoplayer2.metadata.emsg.EventMessage; -import com.google.android.exoplayer2.metadata.id3.ApicFrame; -import com.google.android.exoplayer2.metadata.id3.CommentFrame; -import com.google.android.exoplayer2.metadata.id3.GeobFrame; -import com.google.android.exoplayer2.metadata.id3.Id3Frame; -import com.google.android.exoplayer2.metadata.id3.PrivFrame; -import com.google.android.exoplayer2.metadata.id3.TextInformationFrame; -import com.google.android.exoplayer2.metadata.id3.UrlLinkFrame; -import com.google.android.exoplayer2.source.AdaptiveMediaSourceEventListener; -import com.google.android.exoplayer2.source.ExtractorMediaSource; -import com.google.android.exoplayer2.source.TrackGroup; -import com.google.android.exoplayer2.source.TrackGroupArray; -import com.google.android.exoplayer2.trackselection.MappingTrackSelector; -import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; -import com.google.android.exoplayer2.trackselection.TrackSelection; -import com.google.android.exoplayer2.trackselection.TrackSelectionArray; -import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.video.VideoRendererEventListener; -import java.io.IOException; -import java.text.NumberFormat; -import java.util.Locale; - -/** - * Logs player events using {@link Log}. - */ -/* package */ final class EventLogger implements ExoPlayer.EventListener, - AudioRendererEventListener, VideoRendererEventListener, AdaptiveMediaSourceEventListener, - ExtractorMediaSource.EventListener, DefaultDrmSessionManager.EventListener, - MetadataRenderer.Output { - - private static final String TAG = "EventLogger"; - private static final int MAX_TIMELINE_ITEM_LINES = 3; - private static final NumberFormat TIME_FORMAT; - static { - TIME_FORMAT = NumberFormat.getInstance(Locale.US); - TIME_FORMAT.setMinimumFractionDigits(2); - TIME_FORMAT.setMaximumFractionDigits(2); - TIME_FORMAT.setGroupingUsed(false); - } - - private final MappingTrackSelector trackSelector; - private final Timeline.Window window; - private final Timeline.Period period; - private final long startTimeMs; - - public EventLogger(MappingTrackSelector trackSelector) { - this.trackSelector = trackSelector; - window = new Timeline.Window(); - period = new Timeline.Period(); - startTimeMs = SystemClock.elapsedRealtime(); - } - - // ExoPlayer.EventListener - - @Override - public void onLoadingChanged(boolean isLoading) { - Log.d(TAG, "loading [" + isLoading + "]"); - } - - @Override - public void onPlayerStateChanged(boolean playWhenReady, int state) { - Log.d(TAG, "state [" + getSessionTimeString() + ", " + playWhenReady + ", " - + getStateString(state) + "]"); - } - - @Override - public void onRepeatModeChanged(@ExoPlayer.RepeatMode int repeatMode) { - Log.d(TAG, "repeatMode [" + getRepeatModeString(repeatMode) + "]"); - } - - @Override - public void onPositionDiscontinuity() { - Log.d(TAG, "positionDiscontinuity"); - } - - @Override - public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { - Log.d(TAG, "playbackParameters " + String.format( - "[speed=%.2f, pitch=%.2f]", playbackParameters.speed, playbackParameters.pitch)); - } - - @Override - public void onTimelineChanged(Timeline timeline, Object manifest) { - int periodCount = timeline.getPeriodCount(); - int windowCount = timeline.getWindowCount(); - Log.d(TAG, "sourceInfo [periodCount=" + periodCount + ", windowCount=" + windowCount); - for (int i = 0; i < Math.min(periodCount, MAX_TIMELINE_ITEM_LINES); i++) { - timeline.getPeriod(i, period); - Log.d(TAG, " " + "period [" + getTimeString(period.getDurationMs()) + "]"); - } - if (periodCount > MAX_TIMELINE_ITEM_LINES) { - Log.d(TAG, " ..."); - } - for (int i = 0; i < Math.min(windowCount, MAX_TIMELINE_ITEM_LINES); i++) { - timeline.getWindow(i, window); - Log.d(TAG, " " + "window [" + getTimeString(window.getDurationMs()) + ", " - + window.isSeekable + ", " + window.isDynamic + "]"); - } - if (windowCount > MAX_TIMELINE_ITEM_LINES) { - Log.d(TAG, " ..."); - } - Log.d(TAG, "]"); - } - - @Override - public void onPlayerError(ExoPlaybackException e) { - Log.e(TAG, "playerFailed [" + getSessionTimeString() + "]", e); - } - - @Override - public void onTracksChanged(TrackGroupArray ignored, TrackSelectionArray trackSelections) { - MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo(); - if (mappedTrackInfo == null) { - Log.d(TAG, "Tracks []"); - return; - } - Log.d(TAG, "Tracks ["); - // Log tracks associated to renderers. - for (int rendererIndex = 0; rendererIndex < mappedTrackInfo.length; rendererIndex++) { - TrackGroupArray rendererTrackGroups = mappedTrackInfo.getTrackGroups(rendererIndex); - TrackSelection trackSelection = trackSelections.get(rendererIndex); - if (rendererTrackGroups.length > 0) { - Log.d(TAG, " Renderer:" + rendererIndex + " ["); - for (int groupIndex = 0; groupIndex < rendererTrackGroups.length; groupIndex++) { - TrackGroup trackGroup = rendererTrackGroups.get(groupIndex); - String adaptiveSupport = getAdaptiveSupportString(trackGroup.length, - mappedTrackInfo.getAdaptiveSupport(rendererIndex, groupIndex, false)); - Log.d(TAG, " Group:" + groupIndex + ", adaptive_supported=" + adaptiveSupport + " ["); - for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { - String status = getTrackStatusString(trackSelection, trackGroup, trackIndex); - String formatSupport = getFormatSupportString( - mappedTrackInfo.getTrackFormatSupport(rendererIndex, groupIndex, trackIndex)); - Log.d(TAG, " " + status + " Track:" + trackIndex + ", " - + Format.toLogString(trackGroup.getFormat(trackIndex)) - + ", supported=" + formatSupport); - } - Log.d(TAG, " ]"); - } - // Log metadata for at most one of the tracks selected for the renderer. - if (trackSelection != null) { - for (int selectionIndex = 0; selectionIndex < trackSelection.length(); selectionIndex++) { - Metadata metadata = trackSelection.getFormat(selectionIndex).metadata; - if (metadata != null) { - Log.d(TAG, " Metadata ["); - printMetadata(metadata, " "); - Log.d(TAG, " ]"); - break; - } - } - } - Log.d(TAG, " ]"); - } - } - // Log tracks not associated with a renderer. - TrackGroupArray unassociatedTrackGroups = mappedTrackInfo.getUnassociatedTrackGroups(); - if (unassociatedTrackGroups.length > 0) { - Log.d(TAG, " Renderer:None ["); - for (int groupIndex = 0; groupIndex < unassociatedTrackGroups.length; groupIndex++) { - Log.d(TAG, " Group:" + groupIndex + " ["); - TrackGroup trackGroup = unassociatedTrackGroups.get(groupIndex); - for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { - String status = getTrackStatusString(false); - String formatSupport = getFormatSupportString( - RendererCapabilities.FORMAT_UNSUPPORTED_TYPE); - Log.d(TAG, " " + status + " Track:" + trackIndex + ", " - + Format.toLogString(trackGroup.getFormat(trackIndex)) - + ", supported=" + formatSupport); - } - Log.d(TAG, " ]"); - } - Log.d(TAG, " ]"); - } - Log.d(TAG, "]"); - } - - // MetadataRenderer.Output - - @Override - public void onMetadata(Metadata metadata) { - Log.d(TAG, "onMetadata ["); - printMetadata(metadata, " "); - Log.d(TAG, "]"); - } - - // AudioRendererEventListener - - @Override - public void onAudioEnabled(DecoderCounters counters) { - Log.d(TAG, "audioEnabled [" + getSessionTimeString() + "]"); - } - - @Override - public void onAudioSessionId(int audioSessionId) { - Log.d(TAG, "audioSessionId [" + audioSessionId + "]"); - } - - @Override - public void onAudioDecoderInitialized(String decoderName, long elapsedRealtimeMs, - long initializationDurationMs) { - Log.d(TAG, "audioDecoderInitialized [" + getSessionTimeString() + ", " + decoderName + "]"); - } - - @Override - public void onAudioInputFormatChanged(Format format) { - Log.d(TAG, "audioFormatChanged [" + getSessionTimeString() + ", " + Format.toLogString(format) - + "]"); - } - - @Override - public void onAudioDisabled(DecoderCounters counters) { - Log.d(TAG, "audioDisabled [" + getSessionTimeString() + "]"); - } - - @Override - public void onAudioTrackUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { - printInternalError("audioTrackUnderrun [" + bufferSize + ", " + bufferSizeMs + ", " - + elapsedSinceLastFeedMs + "]", null); - } - - // VideoRendererEventListener - - @Override - public void onVideoEnabled(DecoderCounters counters) { - Log.d(TAG, "videoEnabled [" + getSessionTimeString() + "]"); - } - - @Override - public void onVideoDecoderInitialized(String decoderName, long elapsedRealtimeMs, - long initializationDurationMs) { - Log.d(TAG, "videoDecoderInitialized [" + getSessionTimeString() + ", " + decoderName + "]"); - } - - @Override - public void onVideoInputFormatChanged(Format format) { - Log.d(TAG, "videoFormatChanged [" + getSessionTimeString() + ", " + Format.toLogString(format) - + "]"); - } - - @Override - public void onVideoDisabled(DecoderCounters counters) { - Log.d(TAG, "videoDisabled [" + getSessionTimeString() + "]"); - } - - @Override - public void onDroppedFrames(int count, long elapsed) { - Log.d(TAG, "droppedFrames [" + getSessionTimeString() + ", " + count + "]"); - } - - @Override - public void onVideoSizeChanged(int width, int height, int unappliedRotationDegrees, - float pixelWidthHeightRatio) { - Log.d(TAG, "videoSizeChanged [" + width + ", " + height + "]"); - } - - @Override - public void onRenderedFirstFrame(Surface surface) { - Log.d(TAG, "renderedFirstFrame [" + surface + "]"); - } - - // DefaultDrmSessionManager.EventListener - - @Override - public void onDrmSessionManagerError(Exception e) { - printInternalError("drmSessionManagerError", e); - } - - @Override - public void onDrmKeysRestored() { - Log.d(TAG, "drmKeysRestored [" + getSessionTimeString() + "]"); - } - - @Override - public void onDrmKeysRemoved() { - Log.d(TAG, "drmKeysRemoved [" + getSessionTimeString() + "]"); - } - - @Override - public void onDrmKeysLoaded() { - Log.d(TAG, "drmKeysLoaded [" + getSessionTimeString() + "]"); - } - - // ExtractorMediaSource.EventListener - - @Override - public void onLoadError(IOException error) { - printInternalError("loadError", error); - } - - // AdaptiveMediaSourceEventListener - - @Override - public void onLoadStarted(DataSpec dataSpec, int dataType, int trackType, Format trackFormat, - int trackSelectionReason, Object trackSelectionData, long mediaStartTimeMs, - long mediaEndTimeMs, long elapsedRealtimeMs) { - // Do nothing. - } - - @Override - public void onLoadError(DataSpec dataSpec, int dataType, int trackType, Format trackFormat, - int trackSelectionReason, Object trackSelectionData, long mediaStartTimeMs, - long mediaEndTimeMs, long elapsedRealtimeMs, long loadDurationMs, long bytesLoaded, - IOException error, boolean wasCanceled) { - printInternalError("loadError", error); - } - - @Override - public void onLoadCanceled(DataSpec dataSpec, int dataType, int trackType, Format trackFormat, - int trackSelectionReason, Object trackSelectionData, long mediaStartTimeMs, - long mediaEndTimeMs, long elapsedRealtimeMs, long loadDurationMs, long bytesLoaded) { - // Do nothing. - } - - @Override - public void onLoadCompleted(DataSpec dataSpec, int dataType, int trackType, Format trackFormat, - int trackSelectionReason, Object trackSelectionData, long mediaStartTimeMs, - long mediaEndTimeMs, long elapsedRealtimeMs, long loadDurationMs, long bytesLoaded) { - // Do nothing. - } - - @Override - public void onUpstreamDiscarded(int trackType, long mediaStartTimeMs, long mediaEndTimeMs) { - // Do nothing. - } - - @Override - public void onDownstreamFormatChanged(int trackType, Format trackFormat, int trackSelectionReason, - Object trackSelectionData, long mediaTimeMs) { - // Do nothing. - } - - // Internal methods - - private void printInternalError(String type, Exception e) { - Log.e(TAG, "internalError [" + getSessionTimeString() + ", " + type + "]", e); - } - - private void printMetadata(Metadata metadata, String prefix) { - for (int i = 0; i < metadata.length(); i++) { - Metadata.Entry entry = metadata.get(i); - if (entry instanceof TextInformationFrame) { - TextInformationFrame textInformationFrame = (TextInformationFrame) entry; - Log.d(TAG, prefix + String.format("%s: value=%s", textInformationFrame.id, - textInformationFrame.value)); - } else if (entry instanceof UrlLinkFrame) { - UrlLinkFrame urlLinkFrame = (UrlLinkFrame) entry; - Log.d(TAG, prefix + String.format("%s: url=%s", urlLinkFrame.id, urlLinkFrame.url)); - } else if (entry instanceof PrivFrame) { - PrivFrame privFrame = (PrivFrame) entry; - Log.d(TAG, prefix + String.format("%s: owner=%s", privFrame.id, privFrame.owner)); - } else if (entry instanceof GeobFrame) { - GeobFrame geobFrame = (GeobFrame) entry; - Log.d(TAG, prefix + String.format("%s: mimeType=%s, filename=%s, description=%s", - geobFrame.id, geobFrame.mimeType, geobFrame.filename, geobFrame.description)); - } else if (entry instanceof ApicFrame) { - ApicFrame apicFrame = (ApicFrame) entry; - Log.d(TAG, prefix + String.format("%s: mimeType=%s, description=%s", - apicFrame.id, apicFrame.mimeType, apicFrame.description)); - } else if (entry instanceof CommentFrame) { - CommentFrame commentFrame = (CommentFrame) entry; - Log.d(TAG, prefix + String.format("%s: language=%s, description=%s", commentFrame.id, - commentFrame.language, commentFrame.description)); - } else if (entry instanceof Id3Frame) { - Id3Frame id3Frame = (Id3Frame) entry; - Log.d(TAG, prefix + String.format("%s", id3Frame.id)); - } else if (entry instanceof EventMessage) { - EventMessage eventMessage = (EventMessage) entry; - Log.d(TAG, prefix + String.format("EMSG: scheme=%s, id=%d, value=%s", - eventMessage.schemeIdUri, eventMessage.id, eventMessage.value)); - } - } - } - - private String getSessionTimeString() { - return getTimeString(SystemClock.elapsedRealtime() - startTimeMs); - } - - private static String getTimeString(long timeMs) { - return timeMs == C.TIME_UNSET ? "?" : TIME_FORMAT.format((timeMs) / 1000f); - } - - private static String getStateString(int state) { - switch (state) { - case ExoPlayer.STATE_BUFFERING: - return "B"; - case ExoPlayer.STATE_ENDED: - return "E"; - case ExoPlayer.STATE_IDLE: - return "I"; - case ExoPlayer.STATE_READY: - return "R"; - default: - return "?"; - } - } - - private static String getFormatSupportString(int formatSupport) { - switch (formatSupport) { - case RendererCapabilities.FORMAT_HANDLED: - return "YES"; - case RendererCapabilities.FORMAT_EXCEEDS_CAPABILITIES: - return "NO_EXCEEDS_CAPABILITIES"; - case RendererCapabilities.FORMAT_UNSUPPORTED_SUBTYPE: - return "NO_UNSUPPORTED_TYPE"; - case RendererCapabilities.FORMAT_UNSUPPORTED_TYPE: - return "NO"; - default: - return "?"; - } - } - - private static String getAdaptiveSupportString(int trackCount, int adaptiveSupport) { - if (trackCount < 2) { - return "N/A"; - } - switch (adaptiveSupport) { - case RendererCapabilities.ADAPTIVE_SEAMLESS: - return "YES"; - case RendererCapabilities.ADAPTIVE_NOT_SEAMLESS: - return "YES_NOT_SEAMLESS"; - case RendererCapabilities.ADAPTIVE_NOT_SUPPORTED: - return "NO"; - default: - return "?"; - } - } - - private static String getTrackStatusString(TrackSelection selection, TrackGroup group, - int trackIndex) { - return getTrackStatusString(selection != null && selection.getTrackGroup() == group - && selection.indexOf(trackIndex) != C.INDEX_UNSET); - } - - private static String getTrackStatusString(boolean enabled) { - return enabled ? "[X]" : "[ ]"; - } - - private static String getRepeatModeString(@ExoPlayer.RepeatMode int repeatMode) { - switch (repeatMode) { - case ExoPlayer.REPEAT_MODE_OFF: - return "OFF"; - case ExoPlayer.REPEAT_MODE_ONE: - return "ONE"; - case ExoPlayer.REPEAT_MODE_ALL: - return "ALL"; - default: - return "?"; - } - } -} diff --git a/demo/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demo/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java deleted file mode 100644 index d498b8f0c4..0000000000 --- a/demo/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ /dev/null @@ -1,651 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.demo; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.net.Uri; -import android.os.Bundle; -import android.os.Handler; -import android.support.annotation.NonNull; -import android.text.TextUtils; -import android.view.KeyEvent; -import android.view.View; -import android.view.View.OnClickListener; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.FrameLayout; -import android.widget.LinearLayout; -import android.widget.TextView; -import android.widget.Toast; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.DefaultRenderersFactory; -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.EventListener; -import com.google.android.exoplayer2.SimpleExoPlayer; -import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; -import com.google.android.exoplayer2.drm.DrmSessionManager; -import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; -import com.google.android.exoplayer2.drm.FrameworkMediaDrm; -import com.google.android.exoplayer2.drm.HttpMediaDrmCallback; -import com.google.android.exoplayer2.drm.UnsupportedDrmException; -import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; -import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer.DecoderInitializationException; -import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException; -import com.google.android.exoplayer2.source.BehindLiveWindowException; -import com.google.android.exoplayer2.source.ConcatenatingMediaSource; -import com.google.android.exoplayer2.source.ExtractorMediaSource; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.TrackGroupArray; -import com.google.android.exoplayer2.source.dash.DashMediaSource; -import com.google.android.exoplayer2.source.dash.DefaultDashChunkSource; -import com.google.android.exoplayer2.source.hls.HlsMediaSource; -import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource; -import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; -import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; -import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; -import com.google.android.exoplayer2.trackselection.TrackSelection; -import com.google.android.exoplayer2.trackselection.TrackSelectionArray; -import com.google.android.exoplayer2.ui.DebugTextViewHelper; -import com.google.android.exoplayer2.ui.PlaybackControlView; -import com.google.android.exoplayer2.ui.SimpleExoPlayerView; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; -import com.google.android.exoplayer2.upstream.HttpDataSource; -import com.google.android.exoplayer2.util.Util; -import java.lang.reflect.Constructor; -import java.lang.reflect.Method; -import java.net.CookieHandler; -import java.net.CookieManager; -import java.net.CookiePolicy; -import java.util.UUID; - -/** - * An activity that plays media using {@link SimpleExoPlayer}. - */ -public class PlayerActivity extends Activity implements OnClickListener, EventListener, - PlaybackControlView.VisibilityListener { - - public static final String DRM_SCHEME_UUID_EXTRA = "drm_scheme_uuid"; - public static final String DRM_LICENSE_URL = "drm_license_url"; - public static final String DRM_KEY_REQUEST_PROPERTIES = "drm_key_request_properties"; - public static final String PREFER_EXTENSION_DECODERS = "prefer_extension_decoders"; - - public static final String ACTION_VIEW = "com.google.android.exoplayer.demo.action.VIEW"; - public static final String EXTENSION_EXTRA = "extension"; - - public static final String ACTION_VIEW_LIST = - "com.google.android.exoplayer.demo.action.VIEW_LIST"; - public static final String URI_LIST_EXTRA = "uri_list"; - public static final String EXTENSION_LIST_EXTRA = "extension_list"; - public static final String AD_TAG_URI_EXTRA = "ad_tag_uri"; - - private static final DefaultBandwidthMeter BANDWIDTH_METER = new DefaultBandwidthMeter(); - private static final CookieManager DEFAULT_COOKIE_MANAGER; - static { - DEFAULT_COOKIE_MANAGER = new CookieManager(); - DEFAULT_COOKIE_MANAGER.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER); - } - - private Handler mainHandler; - private EventLogger eventLogger; - private SimpleExoPlayerView simpleExoPlayerView; - private LinearLayout debugRootView; - private TextView debugTextView; - private Button retryButton; - - private DataSource.Factory mediaDataSourceFactory; - private SimpleExoPlayer player; - private DefaultTrackSelector trackSelector; - private TrackSelectionHelper trackSelectionHelper; - private DebugTextViewHelper debugViewHelper; - private boolean needRetrySource; - private TrackGroupArray lastSeenTrackGroupArray; - - private boolean shouldAutoPlay; - private int resumeWindow; - private long resumePosition; - - // Fields used only for ad playback. The ads loader is loaded via reflection. - - private Object imaAdsLoader; // com.google.android.exoplayer2.ext.ima.ImaAdsLoader - private Uri loadedAdTagUri; - private ViewGroup adOverlayViewGroup; - - // Activity lifecycle - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - shouldAutoPlay = true; - clearResumePosition(); - mediaDataSourceFactory = buildDataSourceFactory(true); - mainHandler = new Handler(); - if (CookieHandler.getDefault() != DEFAULT_COOKIE_MANAGER) { - CookieHandler.setDefault(DEFAULT_COOKIE_MANAGER); - } - - setContentView(R.layout.player_activity); - View rootView = findViewById(R.id.root); - rootView.setOnClickListener(this); - debugRootView = (LinearLayout) findViewById(R.id.controls_root); - debugTextView = (TextView) findViewById(R.id.debug_text_view); - retryButton = (Button) findViewById(R.id.retry_button); - retryButton.setOnClickListener(this); - - simpleExoPlayerView = (SimpleExoPlayerView) findViewById(R.id.player_view); - simpleExoPlayerView.setControllerVisibilityListener(this); - simpleExoPlayerView.requestFocus(); - } - - @Override - public void onNewIntent(Intent intent) { - releasePlayer(); - shouldAutoPlay = true; - clearResumePosition(); - setIntent(intent); - } - - @Override - public void onStart() { - super.onStart(); - if (Util.SDK_INT > 23) { - initializePlayer(); - } - } - - @Override - public void onResume() { - super.onResume(); - if ((Util.SDK_INT <= 23 || player == null)) { - initializePlayer(); - } - } - - @Override - public void onPause() { - super.onPause(); - if (Util.SDK_INT <= 23) { - releasePlayer(); - } - } - - @Override - public void onStop() { - super.onStop(); - if (Util.SDK_INT > 23) { - releasePlayer(); - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - releaseAdsLoader(); - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, - @NonNull int[] grantResults) { - if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - initializePlayer(); - } else { - showToast(R.string.storage_permission_denied); - finish(); - } - } - - // Activity input - - @Override - public boolean dispatchKeyEvent(KeyEvent event) { - // If the event was not handled then see if the player view can handle it. - return super.dispatchKeyEvent(event) || simpleExoPlayerView.dispatchKeyEvent(event); - } - - // OnClickListener methods - - @Override - public void onClick(View view) { - if (view == retryButton) { - initializePlayer(); - } else if (view.getParent() == debugRootView) { - MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo(); - if (mappedTrackInfo != null) { - trackSelectionHelper.showSelectionDialog(this, ((Button) view).getText(), - trackSelector.getCurrentMappedTrackInfo(), (int) view.getTag()); - } - } - } - - // PlaybackControlView.VisibilityListener implementation - - @Override - public void onVisibilityChange(int visibility) { - debugRootView.setVisibility(visibility); - } - - // Internal methods - - private void initializePlayer() { - Intent intent = getIntent(); - boolean needNewPlayer = player == null; - if (needNewPlayer) { - TrackSelection.Factory adaptiveTrackSelectionFactory = - new AdaptiveTrackSelection.Factory(BANDWIDTH_METER); - trackSelector = new DefaultTrackSelector(adaptiveTrackSelectionFactory); - trackSelectionHelper = new TrackSelectionHelper(trackSelector, adaptiveTrackSelectionFactory); - lastSeenTrackGroupArray = null; - eventLogger = new EventLogger(trackSelector); - - UUID drmSchemeUuid = intent.hasExtra(DRM_SCHEME_UUID_EXTRA) - ? UUID.fromString(intent.getStringExtra(DRM_SCHEME_UUID_EXTRA)) : null; - DrmSessionManager drmSessionManager = null; - if (drmSchemeUuid != null) { - String drmLicenseUrl = intent.getStringExtra(DRM_LICENSE_URL); - String[] keyRequestPropertiesArray = intent.getStringArrayExtra(DRM_KEY_REQUEST_PROPERTIES); - try { - drmSessionManager = buildDrmSessionManager(drmSchemeUuid, drmLicenseUrl, - keyRequestPropertiesArray); - } catch (UnsupportedDrmException e) { - int errorStringId = Util.SDK_INT < 18 ? R.string.error_drm_not_supported - : (e.reason == UnsupportedDrmException.REASON_UNSUPPORTED_SCHEME - ? R.string.error_drm_unsupported_scheme : R.string.error_drm_unknown); - showToast(errorStringId); - return; - } - } - - boolean preferExtensionDecoders = intent.getBooleanExtra(PREFER_EXTENSION_DECODERS, false); - @DefaultRenderersFactory.ExtensionRendererMode int extensionRendererMode = - ((DemoApplication) getApplication()).useExtensionRenderers() - ? (preferExtensionDecoders ? DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER - : DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) - : DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF; - DefaultRenderersFactory renderersFactory = new DefaultRenderersFactory(this, - drmSessionManager, extensionRendererMode); - - player = ExoPlayerFactory.newSimpleInstance(renderersFactory, trackSelector); - player.addListener(this); - player.addListener(eventLogger); - player.setAudioDebugListener(eventLogger); - player.setVideoDebugListener(eventLogger); - player.setMetadataOutput(eventLogger); - - simpleExoPlayerView.setPlayer(player); - player.setPlayWhenReady(shouldAutoPlay); - debugViewHelper = new DebugTextViewHelper(player, debugTextView); - debugViewHelper.start(); - } - if (needNewPlayer || needRetrySource) { - String action = intent.getAction(); - Uri[] uris; - String[] extensions; - if (ACTION_VIEW.equals(action)) { - uris = new Uri[] {intent.getData()}; - extensions = new String[] {intent.getStringExtra(EXTENSION_EXTRA)}; - } else if (ACTION_VIEW_LIST.equals(action)) { - String[] uriStrings = intent.getStringArrayExtra(URI_LIST_EXTRA); - uris = new Uri[uriStrings.length]; - for (int i = 0; i < uriStrings.length; i++) { - uris[i] = Uri.parse(uriStrings[i]); - } - extensions = intent.getStringArrayExtra(EXTENSION_LIST_EXTRA); - if (extensions == null) { - extensions = new String[uriStrings.length]; - } - } else { - showToast(getString(R.string.unexpected_intent_action, action)); - return; - } - if (Util.maybeRequestReadExternalStoragePermission(this, uris)) { - // The player will be reinitialized if the permission is granted. - return; - } - MediaSource[] mediaSources = new MediaSource[uris.length]; - for (int i = 0; i < uris.length; i++) { - mediaSources[i] = buildMediaSource(uris[i], extensions[i]); - } - MediaSource mediaSource = mediaSources.length == 1 ? mediaSources[0] - : new ConcatenatingMediaSource(mediaSources); - String adTagUriString = intent.getStringExtra(AD_TAG_URI_EXTRA); - if (adTagUriString != null) { - Uri adTagUri = Uri.parse(adTagUriString); - if (!adTagUri.equals(loadedAdTagUri)) { - releaseAdsLoader(); - loadedAdTagUri = adTagUri; - } - try { - mediaSource = createAdsMediaSource(mediaSource, Uri.parse(adTagUriString)); - } catch (Exception e) { - showToast(R.string.ima_not_loaded); - } - } else { - releaseAdsLoader(); - } - boolean haveResumePosition = resumeWindow != C.INDEX_UNSET; - if (haveResumePosition) { - player.seekTo(resumeWindow, resumePosition); - } - player.prepare(mediaSource, !haveResumePosition, false); - needRetrySource = false; - updateButtonVisibilities(); - } - } - - private MediaSource buildMediaSource(Uri uri, String overrideExtension) { - int type = TextUtils.isEmpty(overrideExtension) ? Util.inferContentType(uri) - : Util.inferContentType("." + overrideExtension); - switch (type) { - case C.TYPE_SS: - return new SsMediaSource(uri, buildDataSourceFactory(false), - new DefaultSsChunkSource.Factory(mediaDataSourceFactory), mainHandler, eventLogger); - case C.TYPE_DASH: - return new DashMediaSource(uri, buildDataSourceFactory(false), - new DefaultDashChunkSource.Factory(mediaDataSourceFactory), mainHandler, eventLogger); - case C.TYPE_HLS: - return new HlsMediaSource(uri, mediaDataSourceFactory, mainHandler, eventLogger); - case C.TYPE_OTHER: - return new ExtractorMediaSource(uri, mediaDataSourceFactory, new DefaultExtractorsFactory(), - mainHandler, eventLogger); - default: { - throw new IllegalStateException("Unsupported type: " + type); - } - } - } - - private DrmSessionManager buildDrmSessionManager(UUID uuid, - String licenseUrl, String[] keyRequestPropertiesArray) throws UnsupportedDrmException { - if (Util.SDK_INT < 18) { - return null; - } - HttpMediaDrmCallback drmCallback = new HttpMediaDrmCallback(licenseUrl, - buildHttpDataSourceFactory(false)); - if (keyRequestPropertiesArray != null) { - for (int i = 0; i < keyRequestPropertiesArray.length - 1; i += 2) { - drmCallback.setKeyRequestProperty(keyRequestPropertiesArray[i], - keyRequestPropertiesArray[i + 1]); - } - } - return new DefaultDrmSessionManager<>(uuid, FrameworkMediaDrm.newInstance(uuid), drmCallback, - null, mainHandler, eventLogger); - } - - private void releasePlayer() { - if (player != null) { - debugViewHelper.stop(); - debugViewHelper = null; - shouldAutoPlay = player.getPlayWhenReady(); - updateResumePosition(); - player.release(); - player = null; - trackSelector = null; - trackSelectionHelper = null; - eventLogger = null; - } - } - - private void updateResumePosition() { - resumeWindow = player.getCurrentWindowIndex(); - resumePosition = Math.max(0, player.getContentPosition()); - } - - private void clearResumePosition() { - resumeWindow = C.INDEX_UNSET; - resumePosition = C.TIME_UNSET; - } - - /** - * Returns a new DataSource factory. - * - * @param useBandwidthMeter Whether to set {@link #BANDWIDTH_METER} as a listener to the new - * DataSource factory. - * @return A new DataSource factory. - */ - private DataSource.Factory buildDataSourceFactory(boolean useBandwidthMeter) { - return ((DemoApplication) getApplication()) - .buildDataSourceFactory(useBandwidthMeter ? BANDWIDTH_METER : null); - } - - /** - * Returns a new HttpDataSource factory. - * - * @param useBandwidthMeter Whether to set {@link #BANDWIDTH_METER} as a listener to the new - * DataSource factory. - * @return A new HttpDataSource factory. - */ - private HttpDataSource.Factory buildHttpDataSourceFactory(boolean useBandwidthMeter) { - return ((DemoApplication) getApplication()) - .buildHttpDataSourceFactory(useBandwidthMeter ? BANDWIDTH_METER : null); - } - - /** - * Returns an ads media source, reusing the ads loader if one exists. - * - * @throws Exception Thrown if it was not possible to create an ads media source, for example, due - * to a missing dependency. - */ - private MediaSource createAdsMediaSource(MediaSource mediaSource, Uri adTagUri) throws Exception { - // 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) - .newInstance(this, adTagUri); - adOverlayViewGroup = new FrameLayout(this); - // The demo app has a non-null overlay frame layout. - simpleExoPlayerView.getOverlayFrameLayout().addView(adOverlayViewGroup); - } - 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); - } - - 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; - loadedAdTagUri = null; - simpleExoPlayerView.getOverlayFrameLayout().removeAllViews(); - } - } - - // ExoPlayer.EventListener implementation - - @Override - public void onLoadingChanged(boolean isLoading) { - // Do nothing. - } - - @Override - public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { - if (playbackState == ExoPlayer.STATE_ENDED) { - showControls(); - } - updateButtonVisibilities(); - } - - @Override - public void onRepeatModeChanged(int repeatMode) { - // Do nothing. - } - - @Override - public void onPositionDiscontinuity() { - if (needRetrySource) { - // 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); - } - needRetrySource = 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() { - debugRootView.removeAllViews(); - - retryButton.setVisibility(needRetrySource ? View.VISIBLE : View.GONE); - debugRootView.addView(retryButton); - - if (player == null) { - return; - } - - MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo(); - if (mappedTrackInfo == null) { - return; - } - - for (int i = 0; i < mappedTrackInfo.length; i++) { - TrackGroupArray trackGroups = mappedTrackInfo.getTrackGroups(i); - if (trackGroups.length != 0) { - Button button = new Button(this); - int label; - switch (player.getRendererType(i)) { - case C.TRACK_TYPE_AUDIO: - label = R.string.audio; - break; - case C.TRACK_TYPE_VIDEO: - label = R.string.video; - break; - case C.TRACK_TYPE_TEXT: - label = R.string.text; - break; - default: - continue; - } - button.setText(label); - button.setTag(i); - button.setOnClickListener(this); - debugRootView.addView(button, debugRootView.getChildCount() - 1); - } - } - } - - private void showControls() { - debugRootView.setVisibility(View.VISIBLE); - } - - private void showToast(int messageId) { - showToast(getString(messageId)); - } - - private void showToast(String message) { - Toast.makeText(getApplicationContext(), message, Toast.LENGTH_LONG).show(); - } - - private static boolean isBehindLiveWindow(ExoPlaybackException e) { - if (e.type != ExoPlaybackException.TYPE_SOURCE) { - return false; - } - Throwable cause = e.getSourceException(); - while (cause != null) { - if (cause instanceof BehindLiveWindowException) { - return true; - } - cause = cause.getCause(); - } - return false; - } - -} diff --git a/demo/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionHelper.java b/demo/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionHelper.java deleted file mode 100644 index fb7217f8fd..0000000000 --- a/demo/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionHelper.java +++ /dev/null @@ -1,290 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.demo; - -import android.annotation.SuppressLint; -import android.app.Activity; -import android.app.AlertDialog; -import android.content.Context; -import android.content.DialogInterface; -import android.content.res.TypedArray; -import android.util.Pair; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.CheckedTextView; -import com.google.android.exoplayer2.RendererCapabilities; -import com.google.android.exoplayer2.source.TrackGroup; -import com.google.android.exoplayer2.source.TrackGroupArray; -import com.google.android.exoplayer2.trackselection.FixedTrackSelection; -import com.google.android.exoplayer2.trackselection.MappingTrackSelector; -import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; -import com.google.android.exoplayer2.trackselection.MappingTrackSelector.SelectionOverride; -import com.google.android.exoplayer2.trackselection.RandomTrackSelection; -import com.google.android.exoplayer2.trackselection.TrackSelection; -import java.util.Arrays; - -/** - * Helper class for displaying track selection dialogs. - */ -/* package */ final class TrackSelectionHelper implements View.OnClickListener, - DialogInterface.OnClickListener { - - private static final TrackSelection.Factory FIXED_FACTORY = new FixedTrackSelection.Factory(); - private static final TrackSelection.Factory RANDOM_FACTORY = new RandomTrackSelection.Factory(); - - private final MappingTrackSelector selector; - private final TrackSelection.Factory adaptiveTrackSelectionFactory; - - private MappedTrackInfo trackInfo; - private int rendererIndex; - private TrackGroupArray trackGroups; - private boolean[] trackGroupsAdaptive; - private boolean isDisabled; - private SelectionOverride override; - - private CheckedTextView disableView; - private CheckedTextView defaultView; - private CheckedTextView enableRandomAdaptationView; - private CheckedTextView[][] trackViews; - - /** - * @param selector The track selector. - * @param adaptiveTrackSelectionFactory A factory for adaptive {@link TrackSelection}s, or null - * if the selection helper should not support adaptive tracks. - */ - public TrackSelectionHelper(MappingTrackSelector selector, - TrackSelection.Factory adaptiveTrackSelectionFactory) { - this.selector = selector; - this.adaptiveTrackSelectionFactory = adaptiveTrackSelectionFactory; - } - - /** - * Shows the selection dialog for a given renderer. - * - * @param activity The parent activity. - * @param title The dialog's title. - * @param trackInfo The current track information. - * @param rendererIndex The index of the renderer. - */ - public void showSelectionDialog(Activity activity, CharSequence title, MappedTrackInfo trackInfo, - int rendererIndex) { - this.trackInfo = trackInfo; - this.rendererIndex = rendererIndex; - - trackGroups = trackInfo.getTrackGroups(rendererIndex); - trackGroupsAdaptive = new boolean[trackGroups.length]; - for (int i = 0; i < trackGroups.length; i++) { - trackGroupsAdaptive[i] = adaptiveTrackSelectionFactory != null - && trackInfo.getAdaptiveSupport(rendererIndex, i, false) - != RendererCapabilities.ADAPTIVE_NOT_SUPPORTED - && trackGroups.get(i).length > 1; - } - isDisabled = selector.getRendererDisabled(rendererIndex); - override = selector.getSelectionOverride(rendererIndex, trackGroups); - - AlertDialog.Builder builder = new AlertDialog.Builder(activity); - builder.setTitle(title) - .setView(buildView(builder.getContext())) - .setPositiveButton(android.R.string.ok, this) - .setNegativeButton(android.R.string.cancel, null) - .create() - .show(); - } - - @SuppressLint("InflateParams") - 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); - - TypedArray attributeArray = context.getTheme().obtainStyledAttributes( - new int[] {android.R.attr.selectableItemBackground}); - int selectableItemBackgroundResourceId = attributeArray.getResourceId(0, 0); - attributeArray.recycle(); - - // View for disabling the renderer. - disableView = (CheckedTextView) inflater.inflate( - android.R.layout.simple_list_item_single_choice, root, false); - disableView.setBackgroundResource(selectableItemBackgroundResourceId); - disableView.setText(R.string.selection_disabled); - disableView.setFocusable(true); - disableView.setOnClickListener(this); - root.addView(disableView); - - // View for clearing the override to allow the selector to use its default selection logic. - defaultView = (CheckedTextView) inflater.inflate( - android.R.layout.simple_list_item_single_choice, root, false); - defaultView.setBackgroundResource(selectableItemBackgroundResourceId); - defaultView.setText(R.string.selection_default); - defaultView.setFocusable(true); - defaultView.setOnClickListener(this); - root.addView(inflater.inflate(R.layout.list_divider, root, false)); - root.addView(defaultView); - - // Per-track views. - boolean haveAdaptiveTracks = false; - trackViews = new CheckedTextView[trackGroups.length][]; - for (int groupIndex = 0; groupIndex < trackGroups.length; groupIndex++) { - TrackGroup group = trackGroups.get(groupIndex); - boolean groupIsAdaptive = trackGroupsAdaptive[groupIndex]; - haveAdaptiveTracks |= groupIsAdaptive; - trackViews[groupIndex] = new CheckedTextView[group.length]; - for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { - if (trackIndex == 0) { - root.addView(inflater.inflate(R.layout.list_divider, root, false)); - } - int trackViewLayoutId = groupIsAdaptive ? android.R.layout.simple_list_item_multiple_choice - : android.R.layout.simple_list_item_single_choice; - CheckedTextView trackView = (CheckedTextView) inflater.inflate( - trackViewLayoutId, root, false); - trackView.setBackgroundResource(selectableItemBackgroundResourceId); - trackView.setText(DemoUtil.buildTrackName(group.getFormat(trackIndex))); - if (trackInfo.getTrackFormatSupport(rendererIndex, groupIndex, trackIndex) - == RendererCapabilities.FORMAT_HANDLED) { - trackView.setFocusable(true); - trackView.setTag(Pair.create(groupIndex, trackIndex)); - trackView.setOnClickListener(this); - } else { - trackView.setFocusable(false); - trackView.setEnabled(false); - } - trackViews[groupIndex][trackIndex] = trackView; - root.addView(trackView); - } - } - - if (haveAdaptiveTracks) { - // View for using random adaptation. - enableRandomAdaptationView = (CheckedTextView) inflater.inflate( - android.R.layout.simple_list_item_multiple_choice, root, false); - enableRandomAdaptationView.setBackgroundResource(selectableItemBackgroundResourceId); - enableRandomAdaptationView.setText(R.string.enable_random_adaptation); - enableRandomAdaptationView.setOnClickListener(this); - root.addView(inflater.inflate(R.layout.list_divider, root, false)); - root.addView(enableRandomAdaptationView); - } - - updateViews(); - return view; - } - - private void updateViews() { - disableView.setChecked(isDisabled); - defaultView.setChecked(!isDisabled && override == null); - for (int i = 0; i < trackViews.length; i++) { - for (int j = 0; j < trackViews[i].length; j++) { - trackViews[i][j].setChecked(override != null && override.groupIndex == i - && override.containsTrack(j)); - } - } - if (enableRandomAdaptationView != null) { - boolean enableView = !isDisabled && override != null && override.length > 1; - enableRandomAdaptationView.setEnabled(enableView); - enableRandomAdaptationView.setFocusable(enableView); - if (enableView) { - enableRandomAdaptationView.setChecked(!isDisabled - && override.factory instanceof RandomTrackSelection.Factory); - } - } - } - - // DialogInterface.OnClickListener - - @Override - public void onClick(DialogInterface dialog, int which) { - selector.setRendererDisabled(rendererIndex, isDisabled); - if (override != null) { - selector.setSelectionOverride(rendererIndex, trackGroups, override); - } else { - selector.clearSelectionOverrides(rendererIndex); - } - } - - // View.OnClickListener - - @Override - public void onClick(View view) { - if (view == disableView) { - isDisabled = true; - override = null; - } else if (view == defaultView) { - isDisabled = false; - override = null; - } else if (view == enableRandomAdaptationView) { - setOverride(override.groupIndex, override.tracks, !enableRandomAdaptationView.isChecked()); - } else { - isDisabled = false; - @SuppressWarnings("unchecked") - Pair tag = (Pair) view.getTag(); - int groupIndex = tag.first; - int trackIndex = tag.second; - if (!trackGroupsAdaptive[groupIndex] || override == null - || override.groupIndex != groupIndex) { - override = new SelectionOverride(FIXED_FACTORY, groupIndex, trackIndex); - } else { - // The group being modified is adaptive and we already have a non-null override. - boolean isEnabled = ((CheckedTextView) view).isChecked(); - int overrideLength = override.length; - if (isEnabled) { - // Remove the track from the override. - if (overrideLength == 1) { - // The last track is being removed, so the override becomes empty. - override = null; - isDisabled = true; - } else { - setOverride(groupIndex, getTracksRemoving(override, trackIndex), - enableRandomAdaptationView.isChecked()); - } - } else { - // Add the track to the override. - setOverride(groupIndex, getTracksAdding(override, trackIndex), - enableRandomAdaptationView.isChecked()); - } - } - } - // Update the views with the new state. - updateViews(); - } - - private void setOverride(int group, int[] tracks, boolean enableRandomAdaptation) { - TrackSelection.Factory factory = tracks.length == 1 ? FIXED_FACTORY - : (enableRandomAdaptation ? RANDOM_FACTORY : adaptiveTrackSelectionFactory); - override = new SelectionOverride(factory, group, tracks); - } - - // Track array manipulation. - - private static int[] getTracksAdding(SelectionOverride override, int addedTrack) { - int[] tracks = override.tracks; - tracks = Arrays.copyOf(tracks, tracks.length + 1); - tracks[tracks.length - 1] = addedTrack; - return tracks; - } - - private static int[] getTracksRemoving(SelectionOverride override, int removedTrack) { - int[] tracks = new int[override.length - 1]; - int trackCount = 0; - for (int i = 0; i < tracks.length + 1; i++) { - int track = override.tracks[i]; - if (track != removedTrack) { - tracks[trackCount++] = track; - } - } - return tracks; - } - -} 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 520d83cc3b..0000000000 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 6e8b5499de..0000000000 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 26fe2f0782..0000000000 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 d3251491ce..0000000000 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 b5a12d35f3..0000000000 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 9c26192c32..0000000000 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 0000000000..7e62249db1 --- /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/cast/README.md b/demos/cast/README.md new file mode 100644 index 0000000000..2c68a5277a --- /dev/null +++ b/demos/cast/README.md @@ -0,0 +1,4 @@ +# Cast demo application # + +This folder contains a demo application that showcases ExoPlayer integration +with Google Cast. diff --git a/demos/cast/build.gradle b/demos/cast/build.gradle new file mode 100644 index 0000000000..915bc10b7c --- /dev/null +++ b/demos/cast/build.gradle @@ -0,0 +1,66 @@ +// 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 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + defaultConfig { + versionName project.ext.releaseVersion + versionCode project.ext.releaseVersionCode + minSdkVersion 16 + targetSdkVersion project.ext.targetSdkVersion + } + + buildTypes { + release { + shrinkResources true + minifyEnabled true + proguardFiles = [ + "proguard-rules.txt", + getDefaultProguardFile('proguard-android.txt') + ] + } + debug { + jniDebuggable = true + } + } + + lintOptions { + // The demo app does not have translations. + disable 'MissingTranslation' + } + +} + +dependencies { + implementation project(modulePrefix + 'library-core') + implementation project(modulePrefix + 'library-dash') + implementation project(modulePrefix + 'library-hls') + implementation project(modulePrefix + 'library-smoothstreaming') + implementation project(modulePrefix + 'library-ui') + implementation project(modulePrefix + 'extension-cast') + implementation 'com.android.support:support-v4:' + supportLibraryVersion + implementation 'com.android.support:appcompat-v7:' + supportLibraryVersion + implementation 'com.android.support:recyclerview-v7:' + supportLibraryVersion +} + +apply plugin: 'com.google.android.gms.strict-version-matcher-plugin' diff --git a/demos/cast/proguard-rules.txt b/demos/cast/proguard-rules.txt new file mode 100644 index 0000000000..3221818080 --- /dev/null +++ b/demos/cast/proguard-rules.txt @@ -0,0 +1,6 @@ +# Proguard rules specific to the Cast demo app. + +# Accessed via menu.xml +-keep class android.support.v7.app.MediaRouteActionProvider { + *; +} diff --git a/demos/cast/src/main/AndroidManifest.xml b/demos/cast/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..ae16776333 --- /dev/null +++ b/demos/cast/src/main/AndroidManifest.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DefaultReceiverPlayerManager.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DefaultReceiverPlayerManager.java new file mode 100644 index 0000000000..daaac9486e --- /dev/null +++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DefaultReceiverPlayerManager.java @@ -0,0 +1,408 @@ +/* + * 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.castdemo; + +import android.content.Context; +import android.net.Uri; +import android.support.annotation.Nullable; +import android.view.KeyEvent; +import android.view.View; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.DefaultRenderersFactory; +import com.google.android.exoplayer2.ExoPlayerFactory; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Player.DiscontinuityReason; +import com.google.android.exoplayer2.Player.EventListener; +import com.google.android.exoplayer2.Player.TimelineChangeReason; +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.ext.cast.CastPlayer; +import com.google.android.exoplayer2.ext.cast.MediaItem; +import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener; +import com.google.android.exoplayer2.source.ConcatenatingMediaSource; +import com.google.android.exoplayer2.source.ExtractorMediaSource; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.dash.DashMediaSource; +import com.google.android.exoplayer2.source.hls.HlsMediaSource; +import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.ui.PlayerControlView; +import com.google.android.exoplayer2.ui.PlayerView; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; +import com.google.android.gms.cast.MediaInfo; +import com.google.android.gms.cast.MediaMetadata; +import com.google.android.gms.cast.MediaQueueItem; +import com.google.android.gms.cast.framework.CastContext; +import java.util.ArrayList; + +/** Manages players and an internal media queue for the ExoPlayer/Cast demo app. */ +/* package */ class DefaultReceiverPlayerManager + implements EventListener, SessionAvailabilityListener, PlayerManager { + + private static final String USER_AGENT = "ExoCastDemoPlayer"; + private static final DefaultHttpDataSourceFactory DATA_SOURCE_FACTORY = + new DefaultHttpDataSourceFactory(USER_AGENT); + + private final PlayerView localPlayerView; + private final PlayerControlView castControlView; + private final SimpleExoPlayer exoPlayer; + private final CastPlayer castPlayer; + private final ArrayList mediaQueue; + private final QueuePositionListener queuePositionListener; + private final ConcatenatingMediaSource concatenatingMediaSource; + + private boolean castMediaQueueCreationPending; + private int currentItemIndex; + private Player currentPlayer; + + /** + * @param queuePositionListener A {@link QueuePositionListener} for queue position changes. + * @param localPlayerView The {@link PlayerView} for local playback. + * @param castControlView The {@link PlayerControlView} to control remote playback. + * @param context A {@link Context}. + * @param castContext The {@link CastContext}. + */ + public static DefaultReceiverPlayerManager createPlayerManager( + QueuePositionListener queuePositionListener, + PlayerView localPlayerView, + PlayerControlView castControlView, + Context context, + CastContext castContext) { + DefaultReceiverPlayerManager defaultReceiverPlayerManager = + new DefaultReceiverPlayerManager( + queuePositionListener, localPlayerView, castControlView, context, castContext); + defaultReceiverPlayerManager.init(); + return defaultReceiverPlayerManager; + } + + private DefaultReceiverPlayerManager( + QueuePositionListener queuePositionListener, + PlayerView localPlayerView, + PlayerControlView castControlView, + Context context, + CastContext castContext) { + this.queuePositionListener = queuePositionListener; + this.localPlayerView = localPlayerView; + this.castControlView = castControlView; + mediaQueue = new ArrayList<>(); + currentItemIndex = C.INDEX_UNSET; + concatenatingMediaSource = new ConcatenatingMediaSource(); + + DefaultTrackSelector trackSelector = new DefaultTrackSelector(); + RenderersFactory renderersFactory = new DefaultRenderersFactory(context); + exoPlayer = ExoPlayerFactory.newSimpleInstance(context, renderersFactory, trackSelector); + exoPlayer.addListener(this); + localPlayerView.setPlayer(exoPlayer); + + castPlayer = new CastPlayer(castContext); + castPlayer.addListener(this); + castPlayer.setSessionAvailabilityListener(this); + castControlView.setPlayer(castPlayer); + } + + // Queue manipulation methods. + + /** + * Plays a specified queue item in the current player. + * + * @param itemIndex The index of the item to play. + */ + public void selectQueueItem(int itemIndex) { + setCurrentItem(itemIndex, C.TIME_UNSET, true); + } + + /** + * Returns the index of the currently played item. + */ + public int getCurrentItemIndex() { + return currentItemIndex; + } + + /** + * Appends {@code item} to the media queue. + * + * @param item The {@link MediaItem} to append. + */ + public void addItem(MediaItem item) { + mediaQueue.add(item); + concatenatingMediaSource.addMediaSource(buildMediaSource(item)); + if (currentPlayer == castPlayer) { + castPlayer.addItems(buildMediaQueueItem(item)); + } + } + + /** + * Returns the size of the media queue. + */ + public int getMediaQueueSize() { + return mediaQueue.size(); + } + + /** + * Returns the item at the given index in the media queue. + * + * @param position The index of the item. + * @return The item at the given index in the media queue. + */ + public MediaItem getItem(int position) { + return mediaQueue.get(position); + } + + /** + * Removes the item at the given index from the media queue. + * + * @param itemIndex The index of the item to remove. + * @return Whether the removal was successful. + */ + public boolean removeItem(int itemIndex) { + concatenatingMediaSource.removeMediaSource(itemIndex); + if (currentPlayer == castPlayer) { + if (castPlayer.getPlaybackState() != Player.STATE_IDLE) { + Timeline castTimeline = castPlayer.getCurrentTimeline(); + if (castTimeline.getPeriodCount() <= itemIndex) { + return false; + } + castPlayer.removeItem((int) castTimeline.getPeriod(itemIndex, new Period()).id); + } + } + mediaQueue.remove(itemIndex); + if (itemIndex == currentItemIndex && itemIndex == mediaQueue.size()) { + maybeSetCurrentItemAndNotify(C.INDEX_UNSET); + } else if (itemIndex < currentItemIndex) { + maybeSetCurrentItemAndNotify(currentItemIndex - 1); + } + return true; + } + + /** + * Moves an item within the queue. + * + * @param fromIndex The index of the item to move. + * @param toIndex The target index of the item in the queue. + * @return Whether the item move was successful. + */ + public boolean moveItem(int fromIndex, int toIndex) { + // Player update. + concatenatingMediaSource.moveMediaSource(fromIndex, toIndex); + if (currentPlayer == castPlayer && castPlayer.getPlaybackState() != Player.STATE_IDLE) { + Timeline castTimeline = castPlayer.getCurrentTimeline(); + int periodCount = castTimeline.getPeriodCount(); + if (periodCount <= fromIndex || periodCount <= toIndex) { + return false; + } + int elementId = (int) castTimeline.getPeriod(fromIndex, new Period()).id; + castPlayer.moveItem(elementId, toIndex); + } + + mediaQueue.add(toIndex, mediaQueue.remove(fromIndex)); + + // Index update. + if (fromIndex == currentItemIndex) { + maybeSetCurrentItemAndNotify(toIndex); + } else if (fromIndex < currentItemIndex && toIndex >= currentItemIndex) { + maybeSetCurrentItemAndNotify(currentItemIndex - 1); + } else if (fromIndex > currentItemIndex && toIndex <= currentItemIndex) { + maybeSetCurrentItemAndNotify(currentItemIndex + 1); + } + + return true; + } + + // Miscellaneous methods. + + /** + * Dispatches a given {@link KeyEvent} to the corresponding view of the current player. + * + * @param event The {@link KeyEvent}. + * @return Whether the event was handled by the target view. + */ + public boolean dispatchKeyEvent(KeyEvent event) { + if (currentPlayer == exoPlayer) { + return localPlayerView.dispatchKeyEvent(event); + } else /* currentPlayer == castPlayer */ { + return castControlView.dispatchKeyEvent(event); + } + } + + /** + * Releases the manager and the players that it holds. + */ + public void release() { + currentItemIndex = C.INDEX_UNSET; + mediaQueue.clear(); + concatenatingMediaSource.clear(); + castPlayer.setSessionAvailabilityListener(null); + castPlayer.release(); + localPlayerView.setPlayer(null); + exoPlayer.release(); + } + + // Player.EventListener implementation. + + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + updateCurrentItemIndex(); + } + + @Override + public void onPositionDiscontinuity(@DiscontinuityReason int reason) { + updateCurrentItemIndex(); + } + + @Override + public void onTimelineChanged( + Timeline timeline, @Nullable Object manifest, @TimelineChangeReason int reason) { + updateCurrentItemIndex(); + if (timeline.isEmpty()) { + castMediaQueueCreationPending = true; + } + } + + // CastPlayer.SessionAvailabilityListener implementation. + + @Override + public void onCastSessionAvailable() { + setCurrentPlayer(castPlayer); + } + + @Override + public void onCastSessionUnavailable() { + setCurrentPlayer(exoPlayer); + } + + // Internal methods. + + private void init() { + setCurrentPlayer(castPlayer.isCastSessionAvailable() ? castPlayer : exoPlayer); + } + + private void updateCurrentItemIndex() { + int playbackState = currentPlayer.getPlaybackState(); + maybeSetCurrentItemAndNotify( + playbackState != Player.STATE_IDLE && playbackState != Player.STATE_ENDED + ? currentPlayer.getCurrentWindowIndex() : C.INDEX_UNSET); + } + + private void setCurrentPlayer(Player currentPlayer) { + if (this.currentPlayer == currentPlayer) { + return; + } + + // View management. + if (currentPlayer == exoPlayer) { + localPlayerView.setVisibility(View.VISIBLE); + castControlView.hide(); + } else /* currentPlayer == castPlayer */ { + localPlayerView.setVisibility(View.GONE); + castControlView.show(); + } + + // Player state management. + long playbackPositionMs = C.TIME_UNSET; + int windowIndex = C.INDEX_UNSET; + boolean playWhenReady = false; + if (this.currentPlayer != null) { + int playbackState = this.currentPlayer.getPlaybackState(); + if (playbackState != Player.STATE_ENDED) { + playbackPositionMs = this.currentPlayer.getCurrentPosition(); + playWhenReady = this.currentPlayer.getPlayWhenReady(); + windowIndex = this.currentPlayer.getCurrentWindowIndex(); + if (windowIndex != currentItemIndex) { + playbackPositionMs = C.TIME_UNSET; + windowIndex = currentItemIndex; + } + } + this.currentPlayer.stop(true); + } else { + // This is the initial setup. No need to save any state. + } + + this.currentPlayer = currentPlayer; + + // Media queue management. + castMediaQueueCreationPending = currentPlayer == castPlayer; + if (currentPlayer == exoPlayer) { + exoPlayer.prepare(concatenatingMediaSource); + } + + // Playback transition. + if (windowIndex != C.INDEX_UNSET) { + setCurrentItem(windowIndex, playbackPositionMs, playWhenReady); + } + } + + /** + * Starts playback of the item at the given position. + * + * @param itemIndex The index of the item to play. + * @param positionMs The position at which playback should start. + * @param playWhenReady Whether the player should proceed when ready to do so. + */ + private void setCurrentItem(int itemIndex, long positionMs, boolean playWhenReady) { + maybeSetCurrentItemAndNotify(itemIndex); + if (castMediaQueueCreationPending) { + MediaQueueItem[] items = new MediaQueueItem[mediaQueue.size()]; + for (int i = 0; i < items.length; i++) { + items[i] = buildMediaQueueItem(mediaQueue.get(i)); + } + castMediaQueueCreationPending = false; + castPlayer.loadItems(items, itemIndex, positionMs, Player.REPEAT_MODE_OFF); + } else { + currentPlayer.seekTo(itemIndex, positionMs); + currentPlayer.setPlayWhenReady(playWhenReady); + } + } + + private void maybeSetCurrentItemAndNotify(int currentItemIndex) { + if (this.currentItemIndex != currentItemIndex) { + int oldIndex = this.currentItemIndex; + this.currentItemIndex = currentItemIndex; + queuePositionListener.onQueuePositionChanged(oldIndex, currentItemIndex); + } + } + + private static MediaSource buildMediaSource(MediaItem item) { + Uri uri = item.media.uri; + switch (item.mimeType) { + case DemoUtil.MIME_TYPE_SS: + return new SsMediaSource.Factory(DATA_SOURCE_FACTORY).createMediaSource(uri); + case DemoUtil.MIME_TYPE_DASH: + return new DashMediaSource.Factory(DATA_SOURCE_FACTORY).createMediaSource(uri); + case DemoUtil.MIME_TYPE_HLS: + return new HlsMediaSource.Factory(DATA_SOURCE_FACTORY).createMediaSource(uri); + case DemoUtil.MIME_TYPE_VIDEO_MP4: + return new ExtractorMediaSource.Factory(DATA_SOURCE_FACTORY).createMediaSource(uri); + default: { + throw new IllegalStateException("Unsupported type: " + item.mimeType); + } + } + } + + private static MediaQueueItem buildMediaQueueItem(MediaItem item) { + MediaMetadata movieMetadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_MOVIE); + movieMetadata.putString(MediaMetadata.KEY_TITLE, item.title); + MediaInfo mediaInfo = + new MediaInfo.Builder(item.media.uri.toString()) + .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) + .setContentType(item.mimeType) + .setMetadata(movieMetadata) + .build(); + return new MediaQueueItem.Builder(mediaInfo).build(); + } + +} diff --git a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DemoUtil.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DemoUtil.java new file mode 100644 index 0000000000..369cb8ab90 --- /dev/null +++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DemoUtil.java @@ -0,0 +1,75 @@ +/* + * 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.castdemo; + +import com.google.android.exoplayer2.ext.cast.MediaItem; +import com.google.android.exoplayer2.util.MimeTypes; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Utility methods and constants for the Cast demo application. */ +/* package */ final class DemoUtil { + + public static final String MIME_TYPE_DASH = MimeTypes.APPLICATION_MPD; + public static final String MIME_TYPE_HLS = MimeTypes.APPLICATION_M3U8; + public static final String MIME_TYPE_SS = MimeTypes.APPLICATION_SS; + public static final String MIME_TYPE_VIDEO_MP4 = MimeTypes.VIDEO_MP4; + + /** The list of samples available in the cast demo app. */ + public static final List SAMPLES; + + static { + // App samples. + ArrayList samples = new ArrayList<>(); + MediaItem.Builder sampleBuilder = new MediaItem.Builder(); + + samples.add( + sampleBuilder + .setTitle("DASH (clear,MP4,H264)") + .setMimeType(MIME_TYPE_DASH) + .setMedia("https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd") + .buildAndClear()); + + samples.add( + sampleBuilder + .setTitle("Tears of Steel (HLS)") + .setMimeType(MIME_TYPE_HLS) + .setMedia( + "https://commondatastorage.googleapis.com/gtv-videos-bucket/CastVideos/" + + "hls/TearsOfSteel.m3u8") + .buildAndClear()); + + samples.add( + sampleBuilder + .setTitle("HLS Basic (TS)") + .setMimeType(MIME_TYPE_HLS) + .setMedia( + "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3" + + "/bipbop_4x3_variant.m3u8") + .buildAndClear()); + + samples.add( + sampleBuilder + .setTitle("Dizzy (MP4)") + .setMimeType(MIME_TYPE_VIDEO_MP4) + .setMedia("https://html5demos.com/assets/dizzy.mp4") + .buildAndClear()); + SAMPLES = Collections.unmodifiableList(samples); + } + + private DemoUtil() {} +} diff --git a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/MainActivity.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/MainActivity.java new file mode 100644 index 0000000000..8ebfee1294 --- /dev/null +++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/MainActivity.java @@ -0,0 +1,272 @@ +/* + * 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.castdemo; + +import android.content.Context; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v4.graphics.ColorUtils; +import android.support.v7.app.AlertDialog; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.RecyclerView.ViewHolder; +import android.support.v7.widget.helper.ItemTouchHelper; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.TextView; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.ext.cast.MediaItem; +import com.google.android.exoplayer2.ui.PlayerControlView; +import com.google.android.exoplayer2.ui.PlayerView; +import com.google.android.gms.cast.CastMediaControlIntent; +import com.google.android.gms.cast.framework.CastButtonFactory; +import com.google.android.gms.cast.framework.CastContext; + +/** + * An activity that plays video using {@link SimpleExoPlayer} and supports casting using ExoPlayer's + * Cast extension. + */ +public class MainActivity extends AppCompatActivity + implements OnClickListener, PlayerManager.QueuePositionListener { + + private PlayerView localPlayerView; + private PlayerControlView castControlView; + private PlayerManager playerManager; + private RecyclerView mediaQueueList; + private MediaQueueListAdapter mediaQueueListAdapter; + private CastContext castContext; + + // Activity lifecycle methods. + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + // Getting the cast context later than onStart can cause device discovery not to take place. + castContext = CastContext.getSharedInstance(this); + + setContentView(R.layout.main_activity); + + localPlayerView = findViewById(R.id.local_player_view); + localPlayerView.requestFocus(); + + castControlView = findViewById(R.id.cast_control_view); + + mediaQueueList = findViewById(R.id.sample_list); + ItemTouchHelper helper = new ItemTouchHelper(new RecyclerViewCallback()); + helper.attachToRecyclerView(mediaQueueList); + mediaQueueList.setLayoutManager(new LinearLayoutManager(this)); + mediaQueueList.setHasFixedSize(true); + mediaQueueListAdapter = new MediaQueueListAdapter(); + + findViewById(R.id.add_sample_button).setOnClickListener(this); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + getMenuInflater().inflate(R.menu.menu, menu); + CastButtonFactory.setUpMediaRouteButton(this, menu, R.id.media_route_menu_item); + return true; + } + + @Override + public void onResume() { + super.onResume(); + String applicationId = castContext.getCastOptions().getReceiverApplicationId(); + switch (applicationId) { + case CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID: + playerManager = + DefaultReceiverPlayerManager.createPlayerManager( + /* queuePositionListener= */ this, + localPlayerView, + castControlView, + /* context= */ this, + castContext); + break; + default: + throw new IllegalStateException("Illegal receiver app id: " + applicationId); + } + mediaQueueList.setAdapter(mediaQueueListAdapter); + } + + @Override + public void onPause() { + super.onPause(); + mediaQueueListAdapter.notifyItemRangeRemoved(0, mediaQueueListAdapter.getItemCount()); + mediaQueueList.setAdapter(null); + playerManager.release(); + playerManager = null; + } + + // Activity input. + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + // If the event was not handled then see if the player view can handle it. + return super.dispatchKeyEvent(event) || playerManager.dispatchKeyEvent(event); + } + + @Override + public void onClick(View view) { + new AlertDialog.Builder(this).setTitle(R.string.sample_list_dialog_title) + .setView(buildSampleListView()).setPositiveButton(android.R.string.ok, null).create() + .show(); + } + + // PlayerManager.QueuePositionListener implementation. + + @Override + public void onQueuePositionChanged(int previousIndex, int newIndex) { + if (previousIndex != C.INDEX_UNSET) { + mediaQueueListAdapter.notifyItemChanged(previousIndex); + } + if (newIndex != C.INDEX_UNSET) { + mediaQueueListAdapter.notifyItemChanged(newIndex); + } + } + + // Internal methods. + + private View buildSampleListView() { + View dialogList = getLayoutInflater().inflate(R.layout.sample_list, null); + ListView sampleList = dialogList.findViewById(R.id.sample_list); + sampleList.setAdapter(new SampleListAdapter(this)); + sampleList.setOnItemClickListener( + (parent, view, position, id) -> { + playerManager.addItem(DemoUtil.SAMPLES.get(position)); + mediaQueueListAdapter.notifyItemInserted(playerManager.getMediaQueueSize() - 1); + }); + return dialogList; + } + + // Internal classes. + + private class QueueItemViewHolder extends RecyclerView.ViewHolder implements OnClickListener { + + public final TextView textView; + + public QueueItemViewHolder(TextView textView) { + super(textView); + this.textView = textView; + textView.setOnClickListener(this); + } + + @Override + public void onClick(View v) { + playerManager.selectQueueItem(getAdapterPosition()); + } + + } + + private class MediaQueueListAdapter extends RecyclerView.Adapter { + + @Override + public QueueItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + TextView v = (TextView) LayoutInflater.from(parent.getContext()) + .inflate(android.R.layout.simple_list_item_1, parent, false); + return new QueueItemViewHolder(v); + } + + @Override + public void onBindViewHolder(QueueItemViewHolder holder, int position) { + TextView view = holder.textView; + view.setText(playerManager.getItem(position).title); + // TODO: Solve coloring using the theme's ColorStateList. + view.setTextColor(ColorUtils.setAlphaComponent(view.getCurrentTextColor(), + position == playerManager.getCurrentItemIndex() ? 255 : 100)); + } + + @Override + public int getItemCount() { + return playerManager.getMediaQueueSize(); + } + + } + + private class RecyclerViewCallback extends ItemTouchHelper.SimpleCallback { + + private int draggingFromPosition; + private int draggingToPosition; + + public RecyclerViewCallback() { + super(ItemTouchHelper.UP | ItemTouchHelper.DOWN, ItemTouchHelper.START | ItemTouchHelper.END); + draggingFromPosition = C.INDEX_UNSET; + draggingToPosition = C.INDEX_UNSET; + } + + @Override + public boolean onMove(RecyclerView list, RecyclerView.ViewHolder origin, + RecyclerView.ViewHolder target) { + int fromPosition = origin.getAdapterPosition(); + int toPosition = target.getAdapterPosition(); + if (draggingFromPosition == C.INDEX_UNSET) { + // A drag has started, but changes to the media queue will be reflected in clearView(). + draggingFromPosition = fromPosition; + } + draggingToPosition = toPosition; + mediaQueueListAdapter.notifyItemMoved(fromPosition, toPosition); + return true; + } + + @Override + public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) { + int position = viewHolder.getAdapterPosition(); + if (playerManager.removeItem(position)) { + mediaQueueListAdapter.notifyItemRemoved(position); + } + } + + @Override + public void clearView(RecyclerView recyclerView, ViewHolder viewHolder) { + super.clearView(recyclerView, viewHolder); + if (draggingFromPosition != C.INDEX_UNSET) { + // A drag has ended. We reflect the media queue change in the player. + if (!playerManager.moveItem(draggingFromPosition, draggingToPosition)) { + // The move failed. The entire sequence of onMove calls since the drag started needs to be + // invalidated. + mediaQueueListAdapter.notifyDataSetChanged(); + } + } + draggingFromPosition = C.INDEX_UNSET; + draggingToPosition = C.INDEX_UNSET; + } + + } + + private static final class SampleListAdapter extends ArrayAdapter { + + public SampleListAdapter(Context context) { + super(context, android.R.layout.simple_list_item_1, DemoUtil.SAMPLES); + } + + @Override + public View getView(int position, @Nullable View convertView, ViewGroup parent) { + TextView view = (TextView) super.getView(position, convertView, parent); + MediaItem sample = DemoUtil.SAMPLES.get(position); + view.setText(sample.title); + return view; + } + } + +} diff --git a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java new file mode 100644 index 0000000000..c56f0eb855 --- /dev/null +++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.castdemo; + +import android.view.KeyEvent; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ext.cast.MediaItem; + +/** Manages the players in the Cast demo app. */ +interface PlayerManager { + + /** Listener for changes in the media queue playback position. */ + interface QueuePositionListener { + + /** + * Called when the currently played item of the media queue changes. + */ + void onQueuePositionChanged(int previousIndex, int newIndex); + + } + + /** Redirects the given {@code keyEvent} to the active player. */ + boolean dispatchKeyEvent(KeyEvent keyEvent); + + /** Appends the given {@link MediaItem} to the media queue. */ + void addItem(MediaItem mediaItem); + + /** Returns the number of items in the media queue. */ + int getMediaQueueSize(); + + /** Selects the item at the given position for playback. */ + void selectQueueItem(int position); + + /** + * Returns the position of the item currently being played, or {@link C#INDEX_UNSET} if no item is + * being played. + */ + int getCurrentItemIndex(); + + /** Returns the {@link MediaItem} at the given {@code position}. */ + MediaItem getItem(int position); + + /** Moves the item at position {@code from} to position {@code to}. */ + boolean moveItem(int from, int to); + + /** Removes the item at position {@code index}. */ + boolean removeItem(int index); + + /** Releases any acquired resources. */ + void release(); +} diff --git a/extensions/mediasession/src/main/res/values-be-rBY/strings.xml b/demos/cast/src/main/res/drawable/ic_add_circle_white_24dp.xml similarity index 55% rename from extensions/mediasession/src/main/res/values-be-rBY/strings.xml rename to demos/cast/src/main/res/drawable/ic_add_circle_white_24dp.xml index 2f05607235..5f3c8961ef 100644 --- a/extensions/mediasession/src/main/res/values-be-rBY/strings.xml +++ b/demos/cast/src/main/res/drawable/ic_add_circle_white_24dp.xml @@ -1,6 +1,5 @@ - - - - "Паўтарыць усё" - "Паўтараць ні" - "Паўтарыць адзін" - + + + diff --git a/demos/cast/src/main/res/layout/main_activity.xml b/demos/cast/src/main/res/layout/main_activity.xml new file mode 100644 index 0000000000..01e48cdea7 --- /dev/null +++ b/demos/cast/src/main/res/layout/main_activity.xml @@ -0,0 +1,52 @@ + + + + + + + + + + diff --git a/extensions/mediasession/src/main/res/values-az-rAZ/strings.xml b/demos/cast/src/main/res/layout/sample_list.xml similarity index 57% rename from extensions/mediasession/src/main/res/values-az-rAZ/strings.xml rename to demos/cast/src/main/res/layout/sample_list.xml index 34408143fa..910db9e058 100644 --- a/extensions/mediasession/src/main/res/values-az-rAZ/strings.xml +++ b/demos/cast/src/main/res/layout/sample_list.xml @@ -1,6 +1,5 @@ - - - - "Bütün təkrarlayın" - "Təkrar bir" - "Heç bir təkrar" - + + + + + diff --git a/demos/cast/src/main/res/menu/menu.xml b/demos/cast/src/main/res/menu/menu.xml new file mode 100644 index 0000000000..075ad34ec4 --- /dev/null +++ b/demos/cast/src/main/res/menu/menu.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/demos/cast/src/main/res/mipmap-hdpi/ic_launcher.png b/demos/cast/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..52e8dc93d9 Binary files /dev/null and b/demos/cast/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/demos/cast/src/main/res/mipmap-mdpi/ic_launcher.png b/demos/cast/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..b55576eff3 Binary files /dev/null and b/demos/cast/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/demos/cast/src/main/res/mipmap-xhdpi/ic_launcher.png b/demos/cast/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..ca84d6a60e Binary files /dev/null and b/demos/cast/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/demos/cast/src/main/res/mipmap-xxhdpi/ic_launcher.png b/demos/cast/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..27ab9b1054 Binary files /dev/null and b/demos/cast/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/demos/cast/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/demos/cast/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..d1eb9b78cf Binary files /dev/null and b/demos/cast/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/extensions/mediasession/src/main/res/values-ms-rMY/strings.xml b/demos/cast/src/main/res/values/strings.xml similarity index 65% rename from extensions/mediasession/src/main/res/values-ms-rMY/strings.xml rename to demos/cast/src/main/res/values/strings.xml index 829542b668..3505c40400 100644 --- a/extensions/mediasession/src/main/res/values-ms-rMY/strings.xml +++ b/demos/cast/src/main/res/values/strings.xml @@ -1,6 +1,5 @@ - - + - "Ulang semua" - "Tiada ulangan" - "Ulangan" + + Exo Cast Demo + + Cast + + Add samples + diff --git a/demos/ima/README.md b/demos/ima/README.md new file mode 100644 index 0000000000..8002b56667 --- /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/demo/build.gradle b/demos/ima/build.gradle similarity index 59% rename from demo/build.gradle rename to demos/ima/build.gradle index 7eea25478f..33cca6ef46 100644 --- a/demo/build.gradle +++ b/demos/ima/build.gradle @@ -1,4 +1,4 @@ -// Copyright (C) 2016 The Android Open Source Project +// 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. @@ -11,14 +11,21 @@ // 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 { compileSdkVersion project.ext.compileSdkVersion buildToolsVersion project.ext.buildToolsVersion + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + defaultConfig { + versionName project.ext.releaseVersion + versionCode project.ext.releaseVersionCode minSdkVersion 16 targetSdkVersion project.ext.targetSdkVersion } @@ -38,22 +45,16 @@ android { // The demo app does not have translations. disable 'MissingTranslation' } - - productFlavors { - noExtensions - withExtensions - } } dependencies { - compile project(modulePrefix + 'library-core') - compile project(modulePrefix + 'library-dash') - compile project(modulePrefix + 'library-hls') - compile project(modulePrefix + 'library-smoothstreaming') - compile project(modulePrefix + 'library-ui') - withExtensionsCompile project(path: modulePrefix + 'extension-ffmpeg') - withExtensionsCompile project(path: modulePrefix + 'extension-flac') - withExtensionsCompile project(path: modulePrefix + 'extension-ima') - withExtensionsCompile project(path: modulePrefix + 'extension-opus') - withExtensionsCompile project(path: modulePrefix + 'extension-vp9') + implementation project(modulePrefix + 'library-core') + implementation project(modulePrefix + 'library-ui') + implementation project(modulePrefix + 'library-dash') + implementation project(modulePrefix + 'library-hls') + implementation project(modulePrefix + 'library-smoothstreaming') + implementation project(modulePrefix + 'extension-ima') + implementation 'com.android.support:support-annotations:' + supportLibraryVersion } + +apply plugin: 'com.google.android.gms.strict-version-matcher-plugin' diff --git a/library/smoothstreaming/src/androidTest/AndroidManifest.xml b/demos/ima/src/main/AndroidManifest.xml similarity index 50% rename from library/smoothstreaming/src/androidTest/AndroidManifest.xml rename to demos/ima/src/main/AndroidManifest.xml index ab314ce806..50ad0c1b54 100644 --- a/library/smoothstreaming/src/androidTest/AndroidManifest.xml +++ b/demos/ima/src/main/AndroidManifest.xml @@ -13,21 +13,25 @@ See the License for the specific language governing permissions and limitations under the License. --> - + package="com.google.android.exoplayer2.imademo"> - + + + + + + + + + + + - - - - 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 0000000000..9988108f32 --- /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.PlayerView; + +/** + * 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 PlayerView 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 0000000000..d67c4549d8 --- /dev/null +++ b/demos/ima/src/main/java/com/google/android/exoplayer2/imademo/PlayerManager.java @@ -0,0 +1,134 @@ +/* + * 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.C; +import com.google.android.exoplayer2.C.ContentType; +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.ExoPlayerFactory; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.ext.ima.ImaAdsLoader; +import com.google.android.exoplayer2.source.ExtractorMediaSource; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ads.AdsMediaSource; +import com.google.android.exoplayer2.source.dash.DashMediaSource; +import com.google.android.exoplayer2.source.hls.HlsMediaSource; +import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; +import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelector; +import com.google.android.exoplayer2.ui.PlayerView; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.util.Util; + +/** Manages the {@link ExoPlayer}, the IMA plugin and all video playback. */ +/* package */ final class PlayerManager implements AdsMediaSource.MediaSourceFactory { + + private final ImaAdsLoader adsLoader; + private final DataSource.Factory dataSourceFactory; + + 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)); + dataSourceFactory = + new DefaultDataSourceFactory( + context, Util.getUserAgent(context, context.getString(R.string.application_name))); + } + + public void init(Context context, PlayerView playerView) { + // Create a default track selector. + TrackSelection.Factory videoTrackSelectionFactory = new AdaptiveTrackSelection.Factory(); + TrackSelector trackSelector = new DefaultTrackSelector(videoTrackSelectionFactory); + + // Create a player instance. + player = ExoPlayerFactory.newSimpleInstance(context, trackSelector); + + // Bind the player to the view. + playerView.setPlayer(player); + + // This is the MediaSource representing the content media (i.e. not the ad). + String contentUrl = context.getString(R.string.content_url); + MediaSource contentMediaSource = buildMediaSource(Uri.parse(contentUrl)); + + // Compose the content media source into a new AdsMediaSource with both ads and content. + MediaSource mediaSourceWithAds = + new AdsMediaSource( + contentMediaSource, + /* adMediaSourceFactory= */ this, + adsLoader, + playerView.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(); + } + + // AdsMediaSource.MediaSourceFactory implementation. + + @Override + public MediaSource createMediaSource(Uri uri) { + return buildMediaSource(uri); + } + + @Override + public int[] getSupportedTypes() { + // IMA does not support Smooth Streaming ads. + return new int[] {C.TYPE_DASH, C.TYPE_HLS, C.TYPE_OTHER}; + } + + // Internal methods. + + private MediaSource buildMediaSource(Uri uri) { + @ContentType int type = Util.inferContentType(uri); + switch (type) { + case C.TYPE_DASH: + return new DashMediaSource.Factory(dataSourceFactory).createMediaSource(uri); + case C.TYPE_SS: + return new SsMediaSource.Factory(dataSourceFactory).createMediaSource(uri); + case C.TYPE_HLS: + return new HlsMediaSource.Factory(dataSourceFactory).createMediaSource(uri); + case C.TYPE_OTHER: + return new ExtractorMediaSource.Factory(dataSourceFactory).createMediaSource(uri); + default: + throw new IllegalStateException("Unsupported type: " + type); + } + } + +} 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 0000000000..f7ea5c9b88 --- /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 0000000000..adaa93220e 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 0000000000..9b6f7d5e80 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 0000000000..2101026c9f 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 0000000000..223ec8bd11 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 0000000000..698ed68c42 Binary files /dev/null and b/demos/ima/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/library/hls/src/androidTest/AndroidManifest.xml b/demos/ima/src/main/res/values/strings.xml similarity index 51% rename from library/hls/src/androidTest/AndroidManifest.xml rename to demos/ima/src/main/res/values/strings.xml index dcf6c2f940..2eb5700bf0 100644 --- a/library/hls/src/androidTest/AndroidManifest.xml +++ b/demos/ima/src/main/res/values/strings.xml @@ -13,21 +13,12 @@ See the License for the specific language governing permissions and limitations under the License. --> + - + Exo IMA Demo - + - - - + - - - + diff --git a/demo/src/main/res/values/styles.xml b/demos/ima/src/main/res/values/styles.xml similarity index 93% rename from demo/src/main/res/values/styles.xml rename to demos/ima/src/main/res/values/styles.xml index 751a224210..1c78ad58df 100644 --- a/demo/src/main/res/values/styles.xml +++ b/demos/ima/src/main/res/values/styles.xml @@ -1,5 +1,5 @@ - - + + + + diff --git a/extensions/README.md b/extensions/README.md new file mode 100644 index 0000000000..bf0effb358 --- /dev/null +++ b/extensions/README.md @@ -0,0 +1,5 @@ +# ExoPlayer extensions # + +ExoPlayer extensions are modules that depend on external libraries to provide +additional functionality. Browse the individual extensions and their READMEs to +learn more. diff --git a/extensions/cast/README.md b/extensions/cast/README.md new file mode 100644 index 0000000000..cc72c5f9bc --- /dev/null +++ b/extensions/cast/README.md @@ -0,0 +1,30 @@ +# ExoPlayer Cast extension # + +## Description ## + +The cast extension is a [Player][] implementation that controls playback on a +Cast receiver app. + +[Player]: https://google.github.io/ExoPlayer/doc/reference/index.html?com/google/android/exoplayer2/Player.html + +## Getting the extension ## + +The easiest way to use the extension is to add it as a gradle dependency: + +```gradle +implementation 'com.google.android.exoplayer:extension-cast:2.X.X' +``` + +where `2.X.X` is the version, which must match the version of the ExoPlayer +library being used. + +Alternatively, you can clone the ExoPlayer repository and depend on the module +locally. Instructions for doing this can be found in ExoPlayer's +[top level README][]. + +[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md + +## Using the extension ## + +Create a `CastPlayer` and use it to integrate Cast into your app using +ExoPlayer's common `Player` interface. diff --git a/extensions/cast/build.gradle b/extensions/cast/build.gradle new file mode 100644 index 0000000000..f6821d5cd2 --- /dev/null +++ b/extensions/cast/build.gradle @@ -0,0 +1,64 @@ +// 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.library' + +android { + compileSdkVersion project.ext.compileSdkVersion + buildToolsVersion project.ext.buildToolsVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + defaultConfig { + minSdkVersion 14 + targetSdkVersion project.ext.targetSdkVersion + consumerProguardFiles 'proguard-rules.txt' + } +} + +dependencies { + api 'com.google.android.gms:play-services-cast-framework:16.0.3' + compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion + implementation project(modulePrefix + 'library-core') + implementation project(modulePrefix + 'library-ui') + testImplementation project(modulePrefix + 'testutils') + testImplementation 'junit:junit:' + junitVersion + testImplementation 'org.mockito:mockito-core:' + mockitoVersion + testImplementation 'org.robolectric:robolectric:' + robolectricVersion + testImplementation project(modulePrefix + 'testutils-robolectric') + // These dependencies are necessary to force the supportLibraryVersion of + // com.android.support:support-v4, com.android.support:appcompat-v7 and + // com.android.support:mediarouter-v7 to be used. Else older versions are + // used, for example via: + // com.google.android.gms:play-services-cast-framework:15.0.1 + // |-- com.android.support:mediarouter-v7:26.1.0 + api 'com.android.support:support-v4:' + supportLibraryVersion + api 'com.android.support:mediarouter-v7:' + supportLibraryVersion + api 'com.android.support:recyclerview-v7:' + supportLibraryVersion +} + +ext { + javadocTitle = 'Cast extension' +} +apply from: '../../javadoc_library.gradle' + +ext { + releaseArtifact = 'extension-cast' + releaseDescription = 'Cast extension for ExoPlayer.' +} +apply from: '../../publish.gradle' diff --git a/extensions/cast/proguard-rules.txt b/extensions/cast/proguard-rules.txt new file mode 100644 index 0000000000..bc94b33c1c --- /dev/null +++ b/extensions/cast/proguard-rules.txt @@ -0,0 +1,4 @@ +# Proguard rules specific to the Cast extension. + +# DefaultCastOptionsProvider is commonly referred to only by the app's manifest. +-keep class com.google.android.exoplayer2.ext.cast.DefaultCastOptionsProvider diff --git a/extensions/cast/src/main/AndroidManifest.xml b/extensions/cast/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..c12fc1289f --- /dev/null +++ b/extensions/cast/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java new file mode 100644 index 0000000000..584ac68305 --- /dev/null +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java @@ -0,0 +1,839 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.cast; + +import android.os.Looper; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.BasePlayer; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.FixedTrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Log; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.gms.cast.CastStatusCodes; +import com.google.android.gms.cast.MediaInfo; +import com.google.android.gms.cast.MediaQueueItem; +import com.google.android.gms.cast.MediaStatus; +import com.google.android.gms.cast.MediaTrack; +import com.google.android.gms.cast.framework.CastContext; +import com.google.android.gms.cast.framework.CastSession; +import com.google.android.gms.cast.framework.SessionManager; +import com.google.android.gms.cast.framework.SessionManagerListener; +import com.google.android.gms.cast.framework.media.RemoteMediaClient; +import com.google.android.gms.cast.framework.media.RemoteMediaClient.MediaChannelResult; +import com.google.android.gms.common.api.PendingResult; +import com.google.android.gms.common.api.ResultCallback; +import java.util.List; +import java.util.concurrent.CopyOnWriteArraySet; + +/** + * {@link Player} implementation that communicates with a Cast receiver app. + * + *

The behavior of this class depends on the underlying Cast session, which is obtained from the + * Cast context passed to {@link #CastPlayer}. To keep track of the session, {@link + * #isCastSessionAvailable()} can be queried and {@link SessionAvailabilityListener} can be + * implemented and attached to the player. + * + *

If no session is available, the player state will remain unchanged and calls to methods that + * alter it will be ignored. Querying the player state is possible even when no session is + * available, in which case, the last observed receiver app state is reported. + * + *

Methods should be called on the application's main thread. + */ +public final class CastPlayer extends BasePlayer { + + private static final String TAG = "CastPlayer"; + + private static final int RENDERER_COUNT = 3; + private static final int RENDERER_INDEX_VIDEO = 0; + private static final int RENDERER_INDEX_AUDIO = 1; + private static final int RENDERER_INDEX_TEXT = 2; + private static final long PROGRESS_REPORT_PERIOD_MS = 1000; + private static final TrackSelectionArray EMPTY_TRACK_SELECTION_ARRAY = + new TrackSelectionArray(null, null, null); + private static final long[] EMPTY_TRACK_ID_ARRAY = new long[0]; + + private final CastContext castContext; + // TODO: Allow custom implementations of CastTimelineTracker. + private final CastTimelineTracker timelineTracker; + private final Timeline.Period period; + + private RemoteMediaClient remoteMediaClient; + + // Result callbacks. + private final StatusListener statusListener; + private final SeekResultCallback seekResultCallback; + + // Listeners. + private final CopyOnWriteArraySet listeners; + private SessionAvailabilityListener sessionAvailabilityListener; + + // Internal state. + private CastTimeline currentTimeline; + private TrackGroupArray currentTrackGroups; + private TrackSelectionArray currentTrackSelection; + private int playbackState; + private int repeatMode; + private int currentWindowIndex; + private boolean playWhenReady; + private long lastReportedPositionMs; + private int pendingSeekCount; + private int pendingSeekWindowIndex; + private long pendingSeekPositionMs; + private boolean waitingForInitialTimeline; + + /** + * @param castContext The context from which the cast session is obtained. + */ + public CastPlayer(CastContext castContext) { + this.castContext = castContext; + timelineTracker = new CastTimelineTracker(); + period = new Timeline.Period(); + statusListener = new StatusListener(); + seekResultCallback = new SeekResultCallback(); + listeners = new CopyOnWriteArraySet<>(); + + SessionManager sessionManager = castContext.getSessionManager(); + sessionManager.addSessionManagerListener(statusListener, CastSession.class); + CastSession session = sessionManager.getCurrentCastSession(); + remoteMediaClient = session != null ? session.getRemoteMediaClient() : null; + + playbackState = STATE_IDLE; + repeatMode = REPEAT_MODE_OFF; + currentTimeline = CastTimeline.EMPTY_CAST_TIMELINE; + currentTrackGroups = TrackGroupArray.EMPTY; + currentTrackSelection = EMPTY_TRACK_SELECTION_ARRAY; + pendingSeekWindowIndex = C.INDEX_UNSET; + pendingSeekPositionMs = C.TIME_UNSET; + updateInternalState(); + } + + // Media Queue manipulation methods. + + /** + * Loads a single item media queue. If no session is available, does nothing. + * + * @param item The item to load. + * @param positionMs The position at which the playback should start in milliseconds relative to + * the start of the item at {@code startIndex}. If {@link C#TIME_UNSET} is passed, playback + * starts at position 0. + * @return The Cast {@code PendingResult}, or null if no session is available. + */ + public PendingResult loadItem(MediaQueueItem item, long positionMs) { + return loadItems(new MediaQueueItem[] {item}, 0, positionMs, REPEAT_MODE_OFF); + } + + /** + * Loads a media queue. If no session is available, does nothing. + * + * @param items The items to load. + * @param startIndex The index of the item at which playback should start. + * @param positionMs The position at which the playback should start in milliseconds relative to + * the start of the item at {@code startIndex}. If {@link C#TIME_UNSET} is passed, playback + * starts at position 0. + * @param repeatMode The repeat mode for the created media queue. + * @return The Cast {@code PendingResult}, or null if no session is available. + */ + public PendingResult loadItems(MediaQueueItem[] items, int startIndex, + long positionMs, @RepeatMode int repeatMode) { + if (remoteMediaClient != null) { + positionMs = positionMs != C.TIME_UNSET ? positionMs : 0; + waitingForInitialTimeline = true; + return remoteMediaClient.queueLoad(items, startIndex, getCastRepeatMode(repeatMode), + positionMs, null); + } + return null; + } + + /** + * Appends a sequence of items to the media queue. If no media queue exists, does nothing. + * + * @param items The items to append. + * @return The Cast {@code PendingResult}, or null if no media queue exists. + */ + public PendingResult addItems(MediaQueueItem... items) { + return addItems(MediaQueueItem.INVALID_ITEM_ID, items); + } + + /** + * Inserts a sequence of items into the media queue. If no media queue or period with id {@code + * periodId} exist, does nothing. + * + * @param periodId The id of the period ({@link #getCurrentTimeline}) that corresponds to the item + * that will follow immediately after the inserted items. + * @param items The items to insert. + * @return The Cast {@code PendingResult}, or null if no media queue or no period with id {@code + * periodId} exist. + */ + public PendingResult addItems(int periodId, MediaQueueItem... items) { + if (getMediaStatus() != null && (periodId == MediaQueueItem.INVALID_ITEM_ID + || currentTimeline.getIndexOfPeriod(periodId) != C.INDEX_UNSET)) { + return remoteMediaClient.queueInsertItems(items, periodId, null); + } + return null; + } + + /** + * Removes an item from the media queue. If no media queue or period with id {@code periodId} + * exist, does nothing. + * + * @param periodId The id of the period ({@link #getCurrentTimeline}) that corresponds to the item + * to remove. + * @return The Cast {@code PendingResult}, or null if no media queue or no period with id {@code + * periodId} exist. + */ + public PendingResult removeItem(int periodId) { + if (getMediaStatus() != null && currentTimeline.getIndexOfPeriod(periodId) != C.INDEX_UNSET) { + return remoteMediaClient.queueRemoveItem(periodId, null); + } + return null; + } + + /** + * Moves an existing item within the media queue. If no media queue or period with id {@code + * periodId} exist, does nothing. + * + * @param periodId The id of the period ({@link #getCurrentTimeline}) that corresponds to the item + * to move. + * @param newIndex The target index of the item in the media queue. Must be in the range 0 <= + * index < {@link Timeline#getPeriodCount()}, as provided by {@link #getCurrentTimeline()}. + * @return The Cast {@code PendingResult}, or null if no media queue or no period with id {@code + * periodId} exist. + */ + public PendingResult moveItem(int periodId, int newIndex) { + Assertions.checkArgument(newIndex >= 0 && newIndex < currentTimeline.getPeriodCount()); + if (getMediaStatus() != null && currentTimeline.getIndexOfPeriod(periodId) != C.INDEX_UNSET) { + return remoteMediaClient.queueMoveItemToNewIndex(periodId, newIndex, null); + } + return null; + } + + /** + * Returns the item that corresponds to the period with the given id, or null if no media queue or + * period with id {@code periodId} exist. + * + * @param periodId The id of the period ({@link #getCurrentTimeline}) that corresponds to the item + * to get. + * @return The item that corresponds to the period with the given id, or null if no media queue or + * period with id {@code periodId} exist. + */ + public MediaQueueItem getItem(int periodId) { + MediaStatus mediaStatus = getMediaStatus(); + return mediaStatus != null && currentTimeline.getIndexOfPeriod(periodId) != C.INDEX_UNSET + ? mediaStatus.getItemById(periodId) : null; + } + + // CastSession methods. + + /** + * Returns whether a cast session is available. + */ + public boolean isCastSessionAvailable() { + return remoteMediaClient != null; + } + + /** + * Sets a listener for updates on the cast session availability. + * + * @param listener The {@link SessionAvailabilityListener}. + */ + public void setSessionAvailabilityListener(SessionAvailabilityListener listener) { + sessionAvailabilityListener = listener; + } + + // Player implementation. + + @Override + public AudioComponent getAudioComponent() { + return null; + } + + @Override + public VideoComponent getVideoComponent() { + return null; + } + + @Override + public TextComponent getTextComponent() { + return null; + } + + @Override + public Looper getApplicationLooper() { + return Looper.getMainLooper(); + } + + @Override + public void addListener(EventListener listener) { + listeners.add(listener); + } + + @Override + public void removeListener(EventListener listener) { + listeners.remove(listener); + } + + @Override + public int getPlaybackState() { + return playbackState; + } + + @Override + public ExoPlaybackException getPlaybackError() { + return null; + } + + @Override + public void setPlayWhenReady(boolean playWhenReady) { + if (remoteMediaClient == null) { + return; + } + if (playWhenReady) { + remoteMediaClient.play(); + } else { + remoteMediaClient.pause(); + } + } + + @Override + public boolean getPlayWhenReady() { + return playWhenReady; + } + + @Override + public void seekTo(int windowIndex, long positionMs) { + MediaStatus mediaStatus = getMediaStatus(); + // We assume the default position is 0. There is no support for seeking to the default position + // in RemoteMediaClient. + positionMs = positionMs != C.TIME_UNSET ? positionMs : 0; + if (mediaStatus != null) { + if (getCurrentWindowIndex() != windowIndex) { + remoteMediaClient.queueJumpToItem((int) currentTimeline.getPeriod(windowIndex, period).uid, + positionMs, null).setResultCallback(seekResultCallback); + } else { + remoteMediaClient.seek(positionMs).setResultCallback(seekResultCallback); + } + pendingSeekCount++; + pendingSeekWindowIndex = windowIndex; + pendingSeekPositionMs = positionMs; + for (EventListener listener : listeners) { + listener.onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); + } + } else if (pendingSeekCount == 0) { + for (EventListener listener : listeners) { + listener.onSeekProcessed(); + } + } + } + + @Override + public void setPlaybackParameters(@Nullable PlaybackParameters playbackParameters) { + // Unsupported by the RemoteMediaClient API. Do nothing. + } + + @Override + public PlaybackParameters getPlaybackParameters() { + return PlaybackParameters.DEFAULT; + } + + @Override + public void stop(boolean reset) { + playbackState = STATE_IDLE; + if (remoteMediaClient != null) { + // TODO(b/69792021): Support or emulate stop without position reset. + remoteMediaClient.stop(); + } + } + + @Override + public void release() { + SessionManager sessionManager = castContext.getSessionManager(); + sessionManager.removeSessionManagerListener(statusListener, CastSession.class); + sessionManager.endCurrentSession(false); + } + + @Override + public int getRendererCount() { + // We assume there are three renderers: video, audio, and text. + return RENDERER_COUNT; + } + + @Override + public int getRendererType(int index) { + switch (index) { + case RENDERER_INDEX_VIDEO: + return C.TRACK_TYPE_VIDEO; + case RENDERER_INDEX_AUDIO: + return C.TRACK_TYPE_AUDIO; + case RENDERER_INDEX_TEXT: + return C.TRACK_TYPE_TEXT; + default: + throw new IndexOutOfBoundsException(); + } + } + + @Override + public void setRepeatMode(@RepeatMode int repeatMode) { + if (remoteMediaClient != null) { + remoteMediaClient.queueSetRepeatMode(getCastRepeatMode(repeatMode), null); + } + } + + @Override + @RepeatMode public int getRepeatMode() { + return repeatMode; + } + + @Override + public void setShuffleModeEnabled(boolean shuffleModeEnabled) { + // TODO: Support shuffle mode. + } + + @Override + public boolean getShuffleModeEnabled() { + // TODO: Support shuffle mode. + return false; + } + + @Override + public TrackSelectionArray getCurrentTrackSelections() { + return currentTrackSelection; + } + + @Override + public TrackGroupArray getCurrentTrackGroups() { + return currentTrackGroups; + } + + @Override + public Timeline getCurrentTimeline() { + return currentTimeline; + } + + @Override + @Nullable public Object getCurrentManifest() { + return null; + } + + @Override + public int getCurrentPeriodIndex() { + return getCurrentWindowIndex(); + } + + @Override + public int getCurrentWindowIndex() { + return pendingSeekWindowIndex != C.INDEX_UNSET ? pendingSeekWindowIndex : currentWindowIndex; + } + + // TODO: Fill the cast timeline information with ProgressListener's duration updates. + // See [Internal: b/65152553]. + @Override + public long getDuration() { + return getContentDuration(); + } + + @Override + public long getCurrentPosition() { + return pendingSeekPositionMs != C.TIME_UNSET + ? pendingSeekPositionMs + : remoteMediaClient != null + ? remoteMediaClient.getApproximateStreamPosition() + : lastReportedPositionMs; + } + + @Override + public long getBufferedPosition() { + return getCurrentPosition(); + } + + @Override + public long getTotalBufferedDuration() { + long bufferedPosition = getBufferedPosition(); + long currentPosition = getCurrentPosition(); + return bufferedPosition == C.TIME_UNSET || currentPosition == C.TIME_UNSET + ? 0 + : bufferedPosition - currentPosition; + } + + @Override + public boolean isPlayingAd() { + return false; + } + + @Override + public int getCurrentAdGroupIndex() { + return C.INDEX_UNSET; + } + + @Override + public int getCurrentAdIndexInAdGroup() { + return C.INDEX_UNSET; + } + + @Override + public boolean isLoading() { + return false; + } + + @Override + public long getContentPosition() { + return getCurrentPosition(); + } + + @Override + public long getContentBufferedPosition() { + return getBufferedPosition(); + } + + // Internal methods. + + public void updateInternalState() { + if (remoteMediaClient == null) { + // There is no session. We leave the state of the player as it is now. + return; + } + + int playbackState = fetchPlaybackState(remoteMediaClient); + boolean playWhenReady = !remoteMediaClient.isPaused(); + if (this.playbackState != playbackState + || this.playWhenReady != playWhenReady) { + this.playbackState = playbackState; + this.playWhenReady = playWhenReady; + for (EventListener listener : listeners) { + listener.onPlayerStateChanged(this.playWhenReady, this.playbackState); + } + } + @RepeatMode int repeatMode = fetchRepeatMode(remoteMediaClient); + if (this.repeatMode != repeatMode) { + this.repeatMode = repeatMode; + for (EventListener listener : listeners) { + listener.onRepeatModeChanged(repeatMode); + } + } + int currentWindowIndex = fetchCurrentWindowIndex(getMediaStatus()); + if (this.currentWindowIndex != currentWindowIndex && pendingSeekCount == 0) { + this.currentWindowIndex = currentWindowIndex; + for (EventListener listener : listeners) { + listener.onPositionDiscontinuity(DISCONTINUITY_REASON_PERIOD_TRANSITION); + } + } + if (updateTracksAndSelections()) { + for (EventListener listener : listeners) { + listener.onTracksChanged(currentTrackGroups, currentTrackSelection); + } + } + maybeUpdateTimelineAndNotify(); + } + + private void maybeUpdateTimelineAndNotify() { + if (updateTimeline()) { + @Player.TimelineChangeReason int reason = waitingForInitialTimeline + ? Player.TIMELINE_CHANGE_REASON_PREPARED : Player.TIMELINE_CHANGE_REASON_DYNAMIC; + waitingForInitialTimeline = false; + for (EventListener listener : listeners) { + listener.onTimelineChanged(currentTimeline, null, reason); + } + } + } + + /** + * Updates the current timeline and returns whether it has changed. + */ + private boolean updateTimeline() { + CastTimeline oldTimeline = currentTimeline; + MediaStatus status = getMediaStatus(); + currentTimeline = + status != null ? timelineTracker.getCastTimeline(status) : CastTimeline.EMPTY_CAST_TIMELINE; + return !oldTimeline.equals(currentTimeline); + } + + /** + * Updates the internal tracks and selection and returns whether they have changed. + */ + private boolean updateTracksAndSelections() { + if (remoteMediaClient == null) { + // There is no session. We leave the state of the player as it is now. + return false; + } + + MediaStatus mediaStatus = getMediaStatus(); + MediaInfo mediaInfo = mediaStatus != null ? mediaStatus.getMediaInfo() : null; + List castMediaTracks = mediaInfo != null ? mediaInfo.getMediaTracks() : null; + if (castMediaTracks == null || castMediaTracks.isEmpty()) { + boolean hasChanged = !currentTrackGroups.isEmpty(); + currentTrackGroups = TrackGroupArray.EMPTY; + currentTrackSelection = EMPTY_TRACK_SELECTION_ARRAY; + return hasChanged; + } + long[] activeTrackIds = mediaStatus.getActiveTrackIds(); + if (activeTrackIds == null) { + activeTrackIds = EMPTY_TRACK_ID_ARRAY; + } + + TrackGroup[] trackGroups = new TrackGroup[castMediaTracks.size()]; + TrackSelection[] trackSelections = new TrackSelection[RENDERER_COUNT]; + for (int i = 0; i < castMediaTracks.size(); i++) { + MediaTrack mediaTrack = castMediaTracks.get(i); + trackGroups[i] = new TrackGroup(CastUtils.mediaTrackToFormat(mediaTrack)); + + long id = mediaTrack.getId(); + int trackType = MimeTypes.getTrackType(mediaTrack.getContentType()); + int rendererIndex = getRendererIndexForTrackType(trackType); + if (isTrackActive(id, activeTrackIds) && rendererIndex != C.INDEX_UNSET + && trackSelections[rendererIndex] == null) { + trackSelections[rendererIndex] = new FixedTrackSelection(trackGroups[i], 0); + } + } + TrackGroupArray newTrackGroups = new TrackGroupArray(trackGroups); + TrackSelectionArray newTrackSelections = new TrackSelectionArray(trackSelections); + + if (!newTrackGroups.equals(currentTrackGroups) + || !newTrackSelections.equals(currentTrackSelection)) { + currentTrackSelection = new TrackSelectionArray(trackSelections); + currentTrackGroups = new TrackGroupArray(trackGroups); + return true; + } + return false; + } + + private void setRemoteMediaClient(@Nullable RemoteMediaClient remoteMediaClient) { + if (this.remoteMediaClient == remoteMediaClient) { + // Do nothing. + return; + } + if (this.remoteMediaClient != null) { + this.remoteMediaClient.removeListener(statusListener); + this.remoteMediaClient.removeProgressListener(statusListener); + } + this.remoteMediaClient = remoteMediaClient; + if (remoteMediaClient != null) { + if (sessionAvailabilityListener != null) { + sessionAvailabilityListener.onCastSessionAvailable(); + } + remoteMediaClient.addListener(statusListener); + remoteMediaClient.addProgressListener(statusListener, PROGRESS_REPORT_PERIOD_MS); + updateInternalState(); + } else { + if (sessionAvailabilityListener != null) { + sessionAvailabilityListener.onCastSessionUnavailable(); + } + } + } + + private @Nullable MediaStatus getMediaStatus() { + return remoteMediaClient != null ? remoteMediaClient.getMediaStatus() : null; + } + + /** + * Retrieves the playback state from {@code remoteMediaClient} and maps it into a {@link Player} + * state + */ + private static int fetchPlaybackState(RemoteMediaClient remoteMediaClient) { + int receiverAppStatus = remoteMediaClient.getPlayerState(); + switch (receiverAppStatus) { + case MediaStatus.PLAYER_STATE_BUFFERING: + return STATE_BUFFERING; + case MediaStatus.PLAYER_STATE_PLAYING: + case MediaStatus.PLAYER_STATE_PAUSED: + return STATE_READY; + case MediaStatus.PLAYER_STATE_IDLE: + case MediaStatus.PLAYER_STATE_UNKNOWN: + default: + return STATE_IDLE; + } + } + + /** + * Retrieves the repeat mode from {@code remoteMediaClient} and maps it into a + * {@link Player.RepeatMode}. + */ + @RepeatMode + private static int fetchRepeatMode(RemoteMediaClient remoteMediaClient) { + MediaStatus mediaStatus = remoteMediaClient.getMediaStatus(); + if (mediaStatus == null) { + // No media session active, yet. + return REPEAT_MODE_OFF; + } + int castRepeatMode = mediaStatus.getQueueRepeatMode(); + switch (castRepeatMode) { + case MediaStatus.REPEAT_MODE_REPEAT_SINGLE: + return REPEAT_MODE_ONE; + case MediaStatus.REPEAT_MODE_REPEAT_ALL: + case MediaStatus.REPEAT_MODE_REPEAT_ALL_AND_SHUFFLE: + return REPEAT_MODE_ALL; + case MediaStatus.REPEAT_MODE_REPEAT_OFF: + return REPEAT_MODE_OFF; + default: + throw new IllegalStateException(); + } + } + + /** + * Retrieves the current item index from {@code mediaStatus} and maps it into a window index. If + * there is no media session, returns 0. + */ + private static int fetchCurrentWindowIndex(@Nullable MediaStatus mediaStatus) { + Integer currentItemId = mediaStatus != null + ? mediaStatus.getIndexById(mediaStatus.getCurrentItemId()) : null; + return currentItemId != null ? currentItemId : 0; + } + + private static boolean isTrackActive(long id, long[] activeTrackIds) { + for (long activeTrackId : activeTrackIds) { + if (activeTrackId == id) { + return true; + } + } + return false; + } + + private static int getRendererIndexForTrackType(int trackType) { + return trackType == C.TRACK_TYPE_VIDEO + ? RENDERER_INDEX_VIDEO + : trackType == C.TRACK_TYPE_AUDIO + ? RENDERER_INDEX_AUDIO + : trackType == C.TRACK_TYPE_TEXT ? RENDERER_INDEX_TEXT : C.INDEX_UNSET; + } + + private static int getCastRepeatMode(@RepeatMode int repeatMode) { + switch (repeatMode) { + case REPEAT_MODE_ONE: + return MediaStatus.REPEAT_MODE_REPEAT_SINGLE; + case REPEAT_MODE_ALL: + return MediaStatus.REPEAT_MODE_REPEAT_ALL; + case REPEAT_MODE_OFF: + return MediaStatus.REPEAT_MODE_REPEAT_OFF; + default: + throw new IllegalArgumentException(); + } + } + + private final class StatusListener implements RemoteMediaClient.Listener, + SessionManagerListener, RemoteMediaClient.ProgressListener { + + // RemoteMediaClient.ProgressListener implementation. + + @Override + public void onProgressUpdated(long progressMs, long unusedDurationMs) { + lastReportedPositionMs = progressMs; + } + + // RemoteMediaClient.Listener implementation. + + @Override + public void onStatusUpdated() { + updateInternalState(); + } + + @Override + public void onMetadataUpdated() {} + + @Override + public void onQueueStatusUpdated() { + maybeUpdateTimelineAndNotify(); + } + + @Override + public void onPreloadStatusUpdated() {} + + @Override + public void onSendingRemoteMediaRequest() {} + + @Override + public void onAdBreakStatusUpdated() {} + + // SessionManagerListener implementation. + + @Override + public void onSessionStarted(CastSession castSession, String s) { + setRemoteMediaClient(castSession.getRemoteMediaClient()); + } + + @Override + public void onSessionResumed(CastSession castSession, boolean b) { + setRemoteMediaClient(castSession.getRemoteMediaClient()); + } + + @Override + public void onSessionEnded(CastSession castSession, int i) { + setRemoteMediaClient(null); + } + + @Override + public void onSessionSuspended(CastSession castSession, int i) { + setRemoteMediaClient(null); + } + + @Override + public void onSessionResumeFailed(CastSession castSession, int statusCode) { + Log.e(TAG, "Session resume failed. Error code " + statusCode + ": " + + CastUtils.getLogString(statusCode)); + } + + @Override + public void onSessionStarting(CastSession castSession) { + // Do nothing. + } + + @Override + public void onSessionStartFailed(CastSession castSession, int statusCode) { + Log.e(TAG, "Session start failed. Error code " + statusCode + ": " + + CastUtils.getLogString(statusCode)); + } + + @Override + public void onSessionEnding(CastSession castSession) { + // Do nothing. + } + + @Override + public void onSessionResuming(CastSession castSession, String s) { + // Do nothing. + } + + } + + // Result callbacks hooks. + + private final class SeekResultCallback implements ResultCallback { + + @Override + public void onResult(@NonNull MediaChannelResult result) { + int statusCode = result.getStatus().getStatusCode(); + if (statusCode != CastStatusCodes.SUCCESS && statusCode != CastStatusCodes.REPLACED) { + Log.e(TAG, "Seek failed. Error code " + statusCode + ": " + + CastUtils.getLogString(statusCode)); + } + if (--pendingSeekCount == 0) { + pendingSeekWindowIndex = C.INDEX_UNSET; + pendingSeekPositionMs = C.TIME_UNSET; + for (EventListener listener : listeners) { + listener.onSeekProcessed(); + } + } + } + } + +} diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimeline.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimeline.java new file mode 100644 index 0000000000..d86c4b3ebf --- /dev/null +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimeline.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.cast; + +import android.support.annotation.Nullable; +import android.util.SparseIntArray; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Timeline; +import com.google.android.gms.cast.MediaInfo; +import com.google.android.gms.cast.MediaQueueItem; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * A {@link Timeline} for Cast media queues. + */ +/* package */ final class CastTimeline extends Timeline { + + public static final CastTimeline EMPTY_CAST_TIMELINE = + new CastTimeline(Collections.emptyList(), Collections.emptyMap()); + + private final SparseIntArray idsToIndex; + private final int[] ids; + private final long[] durationsUs; + private final long[] defaultPositionsUs; + + /** + * @param items A list of cast media queue items to represent. + * @param contentIdToDurationUsMap A map of content id to duration in microseconds. + */ + public CastTimeline(List items, Map contentIdToDurationUsMap) { + int itemCount = items.size(); + int index = 0; + idsToIndex = new SparseIntArray(itemCount); + ids = new int[itemCount]; + durationsUs = new long[itemCount]; + defaultPositionsUs = new long[itemCount]; + for (MediaQueueItem item : items) { + int itemId = item.getItemId(); + ids[index] = itemId; + idsToIndex.put(itemId, index); + MediaInfo mediaInfo = item.getMedia(); + String contentId = mediaInfo.getContentId(); + durationsUs[index] = + contentIdToDurationUsMap.containsKey(contentId) + ? contentIdToDurationUsMap.get(contentId) + : CastUtils.getStreamDurationUs(mediaInfo); + defaultPositionsUs[index] = (long) (item.getStartTime() * C.MICROS_PER_SECOND); + index++; + } + } + + // Timeline implementation. + + @Override + public int getWindowCount() { + return ids.length; + } + + @Override + public Window getWindow( + int windowIndex, Window window, boolean setTag, long defaultPositionProjectionUs) { + long durationUs = durationsUs[windowIndex]; + boolean isDynamic = durationUs == C.TIME_UNSET; + Object tag = setTag ? ids[windowIndex] : null; + return window.set( + tag, + /* presentationStartTimeMs= */ C.TIME_UNSET, + /* windowStartTimeMs= */ C.TIME_UNSET, + /* isSeekable= */ !isDynamic, + isDynamic, + defaultPositionsUs[windowIndex], + durationUs, + /* firstPeriodIndex= */ windowIndex, + /* lastPeriodIndex= */ windowIndex, + /* positionInFirstPeriodUs= */ 0); + } + + @Override + public int getPeriodCount() { + return ids.length; + } + + @Override + public Period getPeriod(int periodIndex, Period period, boolean setIds) { + int id = ids[periodIndex]; + return period.set(id, id, periodIndex, durationsUs[periodIndex], 0); + } + + @Override + public int getIndexOfPeriod(Object uid) { + return uid instanceof Integer ? idsToIndex.get((int) uid, C.INDEX_UNSET) : C.INDEX_UNSET; + } + + @Override + public Integer getUidOfPeriod(int periodIndex) { + return ids[periodIndex]; + } + + // equals and hashCode implementations. + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } else if (!(other instanceof CastTimeline)) { + return false; + } + CastTimeline that = (CastTimeline) other; + return Arrays.equals(ids, that.ids) + && Arrays.equals(durationsUs, that.durationsUs) + && Arrays.equals(defaultPositionsUs, that.defaultPositionsUs); + } + + @Override + public int hashCode() { + int result = Arrays.hashCode(ids); + result = 31 * result + Arrays.hashCode(durationsUs); + result = 31 * result + Arrays.hashCode(defaultPositionsUs); + return result; + } + +} diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimelineTracker.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimelineTracker.java new file mode 100644 index 0000000000..412bfb476d --- /dev/null +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimelineTracker.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.cast; + +import com.google.android.gms.cast.MediaInfo; +import com.google.android.gms.cast.MediaQueueItem; +import com.google.android.gms.cast.MediaStatus; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; + +/** + * Creates {@link CastTimeline}s from cast receiver app media status. + * + *

This class keeps track of the duration reported by the current item to fill any missing + * durations in the media queue items [See internal: b/65152553]. + */ +/* package */ final class CastTimelineTracker { + + private final HashMap contentIdToDurationUsMap; + private final HashSet scratchContentIdSet; + + public CastTimelineTracker() { + contentIdToDurationUsMap = new HashMap<>(); + scratchContentIdSet = new HashSet<>(); + } + + /** + * Returns a {@link CastTimeline} that represent the given {@code status}. + * + * @param status The Cast media status. + * @return A {@link CastTimeline} that represent the given {@code status}. + */ + public CastTimeline getCastTimeline(MediaStatus status) { + MediaInfo mediaInfo = status.getMediaInfo(); + List items = status.getQueueItems(); + removeUnusedDurationEntries(items); + + if (mediaInfo != null) { + String contentId = mediaInfo.getContentId(); + long durationUs = CastUtils.getStreamDurationUs(mediaInfo); + contentIdToDurationUsMap.put(contentId, durationUs); + } + return new CastTimeline(items, contentIdToDurationUsMap); + } + + private void removeUnusedDurationEntries(List items) { + scratchContentIdSet.clear(); + for (MediaQueueItem item : items) { + scratchContentIdSet.add(item.getMedia().getContentId()); + } + contentIdToDurationUsMap.keySet().retainAll(scratchContentIdSet); + } +} diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastUtils.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastUtils.java new file mode 100644 index 0000000000..997857f6b5 --- /dev/null +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastUtils.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.cast; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.gms.cast.CastStatusCodes; +import com.google.android.gms.cast.MediaInfo; +import com.google.android.gms.cast.MediaTrack; + +/** + * Utility methods for ExoPlayer/Cast integration. + */ +/* package */ final class CastUtils { + + /** + * Returns the duration in microseconds advertised by a media info, or {@link C#TIME_UNSET} if + * unknown or not applicable. + * + * @param mediaInfo The media info to get the duration from. + * @return The duration in microseconds. + */ + public static long getStreamDurationUs(MediaInfo mediaInfo) { + long durationMs = + mediaInfo != null ? mediaInfo.getStreamDuration() : MediaInfo.UNKNOWN_DURATION; + return durationMs != MediaInfo.UNKNOWN_DURATION ? C.msToUs(durationMs) : C.TIME_UNSET; + } + + /** + * Returns a descriptive log string for the given {@code statusCode}, or "Unknown." if not one of + * {@link CastStatusCodes}. + * + * @param statusCode A Cast API status code. + * @return A descriptive log string for the given {@code statusCode}, or "Unknown." if not one of + * {@link CastStatusCodes}. + */ + public static String getLogString(int statusCode) { + switch (statusCode) { + case CastStatusCodes.APPLICATION_NOT_FOUND: + return "A requested application could not be found."; + case CastStatusCodes.APPLICATION_NOT_RUNNING: + return "A requested application is not currently running."; + case CastStatusCodes.AUTHENTICATION_FAILED: + return "Authentication failure."; + case CastStatusCodes.CANCELED: + return "An in-progress request has been canceled, most likely because another action has " + + "preempted it."; + case CastStatusCodes.ERROR_SERVICE_CREATION_FAILED: + return "The Cast Remote Display service could not be created."; + case CastStatusCodes.ERROR_SERVICE_DISCONNECTED: + return "The Cast Remote Display service was disconnected."; + case CastStatusCodes.FAILED: + return "The in-progress request failed."; + case CastStatusCodes.INTERNAL_ERROR: + return "An internal error has occurred."; + case CastStatusCodes.INTERRUPTED: + return "A blocking call was interrupted while waiting and did not run to completion."; + case CastStatusCodes.INVALID_REQUEST: + return "An invalid request was made."; + case CastStatusCodes.MESSAGE_SEND_BUFFER_TOO_FULL: + return "A message could not be sent because there is not enough room in the send buffer at " + + "this time."; + case CastStatusCodes.MESSAGE_TOO_LARGE: + return "A message could not be sent because it is too large."; + case CastStatusCodes.NETWORK_ERROR: + return "Network I/O error."; + case CastStatusCodes.NOT_ALLOWED: + return "The request was disallowed and could not be completed."; + case CastStatusCodes.REPLACED: + return "The request's progress is no longer being tracked because another request of the " + + "same type has been made before the first request completed."; + case CastStatusCodes.SUCCESS: + return "Success."; + case CastStatusCodes.TIMEOUT: + return "An operation has timed out."; + case CastStatusCodes.UNKNOWN_ERROR: + return "An unknown, unexpected error has occurred."; + default: + return CastStatusCodes.getStatusCodeString(statusCode); + } + } + + /** + * Creates a {@link Format} instance containing all information contained in the given + * {@link MediaTrack} object. + * + * @param mediaTrack The {@link MediaTrack}. + * @return The equivalent {@link Format}. + */ + public static Format mediaTrackToFormat(MediaTrack mediaTrack) { + return Format.createContainerFormat( + mediaTrack.getContentId(), + /* label= */ null, + mediaTrack.getContentType(), + /* sampleMimeType= */ null, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + /* selectionFlags= */ 0, + mediaTrack.getLanguage()); + } + + private CastUtils() {} + +} diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultCastOptionsProvider.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultCastOptionsProvider.java new file mode 100644 index 0000000000..06f0bec971 --- /dev/null +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultCastOptionsProvider.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.cast; + +import android.content.Context; +import com.google.android.gms.cast.CastMediaControlIntent; +import com.google.android.gms.cast.framework.CastOptions; +import com.google.android.gms.cast.framework.OptionsProvider; +import com.google.android.gms.cast.framework.SessionProvider; +import java.util.List; + +/** + * A convenience {@link OptionsProvider} to target the default cast receiver app. + */ +public final class DefaultCastOptionsProvider implements OptionsProvider { + + @Override + public CastOptions getCastOptions(Context context) { + return new CastOptions.Builder() + .setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID) + .setStopReceiverApplicationWhenEndingSession(true).build(); + } + + @Override + public List getAdditionalSessionProviders(Context context) { + return null; + } + +} diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/MediaItem.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/MediaItem.java new file mode 100644 index 0000000000..8ab10e165d --- /dev/null +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/MediaItem.java @@ -0,0 +1,368 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.cast; + +import android.net.Uri; +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.checkerframework.checker.initialization.qual.UnknownInitialization; +import org.checkerframework.checker.nullness.qual.EnsuresNonNull; + +/** Representation of an item that can be played by a media player. */ +public final class MediaItem { + + /** A builder for {@link MediaItem} instances. */ + public static final class Builder { + + @Nullable private UUID uuid; + private String title; + private String description; + private MediaItem.UriBundle media; + @Nullable private Object attachment; + private List drmSchemes; + private long startPositionUs; + private long endPositionUs; + private String mimeType; + + /** Creates an builder with default field values. */ + public Builder() { + clearInternal(); + } + + /** See {@link MediaItem#uuid}. */ + public Builder setUuid(UUID uuid) { + this.uuid = uuid; + return this; + } + + /** See {@link MediaItem#title}. */ + public Builder setTitle(String title) { + this.title = title; + return this; + } + + /** See {@link MediaItem#description}. */ + public Builder setDescription(String description) { + this.description = description; + return this; + } + + /** Equivalent to {@link #setMedia(UriBundle) setMedia(new UriBundle(Uri.parse(uri)))}. */ + public Builder setMedia(String uri) { + return setMedia(new UriBundle(Uri.parse(uri))); + } + + /** See {@link MediaItem#media}. */ + public Builder setMedia(UriBundle media) { + this.media = media; + return this; + } + + /** See {@link MediaItem#attachment}. */ + public Builder setAttachment(Object attachment) { + this.attachment = attachment; + return this; + } + + /** See {@link MediaItem#drmSchemes}. */ + public Builder setDrmSchemes(List drmSchemes) { + this.drmSchemes = Collections.unmodifiableList(new ArrayList<>(drmSchemes)); + return this; + } + + /** See {@link MediaItem#startPositionUs}. */ + public Builder setStartPositionUs(long startPositionUs) { + this.startPositionUs = startPositionUs; + return this; + } + + /** See {@link MediaItem#endPositionUs}. */ + public Builder setEndPositionUs(long endPositionUs) { + Assertions.checkArgument(endPositionUs != C.TIME_END_OF_SOURCE); + this.endPositionUs = endPositionUs; + return this; + } + + /** See {@link MediaItem#mimeType}. */ + public Builder setMimeType(String mimeType) { + this.mimeType = mimeType; + return this; + } + + /** + * Equivalent to {@link #build()}, except it also calls {@link #clear()} after creating the + * {@link MediaItem}. + */ + public MediaItem buildAndClear() { + MediaItem item = build(); + clearInternal(); + return item; + } + + /** Returns the builder to default values. */ + public Builder clear() { + clearInternal(); + return this; + } + + /** + * Returns a new {@link MediaItem} instance with the current builder values. This method also + * clears any values passed to {@link #setUuid(UUID)}. + */ + public MediaItem build() { + UUID uuid = this.uuid; + this.uuid = null; + return new MediaItem( + uuid != null ? uuid : UUID.randomUUID(), + title, + description, + media, + attachment, + drmSchemes, + startPositionUs, + endPositionUs, + mimeType); + } + + @EnsuresNonNull({"title", "description", "media", "drmSchemes", "mimeType"}) + private void clearInternal(@UnknownInitialization Builder this) { + uuid = null; + title = ""; + description = ""; + media = UriBundle.EMPTY; + attachment = null; + drmSchemes = Collections.emptyList(); + startPositionUs = C.TIME_UNSET; + endPositionUs = C.TIME_UNSET; + mimeType = ""; + } + } + + /** Bundles a resource's URI with headers to attach to any request to that URI. */ + public static final class UriBundle { + + /** An empty {@link UriBundle}. */ + public static final UriBundle EMPTY = new UriBundle(Uri.EMPTY); + + /** A URI. */ + public final Uri uri; + + /** The headers to attach to any request for the given URI. */ + public final Map requestHeaders; + + /** + * Creates an instance with no request headers. + * + * @param uri See {@link #uri}. + */ + public UriBundle(Uri uri) { + this(uri, Collections.emptyMap()); + } + + /** + * Creates an instance with the given URI and request headers. + * + * @param uri See {@link #uri}. + * @param requestHeaders See {@link #requestHeaders}. + */ + public UriBundle(Uri uri, Map requestHeaders) { + this.uri = uri; + this.requestHeaders = Collections.unmodifiableMap(new HashMap<>(requestHeaders)); + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + + UriBundle uriBundle = (UriBundle) other; + return uri.equals(uriBundle.uri) && requestHeaders.equals(uriBundle.requestHeaders); + } + + @Override + public int hashCode() { + int result = uri.hashCode(); + result = 31 * result + requestHeaders.hashCode(); + return result; + } + } + + /** + * Represents a DRM protection scheme, and optionally provides information about how to acquire + * the license for the media. + */ + public static final class DrmScheme { + + /** The UUID of the protection scheme. */ + public final UUID uuid; + + /** + * Optional {@link UriBundle} for the license server. If no license server is provided, the + * server must be provided by the media. + */ + @Nullable public final UriBundle licenseServer; + + /** + * Creates an instance. + * + * @param uuid See {@link #uuid}. + * @param licenseServer See {@link #licenseServer}. + */ + public DrmScheme(UUID uuid, @Nullable UriBundle licenseServer) { + this.uuid = uuid; + this.licenseServer = licenseServer; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + + DrmScheme drmScheme = (DrmScheme) other; + return uuid.equals(drmScheme.uuid) && Util.areEqual(licenseServer, drmScheme.licenseServer); + } + + @Override + public int hashCode() { + int result = uuid.hashCode(); + result = 31 * result + (licenseServer != null ? licenseServer.hashCode() : 0); + return result; + } + } + + /** + * A UUID that identifies this item, potentially across different devices. The default value is + * obtained by calling {@link UUID#randomUUID()}. + */ + public final UUID uuid; + + /** The title of the item. The default value is an empty string. */ + public final String title; + + /** A description for the item. The default value is an empty string. */ + public final String description; + + /** + * A {@link UriBundle} to fetch the media content. The default value is {@link UriBundle#EMPTY}. + */ + public final UriBundle media; + + /** + * An optional opaque object to attach to the media item. Handling of this attachment is + * implementation specific. The default value is null. + */ + @Nullable public final Object attachment; + + /** + * Immutable list of {@link DrmScheme} instances sorted in decreasing order of preference. The + * default value is an empty list. + */ + public final List drmSchemes; + + /** + * The position in microseconds at which playback of this media item should start. {@link + * C#TIME_UNSET} if playback should start at the default position. The default value is {@link + * C#TIME_UNSET}. + */ + public final long startPositionUs; + + /** + * The position in microseconds at which playback of this media item should end. {@link + * C#TIME_UNSET} if playback should end at the end of the media. The default value is {@link + * C#TIME_UNSET}. + */ + public final long endPositionUs; + + /** + * The mime type of this media item. The default value is an empty string. + * + *

The usage of this mime type is optional and player implementation specific. + */ + public final String mimeType; + + // TODO: Add support for sideloaded tracks, artwork, icon, and subtitle. + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + MediaItem mediaItem = (MediaItem) other; + return startPositionUs == mediaItem.startPositionUs + && endPositionUs == mediaItem.endPositionUs + && uuid.equals(mediaItem.uuid) + && title.equals(mediaItem.title) + && description.equals(mediaItem.description) + && media.equals(mediaItem.media) + && Util.areEqual(attachment, mediaItem.attachment) + && drmSchemes.equals(mediaItem.drmSchemes) + && mimeType.equals(mediaItem.mimeType); + } + + @Override + public int hashCode() { + int result = uuid.hashCode(); + result = 31 * result + title.hashCode(); + result = 31 * result + description.hashCode(); + result = 31 * result + media.hashCode(); + result = 31 * result + (attachment != null ? attachment.hashCode() : 0); + result = 31 * result + drmSchemes.hashCode(); + result = 31 * result + (int) (startPositionUs ^ (startPositionUs >>> 32)); + result = 31 * result + (int) (endPositionUs ^ (endPositionUs >>> 32)); + result = 31 * result + mimeType.hashCode(); + return result; + } + + private MediaItem( + UUID uuid, + String title, + String description, + UriBundle media, + @Nullable Object attachment, + List drmSchemes, + long startPositionUs, + long endPositionUs, + String mimeType) { + this.uuid = uuid; + this.title = title; + this.description = description; + this.media = media; + this.attachment = attachment; + this.drmSchemes = drmSchemes; + this.startPositionUs = startPositionUs; + this.endPositionUs = endPositionUs; + this.mimeType = mimeType; + } +} diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/MediaItemQueue.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/MediaItemQueue.java new file mode 100644 index 0000000000..184e347e1c --- /dev/null +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/MediaItemQueue.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.cast; + +/** Represents a sequence of {@link MediaItem MediaItems}. */ +public interface MediaItemQueue { + + /** + * Returns the item at the given index. + * + * @param index The index of the item to retrieve. + * @return The item at the given index. + * @throws IndexOutOfBoundsException If {@code index < 0 || index >= getSize()}. + */ + MediaItem get(int index); + + /** Returns the number of items in this queue. */ + int getSize(); + + /** + * Appends the given sequence of items to the queue. + * + * @param items The sequence of items to append. + */ + void add(MediaItem... items); + + /** + * Adds the given sequence of items to the queue at the given position, so that the first of + * {@code items} is placed at the given index. + * + * @param index The index at which {@code items} will be inserted. + * @param items The sequence of items to append. + * @throws IndexOutOfBoundsException If {@code index < 0 || index > getSize()}. + */ + void add(int index, MediaItem... items); + + /** + * Moves an existing item within the playlist. + * + *

Calling this method is equivalent to removing the item at position {@code indexFrom} and + * immediately inserting it at position {@code indexTo}. If the moved item is being played at the + * moment of the invocation, playback will stick with the moved item. + * + * @param indexFrom The index of the item to move. + * @param indexTo The index at which the item will be placed after this operation. + * @throws IndexOutOfBoundsException If for either index, {@code index < 0 || index >= getSize()}. + */ + void move(int indexFrom, int indexTo); + + /** + * Removes an item from the queue. + * + * @param index The index of the item to remove from the queue. + * @throws IndexOutOfBoundsException If {@code index < 0 || index >= getSize()}. + */ + void remove(int index); + + /** + * Removes a range of items from the queue. + * + *

Does nothing if an empty range ({@code from == exclusiveTo}) is passed. + * + * @param from The inclusive index at which the range to remove starts. + * @param exclusiveTo The exclusive index at which the range to remove ends. + * @throws IndexOutOfBoundsException If {@code from < 0 || exclusiveTo > getSize() || from > + * exclusiveTo}. + */ + void removeRange(int from, int exclusiveTo); + + /** Removes all items in the queue. */ + void clear(); +} diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/SessionAvailabilityListener.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/SessionAvailabilityListener.java new file mode 100644 index 0000000000..c686c496c6 --- /dev/null +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/SessionAvailabilityListener.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.cast; + +/** Listener of changes in the cast session availability. */ +public interface SessionAvailabilityListener { + + /** Called when a cast session becomes available to the player. */ + void onCastSessionAvailable(); + + /** Called when the cast session becomes unavailable. */ + void onCastSessionUnavailable(); +} diff --git a/extensions/cast/src/test/AndroidManifest.xml b/extensions/cast/src/test/AndroidManifest.xml new file mode 100644 index 0000000000..aea8bda663 --- /dev/null +++ b/extensions/cast/src/test/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + diff --git a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastTimelineTrackerTest.java b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastTimelineTrackerTest.java new file mode 100644 index 0000000000..4c60e7c0b3 --- /dev/null +++ b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastTimelineTrackerTest.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.cast; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.testutil.TimelineAsserts; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.gms.cast.MediaInfo; +import com.google.android.gms.cast.MediaQueueItem; +import com.google.android.gms.cast.MediaStatus; +import java.util.ArrayList; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.robolectric.RobolectricTestRunner; + +/** Tests for {@link CastTimelineTracker}. */ +@RunWith(RobolectricTestRunner.class) +public class CastTimelineTrackerTest { + + private static final long DURATION_1_MS = 1000; + private static final long DURATION_2_MS = 2000; + private static final long DURATION_3_MS = 3000; + private static final long DURATION_4_MS = 4000; + private static final long DURATION_5_MS = 5000; + + /** Tests that duration of the current media info is correctly propagated to the timeline. */ + @Test + public void testGetCastTimeline() { + MediaInfo mediaInfo; + MediaStatus status = + mockMediaStatus( + new int[] {1, 2, 3}, + new String[] {"contentId1", "contentId2", "contentId3"}, + new long[] {DURATION_1_MS, MediaInfo.UNKNOWN_DURATION, MediaInfo.UNKNOWN_DURATION}); + + CastTimelineTracker tracker = new CastTimelineTracker(); + mediaInfo = getMediaInfo("contentId1", DURATION_1_MS); + Mockito.when(status.getMediaInfo()).thenReturn(mediaInfo); + TimelineAsserts.assertPeriodDurations( + tracker.getCastTimeline(status), C.msToUs(DURATION_1_MS), C.TIME_UNSET, C.TIME_UNSET); + + mediaInfo = getMediaInfo("contentId3", DURATION_3_MS); + Mockito.when(status.getMediaInfo()).thenReturn(mediaInfo); + TimelineAsserts.assertPeriodDurations( + tracker.getCastTimeline(status), + C.msToUs(DURATION_1_MS), + C.TIME_UNSET, + C.msToUs(DURATION_3_MS)); + + mediaInfo = getMediaInfo("contentId2", DURATION_2_MS); + Mockito.when(status.getMediaInfo()).thenReturn(mediaInfo); + TimelineAsserts.assertPeriodDurations( + tracker.getCastTimeline(status), + C.msToUs(DURATION_1_MS), + C.msToUs(DURATION_2_MS), + C.msToUs(DURATION_3_MS)); + + MediaStatus newStatus = + mockMediaStatus( + new int[] {4, 1, 5, 3}, + new String[] {"contentId4", "contentId1", "contentId5", "contentId3"}, + new long[] { + MediaInfo.UNKNOWN_DURATION, + MediaInfo.UNKNOWN_DURATION, + DURATION_5_MS, + MediaInfo.UNKNOWN_DURATION + }); + mediaInfo = getMediaInfo("contentId5", DURATION_5_MS); + Mockito.when(newStatus.getMediaInfo()).thenReturn(mediaInfo); + TimelineAsserts.assertPeriodDurations( + tracker.getCastTimeline(newStatus), + C.TIME_UNSET, + C.msToUs(DURATION_1_MS), + C.msToUs(DURATION_5_MS), + C.msToUs(DURATION_3_MS)); + + mediaInfo = getMediaInfo("contentId3", DURATION_3_MS); + Mockito.when(newStatus.getMediaInfo()).thenReturn(mediaInfo); + TimelineAsserts.assertPeriodDurations( + tracker.getCastTimeline(newStatus), + C.TIME_UNSET, + C.msToUs(DURATION_1_MS), + C.msToUs(DURATION_5_MS), + C.msToUs(DURATION_3_MS)); + + mediaInfo = getMediaInfo("contentId4", DURATION_4_MS); + Mockito.when(newStatus.getMediaInfo()).thenReturn(mediaInfo); + TimelineAsserts.assertPeriodDurations( + tracker.getCastTimeline(newStatus), + C.msToUs(DURATION_4_MS), + C.msToUs(DURATION_1_MS), + C.msToUs(DURATION_5_MS), + C.msToUs(DURATION_3_MS)); + } + + private static MediaStatus mockMediaStatus( + int[] itemIds, String[] contentIds, long[] durationsMs) { + ArrayList items = new ArrayList<>(); + for (int i = 0; i < contentIds.length; i++) { + MediaInfo mediaInfo = getMediaInfo(contentIds[i], durationsMs[i]); + MediaQueueItem item = Mockito.mock(MediaQueueItem.class); + Mockito.when(item.getMedia()).thenReturn(mediaInfo); + Mockito.when(item.getItemId()).thenReturn(itemIds[i]); + items.add(item); + } + MediaStatus status = Mockito.mock(MediaStatus.class); + Mockito.when(status.getQueueItems()).thenReturn(items); + return status; + } + + private static MediaInfo getMediaInfo(String contentId, long durationMs) { + return new MediaInfo.Builder(contentId) + .setStreamDuration(durationMs) + .setContentType(MimeTypes.APPLICATION_MP4) + .setStreamType(MediaInfo.STREAM_TYPE_NONE) + .build(); + } +} diff --git a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/MediaItemTest.java b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/MediaItemTest.java new file mode 100644 index 0000000000..98df0d5690 --- /dev/null +++ b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/MediaItemTest.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.cast; + +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.util.MimeTypes; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Test for {@link MediaItem}. */ +@RunWith(RobolectricTestRunner.class) +public class MediaItemTest { + + @Test + public void buildMediaItem_resetsUuid() { + MediaItem.Builder builder = new MediaItem.Builder(); + UUID uuid = new UUID(1, 1); + MediaItem item1 = builder.setUuid(uuid).build(); + MediaItem item2 = builder.build(); + MediaItem item3 = builder.build(); + assertThat(item1.uuid).isEqualTo(uuid); + assertThat(item2.uuid).isNotEqualTo(uuid); + assertThat(item3.uuid).isNotEqualTo(item2.uuid); + assertThat(item3.uuid).isNotEqualTo(uuid); + } + + @Test + public void buildMediaItem_doesNotChangeState() { + MediaItem.Builder builder = new MediaItem.Builder(); + MediaItem item1 = + builder + .setUuid(new UUID(0, 1)) + .setMedia("http://example.com") + .setTitle("title") + .setMimeType(MimeTypes.AUDIO_MP4) + .setStartPositionUs(3) + .setEndPositionUs(4) + .build(); + MediaItem item2 = builder.setUuid(new UUID(0, 1)).build(); + assertThat(item1).isEqualTo(item2); + } + + @Test + public void buildMediaItem_assertDefaultValues() { + assertDefaultValues(new MediaItem.Builder().build()); + } + + @Test + public void buildAndClear_assertDefaultValues() { + MediaItem.Builder builder = new MediaItem.Builder(); + builder + .setMedia("http://example.com") + .setTitle("title") + .setMimeType(MimeTypes.AUDIO_MP4) + .setStartPositionUs(3) + .setEndPositionUs(4) + .buildAndClear(); + assertDefaultValues(builder.build()); + } + + @Test + public void equals_withEqualDrmSchemes_returnsTrue() { + MediaItem.Builder builder = new MediaItem.Builder(); + MediaItem mediaItem1 = + builder + .setUuid(new UUID(0, 1)) + .setMedia("www.google.com") + .setDrmSchemes(createDummyDrmSchemes(1)) + .buildAndClear(); + MediaItem mediaItem2 = + builder + .setUuid(new UUID(0, 1)) + .setMedia("www.google.com") + .setDrmSchemes(createDummyDrmSchemes(1)) + .buildAndClear(); + assertThat(mediaItem1).isEqualTo(mediaItem2); + } + + @Test + public void equals_withDifferentDrmRequestHeaders_returnsFalse() { + MediaItem.Builder builder = new MediaItem.Builder(); + MediaItem mediaItem1 = + builder + .setUuid(new UUID(0, 1)) + .setMedia("www.google.com") + .setDrmSchemes(createDummyDrmSchemes(1)) + .buildAndClear(); + MediaItem mediaItem2 = + builder + .setUuid(new UUID(0, 1)) + .setMedia("www.google.com") + .setDrmSchemes(createDummyDrmSchemes(2)) + .buildAndClear(); + assertThat(mediaItem1).isNotEqualTo(mediaItem2); + } + + private static void assertDefaultValues(MediaItem item) { + assertThat(item.title).isEmpty(); + assertThat(item.description).isEmpty(); + assertThat(item.media.uri).isEqualTo(Uri.EMPTY); + assertThat(item.attachment).isNull(); + assertThat(item.drmSchemes).isEmpty(); + assertThat(item.startPositionUs).isEqualTo(C.TIME_UNSET); + assertThat(item.endPositionUs).isEqualTo(C.TIME_UNSET); + assertThat(item.mimeType).isEmpty(); + } + + private static List createDummyDrmSchemes(int seed) { + HashMap requestHeaders1 = new HashMap<>(); + requestHeaders1.put("key1", "value1"); + requestHeaders1.put("key2", "value1"); + MediaItem.UriBundle uriBundle1 = + new MediaItem.UriBundle(Uri.parse("www.uri1.com"), requestHeaders1); + MediaItem.DrmScheme drmScheme1 = new MediaItem.DrmScheme(C.WIDEVINE_UUID, uriBundle1); + HashMap requestHeaders2 = new HashMap<>(); + requestHeaders2.put("key3", "value3"); + requestHeaders2.put("key4", "valueWithSeed" + seed); + MediaItem.UriBundle uriBundle2 = + new MediaItem.UriBundle(Uri.parse("www.uri2.com"), requestHeaders2); + MediaItem.DrmScheme drmScheme2 = new MediaItem.DrmScheme(C.PLAYREADY_UUID, uriBundle2); + return Arrays.asList(drmScheme1, drmScheme2); + } +} diff --git a/extensions/cast/src/test/resources/robolectric.properties b/extensions/cast/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..2f3210368e --- /dev/null +++ b/extensions/cast/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +manifest=src/test/AndroidManifest.xml diff --git a/extensions/cronet/README.md b/extensions/cronet/README.md index 30409fa99e..f1f6d68c81 100644 --- a/extensions/cronet/README.md +++ b/extensions/cronet/README.md @@ -1,35 +1,55 @@ -# ExoPlayer Cronet Extension # +# ExoPlayer Cronet extension # -## Description ## +The Cronet extension is an [HttpDataSource][] implementation using [Cronet][]. -[Cronet][] is Chromium's Networking stack packaged as a library. - -The Cronet Extension is an [HttpDataSource][] implementation using [Cronet][]. - -[HttpDataSource]: https://google.github.io/ExoPlayer/doc/reference/com/google/android/exoplayer/upstream/HttpDataSource.html +[HttpDataSource]: https://google.github.io/ExoPlayer/doc/reference/com/google/android/exoplayer2/upstream/HttpDataSource.html [Cronet]: https://chromium.googlesource.com/chromium/src/+/master/components/cronet?autodive=0%2F%2F -## Build Instructions ## +## Getting the extension ## -To use this extension you need to clone the ExoPlayer repository and depend on -its modules locally. Instructions for doing this can be found in ExoPlayer's -[top level README][]. In addition, it's necessary to get the Cronet libraries -and enable the extension: - -1. Find the latest Cronet release [here][] and navigate to its `Release/cronet` - directory -1. Download `cronet_api.jar`, `cronet_impl_common_java.jar`, - `cronet_impl_native_java.jar` and the `libs` directory -1. Copy the three jar files into the `libs` directory of this extension -1. Copy the content of the downloaded `libs` directory into the `jniLibs` - directory of this extension - -* In your `settings.gradle` file, add the following line before the line that - applies `core_settings.gradle`: +The easiest way to use the extension is to add it as a gradle dependency: ```gradle -gradle.ext.exoplayerIncludeCronetExtension = true; +implementation 'com.google.android.exoplayer:extension-cronet:2.X.X' ``` +where `2.X.X` is the version, which must match the version of the ExoPlayer +library being used. + +Alternatively, you can clone the ExoPlayer repository and depend on the module +locally. Instructions for doing this can be found in ExoPlayer's +[top level README][]. + [top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md -[here]: https://console.cloud.google.com/storage/browser/chromium-cronet/android + +## Using the extension ## + +ExoPlayer requests data through `DataSource` instances. These instances are +either instantiated and injected from application code, or obtained from +instances of `DataSource.Factory` that are instantiated and injected from +application code. + +If your application only needs to play http(s) content, using the Cronet +extension is as simple as updating any `DataSource`s and `DataSource.Factory` +instantiations in your application code to use `CronetDataSource` and +`CronetDataSourceFactory` respectively. If your application also needs to play +non-http(s) content such as local files, use +``` +new DefaultDataSource( + ... + new CronetDataSource(...) /* baseDataSource argument */); +``` +and +``` +new DefaultDataSourceFactory( + ... + new CronetDataSourceFactory(...) /* baseDataSourceFactory argument */); +``` +respectively. + +## Links ## + +* [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.cronet.*` + belong to this module. + +[Javadoc]: https://google.github.io/ExoPlayer/doc/reference/index.html diff --git a/extensions/cronet/build.gradle b/extensions/cronet/build.gradle index 930a53c7c5..7d8c217b58 100644 --- a/extensions/cronet/build.gradle +++ b/extensions/cronet/build.gradle @@ -19,29 +19,31 @@ android { buildToolsVersion project.ext.buildToolsVersion defaultConfig { - minSdkVersion project.ext.minSdkVersion + minSdkVersion 16 targetSdkVersion project.ext.targetSdkVersion - testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } - sourceSets.main { - jniLibs.srcDirs = ['jniLibs'] + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { - compile project(modulePrefix + 'library-core') - compile files('libs/cronet_api.jar') - compile files('libs/cronet_impl_common_java.jar') - compile files('libs/cronet_impl_native_java.jar') - androidTestCompile project(modulePrefix + 'library') - androidTestCompile 'com.google.dexmaker:dexmaker:' + dexmakerVersion - androidTestCompile 'com.google.dexmaker:dexmaker-mockito:' + dexmakerVersion - androidTestCompile 'org.mockito:mockito-core:' + mockitoVersion - androidTestCompile 'com.android.support.test:runner:' + testSupportLibraryVersion + api 'org.chromium.net:cronet-embedded:66.3359.158' + implementation project(modulePrefix + 'library-core') + implementation 'com.android.support:support-annotations:' + supportLibraryVersion + testImplementation project(modulePrefix + 'library') + testImplementation project(modulePrefix + 'testutils-robolectric') } ext { javadocTitle = 'Cronet extension' } apply from: '../../javadoc_library.gradle' + +ext { + releaseArtifact = 'extension-cronet' + releaseDescription = 'Cronet extension for ExoPlayer.' +} +apply from: '../../publish.gradle' diff --git a/extensions/cronet/jniLibs/README.md b/extensions/cronet/jniLibs/README.md deleted file mode 100644 index e9f0717ae6..0000000000 --- a/extensions/cronet/jniLibs/README.md +++ /dev/null @@ -1 +0,0 @@ -Copy folders containing architecture specific .so files here. diff --git a/extensions/cronet/libs/README.md b/extensions/cronet/libs/README.md deleted file mode 100644 index 641a80db18..0000000000 --- a/extensions/cronet/libs/README.md +++ /dev/null @@ -1 +0,0 @@ -Copy cronet.jar and cronet_api.jar here. diff --git a/extensions/cronet/src/androidTest/AndroidManifest.xml b/extensions/cronet/src/androidTest/AndroidManifest.xml deleted file mode 100644 index 1f371a1864..0000000000 --- a/extensions/cronet/src/androidTest/AndroidManifest.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - diff --git a/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java b/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java deleted file mode 100644 index 06a356487e..0000000000 --- a/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java +++ /dev/null @@ -1,819 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.exoplayer2.ext.cronet; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.anyString; -import static org.mockito.Matchers.eq; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.mockito.MockitoAnnotations.initMocks; - -import android.net.Uri; -import android.os.ConditionVariable; -import android.support.test.InstrumentationRegistry; -import android.support.test.runner.AndroidJUnit4; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.upstream.HttpDataSource; -import com.google.android.exoplayer2.upstream.HttpDataSource.HttpDataSourceException; -import com.google.android.exoplayer2.upstream.TransferListener; -import com.google.android.exoplayer2.util.Clock; -import com.google.android.exoplayer2.util.Predicate; -import java.io.IOException; -import java.net.SocketTimeoutException; -import java.net.UnknownHostException; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.Executor; -import java.util.concurrent.atomic.AtomicInteger; -import org.chromium.net.CronetEngine; -import org.chromium.net.NetworkException; -import org.chromium.net.UrlRequest; -import org.chromium.net.UrlResponseInfo; -import org.chromium.net.impl.UrlResponseInfoImpl; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mock; -import org.mockito.invocation.InvocationOnMock; -import org.mockito.stubbing.Answer; - -/** - * Tests for {@link CronetDataSource}. - */ -@RunWith(AndroidJUnit4.class) -public final class CronetDataSourceTest { - - private static final int TEST_CONNECT_TIMEOUT_MS = 100; - private static final int TEST_READ_TIMEOUT_MS = 50; - private static final String TEST_URL = "http://google.com"; - private static final String TEST_CONTENT_TYPE = "test/test"; - private static final byte[] TEST_POST_BODY = "test post body".getBytes(); - private static final long TEST_CONTENT_LENGTH = 16000L; - private static final int TEST_CONNECTION_STATUS = 5; - - private DataSpec testDataSpec; - private DataSpec testPostDataSpec; - private Map testResponseHeader; - private UrlResponseInfo testUrlResponseInfo; - - @Mock private UrlRequest.Builder mockUrlRequestBuilder; - @Mock - private UrlRequest mockUrlRequest; - @Mock - private Predicate mockContentTypePredicate; - @Mock - private TransferListener mockTransferListener; - @Mock - private Clock mockClock; - @Mock - private Executor mockExecutor; - @Mock - private NetworkException mockNetworkException; - @Mock private CronetEngine mockCronetEngine; - - private CronetDataSource dataSourceUnderTest; - - @Before - public void setUp() throws Exception { - System.setProperty("dexmaker.dexcache", - InstrumentationRegistry.getTargetContext().getCacheDir().getPath()); - initMocks(this); - dataSourceUnderTest = spy( - new CronetDataSource( - mockCronetEngine, - mockExecutor, - mockContentTypePredicate, - mockTransferListener, - TEST_CONNECT_TIMEOUT_MS, - TEST_READ_TIMEOUT_MS, - true, // resetTimeoutOnRedirects - mockClock, - null)); - when(mockContentTypePredicate.evaluate(anyString())).thenReturn(true); - when(mockCronetEngine.newUrlRequestBuilder( - anyString(), any(UrlRequest.Callback.class), any(Executor.class))) - .thenReturn(mockUrlRequestBuilder); - when(mockUrlRequestBuilder.allowDirectExecutor()).thenReturn(mockUrlRequestBuilder); - when(mockUrlRequestBuilder.build()).thenReturn(mockUrlRequest); - mockStatusResponse(); - - testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, C.LENGTH_UNSET, null); - testPostDataSpec = new DataSpec( - Uri.parse(TEST_URL), TEST_POST_BODY, 0, 0, C.LENGTH_UNSET, null, 0); - testResponseHeader = new HashMap<>(); - testResponseHeader.put("Content-Type", TEST_CONTENT_TYPE); - // This value can be anything since the DataSpec is unset. - testResponseHeader.put("Content-Length", Long.toString(TEST_CONTENT_LENGTH)); - testUrlResponseInfo = createUrlResponseInfo(200); // statusCode - } - - private UrlResponseInfo createUrlResponseInfo(int statusCode) { - ArrayList> responseHeaderList = new ArrayList<>(); - responseHeaderList.addAll(testResponseHeader.entrySet()); - return new UrlResponseInfoImpl( - Collections.singletonList(TEST_URL), - statusCode, - null, // httpStatusText - responseHeaderList, - false, // wasCached - null, // negotiatedProtocol - null); // proxyServer - } - - @Test(expected = IllegalStateException.class) - public void testOpeningTwiceThrows() throws HttpDataSourceException { - mockResponseStartSuccess(); - dataSourceUnderTest.open(testDataSpec); - dataSourceUnderTest.open(testDataSpec); - } - - @Test - public void testCallbackFromPreviousRequest() throws HttpDataSourceException { - mockResponseStartSuccess(); - - dataSourceUnderTest.open(testDataSpec); - dataSourceUnderTest.close(); - // Prepare a mock UrlRequest to be used in the second open() call. - final UrlRequest mockUrlRequest2 = mock(UrlRequest.class); - when(mockUrlRequestBuilder.build()).thenReturn(mockUrlRequest2); - doAnswer(new Answer() { - @Override - public Object answer(InvocationOnMock invocation) throws Throwable { - // Invoke the callback for the previous request. - dataSourceUnderTest.onFailed( - mockUrlRequest, - testUrlResponseInfo, - mockNetworkException); - dataSourceUnderTest.onResponseStarted( - mockUrlRequest2, - testUrlResponseInfo); - return null; - } - }).when(mockUrlRequest2).start(); - dataSourceUnderTest.open(testDataSpec); - } - - @Test - public void testRequestStartCalled() throws HttpDataSourceException { - mockResponseStartSuccess(); - - dataSourceUnderTest.open(testDataSpec); - verify(mockCronetEngine) - .newUrlRequestBuilder(eq(TEST_URL), any(UrlRequest.Callback.class), any(Executor.class)); - verify(mockUrlRequest).start(); - } - - @Test - public void testRequestHeadersSet() throws HttpDataSourceException { - testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000, null); - mockResponseStartSuccess(); - - dataSourceUnderTest.setRequestProperty("firstHeader", "firstValue"); - dataSourceUnderTest.setRequestProperty("secondHeader", "secondValue"); - - dataSourceUnderTest.open(testDataSpec); - // The header value to add is current position to current position + length - 1. - verify(mockUrlRequestBuilder).addHeader("Range", "bytes=1000-5999"); - verify(mockUrlRequestBuilder).addHeader("firstHeader", "firstValue"); - verify(mockUrlRequestBuilder).addHeader("secondHeader", "secondValue"); - verify(mockUrlRequest).start(); - } - - @Test - public void testRequestOpen() throws HttpDataSourceException { - mockResponseStartSuccess(); - assertEquals(TEST_CONTENT_LENGTH, dataSourceUnderTest.open(testDataSpec)); - verify(mockTransferListener).onTransferStart(dataSourceUnderTest, testDataSpec); - } - - @Test - public void testRequestOpenGzippedCompressedReturnsDataSpecLength() - throws HttpDataSourceException { - testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, 5000, null); - testResponseHeader.put("Content-Encoding", "gzip"); - testResponseHeader.put("Content-Length", Long.toString(50L)); - mockResponseStartSuccess(); - - assertEquals(5000 /* contentLength */, dataSourceUnderTest.open(testDataSpec)); - verify(mockTransferListener).onTransferStart(dataSourceUnderTest, testDataSpec); - } - - @Test - public void testRequestOpenFail() { - mockResponseStartFailure(); - - try { - dataSourceUnderTest.open(testDataSpec); - fail("HttpDataSource.HttpDataSourceException expected"); - } catch (HttpDataSourceException e) { - // Check for connection not automatically closed. - assertFalse(e.getCause() instanceof UnknownHostException); - verify(mockUrlRequest, never()).cancel(); - verify(mockTransferListener, never()).onTransferStart(dataSourceUnderTest, testDataSpec); - } - } - - @Test - public void testRequestOpenFailDueToDnsFailure() { - mockResponseStartFailure(); - when(mockNetworkException.getErrorCode()).thenReturn( - NetworkException.ERROR_HOSTNAME_NOT_RESOLVED); - - try { - dataSourceUnderTest.open(testDataSpec); - fail("HttpDataSource.HttpDataSourceException expected"); - } catch (HttpDataSourceException e) { - // Check for connection not automatically closed. - assertTrue(e.getCause() instanceof UnknownHostException); - verify(mockUrlRequest, never()).cancel(); - verify(mockTransferListener, never()).onTransferStart(dataSourceUnderTest, testDataSpec); - } - } - - @Test - public void testRequestOpenValidatesStatusCode() { - mockResponseStartSuccess(); - testUrlResponseInfo = createUrlResponseInfo(500); // statusCode - - try { - dataSourceUnderTest.open(testDataSpec); - fail("HttpDataSource.HttpDataSourceException expected"); - } catch (HttpDataSourceException e) { - assertTrue(e instanceof HttpDataSource.InvalidResponseCodeException); - // Check for connection not automatically closed. - verify(mockUrlRequest, never()).cancel(); - verify(mockTransferListener, never()).onTransferStart(dataSourceUnderTest, testDataSpec); - } - } - - @Test - public void testRequestOpenValidatesContentTypePredicate() { - mockResponseStartSuccess(); - when(mockContentTypePredicate.evaluate(anyString())).thenReturn(false); - - try { - dataSourceUnderTest.open(testDataSpec); - fail("HttpDataSource.HttpDataSourceException expected"); - } catch (HttpDataSourceException e) { - assertTrue(e instanceof HttpDataSource.InvalidContentTypeException); - // Check for connection not automatically closed. - verify(mockUrlRequest, never()).cancel(); - verify(mockContentTypePredicate).evaluate(TEST_CONTENT_TYPE); - } - } - - @Test - public void testPostRequestOpen() throws HttpDataSourceException { - mockResponseStartSuccess(); - - dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE); - assertEquals(TEST_CONTENT_LENGTH, dataSourceUnderTest.open(testPostDataSpec)); - verify(mockTransferListener).onTransferStart(dataSourceUnderTest, testPostDataSpec); - } - - @Test - public void testPostRequestOpenValidatesContentType() { - mockResponseStartSuccess(); - - try { - dataSourceUnderTest.open(testPostDataSpec); - fail("HttpDataSource.HttpDataSourceException expected"); - } catch (HttpDataSourceException e) { - verify(mockUrlRequest, never()).start(); - } - } - - @Test - public void testPostRequestOpenRejects307Redirects() { - mockResponseStartSuccess(); - mockResponseStartRedirect(); - - try { - dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE); - dataSourceUnderTest.open(testPostDataSpec); - fail("HttpDataSource.HttpDataSourceException expected"); - } catch (HttpDataSourceException e) { - verify(mockUrlRequest, never()).followRedirect(); - } - } - - @Test - public void testRequestReadTwice() throws HttpDataSourceException { - mockResponseStartSuccess(); - mockReadSuccess(0, 16); - - dataSourceUnderTest.open(testDataSpec); - - byte[] returnedBuffer = new byte[8]; - int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8); - assertArrayEquals(buildTestDataArray(0, 8), returnedBuffer); - assertEquals(8, bytesRead); - - returnedBuffer = new byte[8]; - bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8); - assertArrayEquals(buildTestDataArray(8, 8), returnedBuffer); - assertEquals(8, bytesRead); - - // Should have only called read on cronet once. - verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class)); - verify(mockTransferListener, times(2)).onBytesTransferred(dataSourceUnderTest, 8); - } - - @Test - public void testSecondRequestNoContentLength() throws HttpDataSourceException { - mockResponseStartSuccess(); - testResponseHeader.put("Content-Length", Long.toString(1L)); - mockReadSuccess(0, 16); - - // First request. - dataSourceUnderTest.open(testDataSpec); - byte[] returnedBuffer = new byte[8]; - dataSourceUnderTest.read(returnedBuffer, 0, 1); - dataSourceUnderTest.close(); - - testResponseHeader.remove("Content-Length"); - mockReadSuccess(0, 16); - - // Second request. - dataSourceUnderTest.open(testDataSpec); - returnedBuffer = new byte[16]; - int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10); - assertEquals(10, bytesRead); - bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10); - assertEquals(6, bytesRead); - bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10); - assertEquals(C.RESULT_END_OF_INPUT, bytesRead); - } - - @Test - public void testReadWithOffset() throws HttpDataSourceException { - mockResponseStartSuccess(); - mockReadSuccess(0, 16); - - dataSourceUnderTest.open(testDataSpec); - - byte[] returnedBuffer = new byte[16]; - int bytesRead = dataSourceUnderTest.read(returnedBuffer, 8, 8); - assertEquals(8, bytesRead); - assertArrayEquals(prefixZeros(buildTestDataArray(0, 8), 16), returnedBuffer); - verify(mockTransferListener).onBytesTransferred(dataSourceUnderTest, 8); - } - - @Test - public void testRangeRequestWith206Response() throws HttpDataSourceException { - mockResponseStartSuccess(); - mockReadSuccess(1000, 5000); - testUrlResponseInfo = createUrlResponseInfo(206); // Server supports range requests. - testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000, null); - - dataSourceUnderTest.open(testDataSpec); - - byte[] returnedBuffer = new byte[16]; - int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 16); - assertEquals(16, bytesRead); - assertArrayEquals(buildTestDataArray(1000, 16), returnedBuffer); - verify(mockTransferListener).onBytesTransferred(dataSourceUnderTest, 16); - } - - @Test - public void testRangeRequestWith200Response() throws HttpDataSourceException { - mockResponseStartSuccess(); - mockReadSuccess(0, 7000); - testUrlResponseInfo = createUrlResponseInfo(200); // Server does not support range requests. - testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000, null); - - dataSourceUnderTest.open(testDataSpec); - - byte[] returnedBuffer = new byte[16]; - int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 16); - assertEquals(16, bytesRead); - assertArrayEquals(buildTestDataArray(1000, 16), returnedBuffer); - verify(mockTransferListener).onBytesTransferred(dataSourceUnderTest, 16); - } - - @Test - public void testReadWithUnsetLength() throws HttpDataSourceException { - testResponseHeader.remove("Content-Length"); - mockResponseStartSuccess(); - mockReadSuccess(0, 16); - - dataSourceUnderTest.open(testDataSpec); - - byte[] returnedBuffer = new byte[16]; - int bytesRead = dataSourceUnderTest.read(returnedBuffer, 8, 8); - assertArrayEquals(prefixZeros(buildTestDataArray(0, 8), 16), returnedBuffer); - assertEquals(8, bytesRead); - verify(mockTransferListener).onBytesTransferred(dataSourceUnderTest, 8); - } - - @Test - public void testReadReturnsWhatItCan() throws HttpDataSourceException { - mockResponseStartSuccess(); - mockReadSuccess(0, 16); - - dataSourceUnderTest.open(testDataSpec); - - byte[] returnedBuffer = new byte[24]; - int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 24); - assertArrayEquals(suffixZeros(buildTestDataArray(0, 16), 24), returnedBuffer); - assertEquals(16, bytesRead); - verify(mockTransferListener).onBytesTransferred(dataSourceUnderTest, 16); - } - - @Test - public void testClosedMeansClosed() throws HttpDataSourceException { - mockResponseStartSuccess(); - mockReadSuccess(0, 16); - - int bytesRead = 0; - dataSourceUnderTest.open(testDataSpec); - - byte[] returnedBuffer = new byte[8]; - bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8); - assertArrayEquals(buildTestDataArray(0, 8), returnedBuffer); - assertEquals(8, bytesRead); - - dataSourceUnderTest.close(); - verify(mockTransferListener).onTransferEnd(dataSourceUnderTest); - - try { - bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8); - fail(); - } catch (IllegalStateException e) { - // Expected. - } - - // 16 bytes were attempted but only 8 should have been successfully read. - assertEquals(8, bytesRead); - } - - @Test - public void testOverread() throws HttpDataSourceException { - testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, 16, null); - testResponseHeader.put("Content-Length", Long.toString(16L)); - mockResponseStartSuccess(); - mockReadSuccess(0, 16); - - dataSourceUnderTest.open(testDataSpec); - - byte[] returnedBuffer = new byte[8]; - int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8); - assertEquals(8, bytesRead); - assertArrayEquals(buildTestDataArray(0, 8), returnedBuffer); - - // The current buffer is kept if not completely consumed by DataSource reader. - returnedBuffer = new byte[8]; - bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 6); - assertEquals(14, bytesRead); - assertArrayEquals(suffixZeros(buildTestDataArray(8, 6), 8), returnedBuffer); - - // 2 bytes left at this point. - returnedBuffer = new byte[8]; - bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8); - assertEquals(16, bytesRead); - assertArrayEquals(suffixZeros(buildTestDataArray(14, 2), 8), returnedBuffer); - - // Should have only called read on cronet once. - verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class)); - verify(mockTransferListener, times(1)).onBytesTransferred(dataSourceUnderTest, 8); - verify(mockTransferListener, times(1)).onBytesTransferred(dataSourceUnderTest, 6); - verify(mockTransferListener, times(1)).onBytesTransferred(dataSourceUnderTest, 2); - - // Now we already returned the 16 bytes initially asked. - // Try to read again even though all requested 16 bytes are already returned. - // Return C.RESULT_END_OF_INPUT - returnedBuffer = new byte[16]; - int bytesOverRead = dataSourceUnderTest.read(returnedBuffer, 0, 16); - assertEquals(C.RESULT_END_OF_INPUT, bytesOverRead); - assertArrayEquals(new byte[16], returnedBuffer); - // C.RESULT_END_OF_INPUT should not be reported though the TransferListener. - verify(mockTransferListener, never()).onBytesTransferred(dataSourceUnderTest, - C.RESULT_END_OF_INPUT); - // There should still be only one call to read on cronet. - verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class)); - // Check for connection not automatically closed. - verify(mockUrlRequest, never()).cancel(); - assertEquals(16, bytesRead); - } - - @Test - public void testConnectTimeout() { - when(mockClock.elapsedRealtime()).thenReturn(0L); - final ConditionVariable startCondition = buildUrlRequestStartedCondition(); - final ConditionVariable timedOutCondition = new ConditionVariable(); - - new Thread() { - @Override - public void run() { - try { - dataSourceUnderTest.open(testDataSpec); - fail(); - } catch (HttpDataSourceException e) { - // Expected. - assertTrue(e instanceof CronetDataSource.OpenException); - assertTrue(e.getCause() instanceof SocketTimeoutException); - assertEquals( - TEST_CONNECTION_STATUS, - ((CronetDataSource.OpenException) e).cronetConnectionStatus); - timedOutCondition.open(); - } - } - }.start(); - startCondition.block(); - - // We should still be trying to open. - assertFalse(timedOutCondition.block(50)); - // We should still be trying to open as we approach the timeout. - when(mockClock.elapsedRealtime()).thenReturn((long) TEST_CONNECT_TIMEOUT_MS - 1); - assertFalse(timedOutCondition.block(50)); - // Now we timeout. - when(mockClock.elapsedRealtime()).thenReturn((long) TEST_CONNECT_TIMEOUT_MS); - timedOutCondition.block(); - - verify(mockTransferListener, never()).onTransferStart(dataSourceUnderTest, testDataSpec); - } - - @Test - public void testConnectResponseBeforeTimeout() { - when(mockClock.elapsedRealtime()).thenReturn(0L); - final ConditionVariable startCondition = buildUrlRequestStartedCondition(); - final ConditionVariable openCondition = new ConditionVariable(); - - new Thread() { - @Override - public void run() { - try { - dataSourceUnderTest.open(testDataSpec); - openCondition.open(); - } catch (HttpDataSourceException e) { - fail(); - } - } - }.start(); - startCondition.block(); - - // We should still be trying to open. - assertFalse(openCondition.block(50)); - // We should still be trying to open as we approach the timeout. - when(mockClock.elapsedRealtime()).thenReturn((long) TEST_CONNECT_TIMEOUT_MS - 1); - assertFalse(openCondition.block(50)); - // The response arrives just in time. - dataSourceUnderTest.onResponseStarted(mockUrlRequest, testUrlResponseInfo); - openCondition.block(); - } - - @Test - public void testRedirectIncreasesConnectionTimeout() throws InterruptedException { - when(mockClock.elapsedRealtime()).thenReturn(0L); - final ConditionVariable startCondition = buildUrlRequestStartedCondition(); - final ConditionVariable timedOutCondition = new ConditionVariable(); - final AtomicInteger openExceptions = new AtomicInteger(0); - - new Thread() { - @Override - public void run() { - try { - dataSourceUnderTest.open(testDataSpec); - fail(); - } catch (HttpDataSourceException e) { - // Expected. - assertTrue(e instanceof CronetDataSource.OpenException); - assertTrue(e.getCause() instanceof SocketTimeoutException); - openExceptions.getAndIncrement(); - timedOutCondition.open(); - } - } - }.start(); - startCondition.block(); - - // We should still be trying to open. - assertFalse(timedOutCondition.block(50)); - // We should still be trying to open as we approach the timeout. - when(mockClock.elapsedRealtime()).thenReturn((long) TEST_CONNECT_TIMEOUT_MS - 1); - assertFalse(timedOutCondition.block(50)); - // A redirect arrives just in time. - dataSourceUnderTest.onRedirectReceived(mockUrlRequest, testUrlResponseInfo, - "RandomRedirectedUrl1"); - - long newTimeoutMs = 2 * TEST_CONNECT_TIMEOUT_MS - 1; - when(mockClock.elapsedRealtime()).thenReturn(newTimeoutMs - 1); - // Give the thread some time to run. - assertFalse(timedOutCondition.block(newTimeoutMs)); - // We should still be trying to open as we approach the new timeout. - assertFalse(timedOutCondition.block(50)); - // A redirect arrives just in time. - dataSourceUnderTest.onRedirectReceived(mockUrlRequest, testUrlResponseInfo, - "RandomRedirectedUrl2"); - - newTimeoutMs = 3 * TEST_CONNECT_TIMEOUT_MS - 2; - when(mockClock.elapsedRealtime()).thenReturn(newTimeoutMs - 1); - // Give the thread some time to run. - assertFalse(timedOutCondition.block(newTimeoutMs)); - // We should still be trying to open as we approach the new timeout. - assertFalse(timedOutCondition.block(50)); - // Now we timeout. - when(mockClock.elapsedRealtime()).thenReturn(newTimeoutMs); - timedOutCondition.block(); - - verify(mockTransferListener, never()).onTransferStart(dataSourceUnderTest, testDataSpec); - assertEquals(1, openExceptions.get()); - } - - @Test - public void testExceptionFromTransferListener() throws HttpDataSourceException { - mockResponseStartSuccess(); - - // Make mockTransferListener throw an exception in CronetDataSource.close(). Ensure that - // the subsequent open() call succeeds. - doThrow(new NullPointerException()).when(mockTransferListener).onTransferEnd( - dataSourceUnderTest); - dataSourceUnderTest.open(testDataSpec); - try { - dataSourceUnderTest.close(); - fail("NullPointerException expected"); - } catch (NullPointerException e) { - // Expected. - } - // Open should return successfully. - dataSourceUnderTest.open(testDataSpec); - } - - @Test - public void testReadFailure() throws HttpDataSourceException { - mockResponseStartSuccess(); - mockReadFailure(); - - dataSourceUnderTest.open(testDataSpec); - byte[] returnedBuffer = new byte[8]; - try { - dataSourceUnderTest.read(returnedBuffer, 0, 8); - fail("dataSourceUnderTest.read() returned, but IOException expected"); - } catch (IOException e) { - // Expected. - } - } - - @Test - public void testAllowDirectExecutor() throws HttpDataSourceException { - testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000, null); - mockResponseStartSuccess(); - - dataSourceUnderTest.open(testDataSpec); - verify(mockUrlRequestBuilder).allowDirectExecutor(); - } - - // Helper methods. - - private void mockStatusResponse() { - doAnswer(new Answer() { - @Override - public Object answer(InvocationOnMock invocation) throws Throwable { - UrlRequest.StatusListener statusListener = - (UrlRequest.StatusListener) invocation.getArguments()[0]; - statusListener.onStatus(TEST_CONNECTION_STATUS); - return null; - } - }).when(mockUrlRequest).getStatus(any(UrlRequest.StatusListener.class)); - } - - private void mockResponseStartSuccess() { - doAnswer(new Answer() { - @Override - public Object answer(InvocationOnMock invocation) throws Throwable { - dataSourceUnderTest.onResponseStarted( - mockUrlRequest, - testUrlResponseInfo); - return null; - } - }).when(mockUrlRequest).start(); - } - - private void mockResponseStartRedirect() { - doAnswer(new Answer() { - @Override - public Object answer(InvocationOnMock invocation) throws Throwable { - dataSourceUnderTest.onRedirectReceived( - mockUrlRequest, - createUrlResponseInfo(307), // statusCode - "http://redirect.location.com"); - return null; - } - }).when(mockUrlRequest).start(); - } - - private void mockResponseStartFailure() { - doAnswer(new Answer() { - @Override - public Object answer(InvocationOnMock invocation) throws Throwable { - dataSourceUnderTest.onFailed( - mockUrlRequest, - createUrlResponseInfo(500), // statusCode - mockNetworkException); - return null; - } - }).when(mockUrlRequest).start(); - } - - private void mockReadSuccess(int position, int length) { - final int[] positionAndRemaining = new int[] {position, length}; - doAnswer(new Answer() { - @Override - public Void answer(InvocationOnMock invocation) throws Throwable { - if (positionAndRemaining[1] == 0) { - dataSourceUnderTest.onSucceeded(mockUrlRequest, testUrlResponseInfo); - } else { - ByteBuffer inputBuffer = (ByteBuffer) invocation.getArguments()[0]; - int readLength = Math.min(positionAndRemaining[1], inputBuffer.remaining()); - inputBuffer.put(buildTestDataBuffer(positionAndRemaining[0], readLength)); - positionAndRemaining[0] += readLength; - positionAndRemaining[1] -= readLength; - dataSourceUnderTest.onReadCompleted( - mockUrlRequest, - testUrlResponseInfo, - inputBuffer); - } - return null; - } - }).when(mockUrlRequest).read(any(ByteBuffer.class)); - } - - private void mockReadFailure() { - doAnswer(new Answer() { - @Override - public Object answer(InvocationOnMock invocation) throws Throwable { - dataSourceUnderTest.onFailed( - mockUrlRequest, - createUrlResponseInfo(500), // statusCode - mockNetworkException); - return null; - } - }).when(mockUrlRequest).read(any(ByteBuffer.class)); - } - - private ConditionVariable buildUrlRequestStartedCondition() { - final ConditionVariable startedCondition = new ConditionVariable(); - doAnswer(new Answer() { - @Override - public Object answer(InvocationOnMock invocation) throws Throwable { - startedCondition.open(); - return null; - } - }).when(mockUrlRequest).start(); - return startedCondition; - } - - private static byte[] buildTestDataArray(int position, int length) { - return buildTestDataBuffer(position, length).array(); - } - - public static byte[] prefixZeros(byte[] data, int requiredLength) { - byte[] prefixedData = new byte[requiredLength]; - System.arraycopy(data, 0, prefixedData, requiredLength - data.length, data.length); - return prefixedData; - } - - public static byte[] suffixZeros(byte[] data, int requiredLength) { - return Arrays.copyOf(data, requiredLength); - } - - private static ByteBuffer buildTestDataBuffer(int position, int length) { - ByteBuffer testBuffer = ByteBuffer.allocate(length); - for (int i = 0; i < length; i++) { - testBuffer.put((byte) (position + i)); - } - testBuffer.flip(); - return testBuffer; - } - -} diff --git a/extensions/cronet/src/main/AndroidManifest.xml b/extensions/cronet/src/main/AndroidManifest.xml index c81d95f104..5ba54999f4 100644 --- a/extensions/cronet/src/main/AndroidManifest.xml +++ b/extensions/cronet/src/main/AndroidManifest.xml @@ -14,7 +14,7 @@ --> + package="com.google.android.exoplayer2.ext.cronet"> diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java index 204a2756bb..ab10f41d8f 100644 --- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java +++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java @@ -16,22 +16,24 @@ package com.google.android.exoplayer2.ext.cronet; import android.net.Uri; -import android.os.ConditionVariable; +import android.support.annotation.Nullable; import android.text.TextUtils; -import android.util.Log; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlayerLibraryInfo; +import com.google.android.exoplayer2.upstream.BaseDataSource; import com.google.android.exoplayer2.upstream.DataSourceException; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.HttpDataSource; -import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Clock; +import com.google.android.exoplayer2.util.ConditionVariable; +import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Predicate; import java.io.IOException; import java.net.SocketTimeoutException; import java.net.UnknownHostException; import java.nio.ByteBuffer; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Map.Entry; @@ -47,9 +49,10 @@ import org.chromium.net.UrlResponseInfo; /** * DataSource without intermediate buffer based on Cronet API set using UrlRequest. + * *

This class's methods are organized in the sequence of expected calls. */ -public class CronetDataSource extends UrlRequest.Callback implements HttpDataSource { +public class CronetDataSource extends BaseDataSource implements HttpDataSource { /** * Thrown when an error is encountered when trying to open a {@link CronetDataSource}. @@ -74,6 +77,14 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou } + /** Thrown on catching an InterruptedException. */ + public static final class InterruptedIOException extends IOException { + + public InterruptedIOException(InterruptedException e) { + super(e); + } + } + static { ExoPlayerLibraryInfo.registerModule("goog.exo.cronet"); } @@ -87,8 +98,13 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou */ public static final int DEFAULT_READ_TIMEOUT_MILLIS = 8 * 1000; + /* package */ final UrlRequest.Callback urlRequestCallback; + private static final String TAG = "CronetDataSource"; private static final String CONTENT_TYPE = "Content-Type"; + private static final String SET_COOKIE = "Set-Cookie"; + private static final String COOKIE = "Cookie"; + private static final Pattern CONTENT_RANGE_HEADER_PATTERN = Pattern.compile("^bytes (\\d+)-(\\d+)/(\\d+)$"); // The size of read buffer passed to cronet UrlRequest.read(). @@ -97,10 +113,10 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou private final CronetEngine cronetEngine; private final Executor executor; private final Predicate contentTypePredicate; - private final TransferListener listener; private final int connectTimeoutMs; private final int readTimeoutMs; private final boolean resetTimeoutOnRedirects; + private final boolean handleSetCookieRequests; private final RequestProperties defaultRequestProperties; private final RequestProperties requestProperties; private final ConditionVariable operation; @@ -131,59 +147,122 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou /** * @param cronetEngine A CronetEngine. - * @param executor The {@link java.util.concurrent.Executor} that will handle responses. - * This may be a direct executor (i.e. executes tasks on the calling thread) in order - * to avoid a thread hop from Cronet's internal network thread to the response handling - * thread. However, to avoid slowing down overall network performance, care must be taken - * to make sure response handling is a fast operation when using a direct executor. + * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may + * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread + * hop from Cronet's internal network thread to the response handling thread. However, to + * avoid slowing down overall network performance, care must be taken to make sure response + * handling is a fast operation when using a direct executor. * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the - * predicate then an {@link InvalidContentTypeException} is thrown from - * {@link #open(DataSpec)}. - * @param listener An optional listener. + * predicate then an {@link InvalidContentTypeException} is thrown from {@link + * #open(DataSpec)}. */ - public CronetDataSource(CronetEngine cronetEngine, Executor executor, - Predicate contentTypePredicate, TransferListener listener) { - this(cronetEngine, executor, contentTypePredicate, listener, DEFAULT_CONNECT_TIMEOUT_MILLIS, - DEFAULT_READ_TIMEOUT_MILLIS, false, null); + public CronetDataSource( + CronetEngine cronetEngine, Executor executor, Predicate contentTypePredicate) { + this( + cronetEngine, + executor, + contentTypePredicate, + DEFAULT_CONNECT_TIMEOUT_MILLIS, + DEFAULT_READ_TIMEOUT_MILLIS, + false, + null, + false); } /** * @param cronetEngine A CronetEngine. - * @param executor The {@link java.util.concurrent.Executor} that will handle responses. - * This may be a direct executor (i.e. executes tasks on the calling thread) in order - * to avoid a thread hop from Cronet's internal network thread to the response handling - * thread. However, to avoid slowing down overall network performance, care must be taken - * to make sure response handling is a fast operation when using a direct executor. + * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may + * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread + * hop from Cronet's internal network thread to the response handling thread. However, to + * avoid slowing down overall network performance, care must be taken to make sure response + * handling is a fast operation when using a direct executor. * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the - * predicate then an {@link InvalidContentTypeException} is thrown from - * {@link #open(DataSpec)}. - * @param listener An optional listener. + * predicate then an {@link InvalidContentTypeException} is thrown from {@link + * #open(DataSpec)}. * @param connectTimeoutMs The connection timeout, in milliseconds. * @param readTimeoutMs The read timeout, in milliseconds. * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs. * @param defaultRequestProperties The default request properties to be used. */ - public CronetDataSource(CronetEngine cronetEngine, Executor executor, - Predicate contentTypePredicate, TransferListener listener, - int connectTimeoutMs, int readTimeoutMs, boolean resetTimeoutOnRedirects, + public CronetDataSource( + CronetEngine cronetEngine, + Executor executor, + Predicate contentTypePredicate, + int connectTimeoutMs, + int readTimeoutMs, + boolean resetTimeoutOnRedirects, RequestProperties defaultRequestProperties) { - this(cronetEngine, executor, contentTypePredicate, listener, connectTimeoutMs, - readTimeoutMs, resetTimeoutOnRedirects, Clock.DEFAULT, defaultRequestProperties); + this( + cronetEngine, + executor, + contentTypePredicate, + connectTimeoutMs, + readTimeoutMs, + resetTimeoutOnRedirects, + Clock.DEFAULT, + defaultRequestProperties, + false); } - /* package */ CronetDataSource(CronetEngine cronetEngine, Executor executor, - Predicate contentTypePredicate, TransferListener listener, - int connectTimeoutMs, int readTimeoutMs, boolean resetTimeoutOnRedirects, Clock clock, - RequestProperties defaultRequestProperties) { + /** + * @param cronetEngine A CronetEngine. + * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may + * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread + * hop from Cronet's internal network thread to the response handling thread. However, to + * avoid slowing down overall network performance, care must be taken to make sure response + * handling is a fast operation when using a direct executor. + * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the + * predicate then an {@link InvalidContentTypeException} is thrown from {@link + * #open(DataSpec)}. + * @param connectTimeoutMs The connection timeout, in milliseconds. + * @param readTimeoutMs The read timeout, in milliseconds. + * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs. + * @param defaultRequestProperties The default request properties to be used. + * @param handleSetCookieRequests Whether "Set-Cookie" requests on redirect should be forwarded to + * the redirect url in the "Cookie" header. + */ + public CronetDataSource( + CronetEngine cronetEngine, + Executor executor, + Predicate contentTypePredicate, + int connectTimeoutMs, + int readTimeoutMs, + boolean resetTimeoutOnRedirects, + RequestProperties defaultRequestProperties, + boolean handleSetCookieRequests) { + this( + cronetEngine, + executor, + contentTypePredicate, + connectTimeoutMs, + readTimeoutMs, + resetTimeoutOnRedirects, + Clock.DEFAULT, + defaultRequestProperties, + handleSetCookieRequests); + } + + /* package */ CronetDataSource( + CronetEngine cronetEngine, + Executor executor, + Predicate contentTypePredicate, + int connectTimeoutMs, + int readTimeoutMs, + boolean resetTimeoutOnRedirects, + Clock clock, + RequestProperties defaultRequestProperties, + boolean handleSetCookieRequests) { + super(/* isNetwork= */ true); + this.urlRequestCallback = new UrlRequestCallback(); this.cronetEngine = Assertions.checkNotNull(cronetEngine); this.executor = Assertions.checkNotNull(executor); this.contentTypePredicate = contentTypePredicate; - this.listener = listener; this.connectTimeoutMs = connectTimeoutMs; this.readTimeoutMs = readTimeoutMs; this.resetTimeoutOnRedirects = resetTimeoutOnRedirects; this.clock = Assertions.checkNotNull(clock); this.defaultRequestProperties = defaultRequestProperties; + this.handleSetCookieRequests = handleSetCookieRequests; requestProperties = new RequestProperties(); operation = new ConditionVariable(); } @@ -207,7 +286,7 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou @Override public Map> getResponseHeaders() { - return responseInfo == null ? null : responseInfo.getAllHeaders(); + return responseInfo == null ? Collections.emptyMap() : responseInfo.getAllHeaders(); } @Override @@ -223,22 +302,37 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou operation.close(); resetConnectTimeout(); currentDataSpec = dataSpec; - currentUrlRequest = buildRequest(dataSpec); + try { + currentUrlRequest = buildRequestBuilder(dataSpec).build(); + } catch (IOException e) { + throw new OpenException(e, currentDataSpec, Status.IDLE); + } currentUrlRequest.start(); - boolean requestStarted = blockUntilConnectTimeout(); - if (exception != null) { - throw new OpenException(exception, currentDataSpec, getStatus(currentUrlRequest)); - } else if (!requestStarted) { - // The timeout was reached before the connection was opened. - throw new OpenException(new SocketTimeoutException(), dataSpec, getStatus(currentUrlRequest)); + transferInitializing(dataSpec); + try { + boolean connectionOpened = blockUntilConnectTimeout(); + if (exception != null) { + throw new OpenException(exception, currentDataSpec, getStatus(currentUrlRequest)); + } else if (!connectionOpened) { + // The timeout was reached before the connection was opened. + throw new OpenException( + new SocketTimeoutException(), dataSpec, getStatus(currentUrlRequest)); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new OpenException(new InterruptedIOException(e), dataSpec, Status.INVALID); } // Check for a valid response code. int responseCode = responseInfo.getHttpStatusCode(); if (responseCode < 200 || responseCode > 299) { - InvalidResponseCodeException exception = new InvalidResponseCodeException(responseCode, - responseInfo.getAllHeaders(), currentDataSpec); + InvalidResponseCodeException exception = + new InvalidResponseCodeException( + responseCode, + responseInfo.getHttpStatusText(), + responseInfo.getAllHeaders(), + currentDataSpec); if (responseCode == 416) { exception.initCause(new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE)); } @@ -273,9 +367,7 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou } opened = true; - if (listener != null) { - listener.onTransferStart(this, dataSpec); - } + transferStarted(dataSpec); return bytesRemaining; } @@ -299,17 +391,29 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou operation.close(); readBuffer.clear(); currentUrlRequest.read(readBuffer); - if (!operation.block(readTimeoutMs)) { - // We're timing out, but since the operation is still ongoing we'll need to replace - // readBuffer to avoid the possibility of it being written to by this operation during a - // subsequent request. + try { + if (!operation.block(readTimeoutMs)) { + throw new SocketTimeoutException(); + } + } catch (InterruptedException e) { + // The operation is ongoing so replace readBuffer to avoid it being written to by this + // operation during a subsequent request. readBuffer = null; + Thread.currentThread().interrupt(); throw new HttpDataSourceException( - new SocketTimeoutException(), currentDataSpec, HttpDataSourceException.TYPE_READ); - } else if (exception != null) { + new InterruptedIOException(e), currentDataSpec, HttpDataSourceException.TYPE_READ); + } catch (SocketTimeoutException e) { + // The operation is ongoing so replace readBuffer to avoid it being written to by this + // operation during a subsequent request. + readBuffer = null; + throw new HttpDataSourceException(e, currentDataSpec, HttpDataSourceException.TYPE_READ); + } + + if (exception != null) { throw new HttpDataSourceException(exception, currentDataSpec, HttpDataSourceException.TYPE_READ); } else if (finished) { + bytesRemaining = 0; return C.RESULT_END_OF_INPUT; } else { // The operation didn't time out, fail or finish, and therefore data must have been read. @@ -329,9 +433,7 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou if (bytesRemaining != C.LENGTH_UNSET) { bytesRemaining -= bytesRead; } - if (listener != null) { - listener.onBytesTransferred(this, bytesRead); - } + bytesTransferred(bytesRead); return bytesRead; } @@ -350,86 +452,29 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou finished = false; if (opened) { opened = false; - if (listener != null) { - listener.onTransferEnd(this); - } + transferEnded(); } } - // UrlRequest.Callback implementation - - @Override - public synchronized void onRedirectReceived(UrlRequest request, UrlResponseInfo info, - String newLocationUrl) { - if (request != currentUrlRequest) { - return; - } - if (currentDataSpec.postBody != null) { - int responseCode = info.getHttpStatusCode(); - // The industry standard is to disregard POST redirects when the status code is 307 or 308. - // For other redirect response codes the POST request is converted to a GET request and the - // redirect is followed. - if (responseCode == 307 || responseCode == 308) { - exception = new InvalidResponseCodeException(responseCode, info.getAllHeaders(), - currentDataSpec); - operation.open(); - return; - } - } - if (resetTimeoutOnRedirects) { - resetConnectTimeout(); - } - request.followRedirect(); + /** Returns current {@link UrlRequest}. May be null if the data source is not opened. */ + @Nullable + protected UrlRequest getCurrentUrlRequest() { + return currentUrlRequest; } - @Override - public synchronized void onResponseStarted(UrlRequest request, UrlResponseInfo info) { - if (request != currentUrlRequest) { - return; - } - responseInfo = info; - operation.open(); - } - - @Override - public synchronized void onReadCompleted(UrlRequest request, UrlResponseInfo info, - ByteBuffer buffer) { - if (request != currentUrlRequest) { - return; - } - operation.open(); - } - - @Override - public synchronized void onSucceeded(UrlRequest request, UrlResponseInfo info) { - if (request != currentUrlRequest) { - return; - } - finished = true; - operation.open(); - } - - @Override - public synchronized void onFailed(UrlRequest request, UrlResponseInfo info, - CronetException error) { - if (request != currentUrlRequest) { - return; - } - if (error instanceof NetworkException - && ((NetworkException) error).getErrorCode() - == NetworkException.ERROR_HOSTNAME_NOT_RESOLVED) { - exception = new UnknownHostException(); - } else { - exception = error; - } - operation.open(); + /** Returns current {@link UrlResponseInfo}. May be null if the data source is not opened. */ + @Nullable + protected UrlResponseInfo getCurrentUrlResponseInfo() { + return responseInfo; } // Internal methods. - private UrlRequest buildRequest(DataSpec dataSpec) throws OpenException { - UrlRequest.Builder requestBuilder = cronetEngine.newUrlRequestBuilder( - dataSpec.uri.toString(), this, executor).allowDirectExecutor(); + private UrlRequest.Builder buildRequestBuilder(DataSpec dataSpec) throws IOException { + UrlRequest.Builder requestBuilder = + cronetEngine + .newUrlRequestBuilder(dataSpec.uri.toString(), urlRequestCallback, executor) + .allowDirectExecutor(); // Set the headers. boolean isContentTypeHeaderSet = false; if (defaultRequestProperties != null) { @@ -445,33 +490,36 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou isContentTypeHeaderSet = isContentTypeHeaderSet || CONTENT_TYPE.equals(key); requestBuilder.addHeader(key, headerEntry.getValue()); } - if (dataSpec.postBody != null && dataSpec.postBody.length != 0 && !isContentTypeHeaderSet) { - throw new OpenException("POST request with non-empty body must set Content-Type", dataSpec, - Status.IDLE); + if (dataSpec.httpBody != null && !isContentTypeHeaderSet) { + throw new IOException("HTTP request with non-empty body must set Content-Type"); } // Set the Range header. - if (currentDataSpec.position != 0 || currentDataSpec.length != C.LENGTH_UNSET) { + if (dataSpec.position != 0 || dataSpec.length != C.LENGTH_UNSET) { StringBuilder rangeValue = new StringBuilder(); rangeValue.append("bytes="); - rangeValue.append(currentDataSpec.position); + rangeValue.append(dataSpec.position); rangeValue.append("-"); - if (currentDataSpec.length != C.LENGTH_UNSET) { - rangeValue.append(currentDataSpec.position + currentDataSpec.length - 1); + if (dataSpec.length != C.LENGTH_UNSET) { + rangeValue.append(dataSpec.position + dataSpec.length - 1); } requestBuilder.addHeader("Range", rangeValue.toString()); } + // TODO: Uncomment when https://bugs.chromium.org/p/chromium/issues/detail?id=767025 is fixed + // (adjusting the code as necessary). + // Force identity encoding unless gzip is allowed. + // if (!dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_GZIP)) { + // requestBuilder.addHeader("Accept-Encoding", "identity"); + // } // Set the method and (if non-empty) the body. - if (dataSpec.postBody != null) { - requestBuilder.setHttpMethod("POST"); - if (dataSpec.postBody.length != 0) { - requestBuilder.setUploadDataProvider(new ByteArrayUploadDataProvider(dataSpec.postBody), - executor); - } + requestBuilder.setHttpMethod(dataSpec.getHttpMethodString()); + if (dataSpec.httpBody != null) { + requestBuilder.setUploadDataProvider( + new ByteArrayUploadDataProvider(dataSpec.httpBody), executor); } - return requestBuilder.build(); + return requestBuilder; } - private boolean blockUntilConnectTimeout() { + private boolean blockUntilConnectTimeout() throws InterruptedException { long now = clock.elapsedRealtime(); boolean opened = false; while (!opened && now < currentConnectTimeoutMs) { @@ -538,7 +586,18 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou return contentLength; } - private static int getStatus(UrlRequest request) { + private static String parseCookies(List setCookieHeaders) { + return TextUtils.join(";", setCookieHeaders); + } + + private static void attachCookies(UrlRequest.Builder requestBuilder, String cookies) { + if (TextUtils.isEmpty(cookies)) { + return; + } + requestBuilder.addHeader(COOKIE, cookies); + } + + private static int getStatus(UrlRequest request) throws InterruptedException { final ConditionVariable conditionVariable = new ConditionVariable(); final int[] statusHolder = new int[1]; request.getStatus(new UrlRequest.StatusListener() { @@ -556,4 +615,106 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou return list == null || list.isEmpty(); } + private final class UrlRequestCallback extends UrlRequest.Callback { + + @Override + public synchronized void onRedirectReceived( + UrlRequest request, UrlResponseInfo info, String newLocationUrl) { + if (request != currentUrlRequest) { + return; + } + if (currentDataSpec.httpMethod == DataSpec.HTTP_METHOD_POST) { + int responseCode = info.getHttpStatusCode(); + // The industry standard is to disregard POST redirects when the status code is 307 or 308. + if (responseCode == 307 || responseCode == 308) { + exception = + new InvalidResponseCodeException( + responseCode, info.getHttpStatusText(), info.getAllHeaders(), currentDataSpec); + operation.open(); + return; + } + } + if (resetTimeoutOnRedirects) { + resetConnectTimeout(); + } + + Map> headers = info.getAllHeaders(); + if (!handleSetCookieRequests || isEmpty(headers.get(SET_COOKIE))) { + request.followRedirect(); + } else { + currentUrlRequest.cancel(); + DataSpec redirectUrlDataSpec; + if (currentDataSpec.httpMethod == DataSpec.HTTP_METHOD_POST) { + // For POST redirects that aren't 307 or 308, the redirect is followed but request is + // transformed into a GET. + redirectUrlDataSpec = + new DataSpec( + Uri.parse(newLocationUrl), + DataSpec.HTTP_METHOD_GET, + /* httpBody= */ null, + currentDataSpec.absoluteStreamPosition, + currentDataSpec.position, + currentDataSpec.length, + currentDataSpec.key, + currentDataSpec.flags); + } else { + redirectUrlDataSpec = currentDataSpec.withUri(Uri.parse(newLocationUrl)); + } + UrlRequest.Builder requestBuilder; + try { + requestBuilder = buildRequestBuilder(redirectUrlDataSpec); + } catch (IOException e) { + exception = e; + return; + } + String cookieHeadersValue = parseCookies(headers.get(SET_COOKIE)); + attachCookies(requestBuilder, cookieHeadersValue); + currentUrlRequest = requestBuilder.build(); + currentUrlRequest.start(); + } + } + + @Override + public synchronized void onResponseStarted(UrlRequest request, UrlResponseInfo info) { + if (request != currentUrlRequest) { + return; + } + responseInfo = info; + operation.open(); + } + + @Override + public synchronized void onReadCompleted( + UrlRequest request, UrlResponseInfo info, ByteBuffer buffer) { + if (request != currentUrlRequest) { + return; + } + operation.open(); + } + + @Override + public synchronized void onSucceeded(UrlRequest request, UrlResponseInfo info) { + if (request != currentUrlRequest) { + return; + } + finished = true; + operation.open(); + } + + @Override + public synchronized void onFailed( + UrlRequest request, UrlResponseInfo info, CronetException error) { + if (request != currentUrlRequest) { + return; + } + if (error instanceof NetworkException + && ((NetworkException) error).getErrorCode() + == NetworkException.ERROR_HOSTNAME_NOT_RESOLVED) { + exception = new UnknownHostException(); + } else { + exception = error; + } + operation.open(); + } + } } diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java index d6237fc988..d832e4625d 100644 --- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java +++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java @@ -15,7 +15,7 @@ */ package com.google.android.exoplayer2.ext.cronet; -import com.google.android.exoplayer2.upstream.DataSource; +import android.support.annotation.Nullable; import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.HttpDataSource.BaseFactory; @@ -46,7 +46,7 @@ public final class CronetDataSourceFactory extends BaseFactory { private final CronetEngineWrapper cronetEngineWrapper; private final Executor executor; private final Predicate contentTypePredicate; - private final TransferListener transferListener; + private final @Nullable TransferListener transferListener; private final int connectTimeoutMs; private final int readTimeoutMs; private final boolean resetTimeoutOnRedirects; @@ -54,26 +54,176 @@ public final class CronetDataSourceFactory extends BaseFactory { /** * Constructs a CronetDataSourceFactory. - *

- * If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided + * + *

If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided * fallback {@link HttpDataSource.Factory} will be used instead. * - * Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, {@link - * CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables + *

Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables * cross-protocol redirects. * * @param cronetEngineWrapper A {@link CronetEngineWrapper}. * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the - * predicate then an {@link InvalidContentTypeException} is thrown from - * {@link CronetDataSource#open}. - * @param transferListener An optional listener. - * @param fallbackFactory A {@link HttpDataSource.Factory} which is used as a fallback in case - * no suitable CronetEngine can be build. + * predicate then an {@link InvalidContentTypeException} is thrown from {@link + * CronetDataSource#open}. + * @param fallbackFactory A {@link HttpDataSource.Factory} which is used as a fallback in case no + * suitable CronetEngine can be build. */ - public CronetDataSourceFactory(CronetEngineWrapper cronetEngineWrapper, - Executor executor, Predicate contentTypePredicate, - TransferListener transferListener, + public CronetDataSourceFactory( + CronetEngineWrapper cronetEngineWrapper, + Executor executor, + Predicate contentTypePredicate, + HttpDataSource.Factory fallbackFactory) { + this( + cronetEngineWrapper, + executor, + contentTypePredicate, + /* transferListener= */ null, + DEFAULT_CONNECT_TIMEOUT_MILLIS, + DEFAULT_READ_TIMEOUT_MILLIS, + false, + fallbackFactory); + } + + /** + * Constructs a CronetDataSourceFactory. + * + *

If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link + * DefaultHttpDataSourceFactory} will be used instead. + * + *

Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables + * cross-protocol redirects. + * + * @param cronetEngineWrapper A {@link CronetEngineWrapper}. + * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. + * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the + * predicate then an {@link InvalidContentTypeException} is thrown from {@link + * CronetDataSource#open}. + * @param userAgent A user agent used to create a fallback HttpDataSource if needed. + */ + public CronetDataSourceFactory( + CronetEngineWrapper cronetEngineWrapper, + Executor executor, + Predicate contentTypePredicate, + String userAgent) { + this( + cronetEngineWrapper, + executor, + contentTypePredicate, + /* transferListener= */ null, + DEFAULT_CONNECT_TIMEOUT_MILLIS, + DEFAULT_READ_TIMEOUT_MILLIS, + false, + new DefaultHttpDataSourceFactory( + userAgent, + /* listener= */ null, + DEFAULT_CONNECT_TIMEOUT_MILLIS, + DEFAULT_READ_TIMEOUT_MILLIS, + false)); + } + + /** + * Constructs a CronetDataSourceFactory. + * + *

If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link + * DefaultHttpDataSourceFactory} will be used instead. + * + * @param cronetEngineWrapper A {@link CronetEngineWrapper}. + * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. + * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the + * predicate then an {@link InvalidContentTypeException} is thrown from {@link + * CronetDataSource#open}. + * @param connectTimeoutMs The connection timeout, in milliseconds. + * @param readTimeoutMs The read timeout, in milliseconds. + * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs. + * @param userAgent A user agent used to create a fallback HttpDataSource if needed. + */ + public CronetDataSourceFactory( + CronetEngineWrapper cronetEngineWrapper, + Executor executor, + Predicate contentTypePredicate, + int connectTimeoutMs, + int readTimeoutMs, + boolean resetTimeoutOnRedirects, + String userAgent) { + this( + cronetEngineWrapper, + executor, + contentTypePredicate, + /* transferListener= */ null, + DEFAULT_CONNECT_TIMEOUT_MILLIS, + DEFAULT_READ_TIMEOUT_MILLIS, + resetTimeoutOnRedirects, + new DefaultHttpDataSourceFactory( + userAgent, + /* listener= */ null, + connectTimeoutMs, + readTimeoutMs, + resetTimeoutOnRedirects)); + } + + /** + * Constructs a CronetDataSourceFactory. + * + *

If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided + * fallback {@link HttpDataSource.Factory} will be used instead. + * + * @param cronetEngineWrapper A {@link CronetEngineWrapper}. + * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. + * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the + * predicate then an {@link InvalidContentTypeException} is thrown from {@link + * CronetDataSource#open}. + * @param connectTimeoutMs The connection timeout, in milliseconds. + * @param readTimeoutMs The read timeout, in milliseconds. + * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs. + * @param fallbackFactory A {@link HttpDataSource.Factory} which is used as a fallback in case no + * suitable CronetEngine can be build. + */ + public CronetDataSourceFactory( + CronetEngineWrapper cronetEngineWrapper, + Executor executor, + Predicate contentTypePredicate, + int connectTimeoutMs, + int readTimeoutMs, + boolean resetTimeoutOnRedirects, + HttpDataSource.Factory fallbackFactory) { + this( + cronetEngineWrapper, + executor, + contentTypePredicate, + /* transferListener= */ null, + connectTimeoutMs, + readTimeoutMs, + resetTimeoutOnRedirects, + fallbackFactory); + } + + /** + * Constructs a CronetDataSourceFactory. + * + *

If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided + * fallback {@link HttpDataSource.Factory} will be used instead. + * + *

Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables + * cross-protocol redirects. + * + * @param cronetEngineWrapper A {@link CronetEngineWrapper}. + * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. + * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the + * predicate then an {@link InvalidContentTypeException} is thrown from {@link + * CronetDataSource#open}. + * @param transferListener An optional listener. + * @param fallbackFactory A {@link HttpDataSource.Factory} which is used as a fallback in case no + * suitable CronetEngine can be build. + */ + public CronetDataSourceFactory( + CronetEngineWrapper cronetEngineWrapper, + Executor executor, + Predicate contentTypePredicate, + @Nullable TransferListener transferListener, HttpDataSource.Factory fallbackFactory) { this(cronetEngineWrapper, executor, contentTypePredicate, transferListener, DEFAULT_CONNECT_TIMEOUT_MILLIS, DEFAULT_READ_TIMEOUT_MILLIS, false, fallbackFactory); @@ -81,25 +231,28 @@ public final class CronetDataSourceFactory extends BaseFactory { /** * Constructs a CronetDataSourceFactory. - *

- * If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a - * {@link DefaultHttpDataSourceFactory} will be used instead. * - * Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, {@link - * CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables + *

If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link + * DefaultHttpDataSourceFactory} will be used instead. + * + *

Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables * cross-protocol redirects. * * @param cronetEngineWrapper A {@link CronetEngineWrapper}. * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the - * predicate then an {@link InvalidContentTypeException} is thrown from - * {@link CronetDataSource#open}. + * predicate then an {@link InvalidContentTypeException} is thrown from {@link + * CronetDataSource#open}. * @param transferListener An optional listener. * @param userAgent A user agent used to create a fallback HttpDataSource if needed. */ - public CronetDataSourceFactory(CronetEngineWrapper cronetEngineWrapper, - Executor executor, Predicate contentTypePredicate, - TransferListener transferListener, String userAgent) { + public CronetDataSourceFactory( + CronetEngineWrapper cronetEngineWrapper, + Executor executor, + Predicate contentTypePredicate, + @Nullable TransferListener transferListener, + String userAgent) { this(cronetEngineWrapper, executor, contentTypePredicate, transferListener, DEFAULT_CONNECT_TIMEOUT_MILLIS, DEFAULT_READ_TIMEOUT_MILLIS, false, new DefaultHttpDataSourceFactory(userAgent, transferListener, @@ -108,25 +261,30 @@ public final class CronetDataSourceFactory extends BaseFactory { /** * Constructs a CronetDataSourceFactory. - *

- * If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a - * {@link DefaultHttpDataSourceFactory} will be used instead. + * + *

If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link + * DefaultHttpDataSourceFactory} will be used instead. * * @param cronetEngineWrapper A {@link CronetEngineWrapper}. * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the - * predicate then an {@link InvalidContentTypeException} is thrown from - * {@link CronetDataSource#open}. + * predicate then an {@link InvalidContentTypeException} is thrown from {@link + * CronetDataSource#open}. * @param transferListener An optional listener. * @param connectTimeoutMs The connection timeout, in milliseconds. * @param readTimeoutMs The read timeout, in milliseconds. * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs. * @param userAgent A user agent used to create a fallback HttpDataSource if needed. */ - public CronetDataSourceFactory(CronetEngineWrapper cronetEngineWrapper, - Executor executor, Predicate contentTypePredicate, - TransferListener transferListener, int connectTimeoutMs, - int readTimeoutMs, boolean resetTimeoutOnRedirects, String userAgent) { + public CronetDataSourceFactory( + CronetEngineWrapper cronetEngineWrapper, + Executor executor, + Predicate contentTypePredicate, + @Nullable TransferListener transferListener, + int connectTimeoutMs, + int readTimeoutMs, + boolean resetTimeoutOnRedirects, + String userAgent) { this(cronetEngineWrapper, executor, contentTypePredicate, transferListener, DEFAULT_CONNECT_TIMEOUT_MILLIS, DEFAULT_READ_TIMEOUT_MILLIS, resetTimeoutOnRedirects, new DefaultHttpDataSourceFactory(userAgent, transferListener, connectTimeoutMs, @@ -135,26 +293,30 @@ public final class CronetDataSourceFactory extends BaseFactory { /** * Constructs a CronetDataSourceFactory. - *

- * If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided + * + *

If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided * fallback {@link HttpDataSource.Factory} will be used instead. * * @param cronetEngineWrapper A {@link CronetEngineWrapper}. * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the - * predicate then an {@link InvalidContentTypeException} is thrown from - * {@link CronetDataSource#open}. + * predicate then an {@link InvalidContentTypeException} is thrown from {@link + * CronetDataSource#open}. * @param transferListener An optional listener. * @param connectTimeoutMs The connection timeout, in milliseconds. * @param readTimeoutMs The read timeout, in milliseconds. * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs. - * @param fallbackFactory A {@link HttpDataSource.Factory} which is used as a fallback in case - * no suitable CronetEngine can be build. + * @param fallbackFactory A {@link HttpDataSource.Factory} which is used as a fallback in case no + * suitable CronetEngine can be build. */ - public CronetDataSourceFactory(CronetEngineWrapper cronetEngineWrapper, - Executor executor, Predicate contentTypePredicate, - TransferListener transferListener, int connectTimeoutMs, - int readTimeoutMs, boolean resetTimeoutOnRedirects, + public CronetDataSourceFactory( + CronetEngineWrapper cronetEngineWrapper, + Executor executor, + Predicate contentTypePredicate, + @Nullable TransferListener transferListener, + int connectTimeoutMs, + int readTimeoutMs, + boolean resetTimeoutOnRedirects, HttpDataSource.Factory fallbackFactory) { this.cronetEngineWrapper = cronetEngineWrapper; this.executor = executor; @@ -173,8 +335,19 @@ public final class CronetDataSourceFactory extends BaseFactory { if (cronetEngine == null) { return fallbackFactory.createDataSource(); } - return new CronetDataSource(cronetEngine, executor, contentTypePredicate, transferListener, - connectTimeoutMs, readTimeoutMs, resetTimeoutOnRedirects, defaultRequestProperties); + CronetDataSource dataSource = + new CronetDataSource( + cronetEngine, + executor, + contentTypePredicate, + connectTimeoutMs, + readTimeoutMs, + resetTimeoutOnRedirects, + defaultRequestProperties); + if (transferListener != null) { + dataSource.addTransferListener(transferListener); + } + return dataSource; } } diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java index efe30d6525..829b53f863 100644 --- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java +++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java @@ -17,10 +17,13 @@ package com.google.android.exoplayer2.ext.cronet; import android.content.Context; import android.support.annotation.IntDef; -import android.util.Log; +import com.google.android.exoplayer2.util.Log; +import com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.reflect.Field; +import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; @@ -38,8 +41,10 @@ public final class CronetEngineWrapper { private final @CronetEngineSource int cronetEngineSource; /** - * Source of {@link CronetEngine}. + * Source of {@link CronetEngine}. One of {@link #SOURCE_NATIVE}, {@link #SOURCE_GMS}, {@link + * #SOURCE_UNKNOWN}, {@link #SOURCE_USER_PROVIDED} or {@link #SOURCE_UNAVAILABLE}. */ + @Documented @Retention(RetentionPolicy.SOURCE) @IntDef({SOURCE_NATIVE, SOURCE_GMS, SOURCE_UNKNOWN, SOURCE_USER_PROVIDED, SOURCE_UNAVAILABLE}) public @interface CronetEngineSource {} @@ -86,7 +91,7 @@ public final class CronetEngineWrapper { public CronetEngineWrapper(Context context, boolean preferGMSCoreCronet) { CronetEngine cronetEngine = null; @CronetEngineSource int cronetEngineSource = SOURCE_UNAVAILABLE; - List cronetProviders = CronetProvider.getAllProviders(context); + List cronetProviders = new ArrayList<>(CronetProvider.getAllProviders(context)); // Remove disabled and fallback Cronet providers from list for (int i = cronetProviders.size() - 1; i >= 0; i--) { if (!cronetProviders.get(i).isEnabled() @@ -157,6 +162,8 @@ public final class CronetEngineWrapper { private final String gmsCoreCronetName; private final boolean preferGMSCoreCronet; + // Multi-catch can only be used for API 19+ in this case. + @SuppressWarnings("UseMultiCatch") public CronetProviderComparator(boolean preferGMSCoreCronet) { // GMSCore CronetProvider classes are only available in some configurations. // Thus, we use reflection to copy static name. @@ -217,8 +224,8 @@ public final class CronetEngineWrapper { if (versionLeft == null || versionRight == null) { return 0; } - String[] versionStringsLeft = versionLeft.split("\\."); - String[] versionStringsRight = versionRight.split("\\."); + String[] versionStringsLeft = Util.split(versionLeft, "\\."); + String[] versionStringsRight = Util.split(versionRight, "\\."); int minLength = Math.min(versionStringsLeft.length, versionStringsRight.length); for (int i = 0; i < minLength; i++) { if (!versionStringsLeft[i].equals(versionStringsRight[i])) { diff --git a/library/ui/src/main/res/values-v11/styles.xml b/extensions/cronet/src/test/AndroidManifest.xml similarity index 68% rename from library/ui/src/main/res/values-v11/styles.xml rename to extensions/cronet/src/test/AndroidManifest.xml index 6f77440287..82cffe17c2 100644 --- a/library/ui/src/main/res/values-v11/styles.xml +++ b/extensions/cronet/src/test/AndroidManifest.xml @@ -13,12 +13,5 @@ See the License for the specific language governing permissions and limitations under the License. --> - - - - + diff --git a/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProviderTest.java b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProviderTest.java similarity index 71% rename from extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProviderTest.java rename to extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProviderTest.java index 4282244a7a..117518a1eb 100644 --- a/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProviderTest.java +++ b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProviderTest.java @@ -13,19 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.android.exoplayer2.ext.cronet; -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; +import static com.google.common.truth.Truth.assertThat; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import static org.mockito.MockitoAnnotations.initMocks; - -import android.annotation.TargetApi; -import android.os.Build.VERSION_CODES; -import android.support.test.InstrumentationRegistry; -import android.support.test.runner.AndroidJUnit4; import java.io.IOException; import java.nio.ByteBuffer; @@ -35,11 +27,11 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; -/** - * Tests for {@link ByteArrayUploadDataProvider}. - */ -@RunWith(AndroidJUnit4.class) +/** Tests for {@link ByteArrayUploadDataProvider}. */ +@RunWith(RobolectricTestRunner.class) public final class ByteArrayUploadDataProviderTest { private static final byte[] TEST_DATA = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; @@ -50,38 +42,35 @@ public final class ByteArrayUploadDataProviderTest { @Before public void setUp() { - System.setProperty("dexmaker.dexcache", - InstrumentationRegistry.getTargetContext().getCacheDir().getPath()); - initMocks(this); + MockitoAnnotations.initMocks(this); byteBuffer = ByteBuffer.allocate(TEST_DATA.length); byteArrayUploadDataProvider = new ByteArrayUploadDataProvider(TEST_DATA); } @Test public void testGetLength() { - assertEquals(TEST_DATA.length, byteArrayUploadDataProvider.getLength()); + assertThat(byteArrayUploadDataProvider.getLength()).isEqualTo(TEST_DATA.length); } @Test public void testReadFullBuffer() throws IOException { byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer); - assertArrayEquals(TEST_DATA, byteBuffer.array()); + assertThat(byteBuffer.array()).isEqualTo(TEST_DATA); } - @TargetApi(VERSION_CODES.GINGERBREAD) @Test public void testReadPartialBuffer() throws IOException { - byte[] firstHalf = Arrays.copyOfRange(TEST_DATA, 0, TEST_DATA.length / 2); + byte[] firstHalf = Arrays.copyOf(TEST_DATA, TEST_DATA.length / 2); byte[] secondHalf = Arrays.copyOfRange(TEST_DATA, TEST_DATA.length / 2, TEST_DATA.length); byteBuffer = ByteBuffer.allocate(TEST_DATA.length / 2); // Read half of the data. byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer); - assertArrayEquals(firstHalf, byteBuffer.array()); + assertThat(byteBuffer.array()).isEqualTo(firstHalf); // Read the second half of the data. byteBuffer.rewind(); byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer); - assertArrayEquals(secondHalf, byteBuffer.array()); + assertThat(byteBuffer.array()).isEqualTo(secondHalf); verify(mockUploadDataSink, times(2)).onReadSucceeded(false); } @@ -89,14 +78,13 @@ public final class ByteArrayUploadDataProviderTest { public void testRewind() throws IOException { // Read all the data. byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer); - assertArrayEquals(TEST_DATA, byteBuffer.array()); + assertThat(byteBuffer.array()).isEqualTo(TEST_DATA); // Rewind and make sure it can be read again. byteBuffer.clear(); byteArrayUploadDataProvider.rewind(mockUploadDataSink); byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer); - assertArrayEquals(TEST_DATA, byteBuffer.array()); + assertThat(byteBuffer.array()).isEqualTo(TEST_DATA); verify(mockUploadDataSink).onRewindSucceeded(); } - } diff --git a/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java new file mode 100644 index 0000000000..7d47b0da64 --- /dev/null +++ b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java @@ -0,0 +1,1067 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.cronet; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.net.Uri; +import android.os.ConditionVariable; +import android.os.SystemClock; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.HttpDataSource; +import com.google.android.exoplayer2.upstream.HttpDataSource.HttpDataSourceException; +import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.Clock; +import com.google.android.exoplayer2.util.Predicate; +import com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.chromium.net.CronetEngine; +import org.chromium.net.NetworkException; +import org.chromium.net.UrlRequest; +import org.chromium.net.UrlResponseInfo; +import org.chromium.net.impl.UrlResponseInfoImpl; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; + +/** Tests for {@link CronetDataSource}. */ +@RunWith(RobolectricTestRunner.class) +public final class CronetDataSourceTest { + + private static final int TEST_CONNECT_TIMEOUT_MS = 100; + private static final int TEST_READ_TIMEOUT_MS = 100; + private static final String TEST_URL = "http://google.com"; + private static final String TEST_CONTENT_TYPE = "test/test"; + private static final byte[] TEST_POST_BODY = Util.getUtf8Bytes("test post body"); + private static final long TEST_CONTENT_LENGTH = 16000L; + private static final int TEST_CONNECTION_STATUS = 5; + private static final int TEST_INVALID_CONNECTION_STATUS = -1; + + private DataSpec testDataSpec; + private DataSpec testPostDataSpec; + private DataSpec testHeadDataSpec; + private Map testResponseHeader; + private UrlResponseInfo testUrlResponseInfo; + + @Mock private UrlRequest.Builder mockUrlRequestBuilder; + @Mock private UrlRequest mockUrlRequest; + @Mock private Predicate mockContentTypePredicate; + @Mock private TransferListener mockTransferListener; + @Mock private Executor mockExecutor; + @Mock private NetworkException mockNetworkException; + @Mock private CronetEngine mockCronetEngine; + + private CronetDataSource dataSourceUnderTest; + private boolean redirectCalled; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + dataSourceUnderTest = + new CronetDataSource( + mockCronetEngine, + mockExecutor, + mockContentTypePredicate, + TEST_CONNECT_TIMEOUT_MS, + TEST_READ_TIMEOUT_MS, + true, // resetTimeoutOnRedirects + Clock.DEFAULT, + null, + false); + dataSourceUnderTest.addTransferListener(mockTransferListener); + when(mockContentTypePredicate.evaluate(anyString())).thenReturn(true); + when(mockCronetEngine.newUrlRequestBuilder( + anyString(), any(UrlRequest.Callback.class), any(Executor.class))) + .thenReturn(mockUrlRequestBuilder); + when(mockUrlRequestBuilder.allowDirectExecutor()).thenReturn(mockUrlRequestBuilder); + when(mockUrlRequestBuilder.build()).thenReturn(mockUrlRequest); + mockStatusResponse(); + + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, C.LENGTH_UNSET, null); + testPostDataSpec = + new DataSpec(Uri.parse(TEST_URL), TEST_POST_BODY, 0, 0, C.LENGTH_UNSET, null, 0); + testHeadDataSpec = + new DataSpec( + Uri.parse(TEST_URL), DataSpec.HTTP_METHOD_HEAD, null, 0, 0, C.LENGTH_UNSET, null, 0); + testResponseHeader = new HashMap<>(); + testResponseHeader.put("Content-Type", TEST_CONTENT_TYPE); + // This value can be anything since the DataSpec is unset. + testResponseHeader.put("Content-Length", Long.toString(TEST_CONTENT_LENGTH)); + testUrlResponseInfo = createUrlResponseInfo(200); // statusCode + } + + private UrlResponseInfo createUrlResponseInfo(int statusCode) { + return createUrlResponseInfoWithUrl(TEST_URL, statusCode); + } + + private UrlResponseInfo createUrlResponseInfoWithUrl(String url, int statusCode) { + ArrayList> responseHeaderList = new ArrayList<>(); + responseHeaderList.addAll(testResponseHeader.entrySet()); + return new UrlResponseInfoImpl( + Collections.singletonList(url), + statusCode, + null, // httpStatusText + responseHeaderList, + false, // wasCached + null, // negotiatedProtocol + null); // proxyServer + } + + @Test + public void testOpeningTwiceThrows() throws HttpDataSourceException { + mockResponseStartSuccess(); + dataSourceUnderTest.open(testDataSpec); + try { + dataSourceUnderTest.open(testDataSpec); + fail("Expected IllegalStateException."); + } catch (IllegalStateException e) { + // Expected. + } + } + + @Test + public void testCallbackFromPreviousRequest() throws HttpDataSourceException { + mockResponseStartSuccess(); + + dataSourceUnderTest.open(testDataSpec); + dataSourceUnderTest.close(); + // Prepare a mock UrlRequest to be used in the second open() call. + final UrlRequest mockUrlRequest2 = mock(UrlRequest.class); + when(mockUrlRequestBuilder.build()).thenReturn(mockUrlRequest2); + doAnswer( + invocation -> { + // Invoke the callback for the previous request. + dataSourceUnderTest.urlRequestCallback.onFailed( + mockUrlRequest, testUrlResponseInfo, mockNetworkException); + dataSourceUnderTest.urlRequestCallback.onResponseStarted( + mockUrlRequest2, testUrlResponseInfo); + return null; + }) + .when(mockUrlRequest2) + .start(); + dataSourceUnderTest.open(testDataSpec); + } + + @Test + public void testRequestStartCalled() throws HttpDataSourceException { + mockResponseStartSuccess(); + + dataSourceUnderTest.open(testDataSpec); + verify(mockCronetEngine) + .newUrlRequestBuilder(eq(TEST_URL), any(UrlRequest.Callback.class), any(Executor.class)); + verify(mockUrlRequest).start(); + } + + @Test + public void testRequestHeadersSet() throws HttpDataSourceException { + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000, null); + mockResponseStartSuccess(); + + dataSourceUnderTest.setRequestProperty("firstHeader", "firstValue"); + dataSourceUnderTest.setRequestProperty("secondHeader", "secondValue"); + + dataSourceUnderTest.open(testDataSpec); + // The header value to add is current position to current position + length - 1. + verify(mockUrlRequestBuilder).addHeader("Range", "bytes=1000-5999"); + verify(mockUrlRequestBuilder).addHeader("firstHeader", "firstValue"); + verify(mockUrlRequestBuilder).addHeader("secondHeader", "secondValue"); + verify(mockUrlRequest).start(); + } + + @Test + public void testRequestOpen() throws HttpDataSourceException { + mockResponseStartSuccess(); + assertThat(dataSourceUnderTest.open(testDataSpec)).isEqualTo(TEST_CONTENT_LENGTH); + verify(mockTransferListener) + .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + } + + @Test + public void testRequestOpenGzippedCompressedReturnsDataSpecLength() + throws HttpDataSourceException { + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, 5000, null); + testResponseHeader.put("Content-Encoding", "gzip"); + testResponseHeader.put("Content-Length", Long.toString(50L)); + mockResponseStartSuccess(); + + assertThat(dataSourceUnderTest.open(testDataSpec)).isEqualTo(5000 /* contentLength */); + verify(mockTransferListener) + .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + } + + @Test + public void testRequestOpenFail() { + mockResponseStartFailure(); + + try { + dataSourceUnderTest.open(testDataSpec); + fail("HttpDataSource.HttpDataSourceException expected"); + } catch (HttpDataSourceException e) { + // Check for connection not automatically closed. + assertThat(e.getCause() instanceof UnknownHostException).isFalse(); + verify(mockUrlRequest, never()).cancel(); + verify(mockTransferListener, never()) + .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + } + } + + @Test + public void testRequestOpenFailDueToDnsFailure() { + mockResponseStartFailure(); + when(mockNetworkException.getErrorCode()) + .thenReturn(NetworkException.ERROR_HOSTNAME_NOT_RESOLVED); + + try { + dataSourceUnderTest.open(testDataSpec); + fail("HttpDataSource.HttpDataSourceException expected"); + } catch (HttpDataSourceException e) { + // Check for connection not automatically closed. + assertThat(e.getCause() instanceof UnknownHostException).isTrue(); + verify(mockUrlRequest, never()).cancel(); + verify(mockTransferListener, never()) + .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + } + } + + @Test + public void testRequestOpenValidatesStatusCode() { + mockResponseStartSuccess(); + testUrlResponseInfo = createUrlResponseInfo(500); // statusCode + + try { + dataSourceUnderTest.open(testDataSpec); + fail("HttpDataSource.HttpDataSourceException expected"); + } catch (HttpDataSourceException e) { + assertThat(e instanceof HttpDataSource.InvalidResponseCodeException).isTrue(); + // Check for connection not automatically closed. + verify(mockUrlRequest, never()).cancel(); + verify(mockTransferListener, never()) + .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + } + } + + @Test + public void testRequestOpenValidatesContentTypePredicate() { + mockResponseStartSuccess(); + when(mockContentTypePredicate.evaluate(anyString())).thenReturn(false); + + try { + dataSourceUnderTest.open(testDataSpec); + fail("HttpDataSource.HttpDataSourceException expected"); + } catch (HttpDataSourceException e) { + assertThat(e instanceof HttpDataSource.InvalidContentTypeException).isTrue(); + // Check for connection not automatically closed. + verify(mockUrlRequest, never()).cancel(); + verify(mockContentTypePredicate).evaluate(TEST_CONTENT_TYPE); + } + } + + @Test + public void testPostRequestOpen() throws HttpDataSourceException { + mockResponseStartSuccess(); + + dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE); + assertThat(dataSourceUnderTest.open(testPostDataSpec)).isEqualTo(TEST_CONTENT_LENGTH); + verify(mockTransferListener) + .onTransferStart(dataSourceUnderTest, testPostDataSpec, /* isNetwork= */ true); + } + + @Test + public void testPostRequestOpenValidatesContentType() { + mockResponseStartSuccess(); + + try { + dataSourceUnderTest.open(testPostDataSpec); + fail("HttpDataSource.HttpDataSourceException expected"); + } catch (HttpDataSourceException e) { + verify(mockUrlRequest, never()).start(); + } + } + + @Test + public void testPostRequestOpenRejects307Redirects() { + mockResponseStartSuccess(); + mockResponseStartRedirect(); + + try { + dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE); + dataSourceUnderTest.open(testPostDataSpec); + fail("HttpDataSource.HttpDataSourceException expected"); + } catch (HttpDataSourceException e) { + verify(mockUrlRequest, never()).followRedirect(); + } + } + + @Test + public void testHeadRequestOpen() throws HttpDataSourceException { + mockResponseStartSuccess(); + dataSourceUnderTest.open(testHeadDataSpec); + verify(mockTransferListener) + .onTransferStart(dataSourceUnderTest, testHeadDataSpec, /* isNetwork= */ true); + dataSourceUnderTest.close(); + } + + @Test + public void testRequestReadTwice() throws HttpDataSourceException { + mockResponseStartSuccess(); + mockReadSuccess(0, 16); + + dataSourceUnderTest.open(testDataSpec); + + byte[] returnedBuffer = new byte[8]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8); + assertThat(returnedBuffer).isEqualTo(buildTestDataArray(0, 8)); + assertThat(bytesRead).isEqualTo(8); + + returnedBuffer = new byte[8]; + bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8); + assertThat(returnedBuffer).isEqualTo(buildTestDataArray(8, 8)); + assertThat(bytesRead).isEqualTo(8); + + // Should have only called read on cronet once. + verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class)); + verify(mockTransferListener, times(2)) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8); + } + + @Test + public void testSecondRequestNoContentLength() throws HttpDataSourceException { + mockResponseStartSuccess(); + testResponseHeader.put("Content-Length", Long.toString(1L)); + mockReadSuccess(0, 16); + + // First request. + dataSourceUnderTest.open(testDataSpec); + byte[] returnedBuffer = new byte[8]; + dataSourceUnderTest.read(returnedBuffer, 0, 1); + dataSourceUnderTest.close(); + + testResponseHeader.remove("Content-Length"); + mockReadSuccess(0, 16); + + // Second request. + dataSourceUnderTest.open(testDataSpec); + returnedBuffer = new byte[16]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10); + assertThat(bytesRead).isEqualTo(10); + bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10); + assertThat(bytesRead).isEqualTo(6); + bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10); + assertThat(bytesRead).isEqualTo(C.RESULT_END_OF_INPUT); + } + + @Test + public void testReadWithOffset() throws HttpDataSourceException { + mockResponseStartSuccess(); + mockReadSuccess(0, 16); + + dataSourceUnderTest.open(testDataSpec); + + byte[] returnedBuffer = new byte[16]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 8, 8); + assertThat(bytesRead).isEqualTo(8); + assertThat(returnedBuffer).isEqualTo(prefixZeros(buildTestDataArray(0, 8), 16)); + verify(mockTransferListener) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8); + } + + @Test + public void testRangeRequestWith206Response() throws HttpDataSourceException { + mockResponseStartSuccess(); + mockReadSuccess(1000, 5000); + testUrlResponseInfo = createUrlResponseInfo(206); // Server supports range requests. + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000, null); + + dataSourceUnderTest.open(testDataSpec); + + byte[] returnedBuffer = new byte[16]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 16); + assertThat(bytesRead).isEqualTo(16); + assertThat(returnedBuffer).isEqualTo(buildTestDataArray(1000, 16)); + verify(mockTransferListener) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16); + } + + @Test + public void testRangeRequestWith200Response() throws HttpDataSourceException { + mockResponseStartSuccess(); + mockReadSuccess(0, 7000); + testUrlResponseInfo = createUrlResponseInfo(200); // Server does not support range requests. + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000, null); + + dataSourceUnderTest.open(testDataSpec); + + byte[] returnedBuffer = new byte[16]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 16); + assertThat(bytesRead).isEqualTo(16); + assertThat(returnedBuffer).isEqualTo(buildTestDataArray(1000, 16)); + verify(mockTransferListener) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16); + } + + @Test + public void testReadWithUnsetLength() throws HttpDataSourceException { + testResponseHeader.remove("Content-Length"); + mockResponseStartSuccess(); + mockReadSuccess(0, 16); + + dataSourceUnderTest.open(testDataSpec); + + byte[] returnedBuffer = new byte[16]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 8, 8); + assertThat(returnedBuffer).isEqualTo(prefixZeros(buildTestDataArray(0, 8), 16)); + assertThat(bytesRead).isEqualTo(8); + verify(mockTransferListener) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8); + } + + @Test + public void testReadReturnsWhatItCan() throws HttpDataSourceException { + mockResponseStartSuccess(); + mockReadSuccess(0, 16); + + dataSourceUnderTest.open(testDataSpec); + + byte[] returnedBuffer = new byte[24]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 24); + assertThat(returnedBuffer).isEqualTo(suffixZeros(buildTestDataArray(0, 16), 24)); + assertThat(bytesRead).isEqualTo(16); + verify(mockTransferListener) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16); + } + + @Test + public void testClosedMeansClosed() throws HttpDataSourceException { + mockResponseStartSuccess(); + mockReadSuccess(0, 16); + + int bytesRead = 0; + dataSourceUnderTest.open(testDataSpec); + + byte[] returnedBuffer = new byte[8]; + bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8); + assertThat(returnedBuffer).isEqualTo(buildTestDataArray(0, 8)); + assertThat(bytesRead).isEqualTo(8); + + dataSourceUnderTest.close(); + verify(mockTransferListener) + .onTransferEnd(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + + try { + bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8); + fail(); + } catch (IllegalStateException e) { + // Expected. + } + + // 16 bytes were attempted but only 8 should have been successfully read. + assertThat(bytesRead).isEqualTo(8); + } + + @Test + public void testOverread() throws HttpDataSourceException { + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, 16, null); + testResponseHeader.put("Content-Length", Long.toString(16L)); + mockResponseStartSuccess(); + mockReadSuccess(0, 16); + + dataSourceUnderTest.open(testDataSpec); + + byte[] returnedBuffer = new byte[8]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8); + assertThat(bytesRead).isEqualTo(8); + assertThat(returnedBuffer).isEqualTo(buildTestDataArray(0, 8)); + + // The current buffer is kept if not completely consumed by DataSource reader. + returnedBuffer = new byte[8]; + bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 6); + assertThat(bytesRead).isEqualTo(14); + assertThat(returnedBuffer).isEqualTo(suffixZeros(buildTestDataArray(8, 6), 8)); + + // 2 bytes left at this point. + returnedBuffer = new byte[8]; + bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8); + assertThat(bytesRead).isEqualTo(16); + assertThat(returnedBuffer).isEqualTo(suffixZeros(buildTestDataArray(14, 2), 8)); + + // Should have only called read on cronet once. + verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class)); + verify(mockTransferListener, times(1)) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8); + verify(mockTransferListener, times(1)) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 6); + verify(mockTransferListener, times(1)) + .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 2); + + // Now we already returned the 16 bytes initially asked. + // Try to read again even though all requested 16 bytes are already returned. + // Return C.RESULT_END_OF_INPUT + returnedBuffer = new byte[16]; + int bytesOverRead = dataSourceUnderTest.read(returnedBuffer, 0, 16); + assertThat(bytesOverRead).isEqualTo(C.RESULT_END_OF_INPUT); + assertThat(returnedBuffer).isEqualTo(new byte[16]); + // C.RESULT_END_OF_INPUT should not be reported though the TransferListener. + verify(mockTransferListener, never()) + .onBytesTransferred( + dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, C.RESULT_END_OF_INPUT); + // There should still be only one call to read on cronet. + verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class)); + // Check for connection not automatically closed. + verify(mockUrlRequest, never()).cancel(); + assertThat(bytesRead).isEqualTo(16); + } + + @Test + public void testConnectTimeout() throws InterruptedException { + long startTimeMs = SystemClock.elapsedRealtime(); + final ConditionVariable startCondition = buildUrlRequestStartedCondition(); + final CountDownLatch timedOutLatch = new CountDownLatch(1); + + new Thread() { + @Override + public void run() { + try { + dataSourceUnderTest.open(testDataSpec); + fail(); + } catch (HttpDataSourceException e) { + // Expected. + assertThat(e instanceof CronetDataSource.OpenException).isTrue(); + assertThat(e.getCause() instanceof SocketTimeoutException).isTrue(); + assertThat(((CronetDataSource.OpenException) e).cronetConnectionStatus) + .isEqualTo(TEST_CONNECTION_STATUS); + timedOutLatch.countDown(); + } + } + }.start(); + startCondition.block(); + + // We should still be trying to open. + assertNotCountedDown(timedOutLatch); + // We should still be trying to open as we approach the timeout. + SystemClock.setCurrentTimeMillis(startTimeMs + TEST_CONNECT_TIMEOUT_MS - 1); + assertNotCountedDown(timedOutLatch); + // Now we timeout. + SystemClock.setCurrentTimeMillis(startTimeMs + TEST_CONNECT_TIMEOUT_MS + 10); + timedOutLatch.await(); + + verify(mockTransferListener, never()) + .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + } + + @Test + public void testConnectInterrupted() throws InterruptedException { + long startTimeMs = SystemClock.elapsedRealtime(); + final ConditionVariable startCondition = buildUrlRequestStartedCondition(); + final CountDownLatch timedOutLatch = new CountDownLatch(1); + + Thread thread = + new Thread() { + @Override + public void run() { + try { + dataSourceUnderTest.open(testDataSpec); + fail(); + } catch (HttpDataSourceException e) { + // Expected. + assertThat(e instanceof CronetDataSource.OpenException).isTrue(); + assertThat(e.getCause() instanceof CronetDataSource.InterruptedIOException).isTrue(); + assertThat(((CronetDataSource.OpenException) e).cronetConnectionStatus) + .isEqualTo(TEST_INVALID_CONNECTION_STATUS); + timedOutLatch.countDown(); + } + } + }; + thread.start(); + startCondition.block(); + + // We should still be trying to open. + assertNotCountedDown(timedOutLatch); + // We should still be trying to open as we approach the timeout. + SystemClock.setCurrentTimeMillis(startTimeMs + TEST_CONNECT_TIMEOUT_MS - 1); + assertNotCountedDown(timedOutLatch); + // Now we interrupt. + thread.interrupt(); + timedOutLatch.await(); + + verify(mockTransferListener, never()) + .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + } + + @Test + public void testConnectResponseBeforeTimeout() throws Exception { + long startTimeMs = SystemClock.elapsedRealtime(); + final ConditionVariable startCondition = buildUrlRequestStartedCondition(); + final CountDownLatch openLatch = new CountDownLatch(1); + + AtomicReference exceptionOnTestThread = new AtomicReference<>(); + new Thread() { + @Override + public void run() { + try { + dataSourceUnderTest.open(testDataSpec); + } catch (HttpDataSourceException e) { + exceptionOnTestThread.set(e); + } finally { + openLatch.countDown(); + } + } + }.start(); + startCondition.block(); + + // We should still be trying to open. + assertNotCountedDown(openLatch); + // We should still be trying to open as we approach the timeout. + SystemClock.setCurrentTimeMillis(startTimeMs + TEST_CONNECT_TIMEOUT_MS - 1); + assertNotCountedDown(openLatch); + // The response arrives just in time. + dataSourceUnderTest.urlRequestCallback.onResponseStarted(mockUrlRequest, testUrlResponseInfo); + openLatch.await(); + assertThat(exceptionOnTestThread.get()).isNull(); + } + + @Test + public void testRedirectIncreasesConnectionTimeout() throws Exception { + long startTimeMs = SystemClock.elapsedRealtime(); + final ConditionVariable startCondition = buildUrlRequestStartedCondition(); + final CountDownLatch timedOutLatch = new CountDownLatch(1); + final AtomicInteger openExceptions = new AtomicInteger(0); + + new Thread() { + @Override + public void run() { + try { + dataSourceUnderTest.open(testDataSpec); + fail(); + } catch (HttpDataSourceException e) { + // Expected. + assertThat(e instanceof CronetDataSource.OpenException).isTrue(); + assertThat(e.getCause() instanceof SocketTimeoutException).isTrue(); + openExceptions.getAndIncrement(); + timedOutLatch.countDown(); + } + } + }.start(); + startCondition.block(); + + // We should still be trying to open. + assertNotCountedDown(timedOutLatch); + // We should still be trying to open as we approach the timeout. + SystemClock.setCurrentTimeMillis(startTimeMs + TEST_CONNECT_TIMEOUT_MS - 1); + assertNotCountedDown(timedOutLatch); + // A redirect arrives just in time. + dataSourceUnderTest.urlRequestCallback.onRedirectReceived( + mockUrlRequest, testUrlResponseInfo, "RandomRedirectedUrl1"); + + long newTimeoutMs = 2 * TEST_CONNECT_TIMEOUT_MS - 1; + SystemClock.setCurrentTimeMillis(startTimeMs + newTimeoutMs - 1); + // We should still be trying to open as we approach the new timeout. + assertNotCountedDown(timedOutLatch); + // A redirect arrives just in time. + dataSourceUnderTest.urlRequestCallback.onRedirectReceived( + mockUrlRequest, testUrlResponseInfo, "RandomRedirectedUrl2"); + + newTimeoutMs = 3 * TEST_CONNECT_TIMEOUT_MS - 2; + SystemClock.setCurrentTimeMillis(startTimeMs + newTimeoutMs - 1); + // We should still be trying to open as we approach the new timeout. + assertNotCountedDown(timedOutLatch); + // Now we timeout. + SystemClock.setCurrentTimeMillis(startTimeMs + newTimeoutMs + 10); + timedOutLatch.await(); + + verify(mockTransferListener, never()) + .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + assertThat(openExceptions.get()).isEqualTo(1); + } + + @Test + public void testRedirectParseAndAttachCookie_dataSourceDoesNotHandleSetCookie_followsRedirect() + throws HttpDataSourceException { + mockSingleRedirectSuccess(); + mockFollowRedirectSuccess(); + + testResponseHeader.put("Set-Cookie", "testcookie=testcookie; Path=/video"); + + dataSourceUnderTest.open(testDataSpec); + verify(mockUrlRequestBuilder, never()).addHeader(eq("Cookie"), any(String.class)); + verify(mockUrlRequest).followRedirect(); + } + + @Test + public void + testRedirectParseAndAttachCookie_dataSourceHandlesSetCookie_andPreservesOriginalRequestHeaders() + throws HttpDataSourceException { + dataSourceUnderTest = + new CronetDataSource( + mockCronetEngine, + mockExecutor, + mockContentTypePredicate, + TEST_CONNECT_TIMEOUT_MS, + TEST_READ_TIMEOUT_MS, + true, // resetTimeoutOnRedirects + Clock.DEFAULT, + null, + true); + dataSourceUnderTest.addTransferListener(mockTransferListener); + dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE); + + mockSingleRedirectSuccess(); + + testResponseHeader.put("Set-Cookie", "testcookie=testcookie; Path=/video"); + + dataSourceUnderTest.open(testDataSpec); + verify(mockUrlRequestBuilder).addHeader(eq("Cookie"), any(String.class)); + verify(mockUrlRequestBuilder, never()).addHeader(eq("Range"), any(String.class)); + verify(mockUrlRequestBuilder, times(2)).addHeader("Content-Type", TEST_CONTENT_TYPE); + verify(mockUrlRequest, never()).followRedirect(); + verify(mockUrlRequest, times(2)).start(); + } + + @Test + public void + testRedirectParseAndAttachCookie_dataSourceHandlesSetCookie_andPreservesOriginalRequestHeadersIncludingByteRangeHeader() + throws HttpDataSourceException { + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000, null); + dataSourceUnderTest = + new CronetDataSource( + mockCronetEngine, + mockExecutor, + mockContentTypePredicate, + TEST_CONNECT_TIMEOUT_MS, + TEST_READ_TIMEOUT_MS, + true, // resetTimeoutOnRedirects + Clock.DEFAULT, + null, + true); + dataSourceUnderTest.addTransferListener(mockTransferListener); + dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE); + + mockSingleRedirectSuccess(); + + testResponseHeader.put("Set-Cookie", "testcookie=testcookie; Path=/video"); + + dataSourceUnderTest.open(testDataSpec); + verify(mockUrlRequestBuilder).addHeader(eq("Cookie"), any(String.class)); + verify(mockUrlRequestBuilder, times(2)).addHeader("Range", "bytes=1000-5999"); + verify(mockUrlRequestBuilder, times(2)).addHeader("Content-Type", TEST_CONTENT_TYPE); + verify(mockUrlRequest, never()).followRedirect(); + verify(mockUrlRequest, times(2)).start(); + } + + @Test + public void testRedirectNoSetCookieFollowsRedirect() throws HttpDataSourceException { + mockSingleRedirectSuccess(); + mockFollowRedirectSuccess(); + + dataSourceUnderTest.open(testDataSpec); + verify(mockUrlRequestBuilder, never()).addHeader(eq("Cookie"), any(String.class)); + verify(mockUrlRequest).followRedirect(); + } + + @Test + public void testRedirectNoSetCookieFollowsRedirect_dataSourceHandlesSetCookie() + throws HttpDataSourceException { + dataSourceUnderTest = + new CronetDataSource( + mockCronetEngine, + mockExecutor, + mockContentTypePredicate, + TEST_CONNECT_TIMEOUT_MS, + TEST_READ_TIMEOUT_MS, + true, // resetTimeoutOnRedirects + Clock.DEFAULT, + null, + true); + dataSourceUnderTest.addTransferListener(mockTransferListener); + mockSingleRedirectSuccess(); + mockFollowRedirectSuccess(); + + dataSourceUnderTest.open(testDataSpec); + verify(mockUrlRequestBuilder, never()).addHeader(eq("Cookie"), any(String.class)); + verify(mockUrlRequest).followRedirect(); + } + + @Test + public void testExceptionFromTransferListener() throws HttpDataSourceException { + mockResponseStartSuccess(); + + // Make mockTransferListener throw an exception in CronetDataSource.close(). Ensure that + // the subsequent open() call succeeds. + doThrow(new NullPointerException()) + .when(mockTransferListener) + .onTransferEnd(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); + dataSourceUnderTest.open(testDataSpec); + try { + dataSourceUnderTest.close(); + fail("NullPointerException expected"); + } catch (NullPointerException e) { + // Expected. + } + // Open should return successfully. + dataSourceUnderTest.open(testDataSpec); + } + + @Test + public void testReadFailure() throws HttpDataSourceException { + mockResponseStartSuccess(); + mockReadFailure(); + + dataSourceUnderTest.open(testDataSpec); + byte[] returnedBuffer = new byte[8]; + try { + dataSourceUnderTest.read(returnedBuffer, 0, 8); + fail("dataSourceUnderTest.read() returned, but IOException expected"); + } catch (IOException e) { + // Expected. + } + } + + @Test + public void testReadInterrupted() throws HttpDataSourceException, InterruptedException { + mockResponseStartSuccess(); + dataSourceUnderTest.open(testDataSpec); + + final ConditionVariable startCondition = buildReadStartedCondition(); + final CountDownLatch timedOutLatch = new CountDownLatch(1); + byte[] returnedBuffer = new byte[8]; + Thread thread = + new Thread() { + @Override + public void run() { + try { + dataSourceUnderTest.read(returnedBuffer, 0, 8); + fail(); + } catch (HttpDataSourceException e) { + // Expected. + assertThat(e.getCause() instanceof CronetDataSource.InterruptedIOException).isTrue(); + timedOutLatch.countDown(); + } + } + }; + thread.start(); + startCondition.block(); + + assertNotCountedDown(timedOutLatch); + // Now we interrupt. + thread.interrupt(); + timedOutLatch.await(); + } + + @Test + public void testAllowDirectExecutor() throws HttpDataSourceException { + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000, null); + mockResponseStartSuccess(); + + dataSourceUnderTest.open(testDataSpec); + verify(mockUrlRequestBuilder).allowDirectExecutor(); + } + + // Helper methods. + + private void mockStatusResponse() { + doAnswer( + invocation -> { + UrlRequest.StatusListener statusListener = + (UrlRequest.StatusListener) invocation.getArguments()[0]; + statusListener.onStatus(TEST_CONNECTION_STATUS); + return null; + }) + .when(mockUrlRequest) + .getStatus(any(UrlRequest.StatusListener.class)); + } + + private void mockResponseStartSuccess() { + doAnswer( + invocation -> { + dataSourceUnderTest.urlRequestCallback.onResponseStarted( + mockUrlRequest, testUrlResponseInfo); + return null; + }) + .when(mockUrlRequest) + .start(); + } + + private void mockResponseStartRedirect() { + doAnswer( + invocation -> { + dataSourceUnderTest.urlRequestCallback.onRedirectReceived( + mockUrlRequest, + createUrlResponseInfo(307), // statusCode + "http://redirect.location.com"); + return null; + }) + .when(mockUrlRequest) + .start(); + } + + private void mockSingleRedirectSuccess() { + doAnswer( + invocation -> { + if (!redirectCalled) { + redirectCalled = true; + dataSourceUnderTest.urlRequestCallback.onRedirectReceived( + mockUrlRequest, + createUrlResponseInfoWithUrl("http://example.com/video", 300), + "http://example.com/video/redirect"); + } else { + dataSourceUnderTest.urlRequestCallback.onResponseStarted( + mockUrlRequest, testUrlResponseInfo); + } + return null; + }) + .when(mockUrlRequest) + .start(); + } + + private void mockFollowRedirectSuccess() { + doAnswer( + invocation -> { + dataSourceUnderTest.urlRequestCallback.onResponseStarted( + mockUrlRequest, testUrlResponseInfo); + return null; + }) + .when(mockUrlRequest) + .followRedirect(); + } + + private void mockResponseStartFailure() { + doAnswer( + invocation -> { + dataSourceUnderTest.urlRequestCallback.onFailed( + mockUrlRequest, + createUrlResponseInfo(500), // statusCode + mockNetworkException); + return null; + }) + .when(mockUrlRequest) + .start(); + } + + private void mockReadSuccess(int position, int length) { + final int[] positionAndRemaining = new int[] {position, length}; + doAnswer( + invocation -> { + if (positionAndRemaining[1] == 0) { + dataSourceUnderTest.urlRequestCallback.onSucceeded( + mockUrlRequest, testUrlResponseInfo); + } else { + ByteBuffer inputBuffer = (ByteBuffer) invocation.getArguments()[0]; + int readLength = Math.min(positionAndRemaining[1], inputBuffer.remaining()); + inputBuffer.put(buildTestDataBuffer(positionAndRemaining[0], readLength)); + positionAndRemaining[0] += readLength; + positionAndRemaining[1] -= readLength; + dataSourceUnderTest.urlRequestCallback.onReadCompleted( + mockUrlRequest, testUrlResponseInfo, inputBuffer); + } + return null; + }) + .when(mockUrlRequest) + .read(any(ByteBuffer.class)); + } + + private void mockReadFailure() { + doAnswer( + invocation -> { + dataSourceUnderTest.urlRequestCallback.onFailed( + mockUrlRequest, + createUrlResponseInfo(500), // statusCode + mockNetworkException); + return null; + }) + .when(mockUrlRequest) + .read(any(ByteBuffer.class)); + } + + private ConditionVariable buildReadStartedCondition() { + final ConditionVariable startedCondition = new ConditionVariable(); + doAnswer( + invocation -> { + startedCondition.open(); + return null; + }) + .when(mockUrlRequest) + .read(any(ByteBuffer.class)); + return startedCondition; + } + + private ConditionVariable buildUrlRequestStartedCondition() { + final ConditionVariable startedCondition = new ConditionVariable(); + doAnswer( + invocation -> { + startedCondition.open(); + return null; + }) + .when(mockUrlRequest) + .start(); + return startedCondition; + } + + private void assertNotCountedDown(CountDownLatch countDownLatch) throws InterruptedException { + // We are asserting that another thread does not count down the latch. We therefore sleep some + // time to give the other thread the chance to fail this test. + Thread.sleep(50); + assertThat(countDownLatch.getCount()).isGreaterThan(0L); + } + + private static byte[] buildTestDataArray(int position, int length) { + return buildTestDataBuffer(position, length).array(); + } + + public static byte[] prefixZeros(byte[] data, int requiredLength) { + byte[] prefixedData = new byte[requiredLength]; + System.arraycopy(data, 0, prefixedData, requiredLength - data.length, data.length); + return prefixedData; + } + + public static byte[] suffixZeros(byte[] data, int requiredLength) { + return Arrays.copyOf(data, requiredLength); + } + + private static ByteBuffer buildTestDataBuffer(int position, int length) { + ByteBuffer testBuffer = ByteBuffer.allocate(length); + for (int i = 0; i < length; i++) { + testBuffer.put((byte) (position + i)); + } + testBuffer.flip(); + return testBuffer; + } +} diff --git a/extensions/cronet/src/test/resources/robolectric.properties b/extensions/cronet/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..2f3210368e --- /dev/null +++ b/extensions/cronet/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +manifest=src/test/AndroidManifest.xml diff --git a/extensions/ffmpeg/README.md b/extensions/ffmpeg/README.md index ab3e5ffb94..52dacf8166 100644 --- a/extensions/ffmpeg/README.md +++ b/extensions/ffmpeg/README.md @@ -1,18 +1,25 @@ -# FfmpegAudioRenderer # +# ExoPlayer FFmpeg extension # -## Description ## +The FFmpeg extension provides `FfmpegAudioRenderer`, which uses FFmpeg for +decoding and can render audio encoded in a variety of formats. -The FFmpeg extension is a [Renderer][] implementation that uses FFmpeg to decode -audio. +## License note ## -[Renderer]: https://google.github.io/ExoPlayer/doc/reference/com/google/android/exoplayer2/Renderer.html +Please note that whilst the code in this repository is licensed under +[Apache 2.0][], using this extension also requires building and including one or +more external libraries as described below. These are licensed separately. + +[Apache 2.0]: https://github.com/google/ExoPlayer/blob/release-v2/LICENSE ## Build instructions ## To use this extension you need to clone the ExoPlayer repository and depend on its modules locally. Instructions for doing this can be found in ExoPlayer's -[top level README][]. In addition, it's necessary to build the extension's -native components as follows: +[top level README][]. The extension is not provided via JCenter (see [#2781][] +for more information). + +In addition, it's necessary to build the extension's native components as +follows: * Set the following environment variables: @@ -22,7 +29,8 @@ EXOPLAYER_ROOT="$(pwd)" FFMPEG_EXT_PATH="${EXOPLAYER_ROOT}/extensions/ffmpeg/src/main" ``` -* Download the [Android NDK][] and set its location in an environment variable: +* Download the [Android NDK][] and set its location in an environment variable. + Only versions up to NDK 15c are supported currently. ``` NDK_PATH="" @@ -34,7 +42,11 @@ NDK_PATH="" HOST_PLATFORM="linux-x86_64" ``` -* Fetch and build FFmpeg. For example, to fetch and build for armeabi-v7a, +* Fetch and build FFmpeg. The configuration flags determine which formats will + be supported. See the [Supported formats][] page for more details of the + available flags. + +For example, to fetch and build FFmpeg release 4.0 for armeabi-v7a, arm64-v8a and x86 on Linux x86_64: ``` @@ -58,7 +70,8 @@ COMMON_OPTIONS="\ --enable-decoder=flac \ " && \ cd "${FFMPEG_EXT_PATH}/jni" && \ -git clone git://source.ffmpeg.org/ffmpeg ffmpeg && cd ffmpeg && \ +(git -C ffmpeg pull || git clone git://source.ffmpeg.org/ffmpeg ffmpeg) && \ +cd ffmpeg && git checkout release/4.0 && \ ./configure \ --libdir=android-libs/armeabi-v7a \ --arch=arm \ @@ -103,5 +116,42 @@ cd "${FFMPEG_EXT_PATH}"/jni && \ ${NDK_PATH}/ndk-build APP_ABI="armeabi-v7a arm64-v8a x86" -j4 ``` +## Using the extension ## + +Once you've followed the instructions above to check out, build and depend on +the extension, the next step is to tell ExoPlayer to use `FfmpegAudioRenderer`. +How you do this depends on which player API you're using: + +* If you're passing a `DefaultRenderersFactory` to + `ExoPlayerFactory.newSimpleInstance`, you can enable using the extension by + setting the `extensionRendererMode` parameter of the `DefaultRenderersFactory` + constructor to `EXTENSION_RENDERER_MODE_ON`. This will use + `FfmpegAudioRenderer` for playback if `MediaCodecAudioRenderer` doesn't + support the input format. Pass `EXTENSION_RENDERER_MODE_PREFER` to give + `FfmpegAudioRenderer` priority over `MediaCodecAudioRenderer`. +* If you've subclassed `DefaultRenderersFactory`, add an `FfmpegAudioRenderer` + to the output list in `buildAudioRenderers`. ExoPlayer will use the first + `Renderer` in the list that supports the input media format. +* If you've implemented your own `RenderersFactory`, return an + `FfmpegAudioRenderer` instance from `createRenderers`. ExoPlayer will use the + first `Renderer` in the returned array that supports the input media format. +* If you're using `ExoPlayerFactory.newInstance`, pass an `FfmpegAudioRenderer` + in the array of `Renderer`s. ExoPlayer will use the first `Renderer` in the + list that supports the input media format. + +Note: These instructions assume you're using `DefaultTrackSelector`. If you have +a custom track selector the choice of `Renderer` is up to your implementation, +so you need to make sure you are passing an `FfmpegAudioRenderer` to the player, +then implement your own logic to use the renderer for a given track. + [top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md [Android NDK]: https://developer.android.com/tools/sdk/ndk/index.html +[#2781]: https://github.com/google/ExoPlayer/issues/2781 +[Supported formats]: https://google.github.io/ExoPlayer/supported-formats.html#ffmpeg-extension + +## Links ## + +* [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.ffmpeg.*` + belong to this module. + +[Javadoc]: https://google.github.io/ExoPlayer/doc/reference/index.html diff --git a/extensions/ffmpeg/build.gradle b/extensions/ffmpeg/build.gradle index 9820818f3e..1630b6f775 100644 --- a/extensions/ffmpeg/build.gradle +++ b/extensions/ffmpeg/build.gradle @@ -18,6 +18,11 @@ android { compileSdkVersion project.ext.compileSdkVersion buildToolsVersion project.ext.buildToolsVersion + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + defaultConfig { minSdkVersion project.ext.minSdkVersion targetSdkVersion project.ext.targetSdkVersion @@ -31,7 +36,9 @@ android { } dependencies { - compile project(modulePrefix + 'library-core') + implementation project(modulePrefix + 'library-core') + implementation 'com.android.support:support-annotations:' + supportLibraryVersion + compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion } ext { diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java index 8d75ca3dbb..f0b30baa8a 100644 --- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java +++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java @@ -16,27 +16,38 @@ package com.google.android.exoplayer2.ext.ffmpeg; import android.os.Handler; +import android.support.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.audio.AudioProcessor; import com.google.android.exoplayer2.audio.AudioRendererEventListener; +import com.google.android.exoplayer2.audio.AudioSink; +import com.google.android.exoplayer2.audio.DefaultAudioSink; import com.google.android.exoplayer2.audio.SimpleDecoderAudioRenderer; +import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.drm.ExoMediaCrypto; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; +import java.util.Collections; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** * Decodes and renders audio using FFmpeg. */ public final class FfmpegAudioRenderer extends SimpleDecoderAudioRenderer { + /** The number of input and output buffers. */ private static final int NUM_BUFFERS = 16; - private static final int INITIAL_INPUT_BUFFER_SIZE = 960 * 6; + /** The default input buffer size. */ + private static final int DEFAULT_INPUT_BUFFER_SIZE = 960 * 6; - private FfmpegDecoder decoder; + private final boolean enableFloatOutput; + + private @MonotonicNonNull FfmpegDecoder decoder; public FfmpegAudioRenderer() { - this(null, null); + this(/* eventHandler= */ null, /* eventListener= */ null); } /** @@ -45,19 +56,55 @@ public final class FfmpegAudioRenderer extends SimpleDecoderAudioRenderer { * @param eventListener A listener of events. May be null if delivery of events is not required. * @param audioProcessors Optional {@link AudioProcessor}s that will process audio before output. */ - public FfmpegAudioRenderer(Handler eventHandler, AudioRendererEventListener eventListener, + public FfmpegAudioRenderer( + @Nullable Handler eventHandler, + @Nullable AudioRendererEventListener eventListener, AudioProcessor... audioProcessors) { - super(eventHandler, eventListener, audioProcessors); + this( + eventHandler, + eventListener, + new DefaultAudioSink(/* audioCapabilities= */ null, audioProcessors), + /* enableFloatOutput= */ false); + } + + /** + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param audioSink The sink to which audio will be output. + * @param enableFloatOutput Whether to enable 32-bit float audio format, if supported on the + * device/build and if the input format may have bit depth higher than 16-bit. When using + * 32-bit float output, any audio processing will be disabled, including playback speed/pitch + * adjustment. + */ + public FfmpegAudioRenderer( + @Nullable Handler eventHandler, + @Nullable AudioRendererEventListener eventListener, + AudioSink audioSink, + boolean enableFloatOutput) { + super( + eventHandler, + eventListener, + /* drmSessionManager= */ null, + /* playClearSamplesWithoutKeys= */ false, + audioSink); + this.enableFloatOutput = enableFloatOutput; } @Override - protected int supportsFormatInternal(Format format) { + protected int supportsFormatInternal(DrmSessionManager drmSessionManager, + Format format) { + Assertions.checkNotNull(format.sampleMimeType); if (!FfmpegLibrary.isAvailable()) { return FORMAT_UNSUPPORTED_TYPE; + } else if (!FfmpegLibrary.supportsFormat(format.sampleMimeType, format.pcmEncoding) + || !isOutputSupported(format)) { + return FORMAT_UNSUPPORTED_SUBTYPE; + } else if (!supportsFormatDrm(drmSessionManager, format.drmInitData)) { + return FORMAT_UNSUPPORTED_DRM; + } else { + return FORMAT_HANDLED; } - String mimeType = format.sampleMimeType; - return FfmpegLibrary.supportsFormat(mimeType) ? FORMAT_HANDLED - : MimeTypes.isAudio(mimeType) ? FORMAT_UNSUPPORTED_SUBTYPE : FORMAT_UNSUPPORTED_TYPE; } @Override @@ -68,17 +115,58 @@ public final class FfmpegAudioRenderer extends SimpleDecoderAudioRenderer { @Override protected FfmpegDecoder createDecoder(Format format, ExoMediaCrypto mediaCrypto) throws FfmpegDecoderException { - decoder = new FfmpegDecoder(NUM_BUFFERS, NUM_BUFFERS, INITIAL_INPUT_BUFFER_SIZE, - format.sampleMimeType, format.initializationData); + int initialInputBufferSize = + format.maxInputSize != Format.NO_VALUE ? format.maxInputSize : DEFAULT_INPUT_BUFFER_SIZE; + decoder = + new FfmpegDecoder( + NUM_BUFFERS, NUM_BUFFERS, initialInputBufferSize, format, shouldUseFloatOutput(format)); return decoder; } @Override public Format getOutputFormat() { + Assertions.checkNotNull(decoder); int channelCount = decoder.getChannelCount(); int sampleRate = decoder.getSampleRate(); - return Format.createAudioSampleFormat(null, MimeTypes.AUDIO_RAW, null, Format.NO_VALUE, - Format.NO_VALUE, channelCount, sampleRate, C.ENCODING_PCM_16BIT, null, null, 0, null); + @C.PcmEncoding int encoding = decoder.getEncoding(); + return Format.createAudioSampleFormat( + /* id= */ null, + MimeTypes.AUDIO_RAW, + /* codecs= */ null, + Format.NO_VALUE, + Format.NO_VALUE, + channelCount, + sampleRate, + encoding, + Collections.emptyList(), + /* drmInitData= */ null, + /* selectionFlags= */ 0, + /* language= */ null); + } + + private boolean isOutputSupported(Format inputFormat) { + return shouldUseFloatOutput(inputFormat) + || supportsOutput(inputFormat.channelCount, C.ENCODING_PCM_16BIT); + } + + private boolean shouldUseFloatOutput(Format inputFormat) { + Assertions.checkNotNull(inputFormat.sampleMimeType); + if (!enableFloatOutput || !supportsOutput(inputFormat.channelCount, C.ENCODING_PCM_FLOAT)) { + return false; + } + switch (inputFormat.sampleMimeType) { + case MimeTypes.AUDIO_RAW: + // For raw audio, output in 32-bit float encoding if the bit depth is > 16-bit. + return inputFormat.pcmEncoding == C.ENCODING_PCM_24BIT + || inputFormat.pcmEncoding == C.ENCODING_PCM_32BIT + || inputFormat.pcmEncoding == C.ENCODING_PCM_FLOAT; + case MimeTypes.AUDIO_AC3: + // AC-3 is always 16-bit, so there is no point outputting in 32-bit float encoding. + return false; + default: + // For all other formats, assume that it's worth using 32-bit float encoding. + return true; + } } } diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoder.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoder.java index 2af2101ee7..6f3c623f3f 100644 --- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoder.java +++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoder.java @@ -15,9 +15,13 @@ */ package com.google.android.exoplayer2.ext.ffmpeg; +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.decoder.SimpleDecoder; import com.google.android.exoplayer2.decoder.SimpleOutputBuffer; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; import java.nio.ByteBuffer; @@ -29,26 +33,40 @@ import java.util.List; /* package */ final class FfmpegDecoder extends SimpleDecoder { - // Space for 64 ms of 6 channel 48 kHz 16-bit PCM audio. - private static final int OUTPUT_BUFFER_SIZE = 1536 * 6 * 2 * 2; + // Output buffer sizes when decoding PCM mu-law streams, which is the maximum FFmpeg outputs. + private static final int OUTPUT_BUFFER_SIZE_16BIT = 65536; + private static final int OUTPUT_BUFFER_SIZE_32BIT = OUTPUT_BUFFER_SIZE_16BIT * 2; private final String codecName; - private final byte[] extraData; + private final @Nullable byte[] extraData; + private final @C.Encoding int encoding; + private final int outputBufferSize; private long nativeContext; // May be reassigned on resetting the codec. private boolean hasOutputFormat; private volatile int channelCount; private volatile int sampleRate; - public FfmpegDecoder(int numInputBuffers, int numOutputBuffers, int initialInputBufferSize, - String mimeType, List initializationData) throws FfmpegDecoderException { + public FfmpegDecoder( + int numInputBuffers, + int numOutputBuffers, + int initialInputBufferSize, + Format format, + boolean outputFloat) + throws FfmpegDecoderException { super(new DecoderInputBuffer[numInputBuffers], new SimpleOutputBuffer[numOutputBuffers]); if (!FfmpegLibrary.isAvailable()) { throw new FfmpegDecoderException("Failed to load decoder native libraries."); } - codecName = FfmpegLibrary.getCodecName(mimeType); - extraData = getExtraData(mimeType, initializationData); - nativeContext = ffmpegInitialize(codecName, extraData); + Assertions.checkNotNull(format.sampleMimeType); + codecName = + Assertions.checkNotNull( + FfmpegLibrary.getCodecName(format.sampleMimeType, format.pcmEncoding)); + extraData = getExtraData(format.sampleMimeType, format.initializationData); + encoding = outputFloat ? C.ENCODING_PCM_FLOAT : C.ENCODING_PCM_16BIT; + outputBufferSize = outputFloat ? OUTPUT_BUFFER_SIZE_32BIT : OUTPUT_BUFFER_SIZE_16BIT; + nativeContext = + ffmpegInitialize(codecName, extraData, outputFloat, format.sampleRate, format.channelCount); if (nativeContext == 0) { throw new FfmpegDecoderException("Initialization failed."); } @@ -61,18 +79,23 @@ import java.util.List; } @Override - public DecoderInputBuffer createInputBuffer() { + protected DecoderInputBuffer createInputBuffer() { return new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT); } @Override - public SimpleOutputBuffer createOutputBuffer() { + protected SimpleOutputBuffer createOutputBuffer() { return new SimpleOutputBuffer(this); } @Override - public FfmpegDecoderException decode(DecoderInputBuffer inputBuffer, - SimpleOutputBuffer outputBuffer, boolean reset) { + protected FfmpegDecoderException createUnexpectedDecodeException(Throwable error) { + return new FfmpegDecoderException("Unexpected decode error", error); + } + + @Override + protected @Nullable FfmpegDecoderException decode( + DecoderInputBuffer inputBuffer, SimpleOutputBuffer outputBuffer, boolean reset) { if (reset) { nativeContext = ffmpegReset(nativeContext, extraData); if (nativeContext == 0) { @@ -81,8 +104,8 @@ import java.util.List; } ByteBuffer inputData = inputBuffer.data; int inputSize = inputData.limit(); - ByteBuffer outputData = outputBuffer.init(inputBuffer.timeUs, OUTPUT_BUFFER_SIZE); - int result = ffmpegDecode(nativeContext, inputData, inputSize, outputData, OUTPUT_BUFFER_SIZE); + ByteBuffer outputData = outputBuffer.init(inputBuffer.timeUs, outputBufferSize); + int result = ffmpegDecode(nativeContext, inputData, inputSize, outputData, outputBufferSize); if (result < 0) { return new FfmpegDecoderException("Error decoding (see logcat). Code: " + result); } @@ -90,6 +113,7 @@ import java.util.List; channelCount = ffmpegGetChannelCount(nativeContext); sampleRate = ffmpegGetSampleRate(nativeContext); if (sampleRate == 0 && "alac".equals(codecName)) { + Assertions.checkNotNull(extraData); // ALAC decoder did not set the sample rate in earlier versions of FFMPEG. // See https://trac.ffmpeg.org/ticket/6096 ParsableByteArray parsableExtraData = new ParsableByteArray(extraData); @@ -124,11 +148,18 @@ import java.util.List; return sampleRate; } + /** + * Returns the encoding of output audio. + */ + public @C.Encoding int getEncoding() { + return encoding; + } + /** * Returns FFmpeg-compatible codec-specific initialization data ("extra data"), or {@code null} if * not required. */ - private static byte[] getExtraData(String mimeType, List initializationData) { + private static @Nullable byte[] getExtraData(String mimeType, List initializationData) { switch (mimeType) { case MimeTypes.AUDIO_AAC: case MimeTypes.AUDIO_ALAC: @@ -153,12 +184,20 @@ import java.util.List; } } - private native long ffmpegInitialize(String codecName, byte[] extraData); + private native long ffmpegInitialize( + String codecName, + @Nullable byte[] extraData, + boolean outputFloat, + int rawSampleRate, + int rawChannelCount); + private native int ffmpegDecode(long context, ByteBuffer inputData, int inputSize, ByteBuffer outputData, int outputSize); private native int ffmpegGetChannelCount(long context); private native int ffmpegGetSampleRate(long context); - private native long ffmpegReset(long context, byte[] extraData); + + private native long ffmpegReset(long context, @Nullable byte[] extraData); + private native void ffmpegRelease(long context); } diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoderException.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoderException.java index b4cf327198..d6b5a62450 100644 --- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoderException.java +++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoderException.java @@ -26,4 +26,7 @@ public final class FfmpegDecoderException extends AudioDecoderException { super(message); } + /* package */ FfmpegDecoderException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java index 0c065549ca..e5018a49b3 100644 --- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java +++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.ext.ffmpeg; +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlayerLibraryInfo; import com.google.android.exoplayer2.util.LibraryLoader; import com.google.android.exoplayer2.util.MimeTypes; @@ -37,6 +39,8 @@ public final class FfmpegLibrary { * Override the names of the FFmpeg native libraries. If an application wishes to call this * method, it must do so before calling any other method defined by this class, and before * instantiating a {@link FfmpegAudioRenderer} instance. + * + * @param libraries The names of the FFmpeg native libraries. */ public static void setLibraries(String... libraries) { LOADER.setLibraries(libraries); @@ -49,28 +53,30 @@ public final class FfmpegLibrary { return LOADER.isAvailable(); } - /** - * Returns the version of the underlying library if available, or null otherwise. - */ - public static String getVersion() { + /** Returns the version of the underlying library if available, or null otherwise. */ + public static @Nullable String getVersion() { return isAvailable() ? ffmpegGetVersion() : null; } /** * Returns whether the underlying library supports the specified MIME type. + * + * @param mimeType The MIME type to check. + * @param encoding The PCM encoding for raw audio. */ - public static boolean supportsFormat(String mimeType) { + public static boolean supportsFormat(String mimeType, @C.PcmEncoding int encoding) { if (!isAvailable()) { return false; } - String codecName = getCodecName(mimeType); + String codecName = getCodecName(mimeType, encoding); return codecName != null && ffmpegHasDecoder(codecName); } /** - * Returns the name of the FFmpeg decoder that could be used to decode {@code mimeType}. + * Returns the name of the FFmpeg decoder that could be used to decode the format, or {@code null} + * if it's unsupported. */ - /* package */ static String getCodecName(String mimeType) { + /* package */ static @Nullable String getCodecName(String mimeType, @C.PcmEncoding int encoding) { switch (mimeType) { case MimeTypes.AUDIO_AAC: return "aac"; @@ -81,6 +87,7 @@ public final class FfmpegLibrary { case MimeTypes.AUDIO_AC3: return "ac3"; case MimeTypes.AUDIO_E_AC3: + case MimeTypes.AUDIO_E_AC3_JOC: return "eac3"; case MimeTypes.AUDIO_TRUEHD: return "truehd"; @@ -99,6 +106,14 @@ public final class FfmpegLibrary { return "flac"; case MimeTypes.AUDIO_ALAC: return "alac"; + case MimeTypes.AUDIO_RAW: + if (encoding == C.ENCODING_PCM_MU_LAW) { + return "pcm_mulaw"; + } else if (encoding == C.ENCODING_PCM_A_LAW) { + return "pcm_alaw"; + } else { + return null; + } default: return null; } diff --git a/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc b/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc index fa615f2ec1..87579ebb9a 100644 --- a/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc +++ b/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc @@ -27,6 +27,7 @@ extern "C" { #endif #include #include +#include #include #include } @@ -57,8 +58,10 @@ extern "C" { #define ERROR_STRING_BUFFER_LENGTH 256 -// Request a format corresponding to AudioFormat.ENCODING_PCM_16BIT. -static const AVSampleFormat OUTPUT_FORMAT = AV_SAMPLE_FMT_S16; +// Output format corresponding to AudioFormat.ENCODING_PCM_16BIT. +static const AVSampleFormat OUTPUT_FORMAT_PCM_16BIT = AV_SAMPLE_FMT_S16; +// Output format corresponding to AudioFormat.ENCODING_PCM_FLOAT. +static const AVSampleFormat OUTPUT_FORMAT_PCM_FLOAT = AV_SAMPLE_FMT_FLT; /** * Returns the AVCodec with the specified name, or NULL if it is not available. @@ -70,8 +73,9 @@ AVCodec *getCodecByName(JNIEnv* env, jstring codecName); * provided extraData as initialization data for the decoder if it is non-NULL. * Returns the created context. */ -AVCodecContext *createContext(JNIEnv *env, AVCodec *codec, - jbyteArray extraData); +AVCodecContext *createContext(JNIEnv *env, AVCodec *codec, jbyteArray extraData, + jboolean outputFloat, jint rawSampleRate, + jint rawChannelCount); /** * Decodes the packet into the output buffer, returning the number of bytes @@ -107,13 +111,15 @@ LIBRARY_FUNC(jboolean, ffmpegHasDecoder, jstring codecName) { return getCodecByName(env, codecName) != NULL; } -DECODER_FUNC(jlong, ffmpegInitialize, jstring codecName, jbyteArray extraData) { +DECODER_FUNC(jlong, ffmpegInitialize, jstring codecName, jbyteArray extraData, + jboolean outputFloat, jint rawSampleRate, jint rawChannelCount) { AVCodec *codec = getCodecByName(env, codecName); if (!codec) { LOGE("Codec not found."); return 0L; } - return (jlong) createContext(env, codec, extraData); + return (jlong)createContext(env, codec, extraData, outputFloat, rawSampleRate, + rawChannelCount); } DECODER_FUNC(jint, ffmpegDecode, jlong context, jobject inputData, @@ -177,7 +183,11 @@ DECODER_FUNC(jlong, ffmpegReset, jlong jContext, jbyteArray extraData) { LOGE("Unexpected error finding codec %d.", codecId); return 0L; } - return (jlong) createContext(env, codec, extraData); + jboolean outputFloat = + (jboolean)(context->request_sample_fmt == OUTPUT_FORMAT_PCM_FLOAT); + return (jlong)createContext(env, codec, extraData, outputFloat, + /* rawSampleRate= */ -1, + /* rawChannelCount= */ -1); } avcodec_flush_buffers(context); @@ -200,14 +210,16 @@ AVCodec *getCodecByName(JNIEnv* env, jstring codecName) { return codec; } -AVCodecContext *createContext(JNIEnv *env, AVCodec *codec, - jbyteArray extraData) { +AVCodecContext *createContext(JNIEnv *env, AVCodec *codec, jbyteArray extraData, + jboolean outputFloat, jint rawSampleRate, + jint rawChannelCount) { AVCodecContext *context = avcodec_alloc_context3(codec); if (!context) { LOGE("Failed to allocate context."); return NULL; } - context->request_sample_fmt = OUTPUT_FORMAT; + context->request_sample_fmt = + outputFloat ? OUTPUT_FORMAT_PCM_FLOAT : OUTPUT_FORMAT_PCM_16BIT; if (extraData) { jsize size = env->GetArrayLength(extraData); context->extradata_size = size; @@ -220,6 +232,12 @@ AVCodecContext *createContext(JNIEnv *env, AVCodec *codec, } env->GetByteArrayRegion(extraData, 0, size, (jbyte *) context->extradata); } + if (context->codec_id == AV_CODEC_ID_PCM_MULAW || + context->codec_id == AV_CODEC_ID_PCM_ALAW) { + context->sample_rate = rawSampleRate; + context->channels = rawChannelCount; + context->channel_layout = av_get_default_channel_layout(rawChannelCount); + } int result = avcodec_open2(context, codec, NULL); if (result < 0) { logError("avcodec_open2", result); @@ -275,7 +293,9 @@ int decodePacket(AVCodecContext *context, AVPacket *packet, av_opt_set_int(resampleContext, "in_sample_rate", sampleRate, 0); av_opt_set_int(resampleContext, "out_sample_rate", sampleRate, 0); av_opt_set_int(resampleContext, "in_sample_fmt", sampleFormat, 0); - av_opt_set_int(resampleContext, "out_sample_fmt", OUTPUT_FORMAT, 0); + // The output format is always the requested format. + av_opt_set_int(resampleContext, "out_sample_fmt", + context->request_sample_fmt, 0); result = avresample_open(resampleContext); if (result < 0) { logError("avresample_open", result); @@ -285,7 +305,7 @@ int decodePacket(AVCodecContext *context, AVPacket *packet, context->opaque = resampleContext; } int inSampleSize = av_get_bytes_per_sample(sampleFormat); - int outSampleSize = av_get_bytes_per_sample(OUTPUT_FORMAT); + int outSampleSize = av_get_bytes_per_sample(context->request_sample_fmt); int outSamples = avresample_get_out_samples(resampleContext, sampleCount); int bufferOutSize = outSampleSize * channelCount * outSamples; if (outSize + bufferOutSize > outputSize) { diff --git a/extensions/flac/README.md b/extensions/flac/README.md index a35dac7858..54701eea1d 100644 --- a/extensions/flac/README.md +++ b/extensions/flac/README.md @@ -1,19 +1,24 @@ -# ExoPlayer Flac Extension # +# ExoPlayer Flac extension # -## Description ## +The Flac extension provides `FlacExtractor` and `LibflacAudioRenderer`, which +use libFLAC (the Flac decoding library) to extract and decode FLAC audio. -The Flac Extension is a [Renderer][] implementation that helps you bundle -libFLAC (the Flac decoding library) into your app and use it along with -ExoPlayer to play Flac audio on Android devices. +## License note ## -[Renderer]: https://google.github.io/ExoPlayer/doc/reference/com/google/android/exoplayer2/Renderer.html +Please note that whilst the code in this repository is licensed under +[Apache 2.0][], using this extension also requires building and including one or +more external libraries as described below. These are licensed separately. -## Build Instructions ## +[Apache 2.0]: https://github.com/google/ExoPlayer/blob/release-v2/LICENSE + +## Build instructions ## To use this extension you need to clone the ExoPlayer repository and depend on its modules locally. Instructions for doing this can be found in ExoPlayer's -[top level README][]. In addition, it's necessary to build the extension's -native components as follows: +[top level README][]. + +In addition, it's necessary to build the extension's native components as +follows: * Set the following environment variables: @@ -23,18 +28,19 @@ EXOPLAYER_ROOT="$(pwd)" FLAC_EXT_PATH="${EXOPLAYER_ROOT}/extensions/flac/src/main" ``` -* Download the [Android NDK][] and set its location in an environment variable: +* Download the [Android NDK][] (version <= 17c) and set its location in an + environment variable: ``` NDK_PATH="" ``` -* Download and extract flac-1.3.1 as "${FLAC_EXT_PATH}/jni/flac" folder: +* Download and extract flac-1.3.2 as "${FLAC_EXT_PATH}/jni/flac" folder: ``` cd "${FLAC_EXT_PATH}/jni" && \ -curl http://downloads.xiph.org/releases/flac/flac-1.3.1.tar.xz | tar xJ && \ -mv flac-1.3.1 flac +curl https://ftp.osuosl.org/pub/xiph/releases/flac/flac-1.3.2.tar.xz | tar xJ && \ +mv flac-1.3.2 flac ``` * Build the JNI native libraries from the command line: @@ -46,3 +52,47 @@ ${NDK_PATH}/ndk-build APP_ABI=all -j4 [top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md [Android NDK]: https://developer.android.com/tools/sdk/ndk/index.html + +## Using the extension ## + +Once you've followed the instructions above to check out, build and depend on +the extension, the next step is to tell ExoPlayer to use the extractor and/or +renderer. + +### Using `FlacExtractor` ### + +`FlacExtractor` is used via `ExtractorMediaSource`. If you're using +`DefaultExtractorsFactory`, `FlacExtractor` will automatically be used to read +`.flac` files. If you're not using `DefaultExtractorsFactory`, return a +`FlacExtractor` from your `ExtractorsFactory.createExtractors` implementation. + +### Using `LibflacAudioRenderer` ### + +* If you're passing a `DefaultRenderersFactory` to + `ExoPlayerFactory.newSimpleInstance`, you can enable using the extension by + setting the `extensionRendererMode` parameter of the `DefaultRenderersFactory` + constructor to `EXTENSION_RENDERER_MODE_ON`. This will use + `LibflacAudioRenderer` for playback if `MediaCodecAudioRenderer` doesn't + support the input format. Pass `EXTENSION_RENDERER_MODE_PREFER` to give + `LibflacAudioRenderer` priority over `MediaCodecAudioRenderer`. +* If you've subclassed `DefaultRenderersFactory`, add a `LibflacAudioRenderer` + to the output list in `buildAudioRenderers`. ExoPlayer will use the first + `Renderer` in the list that supports the input media format. +* If you've implemented your own `RenderersFactory`, return a + `LibflacAudioRenderer` instance from `createRenderers`. ExoPlayer will use the + first `Renderer` in the returned array that supports the input media format. +* If you're using `ExoPlayerFactory.newInstance`, pass a `LibflacAudioRenderer` + in the array of `Renderer`s. ExoPlayer will use the first `Renderer` in the + list that supports the input media format. + +Note: These instructions assume you're using `DefaultTrackSelector`. If you have +a custom track selector the choice of `Renderer` is up to your implementation, +so you need to make sure you are passing an `LibflacAudioRenderer` to the +player, then implement your own logic to use the renderer for a given track. + +## Links ## + +* [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.flac.*` + belong to this module. + +[Javadoc]: https://google.github.io/ExoPlayer/doc/reference/index.html diff --git a/extensions/flac/build.gradle b/extensions/flac/build.gradle index 4d840d34ac..e5261902c6 100644 --- a/extensions/flac/build.gradle +++ b/extensions/flac/build.gradle @@ -18,10 +18,16 @@ android { compileSdkVersion project.ext.compileSdkVersion buildToolsVersion project.ext.buildToolsVersion + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + defaultConfig { minSdkVersion project.ext.minSdkVersion targetSdkVersion project.ext.targetSdkVersion consumerProguardFiles 'proguard-rules.txt' + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } sourceSets.main { @@ -31,8 +37,11 @@ android { } dependencies { - compile project(modulePrefix + 'library-core') - androidTestCompile project(modulePrefix + 'testutils') + implementation 'com.android.support:support-annotations:' + supportLibraryVersion + implementation project(modulePrefix + 'library-core') + androidTestImplementation 'androidx.test:runner:' + testRunnerVersion + androidTestImplementation project(modulePrefix + 'testutils') + testImplementation project(modulePrefix + 'testutils-robolectric') } ext { diff --git a/extensions/flac/src/androidTest/AndroidManifest.xml b/extensions/flac/src/androidTest/AndroidManifest.xml index 73032ab50c..68ab6fe0c3 100644 --- a/extensions/flac/src/androidTest/AndroidManifest.xml +++ b/extensions/flac/src/androidTest/AndroidManifest.xml @@ -18,7 +18,7 @@ xmlns:tools="http://schemas.android.com/tools" package="com.google.android.exoplayer2.ext.flac.test"> - + + android:name="androidx.test.runner.AndroidJUnitRunner"/> diff --git a/extensions/flac/src/androidTest/assets/bear.flac.0.dump b/extensions/flac/src/androidTest/assets/bear.flac.0.dump index b03636f2bb..71359322b0 100644 --- a/extensions/flac/src/androidTest/assets/bear.flac.0.dump +++ b/extensions/flac/src/androidTest/assets/bear.flac.0.dump @@ -1,7 +1,7 @@ seekMap: isSeekable = true duration = 2741000 - getPosition(0) = 8880 + getPosition(0) = [[timeUs=0, position=8880]] numberOfTracks = 1 track 0: format: @@ -9,22 +9,23 @@ track 0: id = null containerMimeType = null sampleMimeType = audio/raw - maxInputSize = -1 + maxInputSize = 16384 width = -1 height = -1 frameRate = -1.0 - rotationDegrees = -1 - pixelWidthHeightRatio = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 channelCount = 2 sampleRate = 48000 pcmEncoding = 2 - encoderDelay = -1 - encoderPadding = -1 + encoderDelay = 0 + encoderPadding = 0 subsampleOffsetUs = 9223372036854775807 selectionFlags = 0 language = null drmInitData = - initializationData: + total output bytes = 526272 sample count = 33 sample 0: time = 0 diff --git a/extensions/flac/src/androidTest/assets/bear.flac.1.dump b/extensions/flac/src/androidTest/assets/bear.flac.1.dump index 4e8388dba8..820b9eed10 100644 --- a/extensions/flac/src/androidTest/assets/bear.flac.1.dump +++ b/extensions/flac/src/androidTest/assets/bear.flac.1.dump @@ -1,7 +1,7 @@ seekMap: isSeekable = true duration = 2741000 - getPosition(0) = 8880 + getPosition(0) = [[timeUs=0, position=8880]] numberOfTracks = 1 track 0: format: @@ -9,22 +9,23 @@ track 0: id = null containerMimeType = null sampleMimeType = audio/raw - maxInputSize = -1 + maxInputSize = 16384 width = -1 height = -1 frameRate = -1.0 - rotationDegrees = -1 - pixelWidthHeightRatio = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 channelCount = 2 sampleRate = 48000 pcmEncoding = 2 - encoderDelay = -1 - encoderPadding = -1 + encoderDelay = 0 + encoderPadding = 0 subsampleOffsetUs = 9223372036854775807 selectionFlags = 0 language = null drmInitData = - initializationData: + total output bytes = 362432 sample count = 23 sample 0: time = 853333 diff --git a/extensions/flac/src/androidTest/assets/bear.flac.2.dump b/extensions/flac/src/androidTest/assets/bear.flac.2.dump index 0860c36cef..c2d58347eb 100644 --- a/extensions/flac/src/androidTest/assets/bear.flac.2.dump +++ b/extensions/flac/src/androidTest/assets/bear.flac.2.dump @@ -1,7 +1,7 @@ seekMap: isSeekable = true duration = 2741000 - getPosition(0) = 8880 + getPosition(0) = [[timeUs=0, position=8880]] numberOfTracks = 1 track 0: format: @@ -9,22 +9,23 @@ track 0: id = null containerMimeType = null sampleMimeType = audio/raw - maxInputSize = -1 + maxInputSize = 16384 width = -1 height = -1 frameRate = -1.0 - rotationDegrees = -1 - pixelWidthHeightRatio = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 channelCount = 2 sampleRate = 48000 pcmEncoding = 2 - encoderDelay = -1 - encoderPadding = -1 + encoderDelay = 0 + encoderPadding = 0 subsampleOffsetUs = 9223372036854775807 selectionFlags = 0 language = null drmInitData = - initializationData: + total output bytes = 182208 sample count = 12 sample 0: time = 1792000 diff --git a/extensions/flac/src/androidTest/assets/bear.flac.3.dump b/extensions/flac/src/androidTest/assets/bear.flac.3.dump index 6f7f72b806..8c1115f1ec 100644 --- a/extensions/flac/src/androidTest/assets/bear.flac.3.dump +++ b/extensions/flac/src/androidTest/assets/bear.flac.3.dump @@ -1,7 +1,7 @@ seekMap: isSeekable = true duration = 2741000 - getPosition(0) = 8880 + getPosition(0) = [[timeUs=0, position=8880]] numberOfTracks = 1 track 0: format: @@ -9,22 +9,23 @@ track 0: id = null containerMimeType = null sampleMimeType = audio/raw - maxInputSize = -1 + maxInputSize = 16384 width = -1 height = -1 frameRate = -1.0 - rotationDegrees = -1 - pixelWidthHeightRatio = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 channelCount = 2 sampleRate = 48000 pcmEncoding = 2 - encoderDelay = -1 - encoderPadding = -1 + encoderDelay = 0 + encoderPadding = 0 subsampleOffsetUs = 9223372036854775807 selectionFlags = 0 language = null drmInitData = - initializationData: + total output bytes = 18368 sample count = 2 sample 0: time = 2645333 diff --git a/extensions/flac/src/androidTest/assets/bear_no_seek.flac b/extensions/flac/src/androidTest/assets/bear_no_seek.flac new file mode 100644 index 0000000000..cd3271178b Binary files /dev/null and b/extensions/flac/src/androidTest/assets/bear_no_seek.flac differ diff --git a/extensions/flac/src/androidTest/assets/bear_with_id3.flac b/extensions/flac/src/androidTest/assets/bear_with_id3.flac new file mode 100644 index 0000000000..fc945f14ad Binary files /dev/null and b/extensions/flac/src/androidTest/assets/bear_with_id3.flac differ diff --git a/extensions/flac/src/androidTest/assets/bear_with_id3.flac.0.dump b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.0.dump new file mode 100644 index 0000000000..d8903fcade --- /dev/null +++ b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.0.dump @@ -0,0 +1,162 @@ +seekMap: + isSeekable = true + duration = 2741000 + getPosition(0) = [[timeUs=0, position=55284]] +numberOfTracks = 1 +track 0: + format: + bitrate = 768000 + id = null + containerMimeType = null + sampleMimeType = audio/raw + maxInputSize = 16384 + width = -1 + height = -1 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = 2 + sampleRate = 48000 + pcmEncoding = 2 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + initializationData: + total output bytes = 526272 + sample count = 33 + sample 0: + time = 0 + flags = 1 + data = length 16384, hash 61D2C5C2 + sample 1: + time = 85333 + flags = 1 + data = length 16384, hash E6D7F214 + sample 2: + time = 170666 + flags = 1 + data = length 16384, hash 59BF0D5D + sample 3: + time = 256000 + flags = 1 + data = length 16384, hash 3625F468 + sample 4: + time = 341333 + flags = 1 + data = length 16384, hash F66A323 + sample 5: + time = 426666 + flags = 1 + data = length 16384, hash CDBAE629 + sample 6: + time = 512000 + flags = 1 + data = length 16384, hash 536F3A91 + sample 7: + time = 597333 + flags = 1 + data = length 16384, hash D4F35C9C + sample 8: + time = 682666 + flags = 1 + data = length 16384, hash EE04CEBF + sample 9: + time = 768000 + flags = 1 + data = length 16384, hash 647E2A67 + sample 10: + time = 853333 + flags = 1 + data = length 16384, hash 31583F2C + sample 11: + time = 938666 + flags = 1 + data = length 16384, hash E433A93D + sample 12: + time = 1024000 + flags = 1 + data = length 16384, hash 5E1C7051 + sample 13: + time = 1109333 + flags = 1 + data = length 16384, hash 43E6E358 + sample 14: + time = 1194666 + flags = 1 + data = length 16384, hash 5DC1B256 + sample 15: + time = 1280000 + flags = 1 + data = length 16384, hash 3D9D95CF + sample 16: + time = 1365333 + flags = 1 + data = length 16384, hash 2A5BD2C0 + sample 17: + time = 1450666 + flags = 1 + data = length 16384, hash 93E25061 + sample 18: + time = 1536000 + flags = 1 + data = length 16384, hash B81793D8 + sample 19: + time = 1621333 + flags = 1 + data = length 16384, hash 1A3BD49F + sample 20: + time = 1706666 + flags = 1 + data = length 16384, hash FB672FF1 + sample 21: + time = 1792000 + flags = 1 + data = length 16384, hash 48AB8B45 + sample 22: + time = 1877333 + flags = 1 + data = length 16384, hash 13C9640A + sample 23: + time = 1962666 + flags = 1 + data = length 16384, hash 499E4A0B + sample 24: + time = 2048000 + flags = 1 + data = length 16384, hash F9A783E6 + sample 25: + time = 2133333 + flags = 1 + data = length 16384, hash D2B77598 + sample 26: + time = 2218666 + flags = 1 + data = length 16384, hash CE5B826C + sample 27: + time = 2304000 + flags = 1 + data = length 16384, hash E99EE956 + sample 28: + time = 2389333 + flags = 1 + data = length 16384, hash F2DB1486 + sample 29: + time = 2474666 + flags = 1 + data = length 16384, hash 1636EAB + sample 30: + time = 2560000 + flags = 1 + data = length 16384, hash 23457C08 + sample 31: + time = 2645333 + flags = 1 + data = length 16384, hash 30EB8381 + sample 32: + time = 2730666 + flags = 1 + data = length 1984, hash 59CFDE1B +tracksEnded = true diff --git a/extensions/flac/src/androidTest/assets/bear_with_id3.flac.1.dump b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.1.dump new file mode 100644 index 0000000000..100fdd1eaf --- /dev/null +++ b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.1.dump @@ -0,0 +1,122 @@ +seekMap: + isSeekable = true + duration = 2741000 + getPosition(0) = [[timeUs=0, position=55284]] +numberOfTracks = 1 +track 0: + format: + bitrate = 768000 + id = null + containerMimeType = null + sampleMimeType = audio/raw + maxInputSize = 16384 + width = -1 + height = -1 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = 2 + sampleRate = 48000 + pcmEncoding = 2 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + initializationData: + total output bytes = 362432 + sample count = 23 + sample 0: + time = 853333 + flags = 1 + data = length 16384, hash 31583F2C + sample 1: + time = 938666 + flags = 1 + data = length 16384, hash E433A93D + sample 2: + time = 1024000 + flags = 1 + data = length 16384, hash 5E1C7051 + sample 3: + time = 1109333 + flags = 1 + data = length 16384, hash 43E6E358 + sample 4: + time = 1194666 + flags = 1 + data = length 16384, hash 5DC1B256 + sample 5: + time = 1280000 + flags = 1 + data = length 16384, hash 3D9D95CF + sample 6: + time = 1365333 + flags = 1 + data = length 16384, hash 2A5BD2C0 + sample 7: + time = 1450666 + flags = 1 + data = length 16384, hash 93E25061 + sample 8: + time = 1536000 + flags = 1 + data = length 16384, hash B81793D8 + sample 9: + time = 1621333 + flags = 1 + data = length 16384, hash 1A3BD49F + sample 10: + time = 1706666 + flags = 1 + data = length 16384, hash FB672FF1 + sample 11: + time = 1792000 + flags = 1 + data = length 16384, hash 48AB8B45 + sample 12: + time = 1877333 + flags = 1 + data = length 16384, hash 13C9640A + sample 13: + time = 1962666 + flags = 1 + data = length 16384, hash 499E4A0B + sample 14: + time = 2048000 + flags = 1 + data = length 16384, hash F9A783E6 + sample 15: + time = 2133333 + flags = 1 + data = length 16384, hash D2B77598 + sample 16: + time = 2218666 + flags = 1 + data = length 16384, hash CE5B826C + sample 17: + time = 2304000 + flags = 1 + data = length 16384, hash E99EE956 + sample 18: + time = 2389333 + flags = 1 + data = length 16384, hash F2DB1486 + sample 19: + time = 2474666 + flags = 1 + data = length 16384, hash 1636EAB + sample 20: + time = 2560000 + flags = 1 + data = length 16384, hash 23457C08 + sample 21: + time = 2645333 + flags = 1 + data = length 16384, hash 30EB8381 + sample 22: + time = 2730666 + flags = 1 + data = length 1984, hash 59CFDE1B +tracksEnded = true diff --git a/extensions/flac/src/androidTest/assets/bear_with_id3.flac.2.dump b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.2.dump new file mode 100644 index 0000000000..6c3cd731b3 --- /dev/null +++ b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.2.dump @@ -0,0 +1,78 @@ +seekMap: + isSeekable = true + duration = 2741000 + getPosition(0) = [[timeUs=0, position=55284]] +numberOfTracks = 1 +track 0: + format: + bitrate = 768000 + id = null + containerMimeType = null + sampleMimeType = audio/raw + maxInputSize = 16384 + width = -1 + height = -1 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = 2 + sampleRate = 48000 + pcmEncoding = 2 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + initializationData: + total output bytes = 182208 + sample count = 12 + sample 0: + time = 1792000 + flags = 1 + data = length 16384, hash 48AB8B45 + sample 1: + time = 1877333 + flags = 1 + data = length 16384, hash 13C9640A + sample 2: + time = 1962666 + flags = 1 + data = length 16384, hash 499E4A0B + sample 3: + time = 2048000 + flags = 1 + data = length 16384, hash F9A783E6 + sample 4: + time = 2133333 + flags = 1 + data = length 16384, hash D2B77598 + sample 5: + time = 2218666 + flags = 1 + data = length 16384, hash CE5B826C + sample 6: + time = 2304000 + flags = 1 + data = length 16384, hash E99EE956 + sample 7: + time = 2389333 + flags = 1 + data = length 16384, hash F2DB1486 + sample 8: + time = 2474666 + flags = 1 + data = length 16384, hash 1636EAB + sample 9: + time = 2560000 + flags = 1 + data = length 16384, hash 23457C08 + sample 10: + time = 2645333 + flags = 1 + data = length 16384, hash 30EB8381 + sample 11: + time = 2730666 + flags = 1 + data = length 1984, hash 59CFDE1B +tracksEnded = true diff --git a/extensions/flac/src/androidTest/assets/bear_with_id3.flac.3.dump b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.3.dump new file mode 100644 index 0000000000..decf9c6af3 --- /dev/null +++ b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.3.dump @@ -0,0 +1,38 @@ +seekMap: + isSeekable = true + duration = 2741000 + getPosition(0) = [[timeUs=0, position=55284]] +numberOfTracks = 1 +track 0: + format: + bitrate = 768000 + id = null + containerMimeType = null + sampleMimeType = audio/raw + maxInputSize = 16384 + width = -1 + height = -1 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = 2 + sampleRate = 48000 + pcmEncoding = 2 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + initializationData: + total output bytes = 18368 + sample count = 2 + sample 0: + time = 2645333 + flags = 1 + data = length 16384, hash 30EB8381 + sample 1: + time = 2730666 + flags = 1 + data = length 1984, hash 59CFDE1B +tracksEnded = true diff --git a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeekerTest.java b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeekerTest.java new file mode 100644 index 0000000000..f8e61a0609 --- /dev/null +++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeekerTest.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.flac; + +import static com.google.common.truth.Truth.assertThat; + +import android.test.InstrumentationTestCase; +import com.google.android.exoplayer2.extractor.SeekMap; +import com.google.android.exoplayer2.testutil.FakeExtractorInput; +import com.google.android.exoplayer2.testutil.TestUtil; +import java.io.IOException; + +/** Unit test for {@link FlacBinarySearchSeeker}. */ +public final class FlacBinarySearchSeekerTest extends InstrumentationTestCase { + + private static final String NOSEEKTABLE_FLAC = "bear_no_seek.flac"; + private static final int DURATION_US = 2_741_000; + + @Override + protected void setUp() throws Exception { + super.setUp(); + if (!FlacLibrary.isAvailable()) { + fail("Flac library not available."); + } + } + + public void testGetSeekMap_returnsSeekMapWithCorrectDuration() + throws IOException, FlacDecoderException, InterruptedException { + byte[] data = TestUtil.getByteArray(getInstrumentation().getContext(), NOSEEKTABLE_FLAC); + + FakeExtractorInput input = new FakeExtractorInput.Builder().setData(data).build(); + FlacDecoderJni decoderJni = new FlacDecoderJni(); + decoderJni.setData(input); + + FlacBinarySearchSeeker seeker = + new FlacBinarySearchSeeker( + decoderJni.decodeMetadata(), /* firstFramePosition= */ 0, data.length, decoderJni); + + SeekMap seekMap = seeker.getSeekMap(); + assertThat(seekMap).isNotNull(); + assertThat(seekMap.getDurationUs()).isEqualTo(DURATION_US); + assertThat(seekMap.isSeekable()).isTrue(); + } + + public void testSetSeekTargetUs_returnsSeekPending() + throws IOException, FlacDecoderException, InterruptedException { + byte[] data = TestUtil.getByteArray(getInstrumentation().getContext(), NOSEEKTABLE_FLAC); + + FakeExtractorInput input = new FakeExtractorInput.Builder().setData(data).build(); + FlacDecoderJni decoderJni = new FlacDecoderJni(); + decoderJni.setData(input); + FlacBinarySearchSeeker seeker = + new FlacBinarySearchSeeker( + decoderJni.decodeMetadata(), /* firstFramePosition= */ 0, data.length, decoderJni); + + seeker.setSeekTargetUs(/* timeUs= */ 1000); + assertThat(seeker.isSeeking()).isTrue(); + } +} diff --git a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorSeekTest.java b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorSeekTest.java new file mode 100644 index 0000000000..58ab260277 --- /dev/null +++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorSeekTest.java @@ -0,0 +1,281 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.flac; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import android.net.Uri; +import android.support.annotation.Nullable; +import android.test.InstrumentationTestCase; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.extractor.DefaultExtractorInput; +import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.extractor.PositionHolder; +import com.google.android.exoplayer2.extractor.SeekMap; +import com.google.android.exoplayer2.testutil.FakeExtractorInput; +import com.google.android.exoplayer2.testutil.FakeExtractorOutput; +import com.google.android.exoplayer2.testutil.FakeTrackOutput; +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.DefaultDataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.List; +import java.util.Random; + +/** Seeking tests for {@link FlacExtractor} when the FLAC stream does not have a SEEKTABLE. */ +public final class FlacExtractorSeekTest extends InstrumentationTestCase { + + private static final String NO_SEEKTABLE_FLAC = "bear_no_seek.flac"; + private static final int DURATION_US = 2_741_000; + private static final Uri FILE_URI = Uri.parse("file:///android_asset/" + NO_SEEKTABLE_FLAC); + private static final Random RANDOM = new Random(1234L); + + private FakeExtractorOutput expectedOutput; + private FakeTrackOutput expectedTrackOutput; + + private DefaultDataSource dataSource; + private PositionHolder positionHolder; + private long totalInputLength; + + @Override + protected void setUp() throws Exception { + super.setUp(); + if (!FlacLibrary.isAvailable()) { + fail("Flac library not available."); + } + expectedOutput = new FakeExtractorOutput(); + extractAllSamplesFromFileToExpectedOutput(getInstrumentation().getContext(), NO_SEEKTABLE_FLAC); + expectedTrackOutput = expectedOutput.trackOutputs.get(0); + + dataSource = + new DefaultDataSourceFactory(getInstrumentation().getContext(), "UserAgent") + .createDataSource(); + totalInputLength = readInputLength(); + positionHolder = new PositionHolder(); + } + + public void testFlacExtractorReads_nonSeekTableFile_returnSeekableSeekMap() + throws IOException, InterruptedException { + FlacExtractor extractor = new FlacExtractor(); + + SeekMap seekMap = extractSeekMap(extractor, new FakeExtractorOutput()); + + assertThat(seekMap).isNotNull(); + assertThat(seekMap.getDurationUs()).isEqualTo(DURATION_US); + assertThat(seekMap.isSeekable()).isTrue(); + } + + public void testHandlePendingSeek_handlesSeekingToPositionInFile_extractsCorrectFrame() + throws IOException, InterruptedException { + FlacExtractor extractor = new FlacExtractor(); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = extractSeekMap(extractor, extractorOutput); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0); + + long targetSeekTimeUs = 987_000; + int extractedFrameIndex = seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + + public void testHandlePendingSeek_handlesSeekToEoF_extractsLastFrame() + throws IOException, InterruptedException { + FlacExtractor extractor = new FlacExtractor(); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = extractSeekMap(extractor, extractorOutput); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0); + + long targetSeekTimeUs = seekMap.getDurationUs(); + + int extractedFrameIndex = seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + + public void testHandlePendingSeek_handlesSeekingBackward_extractsCorrectFrame() + throws IOException, InterruptedException { + FlacExtractor extractor = new FlacExtractor(); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = extractSeekMap(extractor, extractorOutput); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0); + + long firstSeekTimeUs = 987_000; + seekToTimeUs(extractor, seekMap, firstSeekTimeUs, trackOutput); + + long targetSeekTimeUs = 0; + int extractedFrameIndex = seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + + public void testHandlePendingSeek_handlesSeekingForward_extractsCorrectFrame() + throws IOException, InterruptedException { + FlacExtractor extractor = new FlacExtractor(); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = extractSeekMap(extractor, extractorOutput); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0); + + long firstSeekTimeUs = 987_000; + seekToTimeUs(extractor, seekMap, firstSeekTimeUs, trackOutput); + + long targetSeekTimeUs = 1_234_000; + int extractedFrameIndex = seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + + public void testHandlePendingSeek_handlesRandomSeeks_extractsCorrectFrame() + throws IOException, InterruptedException { + FlacExtractor extractor = new FlacExtractor(); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = extractSeekMap(extractor, extractorOutput); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0); + + long numSeek = 100; + for (long i = 0; i < numSeek; i++) { + long targetSeekTimeUs = RANDOM.nextInt(DURATION_US + 1); + int extractedFrameIndex = seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + } + + // Internal methods + + private long readInputLength() throws IOException { + DataSpec dataSpec = new DataSpec(FILE_URI, 0, C.LENGTH_UNSET, null); + long totalInputLength = dataSource.open(dataSpec); + Util.closeQuietly(dataSource); + return totalInputLength; + } + + /** + * Seeks to the given seek time and keeps reading from input until we can extract at least one + * frame from the seek position, or until end-of-input is reached. + * + * @return The index of the first extracted frame written to the given {@code trackOutput} after + * the seek is completed, or -1 if the seek is completed without any extracted frame. + */ + private int seekToTimeUs( + FlacExtractor flacExtractor, SeekMap seekMap, long seekTimeUs, FakeTrackOutput trackOutput) + throws IOException, InterruptedException { + int numSampleBeforeSeek = trackOutput.getSampleCount(); + SeekMap.SeekPoints seekPoints = seekMap.getSeekPoints(seekTimeUs); + + long initialSeekLoadPosition = seekPoints.first.position; + flacExtractor.seek(initialSeekLoadPosition, seekTimeUs); + + positionHolder.position = C.POSITION_UNSET; + ExtractorInput extractorInput = getExtractorInputFromPosition(initialSeekLoadPosition); + int extractorReadResult = Extractor.RESULT_CONTINUE; + while (true) { + try { + // Keep reading until we can read at least one frame after seek + while (extractorReadResult == Extractor.RESULT_CONTINUE + && trackOutput.getSampleCount() == numSampleBeforeSeek) { + extractorReadResult = flacExtractor.read(extractorInput, positionHolder); + } + } finally { + Util.closeQuietly(dataSource); + } + + if (extractorReadResult == Extractor.RESULT_SEEK) { + extractorInput = getExtractorInputFromPosition(positionHolder.position); + extractorReadResult = Extractor.RESULT_CONTINUE; + } else if (extractorReadResult == Extractor.RESULT_END_OF_INPUT) { + return -1; + } else if (trackOutput.getSampleCount() > numSampleBeforeSeek) { + // First index after seek = num sample before seek. + return numSampleBeforeSeek; + } + } + } + + private @Nullable SeekMap extractSeekMap(FlacExtractor extractor, FakeExtractorOutput output) + throws IOException, InterruptedException { + try { + ExtractorInput input = getExtractorInputFromPosition(0); + extractor.init(output); + while (output.seekMap == null) { + extractor.read(input, positionHolder); + } + } finally { + Util.closeQuietly(dataSource); + } + return output.seekMap; + } + + private void assertFirstFrameAfterSeekContainTargetSeekTime( + FakeTrackOutput trackOutput, long seekTimeUs, int firstFrameIndexAfterSeek) { + int expectedSampleIndex = findTargetFrameInExpectedOutput(seekTimeUs); + // Assert that after seeking, the first sample frame written to output contains the sample + // at seek time. + trackOutput.assertSample( + firstFrameIndexAfterSeek, + expectedTrackOutput.getSampleData(expectedSampleIndex), + expectedTrackOutput.getSampleTimeUs(expectedSampleIndex), + expectedTrackOutput.getSampleFlags(expectedSampleIndex), + expectedTrackOutput.getSampleCryptoData(expectedSampleIndex)); + } + + private int findTargetFrameInExpectedOutput(long seekTimeUs) { + List sampleTimes = expectedTrackOutput.getSampleTimesUs(); + for (int i = 0; i < sampleTimes.size() - 1; i++) { + long currentSampleTime = sampleTimes.get(i); + long nextSampleTime = sampleTimes.get(i + 1); + if (currentSampleTime <= seekTimeUs && nextSampleTime > seekTimeUs) { + return i; + } + } + return sampleTimes.size() - 1; + } + + private ExtractorInput getExtractorInputFromPosition(long position) throws IOException { + DataSpec dataSpec = new DataSpec(FILE_URI, position, totalInputLength, /* key= */ null); + dataSource.open(dataSpec); + return new DefaultExtractorInput(dataSource, position, totalInputLength); + } + + private void extractAllSamplesFromFileToExpectedOutput(Context context, String fileName) + throws IOException, InterruptedException { + byte[] data = TestUtil.getByteArray(context, fileName); + + FlacExtractor extractor = new FlacExtractor(); + extractor.init(expectedOutput); + FakeExtractorInput input = new FakeExtractorInput.Builder().setData(data).build(); + + while (extractor.read(input, new PositionHolder()) != Extractor.RESULT_END_OF_INPUT) {} + } +} diff --git a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java index 5954985100..29a597daa4 100644 --- a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java +++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java @@ -16,21 +16,28 @@ package com.google.android.exoplayer2.ext.flac; import android.test.InstrumentationTestCase; -import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.testutil.ExtractorAsserts; -import com.google.android.exoplayer2.testutil.ExtractorAsserts.ExtractorFactory; /** * Unit test for {@link FlacExtractor}. */ public class FlacExtractorTest extends InstrumentationTestCase { - public void testSample() throws Exception { - ExtractorAsserts.assertOutput(new ExtractorFactory() { - @Override - public Extractor create() { - return new FlacExtractor(); - } - }, "bear.flac", getInstrumentation()); + @Override + protected void setUp() throws Exception { + super.setUp(); + if (!FlacLibrary.isAvailable()) { + fail("Flac library not available."); + } + } + + public void testExtractFlacSample() throws Exception { + ExtractorAsserts.assertBehavior( + FlacExtractor::new, "bear.flac", getInstrumentation().getContext()); + } + + public void testExtractFlacSampleWithId3Header() throws Exception { + ExtractorAsserts.assertBehavior( + FlacExtractor::new, "bear_with_id3.flac", getInstrumentation().getContext()); } } diff --git a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java index a49ae073ef..2efdde4e58 100644 --- a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java +++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java @@ -15,49 +15,57 @@ */ package com.google.android.exoplayer2.ext.flac; +import static androidx.test.InstrumentationRegistry.getContext; +import static org.junit.Assert.fail; + import android.content.Context; import android.net.Uri; import android.os.Looper; -import android.test.InstrumentationTestCase; +import androidx.test.runner.AndroidJUnit4; 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.extractor.mkv.MatroskaExtractor; import com.google.android.exoplayer2.source.ExtractorMediaSource; -import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; -import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; -/** - * Playback tests using {@link LibflacAudioRenderer}. - */ -public class FlacPlaybackTest extends InstrumentationTestCase { +/** Playback tests using {@link LibflacAudioRenderer}. */ +@RunWith(AndroidJUnit4.class) +public class FlacPlaybackTest { private static final String BEAR_FLAC_URI = "asset:///bear-flac.mka"; - public void testBasicPlayback() throws ExoPlaybackException { + @Before + public void setUp() { + if (!FlacLibrary.isAvailable()) { + fail("Flac library not available."); + } + } + + @Test + public void testBasicPlayback() throws Exception { playUri(BEAR_FLAC_URI); } - private void playUri(String uri) throws ExoPlaybackException { - TestPlaybackThread thread = new TestPlaybackThread(Uri.parse(uri), - getInstrumentation().getContext()); + private void playUri(String uri) throws Exception { + TestPlaybackRunnable testPlaybackRunnable = + new TestPlaybackRunnable(Uri.parse(uri), getContext()); + Thread thread = new Thread(testPlaybackRunnable); thread.start(); - try { - thread.join(); - } catch (InterruptedException e) { - fail(); // Should never happen. - } - if (thread.playbackException != null) { - throw thread.playbackException; + thread.join(); + if (testPlaybackRunnable.playbackException != null) { + throw testPlaybackRunnable.playbackException; } } - private static class TestPlaybackThread extends Thread implements ExoPlayer.EventListener { + private static class TestPlaybackRunnable implements Player.EventListener, Runnable { private final Context context; private final Uri uri; @@ -65,7 +73,7 @@ public class FlacPlaybackTest extends InstrumentationTestCase { private ExoPlayer player; private ExoPlaybackException playbackException; - public TestPlaybackThread(Uri uri, Context context) { + public TestPlaybackRunnable(Uri uri, Context context) { this.uri = uri; this.context = context; } @@ -75,44 +83,18 @@ public class FlacPlaybackTest extends InstrumentationTestCase { Looper.prepare(); LibflacAudioRenderer audioRenderer = new LibflacAudioRenderer(); DefaultTrackSelector trackSelector = new DefaultTrackSelector(); - player = ExoPlayerFactory.newInstance(new Renderer[] {audioRenderer}, trackSelector); + player = ExoPlayerFactory.newInstance(context, new Renderer[] {audioRenderer}, trackSelector); player.addListener(this); - ExtractorMediaSource mediaSource = new ExtractorMediaSource( - uri, - new DefaultDataSourceFactory(context, "ExoPlayerExtFlacTest"), - MatroskaExtractor.FACTORY, - null, - null); + MediaSource mediaSource = + new ExtractorMediaSource.Factory( + new DefaultDataSourceFactory(context, "ExoPlayerExtFlacTest")) + .setExtractorsFactory(MatroskaExtractor.FACTORY) + .createMediaSource(uri); player.prepare(mediaSource); player.setPlayWhenReady(true); Looper.loop(); } - @Override - public void onLoadingChanged(boolean isLoading) { - // Do nothing. - } - - @Override - public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { - // Do nothing. - } - - @Override - public void onPositionDiscontinuity() { - // Do nothing. - } - - @Override - public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { - // Do nothing. - } - - @Override - public void onTimelineChanged(Timeline timeline, Object manifest) { - // Do nothing. - } - @Override public void onPlayerError(ExoPlaybackException error) { playbackException = error; @@ -120,22 +102,12 @@ public class FlacPlaybackTest extends InstrumentationTestCase { @Override public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { - if (playbackState == ExoPlayer.STATE_ENDED - || (playbackState == ExoPlayer.STATE_IDLE && playbackException != null)) { - releasePlayerAndQuitLooper(); + if (playbackState == Player.STATE_ENDED + || (playbackState == Player.STATE_IDLE && playbackException != null)) { + player.release(); + Looper.myLooper().quit(); } } - - @Override - public void onRepeatModeChanged(int repeatMode) { - // Do nothing. - } - - private void releasePlayerAndQuitLooper() { - player.release(); - Looper.myLooper().quit(); - } - } } diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java new file mode 100644 index 0000000000..b9c6ea06dd --- /dev/null +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.flac; + +import com.google.android.exoplayer2.extractor.BinarySearchSeeker; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.extractor.SeekMap; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.FlacStreamInfo; +import java.io.IOException; +import java.nio.ByteBuffer; + +/** + * A {@link SeekMap} implementation for FLAC stream using binary search. + * + *

This seeker performs seeking by using binary search within the stream, until it finds the + * frame that contains the target sample. + */ +/* package */ final class FlacBinarySearchSeeker extends BinarySearchSeeker { + + private final FlacDecoderJni decoderJni; + + public FlacBinarySearchSeeker( + FlacStreamInfo streamInfo, + long firstFramePosition, + long inputLength, + FlacDecoderJni decoderJni) { + super( + new FlacSeekTimestampConverter(streamInfo), + new FlacTimestampSeeker(decoderJni), + streamInfo.durationUs(), + /* floorTimePosition= */ 0, + /* ceilingTimePosition= */ streamInfo.totalSamples, + /* floorBytePosition= */ firstFramePosition, + /* ceilingBytePosition= */ inputLength, + /* approxBytesPerFrame= */ streamInfo.getApproxBytesPerFrame(), + /* minimumSearchRange= */ Math.max(1, streamInfo.minFrameSize)); + this.decoderJni = Assertions.checkNotNull(decoderJni); + } + + @Override + protected void onSeekOperationFinished(boolean foundTargetFrame, long resultPosition) { + if (!foundTargetFrame) { + // If we can't find the target frame (sample), we need to reset the decoder jni so that + // it can continue from the result position. + decoderJni.reset(resultPosition); + } + } + + private static final class FlacTimestampSeeker implements TimestampSeeker { + + private final FlacDecoderJni decoderJni; + + private FlacTimestampSeeker(FlacDecoderJni decoderJni) { + this.decoderJni = decoderJni; + } + + @Override + public TimestampSearchResult searchForTimestamp( + ExtractorInput input, long targetSampleIndex, OutputFrameHolder outputFrameHolder) + throws IOException, InterruptedException { + ByteBuffer outputBuffer = outputFrameHolder.byteBuffer; + long searchPosition = input.getPosition(); + decoderJni.reset(searchPosition); + try { + decoderJni.decodeSampleWithBacktrackPosition( + outputBuffer, /* retryPosition= */ searchPosition); + } catch (FlacDecoderJni.FlacFrameDecodeException e) { + // For some reasons, the extractor can't find a frame mid-stream. + // Stop the seeking and let it re-try playing at the last search position. + return TimestampSearchResult.NO_TIMESTAMP_IN_RANGE_RESULT; + } + if (outputBuffer.limit() == 0) { + return TimestampSearchResult.NO_TIMESTAMP_IN_RANGE_RESULT; + } + + long lastFrameSampleIndex = decoderJni.getLastFrameFirstSampleIndex(); + long nextFrameSampleIndex = decoderJni.getNextFrameFirstSampleIndex(); + long nextFrameSamplePosition = decoderJni.getDecodePosition(); + + boolean targetSampleInLastFrame = + lastFrameSampleIndex <= targetSampleIndex && nextFrameSampleIndex > targetSampleIndex; + + if (targetSampleInLastFrame) { + // We are holding the target frame in outputFrameHolder. Set its presentation time now. + outputFrameHolder.timeUs = decoderJni.getLastFrameTimestamp(); + return TimestampSearchResult.targetFoundResult(input.getPosition()); + } else if (nextFrameSampleIndex <= targetSampleIndex) { + return TimestampSearchResult.underestimatedResult( + nextFrameSampleIndex, nextFrameSamplePosition); + } else { + return TimestampSearchResult.overestimatedResult(lastFrameSampleIndex, searchPosition); + } + } + } + + /** + * A {@link SeekTimestampConverter} implementation that returns the frame index (sample index) as + * the timestamp for a stream seek time position. + */ + private static final class FlacSeekTimestampConverter implements SeekTimestampConverter { + private final FlacStreamInfo streamInfo; + + public FlacSeekTimestampConverter(FlacStreamInfo streamInfo) { + this.streamInfo = streamInfo; + } + + @Override + public long timeUsToTargetTime(long timeUs) { + return Assertions.checkNotNull(streamInfo).getSampleIndex(timeUs); + } + } +} diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoder.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoder.java index 3ecccd8246..2d74bce5f1 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoder.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoder.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.ext.flac; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.decoder.SimpleDecoder; import com.google.android.exoplayer2.decoder.SimpleOutputBuffer; @@ -37,11 +38,17 @@ import java.util.List; * * @param numInputBuffers The number of input buffers. * @param numOutputBuffers The number of output buffers. + * @param maxInputBufferSize The maximum required input buffer size if known, or {@link + * Format#NO_VALUE} otherwise. * @param initializationData Codec-specific initialization data. It should contain only one entry - * which is the flac file header. + * which is the flac file header. * @throws FlacDecoderException Thrown if an exception occurs when initializing the decoder. */ - public FlacDecoder(int numInputBuffers, int numOutputBuffers, List initializationData) + public FlacDecoder( + int numInputBuffers, + int numOutputBuffers, + int maxInputBufferSize, + List initializationData) throws FlacDecoderException { super(new DecoderInputBuffer[numInputBuffers], new SimpleOutputBuffer[numOutputBuffers]); if (initializationData.size() != 1) { @@ -60,7 +67,9 @@ import java.util.List; throw new FlacDecoderException("Metadata decoding failed"); } - setInitialInputBufferSize(streamInfo.maxFrameSize); + int initialInputBufferSize = + maxInputBufferSize != Format.NO_VALUE ? maxInputBufferSize : streamInfo.maxFrameSize; + setInitialInputBufferSize(initialInputBufferSize); maxOutputBufferSize = streamInfo.maxDecodedFrameSize(); } @@ -70,35 +79,36 @@ import java.util.List; } @Override - public DecoderInputBuffer createInputBuffer() { + protected DecoderInputBuffer createInputBuffer() { return new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); } @Override - public SimpleOutputBuffer createOutputBuffer() { + protected SimpleOutputBuffer createOutputBuffer() { return new SimpleOutputBuffer(this); } @Override - public FlacDecoderException decode(DecoderInputBuffer inputBuffer, - SimpleOutputBuffer outputBuffer, boolean reset) { + protected FlacDecoderException createUnexpectedDecodeException(Throwable error) { + return new FlacDecoderException("Unexpected decode error", error); + } + + @Override + protected FlacDecoderException decode( + DecoderInputBuffer inputBuffer, SimpleOutputBuffer outputBuffer, boolean reset) { if (reset) { decoderJni.flush(); } decoderJni.setData(inputBuffer.data); ByteBuffer outputData = outputBuffer.init(inputBuffer.timeUs, maxOutputBufferSize); - int result; try { - result = decoderJni.decodeSample(outputData); + decoderJni.decodeSample(outputData); + } catch (FlacDecoderJni.FlacFrameDecodeException e) { + return new FlacDecoderException("Frame decoding failed", e); } catch (IOException | InterruptedException e) { // Never happens. throw new IllegalStateException(e); } - if (result < 0) { - return new FlacDecoderException("Frame decoding failed"); - } - outputData.position(0); - outputData.limit(result); return null; } diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderException.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderException.java index 2bdff62935..95d7f87c05 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderException.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderException.java @@ -26,4 +26,7 @@ public final class FlacDecoderException extends AudioDecoderException { super(message); } + /* package */ FlacDecoderException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderJni.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderJni.java index ce787712da..de038921aa 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderJni.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderJni.java @@ -26,6 +26,17 @@ import java.nio.ByteBuffer; */ /* package */ final class FlacDecoderJni { + /** Exception to be thrown if {@link #decodeSample(ByteBuffer)} fails to decode a frame. */ + public static final class FlacFrameDecodeException extends Exception { + + public final int errorCode; + + public FlacFrameDecodeException(String message, int errorCode) { + super(message); + this.errorCode = errorCode; + } + } + private static final int TEMP_BUFFER_SIZE = 8192; // The same buffer size which libflac has private final long nativeDecoderContext; @@ -116,14 +127,51 @@ import java.nio.ByteBuffer; return byteCount; } + /** Decodes and consumes the StreamInfo section from the FLAC stream. */ public FlacStreamInfo decodeMetadata() throws IOException, InterruptedException { return flacDecodeMetadata(nativeDecoderContext); } - public int decodeSample(ByteBuffer output) throws IOException, InterruptedException { - return output.isDirect() - ? flacDecodeToBuffer(nativeDecoderContext, output) - : flacDecodeToArray(nativeDecoderContext, output.array()); + /** + * Decodes and consumes the next frame from the FLAC stream into the given byte buffer. If any IO + * error occurs, resets the stream and input to the given {@code retryPosition}. + * + * @param output The byte buffer to hold the decoded frame. + * @param retryPosition If any error happens, the input will be rewound to {@code retryPosition}. + */ + public void decodeSampleWithBacktrackPosition(ByteBuffer output, long retryPosition) + throws InterruptedException, IOException, FlacFrameDecodeException { + try { + decodeSample(output); + } catch (IOException e) { + if (retryPosition >= 0) { + reset(retryPosition); + if (extractorInput != null) { + extractorInput.setRetryPosition(retryPosition, e); + } + } + throw e; + } + } + + /** Decodes and consumes the next sample from the FLAC stream into the given byte buffer. */ + @SuppressWarnings("ByteBufferBackingArray") + public void decodeSample(ByteBuffer output) + throws IOException, InterruptedException, FlacFrameDecodeException { + output.clear(); + int frameSize = + output.isDirect() + ? flacDecodeToBuffer(nativeDecoderContext, output) + : flacDecodeToArray(nativeDecoderContext, output.array()); + if (frameSize < 0) { + if (!isDecoderAtEndOfInput()) { + throw new FlacFrameDecodeException("Cannot decode FLAC frame", frameSize); + } + // The decoder has read to EOI. Return a 0-size frame to indicate the EOI. + output.limit(0); + } else { + output.limit(frameSize); + } } /** @@ -133,8 +181,19 @@ import java.nio.ByteBuffer; return flacGetDecodePosition(nativeDecoderContext); } - public long getLastSampleTimestamp() { - return flacGetLastTimestamp(nativeDecoderContext); + /** Returns the timestamp for the first sample in the last decoded frame. */ + public long getLastFrameTimestamp() { + return flacGetLastFrameTimestamp(nativeDecoderContext); + } + + /** Returns the first sample index of the last extracted frame. */ + public long getLastFrameFirstSampleIndex() { + return flacGetLastFrameFirstSampleIndex(nativeDecoderContext); + } + + /** Returns the first sample index of the frame to be extracted next. */ + public long getNextFrameFirstSampleIndex() { + return flacGetNextFrameFirstSampleIndex(nativeDecoderContext); } /** @@ -153,6 +212,11 @@ import java.nio.ByteBuffer; return flacGetStateString(nativeDecoderContext); } + /** Returns whether the decoder has read to the end of the input. */ + public boolean isDecoderAtEndOfInput() { + return flacIsDecoderAtEndOfStream(nativeDecoderContext); + } + public void flush() { flacFlush(nativeDecoderContext); } @@ -181,18 +245,34 @@ import java.nio.ByteBuffer; } private native long flacInit(); + private native FlacStreamInfo flacDecodeMetadata(long context) throws IOException, InterruptedException; + private native int flacDecodeToBuffer(long context, ByteBuffer outputBuffer) throws IOException, InterruptedException; + private native int flacDecodeToArray(long context, byte[] outputArray) throws IOException, InterruptedException; + private native long flacGetDecodePosition(long context); - private native long flacGetLastTimestamp(long context); + + private native long flacGetLastFrameTimestamp(long context); + + private native long flacGetLastFrameFirstSampleIndex(long context); + + private native long flacGetNextFrameFirstSampleIndex(long context); + private native long flacGetSeekPosition(long context, long timeUs); + private native String flacGetStateString(long context); + + private native boolean flacIsDecoderAtEndOfStream(long context); + private native void flacFlush(long context); + private native void flacReset(long context, long newPosition); + private native void flacRelease(long context); } diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java index d13194793e..8f5dcef16b 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java @@ -15,19 +15,31 @@ */ package com.google.android.exoplayer2.ext.flac; +import static com.google.android.exoplayer2.util.Util.getPcmEncoding; + +import android.support.annotation.IntDef; +import android.support.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.extractor.BinarySearchSeeker; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.ExtractorsFactory; +import com.google.android.exoplayer2.extractor.Id3Peeker; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; +import com.google.android.exoplayer2.extractor.SeekPoint; import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.id3.Id3Decoder; import com.google.android.exoplayer2.util.FlacStreamInfo; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.nio.ByteBuffer; import java.util.Arrays; @@ -36,17 +48,25 @@ import java.util.Arrays; */ public final class FlacExtractor implements Extractor { + /** Factory that returns one extractor which is a {@link FlacExtractor}. */ + public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new FlacExtractor()}; + /** - * Factory that returns one extractor which is a {@link FlacExtractor}. + * Flags controlling the behavior of the extractor. Possible flag value is {@link + * #FLAG_DISABLE_ID3_METADATA}. */ - public static final ExtractorsFactory FACTORY = new ExtractorsFactory() { + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {FLAG_DISABLE_ID3_METADATA}) + public @interface Flags {} - @Override - public Extractor[] createExtractors() { - return new Extractor[] {new FlacExtractor()}; - } - - }; + /** + * Flag to disable parsing of ID3 metadata. Can be set to save memory if ID3 metadata is not + * required. + */ + public static final int FLAG_DISABLE_ID3_METADATA = 1; /** * FLAC signature: first 4 is the signature word, second 4 is the sizeof STREAMINFO. 0x22 is the @@ -54,15 +74,38 @@ public final class FlacExtractor implements Extractor { */ private static final byte[] FLAC_SIGNATURE = {'f', 'L', 'a', 'C', 0, 0, 0, 0x22}; - private ExtractorOutput extractorOutput; - private TrackOutput trackOutput; + private final Id3Peeker id3Peeker; + private final boolean isId3MetadataDisabled; private FlacDecoderJni decoderJni; - private boolean metadataParsed; + private ExtractorOutput extractorOutput; + private TrackOutput trackOutput; private ParsableByteArray outputBuffer; private ByteBuffer outputByteBuffer; + private BinarySearchSeeker.OutputFrameHolder outputFrameHolder; + private FlacStreamInfo streamInfo; + + private Metadata id3Metadata; + private @Nullable FlacBinarySearchSeeker flacBinarySearchSeeker; + + private boolean readPastStreamInfo; + + /** Constructs an instance with flags = 0. */ + public FlacExtractor() { + this(0); + } + + /** + * Constructs an instance. + * + * @param flags Flags that control the extractor's behavior. + */ + public FlacExtractor(int flags) { + id3Peeker = new Id3Peeker(); + isId3MetadataDisabled = (flags & FLAG_DISABLE_ID3_METADATA) != 0; + } @Override public void init(ExtractorOutput output) { @@ -78,94 +121,208 @@ public final class FlacExtractor implements Extractor { @Override public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { - byte[] header = new byte[FLAC_SIGNATURE.length]; - input.peekFully(header, 0, FLAC_SIGNATURE.length); - return Arrays.equals(header, FLAC_SIGNATURE); + if (input.getPosition() == 0) { + id3Metadata = peekId3Data(input); + } + return peekFlacSignature(input); } @Override public int read(final ExtractorInput input, PositionHolder seekPosition) throws IOException, InterruptedException { + if (input.getPosition() == 0 && !isId3MetadataDisabled && id3Metadata == null) { + id3Metadata = peekId3Data(input); + } + decoderJni.setData(input); + readPastStreamInfo(input); - if (!metadataParsed) { - final FlacStreamInfo streamInfo; - try { - streamInfo = decoderJni.decodeMetadata(); - if (streamInfo == null) { - throw new IOException("Metadata decoding failed"); - } - } catch (IOException e) { - decoderJni.reset(0); - input.setRetryPosition(0, e); - throw e; // never executes - } - metadataParsed = true; - - extractorOutput.seekMap(new SeekMap() { - final boolean isSeekable = decoderJni.getSeekPosition(0) != -1; - final long durationUs = streamInfo.durationUs(); - - @Override - public boolean isSeekable() { - return isSeekable; - } - - @Override - public long getPosition(long timeUs) { - return isSeekable ? decoderJni.getSeekPosition(timeUs) : 0; - } - - @Override - public long getDurationUs() { - return durationUs; - } - - }); - - Format mediaFormat = Format.createAudioSampleFormat(null, MimeTypes.AUDIO_RAW, null, - streamInfo.bitRate(), Format.NO_VALUE, streamInfo.channels, streamInfo.sampleRate, - C.ENCODING_PCM_16BIT, null, null, 0, null); - trackOutput.format(mediaFormat); - - outputBuffer = new ParsableByteArray(streamInfo.maxDecodedFrameSize()); - outputByteBuffer = ByteBuffer.wrap(outputBuffer.data); + if (flacBinarySearchSeeker != null && flacBinarySearchSeeker.isSeeking()) { + return handlePendingSeek(input, seekPosition); } - outputBuffer.reset(); long lastDecodePosition = decoderJni.getDecodePosition(); - int size; try { - size = decoderJni.decodeSample(outputByteBuffer); - } catch (IOException e) { - if (lastDecodePosition >= 0) { - decoderJni.reset(lastDecodePosition); - input.setRetryPosition(lastDecodePosition, e); - } - throw e; + decoderJni.decodeSampleWithBacktrackPosition(outputByteBuffer, lastDecodePosition); + } catch (FlacDecoderJni.FlacFrameDecodeException e) { + throw new IOException("Cannot read frame at position " + lastDecodePosition, e); } - if (size <= 0) { + int outputSize = outputByteBuffer.limit(); + if (outputSize == 0) { return RESULT_END_OF_INPUT; } - trackOutput.sampleData(outputBuffer, size); - trackOutput.sampleMetadata(decoderJni.getLastSampleTimestamp(), C.BUFFER_FLAG_KEY_FRAME, size, - 0, null); + writeLastSampleToOutput(outputSize, decoderJni.getLastFrameTimestamp()); return decoderJni.isEndOfData() ? RESULT_END_OF_INPUT : RESULT_CONTINUE; } @Override public void seek(long position, long timeUs) { if (position == 0) { - metadataParsed = false; + readPastStreamInfo = false; + } + if (decoderJni != null) { + decoderJni.reset(position); + } + if (flacBinarySearchSeeker != null) { + flacBinarySearchSeeker.setSeekTargetUs(timeUs); } - decoderJni.reset(position); } @Override public void release() { - decoderJni.release(); - decoderJni = null; + flacBinarySearchSeeker = null; + if (decoderJni != null) { + decoderJni.release(); + decoderJni = null; + } } + /** + * Peeks ID3 tag data (if present) at the beginning of the input. + * + * @return The first ID3 tag decoded into a {@link Metadata} object. May be null if ID3 tag is not + * present in the input. + */ + @Nullable + private Metadata peekId3Data(ExtractorInput input) throws IOException, InterruptedException { + input.resetPeekPosition(); + Id3Decoder.FramePredicate id3FramePredicate = + isId3MetadataDisabled ? Id3Decoder.NO_FRAMES_PREDICATE : null; + return id3Peeker.peekId3Data(input, id3FramePredicate); + } + + /** + * Peeks from the beginning of the input to see if {@link #FLAC_SIGNATURE} is present. + * + * @return Whether the input begins with {@link #FLAC_SIGNATURE}. + */ + private boolean peekFlacSignature(ExtractorInput input) throws IOException, InterruptedException { + byte[] header = new byte[FLAC_SIGNATURE.length]; + input.peekFully(header, 0, FLAC_SIGNATURE.length); + return Arrays.equals(header, FLAC_SIGNATURE); + } + + private void readPastStreamInfo(ExtractorInput input) throws InterruptedException, IOException { + if (readPastStreamInfo) { + return; + } + + FlacStreamInfo streamInfo = decodeStreamInfo(input); + readPastStreamInfo = true; + if (this.streamInfo == null) { + updateFlacStreamInfo(input, streamInfo); + } + } + + private void updateFlacStreamInfo(ExtractorInput input, FlacStreamInfo streamInfo) { + this.streamInfo = streamInfo; + outputSeekMap(input, streamInfo); + outputFormat(streamInfo); + outputBuffer = new ParsableByteArray(streamInfo.maxDecodedFrameSize()); + outputByteBuffer = ByteBuffer.wrap(outputBuffer.data); + outputFrameHolder = new BinarySearchSeeker.OutputFrameHolder(outputByteBuffer); + } + + private FlacStreamInfo decodeStreamInfo(ExtractorInput input) + throws InterruptedException, IOException { + try { + FlacStreamInfo streamInfo = decoderJni.decodeMetadata(); + if (streamInfo == null) { + throw new IOException("Metadata decoding failed"); + } + return streamInfo; + } catch (IOException e) { + decoderJni.reset(0); + input.setRetryPosition(0, e); + throw e; + } + } + + private void outputSeekMap(ExtractorInput input, FlacStreamInfo streamInfo) { + boolean hasSeekTable = decoderJni.getSeekPosition(0) != -1; + SeekMap seekMap = + hasSeekTable + ? new FlacSeekMap(streamInfo.durationUs(), decoderJni) + : getSeekMapForNonSeekTableFlac(input, streamInfo); + extractorOutput.seekMap(seekMap); + } + + private SeekMap getSeekMapForNonSeekTableFlac(ExtractorInput input, FlacStreamInfo streamInfo) { + long inputLength = input.getLength(); + if (inputLength != C.LENGTH_UNSET) { + long firstFramePosition = decoderJni.getDecodePosition(); + flacBinarySearchSeeker = + new FlacBinarySearchSeeker(streamInfo, firstFramePosition, inputLength, decoderJni); + return flacBinarySearchSeeker.getSeekMap(); + } else { // can't seek at all, because there's no SeekTable and the input length is unknown. + return new SeekMap.Unseekable(streamInfo.durationUs()); + } + } + + private void outputFormat(FlacStreamInfo streamInfo) { + Format mediaFormat = + Format.createAudioSampleFormat( + /* id= */ null, + MimeTypes.AUDIO_RAW, + /* codecs= */ null, + streamInfo.bitRate(), + streamInfo.maxDecodedFrameSize(), + streamInfo.channels, + streamInfo.sampleRate, + getPcmEncoding(streamInfo.bitsPerSample), + /* encoderDelay= */ 0, + /* encoderPadding= */ 0, + /* initializationData= */ null, + /* drmInitData= */ null, + /* selectionFlags= */ 0, + /* language= */ null, + isId3MetadataDisabled ? null : id3Metadata); + trackOutput.format(mediaFormat); + } + + private int handlePendingSeek(ExtractorInput input, PositionHolder seekPosition) + throws InterruptedException, IOException { + int seekResult = + flacBinarySearchSeeker.handlePendingSeek(input, seekPosition, outputFrameHolder); + ByteBuffer outputByteBuffer = outputFrameHolder.byteBuffer; + if (seekResult == RESULT_CONTINUE && outputByteBuffer.limit() > 0) { + writeLastSampleToOutput(outputByteBuffer.limit(), outputFrameHolder.timeUs); + } + return seekResult; + } + + private void writeLastSampleToOutput(int size, long lastSampleTimestamp) { + outputBuffer.setPosition(0); + trackOutput.sampleData(outputBuffer, size); + trackOutput.sampleMetadata(lastSampleTimestamp, C.BUFFER_FLAG_KEY_FRAME, size, 0, null); + } + + /** A {@link SeekMap} implementation using a SeekTable within the Flac stream. */ + private static final class FlacSeekMap implements SeekMap { + + private final long durationUs; + private final FlacDecoderJni decoderJni; + + public FlacSeekMap(long durationUs, FlacDecoderJni decoderJni) { + this.durationUs = durationUs; + this.decoderJni = decoderJni; + } + + @Override + public boolean isSeekable() { + return true; + } + + @Override + public SeekPoints getSeekPoints(long timeUs) { + // TODO: Access the seek table via JNI to return two seek points when appropriate. + return new SeekPoints(new SeekPoint(timeUs, decoderJni.getSeekPosition(timeUs))); + } + + @Override + public long getDurationUs() { + return durationUs; + } + } } diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacLibrary.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacLibrary.java index 4130c27c59..d8b9b808a6 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacLibrary.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacLibrary.java @@ -35,6 +35,8 @@ public final class FlacLibrary { * Override the names of the Flac native libraries. If an application wishes to call this method, * it must do so before calling any other method defined by this class, and before instantiating * any {@link LibflacAudioRenderer} and {@link FlacExtractor} instances. + * + * @param libraries The names of the Flac native libraries. */ public static void setLibraries(String... libraries) { LOADER.setLibraries(libraries); diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java index 246cde9d2f..424fcbb285 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java @@ -16,10 +16,12 @@ package com.google.android.exoplayer2.ext.flac; import android.os.Handler; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.audio.AudioProcessor; import com.google.android.exoplayer2.audio.AudioRendererEventListener; import com.google.android.exoplayer2.audio.SimpleDecoderAudioRenderer; +import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.drm.ExoMediaCrypto; import com.google.android.exoplayer2.util.MimeTypes; @@ -46,15 +48,25 @@ public class LibflacAudioRenderer extends SimpleDecoderAudioRenderer { } @Override - protected int supportsFormatInternal(Format format) { - return FlacLibrary.isAvailable() && MimeTypes.AUDIO_FLAC.equalsIgnoreCase(format.sampleMimeType) - ? FORMAT_HANDLED : FORMAT_UNSUPPORTED_TYPE; + protected int supportsFormatInternal(DrmSessionManager drmSessionManager, + Format format) { + if (!FlacLibrary.isAvailable() + || !MimeTypes.AUDIO_FLAC.equalsIgnoreCase(format.sampleMimeType)) { + return FORMAT_UNSUPPORTED_TYPE; + } else if (!supportsOutput(format.channelCount, C.ENCODING_PCM_16BIT)) { + return FORMAT_UNSUPPORTED_SUBTYPE; + } else if (!supportsFormatDrm(drmSessionManager, format.drmInitData)) { + return FORMAT_UNSUPPORTED_DRM; + } else { + return FORMAT_HANDLED; + } } @Override protected FlacDecoder createDecoder(Format format, ExoMediaCrypto mediaCrypto) throws FlacDecoderException { - return new FlacDecoder(NUM_BUFFERS, NUM_BUFFERS, format.initializationData); + return new FlacDecoder( + NUM_BUFFERS, NUM_BUFFERS, format.maxInputSize, format.initializationData); } } diff --git a/extensions/flac/src/main/jni/Android.mk b/extensions/flac/src/main/jni/Android.mk index ff54c1b3c0..69520a16e5 100644 --- a/extensions/flac/src/main/jni/Android.mk +++ b/extensions/flac/src/main/jni/Android.mk @@ -30,9 +30,9 @@ LOCAL_C_INCLUDES := \ $(LOCAL_PATH)/flac/src/libFLAC/include LOCAL_SRC_FILES := $(FLAC_SOURCES) -LOCAL_CFLAGS += '-DVERSION="1.3.1"' -DFLAC__NO_MD5 -DFLAC__INTEGER_ONLY_LIBRARY -DFLAC__NO_ASM +LOCAL_CFLAGS += '-DPACKAGE_VERSION="1.3.2"' -DFLAC__NO_MD5 -DFLAC__INTEGER_ONLY_LIBRARY LOCAL_CFLAGS += -D_REENTRANT -DPIC -DU_COMMON_IMPLEMENTATION -fPIC -DHAVE_SYS_PARAM_H -LOCAL_CFLAGS += -O3 -funroll-loops -finline-functions +LOCAL_CFLAGS += -O3 -funroll-loops -finline-functions -DFLAC__NO_ASM '-DFLAC__HAS_OGG=0' LOCAL_LDLIBS := -llog -lz -lm include $(BUILD_SHARED_LIBRARY) diff --git a/extensions/flac/src/main/jni/Application.mk b/extensions/flac/src/main/jni/Application.mk index 59bf5f8f87..eba20352f4 100644 --- a/extensions/flac/src/main/jni/Application.mk +++ b/extensions/flac/src/main/jni/Application.mk @@ -17,4 +17,4 @@ APP_OPTIM := release APP_STL := gnustl_static APP_CPPFLAGS := -frtti -APP_PLATFORM := android-9 +APP_PLATFORM := android-14 diff --git a/extensions/flac/src/main/jni/flac_jni.cc b/extensions/flac/src/main/jni/flac_jni.cc index c9e5d7ab36..298719d48d 100644 --- a/extensions/flac/src/main/jni/flac_jni.cc +++ b/extensions/flac/src/main/jni/flac_jni.cc @@ -50,7 +50,8 @@ class JavaDataSource : public DataSource { ssize_t readAt(off64_t offset, void *const data, size_t size) { jobject byteBuffer = env->NewDirectByteBuffer(data, size); int result = env->CallIntMethod(flacDecoderJni, mid, byteBuffer); - if (env->ExceptionOccurred()) { + if (env->ExceptionCheck()) { + // Exception is thrown in Java when returning from the native call. result = -1; } env->DeleteLocalRef(byteBuffer); @@ -132,9 +133,19 @@ DECODER_FUNC(jlong, flacGetDecodePosition, jlong jContext) { return context->parser->getDecodePosition(); } -DECODER_FUNC(jlong, flacGetLastTimestamp, jlong jContext) { +DECODER_FUNC(jlong, flacGetLastFrameTimestamp, jlong jContext) { Context *context = reinterpret_cast(jContext); - return context->parser->getLastTimestamp(); + return context->parser->getLastFrameTimestamp(); +} + +DECODER_FUNC(jlong, flacGetLastFrameFirstSampleIndex, jlong jContext) { + Context *context = reinterpret_cast(jContext); + return context->parser->getLastFrameFirstSampleIndex(); +} + +DECODER_FUNC(jlong, flacGetNextFrameFirstSampleIndex, jlong jContext) { + Context *context = reinterpret_cast(jContext); + return context->parser->getNextFrameFirstSampleIndex(); } DECODER_FUNC(jlong, flacGetSeekPosition, jlong jContext, jlong timeUs) { @@ -148,6 +159,11 @@ DECODER_FUNC(jstring, flacGetStateString, jlong jContext) { return env->NewStringUTF(str); } +DECODER_FUNC(jboolean, flacIsDecoderAtEndOfStream, jlong jContext) { + Context *context = reinterpret_cast(jContext); + return context->parser->isDecoderAtEndOfStream(); +} + DECODER_FUNC(void, flacFlush, jlong jContext) { Context *context = reinterpret_cast(jContext); context->parser->flush(); diff --git a/extensions/flac/src/main/jni/flac_parser.cc b/extensions/flac/src/main/jni/flac_parser.cc index 6c6e57f5f7..83d3367415 100644 --- a/extensions/flac/src/main/jni/flac_parser.cc +++ b/extensions/flac/src/main/jni/flac_parser.cc @@ -42,6 +42,9 @@ #define CHECK(x) \ if (!(x)) ALOGE("Check failed: %s ", #x) +const int endian = 1; +#define isBigEndian() (*(reinterpret_cast(&endian)) == 0) + // The FLAC parser calls our C++ static callbacks using C calling conventions, // inside FLAC__stream_decoder_process_until_end_of_metadata // and FLAC__stream_decoder_process_single. @@ -180,85 +183,42 @@ void FLACParser::errorCallback(FLAC__StreamDecoderErrorStatus status) { mErrorStatus = status; } -// Copy samples from FLAC native 32-bit non-interleaved to 16-bit interleaved. +// Copy samples from FLAC native 32-bit non-interleaved to +// correct bit-depth (non-zero padded), interleaved. // These are candidates for optimization if needed. - -static void copyMono8(int16_t *dst, const int *const *src, unsigned nSamples, - unsigned /* nChannels */) { - for (unsigned i = 0; i < nSamples; ++i) { - *dst++ = src[0][i] << 8; - } -} - -static void copyStereo8(int16_t *dst, const int *const *src, unsigned nSamples, - unsigned /* nChannels */) { - for (unsigned i = 0; i < nSamples; ++i) { - *dst++ = src[0][i] << 8; - *dst++ = src[1][i] << 8; - } -} - -static void copyMultiCh8(int16_t *dst, const int *const *src, unsigned nSamples, - unsigned nChannels) { +static void copyToByteArrayBigEndian(int8_t *dst, const int *const *src, + unsigned bytesPerSample, unsigned nSamples, + unsigned nChannels) { for (unsigned i = 0; i < nSamples; ++i) { for (unsigned c = 0; c < nChannels; ++c) { - *dst++ = src[c][i] << 8; + // point to the first byte of the source address + // and then skip the first few bytes (most significant bytes) + // depending on the bit depth + const int8_t *byteSrc = + reinterpret_cast(&src[c][i]) + 4 - bytesPerSample; + memcpy(dst, byteSrc, bytesPerSample); + dst = dst + bytesPerSample; } } } -static void copyMono16(int16_t *dst, const int *const *src, unsigned nSamples, - unsigned /* nChannels */) { +static void copyToByteArrayLittleEndian(int8_t *dst, const int *const *src, + unsigned bytesPerSample, + unsigned nSamples, unsigned nChannels) { for (unsigned i = 0; i < nSamples; ++i) { - *dst++ = src[0][i]; + for (unsigned c = 0; c < nChannels; ++c) { + // with little endian, the most significant bytes will be at the end + // copy the bytes in little endian will remove the most significant byte + // so we are good here. + memcpy(dst, &(src[c][i]), bytesPerSample); + dst = dst + bytesPerSample; + } } } -static void copyStereo16(int16_t *dst, const int *const *src, unsigned nSamples, +static void copyTrespass(int8_t * /* dst */, const int *const * /* src */, + unsigned /* bytesPerSample */, unsigned /* nSamples */, unsigned /* nChannels */) { - for (unsigned i = 0; i < nSamples; ++i) { - *dst++ = src[0][i]; - *dst++ = src[1][i]; - } -} - -static void copyMultiCh16(int16_t *dst, const int *const *src, - unsigned nSamples, unsigned nChannels) { - for (unsigned i = 0; i < nSamples; ++i) { - for (unsigned c = 0; c < nChannels; ++c) { - *dst++ = src[c][i]; - } - } -} - -// 24-bit versions should do dithering or noise-shaping, here or in AudioFlinger - -static void copyMono24(int16_t *dst, const int *const *src, unsigned nSamples, - unsigned /* nChannels */) { - for (unsigned i = 0; i < nSamples; ++i) { - *dst++ = src[0][i] >> 8; - } -} - -static void copyStereo24(int16_t *dst, const int *const *src, unsigned nSamples, - unsigned /* nChannels */) { - for (unsigned i = 0; i < nSamples; ++i) { - *dst++ = src[0][i] >> 8; - *dst++ = src[1][i] >> 8; - } -} - -static void copyMultiCh24(int16_t *dst, const int *const *src, - unsigned nSamples, unsigned nChannels) { - for (unsigned i = 0; i < nSamples; ++i) { - for (unsigned c = 0; c < nChannels; ++c) { - *dst++ = src[c][i] >> 8; - } - } -} - -static void copyTrespass(int16_t * /* dst */, const int *const * /* src */, - unsigned /* nSamples */, unsigned /* nChannels */) { TRESPASS(); } @@ -340,6 +300,7 @@ bool FLACParser::decodeMetadata() { case 8: case 16: case 24: + case 32: break; default: ALOGE("unsupported bits per sample %u", getBitsPerSample()); @@ -358,28 +319,18 @@ bool FLACParser::decodeMetadata() { case 48000: case 88200: case 96000: + case 176400: + case 192000: break; default: ALOGE("unsupported sample rate %u", getSampleRate()); return false; } - // configure the appropriate copy function, defaulting to trespass - static const struct { - unsigned mChannels; - unsigned mBitsPerSample; - void (*mCopy)(int16_t *dst, const int *const *src, unsigned nSamples, - unsigned nChannels); - } table[] = { - {1, 8, copyMono8}, {2, 8, copyStereo8}, {8, 8, copyMultiCh8}, - {1, 16, copyMono16}, {2, 16, copyStereo16}, {8, 16, copyMultiCh16}, - {1, 24, copyMono24}, {2, 24, copyStereo24}, {8, 24, copyMultiCh24}, - }; - for (unsigned i = 0; i < sizeof(table) / sizeof(table[0]); ++i) { - if (table[i].mChannels >= getChannels() && - table[i].mBitsPerSample == getBitsPerSample()) { - mCopy = table[i].mCopy; - break; - } + // configure the appropriate copy function based on device endianness. + if (isBigEndian()) { + mCopy = copyToByteArrayBigEndian; + } else { + mCopy = copyToByteArrayLittleEndian; } } else { ALOGE("missing STREAMINFO"); @@ -424,7 +375,8 @@ size_t FLACParser::readBuffer(void *output, size_t output_size) { return -1; } - size_t bufferSize = blocksize * getChannels() * sizeof(int16_t); + unsigned bytesPerSample = getBitsPerSample() >> 3; + size_t bufferSize = blocksize * getChannels() * bytesPerSample; if (bufferSize > output_size) { ALOGE( "FLACParser::readBuffer not enough space in output buffer " @@ -434,8 +386,8 @@ size_t FLACParser::readBuffer(void *output, size_t output_size) { } // copy PCM from FLAC write buffer to our media buffer, with interleaving. - (*mCopy)(reinterpret_cast(output), mWriteBuffer, blocksize, - getChannels()); + (*mCopy)(reinterpret_cast(output), mWriteBuffer, bytesPerSample, + blocksize, getChannels()); // fill in buffer metadata CHECK(mWriteHeader.number_type == FLAC__FRAME_NUMBER_TYPE_SAMPLE_NUMBER); diff --git a/extensions/flac/src/main/jni/include/data_source.h b/extensions/flac/src/main/jni/include/data_source.h index 175431dd7a..88af3e1277 100644 --- a/extensions/flac/src/main/jni/include/data_source.h +++ b/extensions/flac/src/main/jni/include/data_source.h @@ -22,6 +22,7 @@ class DataSource { public: + virtual ~DataSource() {} // Returns the number of bytes read, or -1 on failure. It's not an error if // this returns zero; it just means the given offset is equal to, or // beyond, the end of the source. diff --git a/extensions/flac/src/main/jni/include/flac_parser.h b/extensions/flac/src/main/jni/include/flac_parser.h index 8c302adb36..cea7fbe33b 100644 --- a/extensions/flac/src/main/jni/include/flac_parser.h +++ b/extensions/flac/src/main/jni/include/flac_parser.h @@ -44,10 +44,18 @@ class FLACParser { return mStreamInfo; } - int64_t getLastTimestamp() const { + int64_t getLastFrameTimestamp() const { return (1000000LL * mWriteHeader.number.sample_number) / getSampleRate(); } + int64_t getLastFrameFirstSampleIndex() const { + return mWriteHeader.number.sample_number; + } + + int64_t getNextFrameFirstSampleIndex() const { + return mWriteHeader.number.sample_number + mWriteHeader.blocksize; + } + bool decodeMetadata(); size_t readBuffer(void *output, size_t output_size); @@ -83,11 +91,16 @@ class FLACParser { return FLAC__stream_decoder_get_resolved_state_string(mDecoder); } + bool isDecoderAtEndOfStream() const { + return FLAC__stream_decoder_get_state(mDecoder) == + FLAC__STREAM_DECODER_END_OF_STREAM; + } + private: DataSource *mDataSource; - void (*mCopy)(int16_t *dst, const int *const *src, unsigned nSamples, - unsigned nChannels); + void (*mCopy)(int8_t *dst, const int *const *src, unsigned bytesPerSample, + unsigned nSamples, unsigned nChannels); // handle to underlying libFLAC parser FLAC__StreamDecoder *mDecoder; diff --git a/extensions/flac/src/test/AndroidManifest.xml b/extensions/flac/src/test/AndroidManifest.xml new file mode 100644 index 0000000000..1d68b376ac --- /dev/null +++ b/extensions/flac/src/test/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + diff --git a/extensions/flac/src/test/java/com/google/android/exoplayer2/ext/flac/DefaultExtractorsFactoryTest.java b/extensions/flac/src/test/java/com/google/android/exoplayer2/ext/flac/DefaultExtractorsFactoryTest.java new file mode 100644 index 0000000000..79c4452928 --- /dev/null +++ b/extensions/flac/src/test/java/com/google/android/exoplayer2/ext/flac/DefaultExtractorsFactoryTest.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.flac; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; +import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.extractor.amr.AmrExtractor; +import com.google.android.exoplayer2.extractor.flv.FlvExtractor; +import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; +import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; +import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor; +import com.google.android.exoplayer2.extractor.mp4.Mp4Extractor; +import com.google.android.exoplayer2.extractor.ogg.OggExtractor; +import com.google.android.exoplayer2.extractor.ts.Ac3Extractor; +import com.google.android.exoplayer2.extractor.ts.AdtsExtractor; +import com.google.android.exoplayer2.extractor.ts.PsExtractor; +import com.google.android.exoplayer2.extractor.ts.TsExtractor; +import com.google.android.exoplayer2.extractor.wav.WavExtractor; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Unit test for {@link DefaultExtractorsFactory}. */ +@RunWith(RobolectricTestRunner.class) +public final class DefaultExtractorsFactoryTest { + + @Test + public void testCreateExtractors_returnExpectedClasses() { + DefaultExtractorsFactory defaultExtractorsFactory = new DefaultExtractorsFactory(); + + Extractor[] extractors = defaultExtractorsFactory.createExtractors(); + List> listCreatedExtractorClasses = new ArrayList<>(); + for (Extractor extractor : extractors) { + listCreatedExtractorClasses.add(extractor.getClass()); + } + + Class[] expectedExtractorClassses = + new Class[] { + MatroskaExtractor.class, + FragmentedMp4Extractor.class, + Mp4Extractor.class, + Mp3Extractor.class, + AdtsExtractor.class, + Ac3Extractor.class, + TsExtractor.class, + FlvExtractor.class, + OggExtractor.class, + PsExtractor.class, + WavExtractor.class, + AmrExtractor.class, + FlacExtractor.class + }; + + assertThat(listCreatedExtractorClasses).containsNoDuplicates(); + assertThat(listCreatedExtractorClasses).containsExactlyElementsIn(expectedExtractorClassses); + } +} diff --git a/extensions/flac/src/test/resources/robolectric.properties b/extensions/flac/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..2f3210368e --- /dev/null +++ b/extensions/flac/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +manifest=src/test/AndroidManifest.xml diff --git a/extensions/gvr/README.md b/extensions/gvr/README.md index ad28569121..5dab885436 100644 --- a/extensions/gvr/README.md +++ b/extensions/gvr/README.md @@ -1,6 +1,4 @@ -# ExoPlayer GVR Extension # - -## Description ## +# ExoPlayer GVR extension # The GVR extension wraps the [Google VR SDK for Android][]. It provides a GvrAudioProcessor, which uses [GvrAudioSurround][] to provide binaural rendering @@ -11,23 +9,13 @@ of surround sound and ambisonic soundfields. ## Getting the extension ## -The easiest way to use the extension is to add it as a gradle dependency. You -need to make sure you have the jcenter repository included in the `build.gradle` -file in the root of your project: +The easiest way to use the extension is to add it as a gradle dependency: ```gradle -repositories { - jcenter() -} +implementation 'com.google.android.exoplayer:extension-gvr:2.X.X' ``` -Next, include the following in your module's `build.gradle` file: - -```gradle -compile 'com.google.android.exoplayer:extension-gvr:rX.X.X' -``` - -where `rX.X.X` is the version, which must match the version of the ExoPlayer +where `2.X.X` is the version, which must match the version of the ExoPlayer library being used. Alternatively, you can clone the ExoPlayer repository and depend on the module @@ -36,9 +24,17 @@ locally. Instructions for doing this can be found in ExoPlayer's ## Using the extension ## -* If using SimpleExoPlayer, override SimpleExoPlayer.buildAudioProcessors to - return a GvrAudioProcessor. -* If constructing renderers directly, pass a GvrAudioProcessor to - MediaCodecAudioRenderer's constructor. +* If using `DefaultRenderersFactory`, override + `DefaultRenderersFactory.buildAudioProcessors` to return a + `GvrAudioProcessor`. +* If constructing renderers directly, pass a `GvrAudioProcessor` to + `MediaCodecAudioRenderer`'s constructor. [top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md + +## Links ## + +* [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.gvr.*` + belong to this module. + +[Javadoc]: https://google.github.io/ExoPlayer/doc/reference/index.html diff --git a/extensions/gvr/build.gradle b/extensions/gvr/build.gradle index 66665576bb..c845cb3423 100644 --- a/extensions/gvr/build.gradle +++ b/extensions/gvr/build.gradle @@ -18,6 +18,11 @@ android { compileSdkVersion project.ext.compileSdkVersion buildToolsVersion project.ext.buildToolsVersion + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + defaultConfig { minSdkVersion 19 targetSdkVersion project.ext.targetSdkVersion @@ -25,8 +30,13 @@ android { } dependencies { - compile project(modulePrefix + 'library-core') - compile 'com.google.vr:sdk-audio:1.60.1' + implementation project(modulePrefix + 'library-core') + implementation project(modulePrefix + 'library-ui') + implementation 'com.android.support:support-annotations:' + supportLibraryVersion + implementation 'com.google.vr:sdk-audio:1.80.0' + implementation 'com.google.vr:sdk-controller:1.80.0' + api 'com.google.vr:sdk-base:1.80.0' + compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion } ext { diff --git a/extensions/gvr/src/main/java/com/google/android/exoplayer2/ext/gvr/BaseGvrPlayerActivity.java b/extensions/gvr/src/main/java/com/google/android/exoplayer2/ext/gvr/BaseGvrPlayerActivity.java new file mode 100644 index 0000000000..acddae49e9 --- /dev/null +++ b/extensions/gvr/src/main/java/com/google/android/exoplayer2/ext/gvr/BaseGvrPlayerActivity.java @@ -0,0 +1,356 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.exoplayer2.ext.gvr; + +import android.content.Context; +import android.content.Intent; +import android.graphics.SurfaceTexture; +import android.opengl.Matrix; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.support.annotation.BinderThread; +import android.support.annotation.CallSuper; +import android.support.annotation.Nullable; +import android.support.annotation.UiThread; +import android.view.ContextThemeWrapper; +import android.view.MotionEvent; +import android.view.Surface; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.ui.PlayerControlView; +import com.google.android.exoplayer2.ui.spherical.GlViewGroup; +import com.google.android.exoplayer2.ui.spherical.PointerRenderer; +import com.google.android.exoplayer2.ui.spherical.SceneRenderer; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; +import com.google.vr.ndk.base.DaydreamApi; +import com.google.vr.sdk.base.AndroidCompat; +import com.google.vr.sdk.base.Eye; +import com.google.vr.sdk.base.GvrActivity; +import com.google.vr.sdk.base.GvrView; +import com.google.vr.sdk.base.HeadTransform; +import com.google.vr.sdk.base.Viewport; +import com.google.vr.sdk.controller.Controller; +import com.google.vr.sdk.controller.ControllerManager; +import javax.microedition.khronos.egl.EGLConfig; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** VR 360 video player base activity class. */ +public abstract class BaseGvrPlayerActivity extends GvrActivity { + private static final String TAG = "GvrPlayerActivity"; + + private static final int EXIT_FROM_VR_REQUEST_CODE = 42; + + private final Handler mainHandler; + + @Nullable private Player player; + @MonotonicNonNull private GlViewGroup glView; + @MonotonicNonNull private ControllerManager controllerManager; + @MonotonicNonNull private SurfaceTexture surfaceTexture; + @MonotonicNonNull private Surface surface; + @MonotonicNonNull private SceneRenderer scene; + @MonotonicNonNull private PlayerControlView playerControl; + + public BaseGvrPlayerActivity() { + mainHandler = new Handler(Looper.getMainLooper()); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setScreenAlwaysOn(true); + + GvrView gvrView = new GvrView(this); + // Since videos typically have fewer pixels per degree than the phones, reducing the render + // target scaling factor reduces the work required to render the scene. + gvrView.setRenderTargetScale(.5f); + + // If a custom theme isn't specified, the Context's theme is used. For VR Activities, this is + // the old Android default theme rather than a modern theme. Override this with a custom theme. + Context theme = new ContextThemeWrapper(this, R.style.VrTheme); + glView = new GlViewGroup(theme, R.layout.vr_ui); + + playerControl = Assertions.checkNotNull(glView.findViewById(R.id.controller)); + playerControl.setShowVrButton(true); + playerControl.setVrButtonListener(v -> exit()); + + PointerRenderer pointerRenderer = new PointerRenderer(); + scene = new SceneRenderer(); + Renderer renderer = new Renderer(scene, glView, pointerRenderer); + + // Attach glView to gvrView in order to properly handle UI events. + gvrView.addView(glView, 0); + + // Standard GvrView configuration + gvrView.setEGLConfigChooser( + 8, 8, 8, 8, // RGBA bits. + 16, // Depth bits. + 0); // Stencil bits. + gvrView.setRenderer(renderer); + setContentView(gvrView); + + // Most Daydream phones can render a 4k video at 60fps in sustained performance mode. These + // options can be tweaked along with the render target scale. + if (gvrView.setAsyncReprojectionEnabled(true)) { + AndroidCompat.setSustainedPerformanceMode(this, true); + } + + // Handle the user clicking on the 'X' in the top left corner. Since this is done when the user + // has taken the headset out of VR, it should launch the app's exit flow directly rather than + // using the transition flow. + gvrView.setOnCloseButtonListener(this::finish); + + ControllerManager.EventListener listener = + new ControllerManager.EventListener() { + @Override + public void onApiStatusChanged(int status) { + // Do nothing. + } + + @Override + public void onRecentered() { + // TODO if in cardboard mode call gvrView.recenterHeadTracker(); + glView.post(() -> Util.castNonNull(playerControl).show()); + } + }; + controllerManager = new ControllerManager(this, listener); + + Controller controller = controllerManager.getController(); + ControllerEventListener controllerEventListener = + new ControllerEventListener(controller, pointerRenderer, glView); + controller.setEventListener(controllerEventListener); + } + + /** + * Sets the {@link Player} to use. + * + * @param newPlayer The {@link Player} to use, or {@code null} to detach the current player. + */ + protected void setPlayer(@Nullable Player newPlayer) { + Assertions.checkNotNull(scene); + if (player == newPlayer) { + return; + } + if (player != null) { + Player.VideoComponent videoComponent = player.getVideoComponent(); + if (videoComponent != null) { + if (surface != null) { + videoComponent.clearVideoSurface(surface); + } + videoComponent.clearVideoFrameMetadataListener(scene); + videoComponent.clearCameraMotionListener(scene); + } + } + player = newPlayer; + if (player != null) { + Player.VideoComponent videoComponent = player.getVideoComponent(); + if (videoComponent != null) { + videoComponent.setVideoFrameMetadataListener(scene); + videoComponent.setCameraMotionListener(scene); + videoComponent.setVideoSurface(surface); + } + } + Assertions.checkNotNull(playerControl).setPlayer(player); + } + + /** + * Sets the default stereo mode. If the played video doesn't contain a stereo mode the default one + * is used. + * + * @param stereoMode A {@link C.StereoMode} value. + */ + protected void setDefaultStereoMode(@C.StereoMode int stereoMode) { + Assertions.checkNotNull(scene).setDefaultStereoMode(stereoMode); + } + + @CallSuper + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent unused) { + if (requestCode == EXIT_FROM_VR_REQUEST_CODE && resultCode == RESULT_OK) { + finish(); + } + } + + @Override + protected void onResume() { + super.onResume(); + Util.castNonNull(controllerManager).start(); + } + + @Override + protected void onPause() { + Util.castNonNull(controllerManager).stop(); + super.onPause(); + } + + @Override + protected void onDestroy() { + setPlayer(null); + releaseSurface(surfaceTexture, surface); + super.onDestroy(); + } + + /** Tries to exit gracefully from VR using a VR transition dialog. */ + @SuppressWarnings("nullness:argument.type.incompatible") + protected void exit() { + // This needs to use GVR's exit transition to avoid disorienting the user. + DaydreamApi api = DaydreamApi.create(this); + if (api != null) { + api.exitFromVr(this, EXIT_FROM_VR_REQUEST_CODE, null); + // Eventually, the Activity's onActivityResult will be called. + api.close(); + } else { + finish(); + } + } + + /** Toggles PlayerControl visibility. */ + @UiThread + protected void togglePlayerControlVisibility() { + if (Assertions.checkNotNull(playerControl).isVisible()) { + playerControl.hide(); + } else { + playerControl.show(); + } + } + + // Called on GL thread. + private void onSurfaceTextureAvailable(SurfaceTexture surfaceTexture) { + mainHandler.post( + () -> { + SurfaceTexture oldSurfaceTexture = this.surfaceTexture; + Surface oldSurface = this.surface; + this.surfaceTexture = surfaceTexture; + this.surface = new Surface(surfaceTexture); + if (player != null) { + Player.VideoComponent videoComponent = player.getVideoComponent(); + if (videoComponent != null) { + videoComponent.setVideoSurface(surface); + } + } + releaseSurface(oldSurfaceTexture, oldSurface); + }); + } + + private static void releaseSurface( + @Nullable SurfaceTexture oldSurfaceTexture, @Nullable Surface oldSurface) { + if (oldSurfaceTexture != null) { + oldSurfaceTexture.release(); + } + if (oldSurface != null) { + oldSurface.release(); + } + } + + private class Renderer implements GvrView.StereoRenderer { + private static final float Z_NEAR = .1f; + private static final float Z_FAR = 100; + + private final float[] viewProjectionMatrix = new float[16]; + private final SceneRenderer scene; + private final GlViewGroup glView; + private final PointerRenderer pointerRenderer; + + public Renderer(SceneRenderer scene, GlViewGroup glView, PointerRenderer pointerRenderer) { + this.scene = scene; + this.glView = glView; + this.pointerRenderer = pointerRenderer; + } + + @Override + public void onNewFrame(HeadTransform headTransform) {} + + @Override + public void onDrawEye(Eye eye) { + Matrix.multiplyMM( + viewProjectionMatrix, 0, eye.getPerspective(Z_NEAR, Z_FAR), 0, eye.getEyeView(), 0); + scene.drawFrame(viewProjectionMatrix, eye.getType() == Eye.Type.RIGHT); + if (glView.isVisible()) { + glView.getRenderer().draw(viewProjectionMatrix); + pointerRenderer.draw(viewProjectionMatrix); + } + } + + @Override + public void onFinishFrame(Viewport viewport) {} + + @Override + public void onSurfaceCreated(EGLConfig config) { + onSurfaceTextureAvailable(scene.init()); + glView.getRenderer().init(); + pointerRenderer.init(); + } + + @Override + public void onSurfaceChanged(int width, int height) {} + + @Override + public void onRendererShutdown() { + glView.getRenderer().shutdown(); + pointerRenderer.shutdown(); + scene.shutdown(); + } + } + + private class ControllerEventListener extends Controller.EventListener { + + private final Controller controller; + private final PointerRenderer pointerRenderer; + private final GlViewGroup glView; + private final float[] controllerOrientationMatrix; + private boolean clickButtonDown; + private boolean appButtonDown; + + public ControllerEventListener( + Controller controller, PointerRenderer pointerRenderer, GlViewGroup glView) { + this.controller = controller; + this.pointerRenderer = pointerRenderer; + this.glView = glView; + controllerOrientationMatrix = new float[16]; + } + + @Override + @BinderThread + public void onUpdate() { + controller.update(); + controller.orientation.toRotationMatrix(controllerOrientationMatrix); + pointerRenderer.setControllerOrientation(controllerOrientationMatrix); + + if (clickButtonDown || controller.clickButtonState) { + int action; + if (clickButtonDown != controller.clickButtonState) { + clickButtonDown = controller.clickButtonState; + action = clickButtonDown ? MotionEvent.ACTION_DOWN : MotionEvent.ACTION_UP; + } else { + action = MotionEvent.ACTION_MOVE; + } + glView.post( + () -> { + float[] angles = controller.orientation.toYawPitchRollRadians(new float[3]); + boolean clickedOnView = glView.simulateClick(action, angles[0], angles[1]); + if (action == MotionEvent.ACTION_DOWN && !clickedOnView) { + togglePlayerControlVisibility(); + } + }); + } else if (!appButtonDown && controller.appButtonState) { + glView.post(BaseGvrPlayerActivity.this::togglePlayerControlVisibility); + } + appButtonDown = controller.appButtonState; + } + } +} diff --git a/extensions/gvr/src/main/java/com/google/android/exoplayer2/ext/gvr/GvrAudioProcessor.java b/extensions/gvr/src/main/java/com/google/android/exoplayer2/ext/gvr/GvrAudioProcessor.java index c6e09cf4cc..eca31c98e4 100644 --- a/extensions/gvr/src/main/java/com/google/android/exoplayer2/ext/gvr/GvrAudioProcessor.java +++ b/extensions/gvr/src/main/java/com/google/android/exoplayer2/ext/gvr/GvrAudioProcessor.java @@ -15,10 +15,12 @@ */ package com.google.android.exoplayer2.ext.gvr; +import android.support.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlayerLibraryInfo; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.audio.AudioProcessor; +import com.google.android.exoplayer2.util.Assertions; import com.google.vr.sdk.audio.GvrAudioSurround; import java.nio.ByteBuffer; import java.nio.ByteOrder; @@ -39,7 +41,7 @@ public final class GvrAudioProcessor implements AudioProcessor { private int sampleRateHz; private int channelCount; - private GvrAudioSurround gvrAudioSurround; + @Nullable private GvrAudioSurround gvrAudioSurround; private ByteBuffer buffer; private boolean inputEnded; @@ -48,19 +50,23 @@ public final class GvrAudioProcessor implements AudioProcessor { private float y; private float z; - /** - * Creates a new GVR audio processor. - */ + /** Creates a new GVR audio processor. */ public GvrAudioProcessor() { // Use the identity for the initial orientation. w = 1f; sampleRateHz = Format.NO_VALUE; channelCount = Format.NO_VALUE; + buffer = EMPTY_BUFFER; } /** * Updates the listener head orientation. May be called on any thread. See * {@code GvrAudioSurround.updateNativeOrientation}. + * + * @param w The w component of the quaternion. + * @param x The x component of the quaternion. + * @param y The y component of the quaternion. + * @param z The z component of the quaternion. */ public synchronized void updateOrientation(float w, float x, float y, float z) { this.w = w; @@ -72,9 +78,11 @@ public final class GvrAudioProcessor implements AudioProcessor { } } + @SuppressWarnings("ReferenceEquality") @Override - public synchronized boolean configure(int sampleRateHz, int channelCount, - @C.Encoding int encoding) throws UnhandledFormatException { + public synchronized boolean configure( + int sampleRateHz, int channelCount, @C.Encoding int encoding) + throws UnhandledFormatException { if (encoding != C.ENCODING_PCM_16BIT) { maybeReleaseGvrAudioSurround(); throw new UnhandledFormatException(sampleRateHz, channelCount, encoding); @@ -111,7 +119,7 @@ public final class GvrAudioProcessor implements AudioProcessor { gvrAudioSurround = new GvrAudioSurround(surroundFormat, sampleRateHz, channelCount, FRAMES_PER_OUTPUT_BUFFER); gvrAudioSurround.updateNativeOrientation(w, x, y, z); - if (buffer == null) { + if (buffer == EMPTY_BUFFER) { buffer = ByteBuffer.allocateDirect(FRAMES_PER_OUTPUT_BUFFER * OUTPUT_FRAME_SIZE) .order(ByteOrder.nativeOrder()); } @@ -133,21 +141,29 @@ public final class GvrAudioProcessor implements AudioProcessor { return C.ENCODING_PCM_16BIT; } + @Override + public int getOutputSampleRateHz() { + return sampleRateHz; + } + @Override public void queueInput(ByteBuffer input) { int position = input.position(); + Assertions.checkNotNull(gvrAudioSurround); int readBytes = gvrAudioSurround.addInput(input, position, input.limit() - position); input.position(position + readBytes); } @Override public void queueEndOfStream() { + Assertions.checkNotNull(gvrAudioSurround); inputEnded = true; gvrAudioSurround.triggerProcessing(); } @Override public ByteBuffer getOutput() { + Assertions.checkNotNull(gvrAudioSurround); int writtenBytes = gvrAudioSurround.getOutput(buffer, 0, buffer.capacity()); buffer.position(0).limit(writtenBytes); return buffer; @@ -155,6 +171,7 @@ public final class GvrAudioProcessor implements AudioProcessor { @Override public boolean isEnded() { + Assertions.checkNotNull(gvrAudioSurround); return inputEnded && gvrAudioSurround.getAvailableOutputSize() == 0; } @@ -169,10 +186,11 @@ public final class GvrAudioProcessor implements AudioProcessor { @Override public synchronized void reset() { maybeReleaseGvrAudioSurround(); + updateOrientation(/* w= */ 1f, /* x= */ 0f, /* y= */ 0f, /* z= */ 0f); inputEnded = false; - buffer = null; sampleRateHz = Format.NO_VALUE; channelCount = Format.NO_VALUE; + buffer = EMPTY_BUFFER; } private void maybeReleaseGvrAudioSurround() { diff --git a/extensions/gvr/src/main/res/layout/vr_ui.xml b/extensions/gvr/src/main/res/layout/vr_ui.xml new file mode 100644 index 0000000000..84e7ac7c6f --- /dev/null +++ b/extensions/gvr/src/main/res/layout/vr_ui.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/extensions/mediasession/src/main/res/values-gl-rES/strings.xml b/extensions/gvr/src/main/res/values/styles.xml similarity index 65% rename from extensions/mediasession/src/main/res/values-gl-rES/strings.xml rename to extensions/gvr/src/main/res/values/styles.xml index 6b65b3e843..c79e1dfa60 100644 --- a/extensions/mediasession/src/main/res/values-gl-rES/strings.xml +++ b/extensions/gvr/src/main/res/values/styles.xml @@ -1,6 +1,5 @@ - - + - "Repetir todo" - "Non repetir" - "Repetir un" + @@ -51,4 +51,14 @@ @string/exo_controls_pause_description + + + + diff --git a/library/ui/src/test/AndroidManifest.xml b/library/ui/src/test/AndroidManifest.xml new file mode 100644 index 0000000000..1a749dc82c --- /dev/null +++ b/library/ui/src/test/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + diff --git a/library/ui/src/test/java/com/google/android/exoplayer2/ui/spherical/CanvasRendererTest.java b/library/ui/src/test/java/com/google/android/exoplayer2/ui/spherical/CanvasRendererTest.java new file mode 100644 index 0000000000..d0503d4a93 --- /dev/null +++ b/library/ui/src/test/java/com/google/android/exoplayer2/ui/spherical/CanvasRendererTest.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ui.spherical; + +import static com.google.common.truth.Truth.assertThat; + +import android.graphics.PointF; +import android.support.annotation.Nullable; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Tests for {@link CanvasRenderer}. */ +@RunWith(RobolectricTestRunner.class) +public class CanvasRendererTest { + + private static final float JUST_BELOW_45_DEGREES = (float) (Math.PI / 4 - 1.0E-08); + private static final float JUST_ABOVE_45_DEGREES = (float) (Math.PI / 4 + 1.0E-08); + private static final float TOLERANCE = .00001f; + + @Test + public void testClicksOnCanvas() { + assertClick(translateClick(JUST_BELOW_45_DEGREES, JUST_BELOW_45_DEGREES), 0, 0); + assertClick(translateClick(JUST_BELOW_45_DEGREES, -JUST_BELOW_45_DEGREES), 0, 100); + assertClick(translateClick(0, 0), 50, 50); + assertClick(translateClick(-JUST_BELOW_45_DEGREES, JUST_BELOW_45_DEGREES), 100, 0); + assertClick(translateClick(-JUST_BELOW_45_DEGREES, -JUST_BELOW_45_DEGREES), 100, 100); + } + + @Test + public void testClicksNotOnCanvas() { + assertThat(translateClick(JUST_ABOVE_45_DEGREES, JUST_ABOVE_45_DEGREES)).isNull(); + assertThat(translateClick(JUST_ABOVE_45_DEGREES, -JUST_ABOVE_45_DEGREES)).isNull(); + assertThat(translateClick(-JUST_ABOVE_45_DEGREES, JUST_ABOVE_45_DEGREES)).isNull(); + assertThat(translateClick(-JUST_ABOVE_45_DEGREES, -JUST_ABOVE_45_DEGREES)).isNull(); + assertThat(translateClick((float) (Math.PI / 2), 0)).isNull(); + assertThat(translateClick(0, (float) Math.PI)).isNull(); + } + + private static PointF translateClick(float yaw, float pitch) { + return CanvasRenderer.internalTranslateClick( + yaw, + pitch, + /* xUnit= */ -1, + /* yUnit= */ -1, + /* widthUnit= */ 2, + /* heightUnit= */ 2, + /* widthPixel= */ 100, + /* heightPixel= */ 100); + } + + private static void assertClick(@Nullable PointF actual, float expectedX, float expectedY) { + assertThat(actual).isNotNull(); + assertThat(actual.x).isWithin(TOLERANCE).of(expectedX); + assertThat(actual.y).isWithin(TOLERANCE).of(expectedY); + } +} diff --git a/library/ui/src/test/java/com/google/android/exoplayer2/ui/spherical/TouchTrackerTest.java b/library/ui/src/test/java/com/google/android/exoplayer2/ui/spherical/TouchTrackerTest.java new file mode 100644 index 0000000000..337fb4c593 --- /dev/null +++ b/library/ui/src/test/java/com/google/android/exoplayer2/ui/spherical/TouchTrackerTest.java @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ui.spherical; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import android.view.MotionEvent; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +/** Tests for {@link TouchTracker}. */ +@RunWith(RobolectricTestRunner.class) +public class TouchTrackerTest { + private static final float EPSILON = 0.00001f; + private static final int SWIPE_PX = 100; + private static final float PX_PER_DEGREES = 25; + + private TouchTracker tracker; + private float yaw; + private float pitch; + private float[] dummyMatrix; + + private static void swipe(TouchTracker tracker, float x0, float y0, float x1, float y1) { + tracker.onTouch(null, MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, x0, y0, 0)); + tracker.onTouch(null, MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, x1, y1, 0)); + tracker.onTouch(null, MotionEvent.obtain(0, 0, MotionEvent.ACTION_UP, x1, y1, 0)); + } + + @Before + public void setUp() { + Context context = RuntimeEnvironment.application; + tracker = + new TouchTracker( + context, + scrollOffsetDegrees -> { + pitch = scrollOffsetDegrees.y; + yaw = scrollOffsetDegrees.x; + }, + PX_PER_DEGREES); + dummyMatrix = new float[16]; + tracker.onOrientationChange(dummyMatrix, 0); + } + + @Test + public void testTap() { + // Tap is a noop. + swipe(tracker, 0, 0, 0, 0); + assertThat(yaw).isWithin(EPSILON).of(0); + assertThat(pitch).isWithin(EPSILON).of(0); + } + + @Test + public void testBasicYaw() { + swipe(tracker, 0, 0, SWIPE_PX, 0); + assertThat(yaw).isWithin(EPSILON).of(-SWIPE_PX / PX_PER_DEGREES); + assertThat(pitch).isWithin(EPSILON).of(0); + } + + @Test + public void testBigYaw() { + swipe(tracker, 0, 0, -10 * SWIPE_PX, 0); + assertThat(yaw).isEqualTo(10 * SWIPE_PX / PX_PER_DEGREES); + assertThat(pitch).isWithin(EPSILON).of(0); + } + + @Test + public void testYawUnaffectedByPitch() { + swipe(tracker, 0, 0, 0, SWIPE_PX); + assertThat(yaw).isWithin(EPSILON).of(0); + + swipe(tracker, 0, 0, SWIPE_PX, SWIPE_PX); + assertThat(yaw).isWithin(EPSILON).of(-SWIPE_PX / PX_PER_DEGREES); + } + + @Test + public void testBasicPitch() { + swipe(tracker, 0, 0, 0, SWIPE_PX); + assertThat(yaw).isWithin(EPSILON).of(0); + assertThat(pitch).isWithin(EPSILON).of(SWIPE_PX / PX_PER_DEGREES); + } + + @Test + public void testPitchClipped() { + // Big reverse pitch should be clipped. + swipe(tracker, 0, 0, 0, -20 * SWIPE_PX); + assertThat(yaw).isWithin(EPSILON).of(0); + assertThat(pitch).isEqualTo(-TouchTracker.MAX_PITCH_DEGREES); + + // Big forward pitch should be clipped. + swipe(tracker, 0, 0, 0, 50 * SWIPE_PX); + assertThat(yaw).isWithin(EPSILON).of(0); + assertThat(pitch).isEqualTo(TouchTracker.MAX_PITCH_DEGREES); + } + + @Test + public void testWithRoll90() { + tracker.onOrientationChange(dummyMatrix, (float) Math.toRadians(90)); + + // Y-axis should now control yaw. + swipe(tracker, 0, 0, 0, 2 * SWIPE_PX); + assertThat(yaw).isWithin(EPSILON).of(-2 * SWIPE_PX / PX_PER_DEGREES); + + // X-axis should now control reverse pitch. + swipe(tracker, 0, 0, -3 * SWIPE_PX, 0); + assertThat(pitch).isWithin(EPSILON).of(3 * SWIPE_PX / PX_PER_DEGREES); + } + + @Test + public void testWithRoll180() { + tracker.onOrientationChange(dummyMatrix, (float) Math.toRadians(180)); + + // X-axis should now control reverse yaw. + swipe(tracker, 0, 0, -2 * SWIPE_PX, 0); + assertThat(yaw).isWithin(EPSILON).of(-2 * SWIPE_PX / PX_PER_DEGREES); + + // Y-axis should now control reverse pitch. + swipe(tracker, 0, 0, 0, -3 * SWIPE_PX); + assertThat(pitch).isWithin(EPSILON).of(3 * SWIPE_PX / PX_PER_DEGREES); + } + + @Test + public void testWithRoll270() { + tracker.onOrientationChange(dummyMatrix, (float) Math.toRadians(270)); + + // Y-axis should now control reverse yaw. + swipe(tracker, 0, 0, 0, -2 * SWIPE_PX); + assertThat(yaw).isWithin(EPSILON).of(-2 * SWIPE_PX / PX_PER_DEGREES); + + // X-axis should now control pitch. + swipe(tracker, 0, 0, 3 * SWIPE_PX, 0); + assertThat(pitch).isWithin(EPSILON).of(3 * SWIPE_PX / PX_PER_DEGREES); + } +} diff --git a/library/ui/src/test/resources/robolectric.properties b/library/ui/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..2f3210368e --- /dev/null +++ b/library/ui/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +manifest=src/test/AndroidManifest.xml diff --git a/playbacktests/build.gradle b/playbacktests/build.gradle index 6cd56868f9..fe16d3b2c7 100644 --- a/playbacktests/build.gradle +++ b/playbacktests/build.gradle @@ -18,15 +18,24 @@ android { compileSdkVersion project.ext.compileSdkVersion buildToolsVersion project.ext.buildToolsVersion + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + defaultConfig { minSdkVersion project.ext.minSdkVersion targetSdkVersion project.ext.targetSdkVersion + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } } dependencies { - androidTestCompile project(modulePrefix + 'library-core') - androidTestCompile project(modulePrefix + 'library-dash') - androidTestCompile project(modulePrefix + 'library-hls') - androidTestCompile project(modulePrefix + 'testutils') + androidTestImplementation 'androidx.test:rules:' + testRunnerVersion + androidTestImplementation 'androidx.test:runner:' + testRunnerVersion + androidTestImplementation 'com.android.support:support-annotations:' + supportLibraryVersion + androidTestImplementation project(modulePrefix + 'library-core') + androidTestImplementation project(modulePrefix + 'library-dash') + androidTestImplementation project(modulePrefix + 'library-hls') + androidTestImplementation project(modulePrefix + 'testutils') } diff --git a/playbacktests/src/androidTest/AndroidManifest.xml b/playbacktests/src/androidTest/AndroidManifest.xml index 053fe4e61c..4165a42568 100644 --- a/playbacktests/src/androidTest/AndroidManifest.xml +++ b/playbacktests/src/androidTest/AndroidManifest.xml @@ -18,11 +18,10 @@ xmlns:tools="http://schemas.android.com/tools" package="com.google.android.exoplayer2.playbacktests"> + - - @@ -36,6 +35,6 @@ + android:name="androidx.test.runner.AndroidJUnitRunner"/> diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/CommonEncryptionDrmTest.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/CommonEncryptionDrmTest.java index 3f84b9ea85..1832e16a98 100644 --- a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/CommonEncryptionDrmTest.java +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/CommonEncryptionDrmTest.java @@ -15,82 +15,97 @@ */ package com.google.android.exoplayer2.playbacktests.gts; -import android.test.ActivityInstrumentationTestCase2; +import static androidx.test.InstrumentationRegistry.getInstrumentation; + +import androidx.test.rule.ActivityTestRule; +import androidx.test.runner.AndroidJUnit4; +import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.testutil.ActionSchedule; import com.google.android.exoplayer2.testutil.HostActivity; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; -/** - * Test playback of encrypted DASH streams using different CENC scheme types. - */ -public final class CommonEncryptionDrmTest extends ActivityInstrumentationTestCase2 { +/** Test playback of encrypted DASH streams using different CENC scheme types. */ +@RunWith(AndroidJUnit4.class) +public final class CommonEncryptionDrmTest { private static final String TAG = "CencDrmTest"; - private static final String URL_cenc = - "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd"; - private static final String URL_cbc1 = - "https://storage.googleapis.com/wvmedia/cbc1/h264/tears/tears_aes_cbc1.mpd"; - private static final String URL_cbcs = - "https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs.mpd"; private static final String ID_AUDIO = "0"; private static final String[] IDS_VIDEO = new String[] {"1", "2"}; // Seeks help reproduce playback issues in certain devices. private static final ActionSchedule ACTION_SCHEDULE_WITH_SEEKS = new ActionSchedule.Builder(TAG) - .delay(30000).seek(300000).delay(10000).seek(270000).delay(10000).seek(200000).delay(10000) - .stop().build(); + .waitForPlaybackState(Player.STATE_READY).delay(30000).seekAndWait(300000).delay(10000) + .seekAndWait(270000).delay(10000).seekAndWait(200000).delay(10000).seekAndWait(732000) + .build(); + + @Rule public ActivityTestRule testRule = new ActivityTestRule<>(HostActivity.class); private DashTestRunner testRunner; - public CommonEncryptionDrmTest() { - super(HostActivity.class); + @Before + public void setUp() { + testRunner = + new DashTestRunner(TAG, testRule.getActivity(), getInstrumentation()) + .setWidevineInfo(MimeTypes.VIDEO_H264, false) + .setActionSchedule(ACTION_SCHEDULE_WITH_SEEKS) + .setAudioVideoFormats(ID_AUDIO, IDS_VIDEO) + .setCanIncludeAdditionalVideoFormats(true); } - @Override - protected void setUp() throws Exception { - super.setUp(); - - testRunner = new DashTestRunner(TAG, getActivity(), getInstrumentation()) - .setWidevineInfo(MimeTypes.VIDEO_H264, false) - .setActionSchedule(ACTION_SCHEDULE_WITH_SEEKS) - .setAudioVideoFormats(ID_AUDIO, IDS_VIDEO) - .setCanIncludeAdditionalVideoFormats(true); - } - - @Override - protected void tearDown() throws Exception { + @After + public void tearDown() { testRunner = null; - super.tearDown(); } - public void testCencSchemeType() { + @Test + public void testCencSchemeTypeV18() { if (Util.SDK_INT < 18) { // Pass. return; } - testRunner.setStreamName("test_widevine_h264_scheme_cenc").setManifestUrl(URL_cenc).run(); + testRunner + .setStreamName("test_widevine_h264_scheme_cenc") + .setManifestUrl(DashTestData.WIDEVINE_SCHEME_CENC) + .run(); } - public void testCbc1SchemeType() { - if (Util.SDK_INT < 24) { + @Test + public void testCbc1SchemeTypeV25() { + if (Util.SDK_INT < 25) { + // cbc1 support was added in API 24, but it is stable from API 25 onwards. + // See [internal: b/65634809]. // Pass. return; } - testRunner.setStreamName("test_widevine_h264_scheme_cbc1").setManifestUrl(URL_cbc1).run(); + testRunner + .setStreamName("test_widevine_h264_scheme_cbc1") + .setManifestUrl(DashTestData.WIDEVINE_SCHEME_CBC1) + .run(); } - public void testCbcsSchemeType() { - if (Util.SDK_INT < 24) { + @Test + public void testCbcsSchemeTypeV25() { + if (Util.SDK_INT < 25) { + // cbcs support was added in API 24, but it is stable from API 25 onwards. + // See [internal: b/65634809]. // Pass. return; } - testRunner.setStreamName("test_widevine_h264_scheme_cbcs").setManifestUrl(URL_cbcs).run(); + testRunner + .setStreamName("test_widevine_h264_scheme_cbcs") + .setManifestUrl(DashTestData.WIDEVINE_SCHEME_CBCS) + .run(); } - public void testCensSchemeType() { + @Test + public void testCensSchemeTypeV25() { // TODO: Implement once content is available. Track [internal: b/31219813]. } - } diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashDownloadTest.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashDownloadTest.java new file mode 100644 index 0000000000..0dd05e7fd3 --- /dev/null +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashDownloadTest.java @@ -0,0 +1,133 @@ +/* + * 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.playbacktests.gts; + +import static androidx.test.InstrumentationRegistry.getInstrumentation; +import static com.google.common.truth.Truth.assertWithMessage; + +import android.net.Uri; +import androidx.test.rule.ActivityTestRule; +import androidx.test.runner.AndroidJUnit4; +import com.google.android.exoplayer2.offline.DownloaderConstructorHelper; +import com.google.android.exoplayer2.offline.StreamKey; +import com.google.android.exoplayer2.source.dash.DashUtil; +import com.google.android.exoplayer2.source.dash.manifest.AdaptationSet; +import com.google.android.exoplayer2.source.dash.manifest.DashManifest; +import com.google.android.exoplayer2.source.dash.manifest.Representation; +import com.google.android.exoplayer2.source.dash.offline.DashDownloader; +import com.google.android.exoplayer2.testutil.HostActivity; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; +import com.google.android.exoplayer2.upstream.DummyDataSource; +import com.google.android.exoplayer2.upstream.cache.CacheDataSource; +import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory; +import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor; +import com.google.android.exoplayer2.upstream.cache.SimpleCache; +import com.google.android.exoplayer2.util.Util; +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests downloaded DASH playbacks. */ +@RunWith(AndroidJUnit4.class) +public final class DashDownloadTest { + + private static final String TAG = "DashDownloadTest"; + + private static final Uri MANIFEST_URI = Uri.parse(DashTestData.H264_MANIFEST); + + @Rule public ActivityTestRule testRule = new ActivityTestRule<>(HostActivity.class); + + private DashTestRunner testRunner; + private File tempFolder; + private SimpleCache cache; + private DefaultHttpDataSourceFactory httpDataSourceFactory; + private CacheDataSourceFactory offlineDataSourceFactory; + + @Before + public void setUp() throws Exception { + testRunner = + new DashTestRunner(TAG, testRule.getActivity(), getInstrumentation()) + .setManifestUrl(DashTestData.H264_MANIFEST) + .setFullPlaybackNoSeeking(true) + .setCanIncludeAdditionalVideoFormats(false) + .setAudioVideoFormats( + DashTestData.AAC_AUDIO_REPRESENTATION_ID, DashTestData.H264_CDD_FIXED); + tempFolder = Util.createTempDirectory(testRule.getActivity(), "ExoPlayerTest"); + cache = new SimpleCache(tempFolder, new NoOpCacheEvictor()); + httpDataSourceFactory = new DefaultHttpDataSourceFactory("ExoPlayer", null); + offlineDataSourceFactory = + new CacheDataSourceFactory( + cache, DummyDataSource.FACTORY, CacheDataSource.FLAG_BLOCK_ON_CACHE); + } + + @After + public void tearDown() { + testRunner = null; + Util.recursiveDelete(tempFolder); + cache = null; + } + + // Download tests + + @Test + public void testDownload() throws Exception { + if (Util.SDK_INT < 16) { + return; // Pass. + } + + DashDownloader dashDownloader = downloadContent(); + dashDownloader.download(); + + testRunner + .setStreamName("test_h264_fixed_download") + .setDataSourceFactory(offlineDataSourceFactory) + .run(); + + dashDownloader.remove(); + + assertWithMessage("There should be no cache key left").that(cache.getKeys()).isEmpty(); + assertWithMessage("There should be no content left").that(cache.getCacheSpace()).isEqualTo(0); + } + + private DashDownloader downloadContent() throws Exception { + DashManifest dashManifest = + DashUtil.loadManifest(httpDataSourceFactory.createDataSource(), MANIFEST_URI); + 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 StreamKey(pIndex, aIndex, rIndex)); + } + } + } + } + DownloaderConstructorHelper constructorHelper = + new DownloaderConstructorHelper(cache, httpDataSourceFactory); + return new DashDownloader(MANIFEST_URI, keys, constructorHelper); + } + +} 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 529f57582e..9a54ffd07c 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 @@ -15,8 +15,13 @@ */ package com.google.android.exoplayer2.playbacktests.gts; -import android.test.ActivityInstrumentationTestCase2; +import static androidx.test.InstrumentationRegistry.getInstrumentation; +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.rule.ActivityTestRule; +import androidx.test.runner.AndroidJUnit4; 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; @@ -24,21 +29,27 @@ import com.google.android.exoplayer2.testutil.ActionSchedule; import com.google.android.exoplayer2.testutil.HostActivity; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; -/** - * Tests DASH playbacks using {@link ExoPlayer}. - */ -public final class DashStreamingTest extends ActivityInstrumentationTestCase2 { +/** Tests DASH playbacks using {@link ExoPlayer}. */ +@RunWith(AndroidJUnit4.class) +public final class DashStreamingTest { private static final String TAG = "DashStreamingTest"; private static final ActionSchedule SEEKING_SCHEDULE = new ActionSchedule.Builder(TAG) - .delay(10000).seek(15000) - .delay(10000).seek(30000).seek(31000).seek(32000).seek(33000).seek(34000) + .waitForPlaybackState(Player.STATE_READY) + .delay(10000).seekAndWait(15000) + .delay(10000).seek(30000).seek(31000).seek(32000).seek(33000).seekAndWait(34000) .delay(1000).pause().delay(1000).play() - .delay(1000).pause().seek(120000).delay(1000).play() + .delay(1000).pause().seekAndWait(120000).delay(1000).play() .build(); private static final ActionSchedule RENDERER_DISABLING_SCHEDULE = new ActionSchedule.Builder(TAG) + .waitForPlaybackState(Player.STATE_READY) // Wait 10 seconds, disable the video renderer, wait another 10 seconds and enable it again. .delay(10000).disableRenderer(DashTestRunner.VIDEO_RENDERER_INDEX) .delay(10000).enableRenderer(DashTestRunner.VIDEO_RENDERER_INDEX) @@ -73,27 +84,24 @@ public final class DashStreamingTest extends ActivityInstrumentationTestCase2 testRule = new ActivityTestRule<>(HostActivity.class); + private DashTestRunner testRunner; - public DashStreamingTest() { - super(HostActivity.class); + @Before + public void setUp() { + testRunner = new DashTestRunner(TAG, testRule.getActivity(), getInstrumentation()); } - @Override - protected void setUp() throws Exception { - super.setUp(); - testRunner = new DashTestRunner(TAG, getActivity(), getInstrumentation()); - } - - @Override - protected void tearDown() throws Exception { + @After + public void tearDown() { testRunner = null; - super.tearDown(); } // H264 CDD. + @Test public void testH264Fixed() { if (Util.SDK_INT < 16) { // Pass. @@ -108,6 +116,7 @@ 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) { @@ -263,8 +263,7 @@ public final class DashTestRunner { } @Override - protected MappingTrackSelector buildTrackSelector(HostActivity host, - BandwidthMeter bandwidthMeter) { + protected DefaultTrackSelector buildTrackSelector(HostActivity host) { return trackSelector; } @@ -278,7 +277,7 @@ public final class DashTestRunner { MediaDrmCallback drmCallback = new HttpMediaDrmCallback(widevineLicenseUrl, new DefaultHttpDataSourceFactory(userAgent)); DefaultDrmSessionManager drmSessionManager = - DefaultDrmSessionManager.newWidevineInstance(drmCallback, null, null, null); + DefaultDrmSessionManager.newWidevineInstance(drmCallback, null); if (!useL1Widevine) { drmSessionManager.setPropertyString( SECURITY_LEVEL_PROPERTY, WIDEVINE_SECURITY_LEVEL_3); @@ -294,47 +293,48 @@ public final class DashTestRunner { } @Override - protected SimpleExoPlayer buildExoPlayer(HostActivity host, Surface surface, + protected SimpleExoPlayer buildExoPlayer( + HostActivity host, + Surface surface, MappingTrackSelector trackSelector, DrmSessionManager drmSessionManager) { - SimpleExoPlayer player = ExoPlayerFactory.newSimpleInstance( - new DebugRenderersFactory(host, drmSessionManager), trackSelector); + SimpleExoPlayer player = + ExoPlayerFactory.newSimpleInstance( + host, + new DebugRenderersFactory(host), + trackSelector, + new DefaultLoadControl(), + drmSessionManager); player.setVideoSurface(surface); return player; } @Override - protected MediaSource buildSource(HostActivity host, String userAgent, - TransferListener mediaTransferListener) { - DataSource.Factory manifestDataSourceFactory = dataSourceFactory != null - ? dataSourceFactory : new DefaultDataSourceFactory(host, userAgent); - DataSource.Factory mediaDataSourceFactory = dataSourceFactory != null - ? dataSourceFactory - : new DefaultDataSourceFactory(host, userAgent, mediaTransferListener); + protected MediaSource buildSource(HostActivity host, String userAgent) { + DataSource.Factory dataSourceFactory = + this.dataSourceFactory != null + ? this.dataSourceFactory + : new DefaultDataSourceFactory(host, userAgent); Uri manifestUri = Uri.parse(manifestUrl); - DefaultDashChunkSource.Factory chunkSourceFactory = new DefaultDashChunkSource.Factory( - mediaDataSourceFactory); - return new DashMediaSource(manifestUri, manifestDataSourceFactory, chunkSourceFactory, - MIN_LOADABLE_RETRY_COUNT, 0 /* livePresentationDelayMs */, null, null); + return new DashMediaSource.Factory(dataSourceFactory) + .setLoadErrorHandlingPolicy(new DefaultLoadErrorHandlingPolicy(MIN_LOADABLE_RETRY_COUNT)) + .createMediaSource(manifestUri); } @Override - protected void logMetrics(DecoderCounters audioCounters, DecoderCounters videoCounters) { + protected void onTestFinished(DecoderCounters audioCounters, DecoderCounters videoCounters) { 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, videoCounters.renderedOutputBufferCount); metricsLogger.close(); - } - @Override - protected void assertPassed(DecoderCounters audioCounters, DecoderCounters videoCounters) { if (fullPlaybackNoSeeking) { // We shouldn't have skipped any output buffers. DecoderCountersUtil @@ -343,22 +343,22 @@ public final class DashTestRunner { .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) { + } catch (AssertionError e) { if (trackSelector.includedAdditionalVideoFormats) { // Retry limiting to CDD mandated formats (b/28220076). Log.e(tag, "Too many dropped or consecutive dropped frames.", e); @@ -368,10 +368,9 @@ public final class DashTestRunner { } } } - } - private static final class DashTestTrackSelector extends MappingTrackSelector { + private static final class DashTestTrackSelector extends DefaultTrackSelector { private final String tag; private final String audioFormatId; @@ -382,6 +381,7 @@ public final class DashTestRunner { private DashTestTrackSelector(String tag, String audioFormatId, String[] videoFormatIds, boolean canIncludeAdditionalVideoFormats) { + super(new RandomTrackSelection.Factory(/* seed= */ 0)); this.tag = tag; this.audioFormatId = audioFormatId; this.videoFormatIds = videoFormatIds; @@ -389,32 +389,43 @@ public final class DashTestRunner { } @Override - protected TrackSelection[] selectTracks(RendererCapabilities[] rendererCapabilities, - TrackGroupArray[] rendererTrackGroupArrays, int[][][] rendererFormatSupports) + protected TrackSelection.Definition[] selectAllTracks( + MappedTrackInfo mappedTrackInfo, + int[][][] rendererFormatSupports, + int[] rendererMixedMimeTypeAdaptationSupports, + Parameters parameters) throws ExoPlaybackException { - Assertions.checkState(rendererCapabilities[VIDEO_RENDERER_INDEX].getTrackType() - == C.TRACK_TYPE_VIDEO); - Assertions.checkState(rendererCapabilities[AUDIO_RENDERER_INDEX].getTrackType() - == C.TRACK_TYPE_AUDIO); - Assertions.checkState(rendererTrackGroupArrays[VIDEO_RENDERER_INDEX].length == 1); - Assertions.checkState(rendererTrackGroupArrays[AUDIO_RENDERER_INDEX].length == 1); - TrackSelection[] selections = new TrackSelection[rendererCapabilities.length]; - selections[VIDEO_RENDERER_INDEX] = new RandomTrackSelection( - rendererTrackGroupArrays[VIDEO_RENDERER_INDEX].get(0), - getVideoTrackIndices(rendererTrackGroupArrays[VIDEO_RENDERER_INDEX].get(0), - rendererFormatSupports[VIDEO_RENDERER_INDEX][0], videoFormatIds, - canIncludeAdditionalVideoFormats), - 0 /* seed */); - selections[AUDIO_RENDERER_INDEX] = new FixedTrackSelection( - rendererTrackGroupArrays[AUDIO_RENDERER_INDEX].get(0), - getTrackIndex(rendererTrackGroupArrays[AUDIO_RENDERER_INDEX].get(0), audioFormatId)); + Assertions.checkState( + mappedTrackInfo.getRendererType(VIDEO_RENDERER_INDEX) == C.TRACK_TYPE_VIDEO); + Assertions.checkState( + mappedTrackInfo.getRendererType(AUDIO_RENDERER_INDEX) == C.TRACK_TYPE_AUDIO); + TrackGroupArray videoTrackGroups = mappedTrackInfo.getTrackGroups(VIDEO_RENDERER_INDEX); + TrackGroupArray audioTrackGroups = mappedTrackInfo.getTrackGroups(AUDIO_RENDERER_INDEX); + Assertions.checkState(videoTrackGroups.length == 1); + Assertions.checkState(audioTrackGroups.length == 1); + TrackSelection.Definition[] definitions = + new TrackSelection.Definition[mappedTrackInfo.getRendererCount()]; + definitions[VIDEO_RENDERER_INDEX] = + new TrackSelection.Definition( + videoTrackGroups.get(0), + getVideoTrackIndices( + videoTrackGroups.get(0), + rendererFormatSupports[VIDEO_RENDERER_INDEX][0], + videoFormatIds, + canIncludeAdditionalVideoFormats)); + definitions[AUDIO_RENDERER_INDEX] = + new TrackSelection.Definition( + audioTrackGroups.get(0), getTrackIndex(audioTrackGroups.get(0), audioFormatId)); includedAdditionalVideoFormats = - selections[VIDEO_RENDERER_INDEX].length() > videoFormatIds.length; - return selections; + definitions[VIDEO_RENDERER_INDEX].tracks.length > videoFormatIds.length; + return definitions; } - private int[] getVideoTrackIndices(TrackGroup trackGroup, int[] formatSupport, - String[] formatIds, boolean canIncludeAdditionalFormats) { + private int[] getVideoTrackIndices( + TrackGroup trackGroup, + int[] formatSupports, + String[] formatIds, + boolean canIncludeAdditionalFormats) { List trackIndices = new ArrayList<>(); // Always select explicitly listed representations. @@ -428,7 +439,7 @@ public final class DashTestRunner { // Select additional video representations, if supported by the device. if (canIncludeAdditionalFormats) { for (int i = 0; i < trackGroup.length; i++) { - if (!trackIndices.contains(i) && isFormatHandled(formatSupport[i])) { + if (!trackIndices.contains(i) && isFormatHandled(formatSupports[i])) { Log.d(tag, "Adding extra video format: " + Format.toLogString(trackGroup.getFormat(i))); trackIndices.add(i); @@ -457,4 +468,21 @@ public final class DashTestRunner { } + /** + * 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 c2b102d1ec..7beaafd143 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 @@ -15,9 +15,17 @@ */ package com.google.android.exoplayer2.playbacktests.gts; +import static androidx.test.InstrumentationRegistry.getInstrumentation; +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; +import static org.junit.Assert.fail; + import android.media.MediaDrm.MediaDrmStateException; -import android.test.ActivityInstrumentationTestCase2; +import android.net.Uri; import android.util.Pair; +import androidx.test.rule.ActivityTestRule; +import androidx.test.runner.AndroidJUnit4; +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; @@ -31,12 +39,15 @@ import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.io.IOException; -import junit.framework.Assert; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; -/** - * Tests Widevine encrypted DASH playbacks using offline keys. - */ -public final class DashWidevineOfflineTest extends ActivityInstrumentationTestCase2 { +/** Tests Widevine encrypted DASH playbacks using offline keys. */ +@RunWith(AndroidJUnit4.class) +public final class DashWidevineOfflineTest { private static final String TAG = "DashWidevineOfflineTest"; private static final String USER_AGENT = "ExoPlayerPlaybackTests"; @@ -46,31 +57,32 @@ public final class DashWidevineOfflineTest extends ActivityInstrumentationTestCa private OfflineLicenseHelper offlineLicenseHelper; private byte[] offlineLicenseKeySetId; - public DashWidevineOfflineTest() { - super(HostActivity.class); - } + @Rule public ActivityTestRule testRule = new ActivityTestRule<>(HostActivity.class); - @Override - protected void setUp() throws Exception { - super.setUp(); - testRunner = new DashTestRunner(TAG, getActivity(), getInstrumentation()) - .setStreamName("test_widevine_h264_fixed_offline") - .setManifestUrl(DashTestData.WIDEVINE_H264_MANIFEST) - .setWidevineInfo(MimeTypes.VIDEO_H264, true) - .setFullPlaybackNoSeeking(true) - .setCanIncludeAdditionalVideoFormats(false) - .setAudioVideoFormats(DashTestData.WIDEVINE_AAC_AUDIO_REPRESENTATION_ID, - DashTestData.WIDEVINE_H264_CDD_FIXED); + @Before + public void setUp() throws Exception { + testRunner = + new DashTestRunner(TAG, testRule.getActivity(), getInstrumentation()) + .setStreamName("test_widevine_h264_fixed_offline") + .setManifestUrl(DashTestData.WIDEVINE_H264_MANIFEST) + .setWidevineInfo(MimeTypes.VIDEO_H264, true) + .setFullPlaybackNoSeeking(true) + .setCanIncludeAdditionalVideoFormats(false) + .setAudioVideoFormats( + DashTestData.WIDEVINE_AAC_AUDIO_REPRESENTATION_ID, + DashTestData.WIDEVINE_H264_CDD_FIXED); 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 - protected void tearDown() throws Exception { + @After + public void tearDown() throws Exception { testRunner = null; if (offlineLicenseKeySetId != null) { releaseLicense(); @@ -80,12 +92,12 @@ public final class DashWidevineOfflineTest extends ActivityInstrumentationTestCa } offlineLicenseHelper = null; httpDataSourceFactory = null; - super.tearDown(); } // Offline license tests - public void testWidevineOfflineLicense() throws Exception { + @Test + public void testWidevineOfflineLicenseV22() throws Exception { if (Util.SDK_INT < 22) { return; // Pass. } @@ -94,10 +106,11 @@ public final class DashWidevineOfflineTest extends ActivityInstrumentationTestCa // Renew license after playback should still work offlineLicenseKeySetId = offlineLicenseHelper.renewLicense(offlineLicenseKeySetId); - Assert.assertNotNull(offlineLicenseKeySetId); + assertThat(offlineLicenseKeySetId).isNotNull(); } - public void testWidevineOfflineReleasedLicense() throws Throwable { + @Test + public void testWidevineOfflineReleasedLicenseV22() throws Throwable { if (Util.SDK_INT < 22) { return; // Pass. } @@ -123,7 +136,8 @@ public final class DashWidevineOfflineTest extends ActivityInstrumentationTestCa } } - public void testWidevineOfflineExpiredLicense() throws Exception { + @Test + public void testWidevineOfflineExpiredLicenseV22() throws Exception { if (Util.SDK_INT < 22) { return; // Pass. } @@ -132,8 +146,10 @@ public final class DashWidevineOfflineTest extends ActivityInstrumentationTestCa // Wait until the license expires long licenseDuration = offlineLicenseHelper.getLicenseDurationRemainingSec(offlineLicenseKeySetId).first; - assertTrue("License duration should be less than 30 sec. " - + "Server settings might have changed.", licenseDuration < 30); + assertWithMessage( + "License duration should be less than 30 sec. " + "Server settings might have changed.") + .that(licenseDuration < 30) + .isTrue(); while (licenseDuration > 0) { synchronized (this) { wait(licenseDuration * 1000 + 2000); @@ -141,14 +157,17 @@ public final class DashWidevineOfflineTest extends ActivityInstrumentationTestCa long previousDuration = licenseDuration; licenseDuration = offlineLicenseHelper.getLicenseDurationRemainingSec(offlineLicenseKeySetId).first; - assertTrue("License duration should be decreasing.", previousDuration > licenseDuration); + assertWithMessage("License duration should be decreasing.") + .that(previousDuration > licenseDuration) + .isTrue(); } // DefaultDrmSessionManager should renew the license and stream play fine testRunner.run(); } - public void testWidevineOfflineLicenseExpiresOnPause() throws Exception { + @Test + public void testWidevineOfflineLicenseExpiresOnPauseV22() throws Exception { if (Util.SDK_INT < 22) { return; // Pass. } @@ -158,9 +177,12 @@ public final class DashWidevineOfflineTest extends ActivityInstrumentationTestCa Pair licenseDurationRemainingSec = offlineLicenseHelper.getLicenseDurationRemainingSec(offlineLicenseKeySetId); long licenseDuration = licenseDurationRemainingSec.first; - assertTrue("License duration should be less than 30 sec. " - + "Server settings might have changed.", licenseDuration < 30); + assertWithMessage( + "License duration should be less than 30 sec. " + "Server settings might have changed.") + .that(licenseDuration < 30) + .isTrue(); 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,11 +192,11 @@ public final class DashWidevineOfflineTest extends ActivityInstrumentationTestCa 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); - Assert.assertTrue(offlineLicenseKeySetId.length > 0); + assertThat(offlineLicenseKeySetId).isNotNull(); + assertThat(offlineLicenseKeySetId.length).isGreaterThan(0); testRunner.setOfflineLicenseKeySetId(offlineLicenseKeySetId); } diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/EnumerateDecodersTest.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/EnumerateDecodersTest.java new file mode 100644 index 0000000000..b9c513fe72 --- /dev/null +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/EnumerateDecodersTest.java @@ -0,0 +1,201 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.playbacktests.gts; + +import static androidx.test.InstrumentationRegistry.getInstrumentation; + +import android.media.MediaCodecInfo.AudioCapabilities; +import android.media.MediaCodecInfo.CodecCapabilities; +import android.media.MediaCodecInfo.CodecProfileLevel; +import android.media.MediaCodecInfo.VideoCapabilities; +import androidx.test.runner.AndroidJUnit4; +import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; +import com.google.android.exoplayer2.mediacodec.MediaCodecUtil; +import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException; +import com.google.android.exoplayer2.testutil.MetricsLogger; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.Util; +import java.util.Arrays; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests enumeration of decoders using {@link MediaCodecUtil}. */ +@RunWith(AndroidJUnit4.class) +public class EnumerateDecodersTest { + + private static final String TAG = "EnumerateDecodersTest"; + + private static final String REPORT_NAME = "GtsExoPlayerTestCases"; + private static final String REPORT_OBJECT_NAME = "enumeratedecoderstest"; + + private MetricsLogger metricsLogger; + + @Before + public void setUp() { + metricsLogger = + MetricsLogger.Factory.createDefault( + getInstrumentation(), TAG, REPORT_NAME, REPORT_OBJECT_NAME); + } + + @Test + public void testEnumerateDecoders() throws Exception { + enumerateDecoders(MimeTypes.VIDEO_H263); + enumerateDecoders(MimeTypes.VIDEO_H264); + enumerateDecoders(MimeTypes.VIDEO_H265); + enumerateDecoders(MimeTypes.VIDEO_VP8); + enumerateDecoders(MimeTypes.VIDEO_VP9); + enumerateDecoders(MimeTypes.VIDEO_MP4V); + enumerateDecoders(MimeTypes.VIDEO_MPEG); + enumerateDecoders(MimeTypes.VIDEO_MPEG2); + enumerateDecoders(MimeTypes.VIDEO_VC1); + enumerateDecoders(MimeTypes.AUDIO_AAC); + enumerateDecoders(MimeTypes.AUDIO_MPEG_L1); + enumerateDecoders(MimeTypes.AUDIO_MPEG_L2); + enumerateDecoders(MimeTypes.AUDIO_MPEG); + enumerateDecoders(MimeTypes.AUDIO_RAW); + enumerateDecoders(MimeTypes.AUDIO_ALAW); + enumerateDecoders(MimeTypes.AUDIO_MLAW); + enumerateDecoders(MimeTypes.AUDIO_AC3); + enumerateDecoders(MimeTypes.AUDIO_E_AC3); + enumerateDecoders(MimeTypes.AUDIO_E_AC3_JOC); + enumerateDecoders(MimeTypes.AUDIO_TRUEHD); + enumerateDecoders(MimeTypes.AUDIO_DTS); + enumerateDecoders(MimeTypes.AUDIO_DTS_HD); + enumerateDecoders(MimeTypes.AUDIO_DTS_EXPRESS); + enumerateDecoders(MimeTypes.AUDIO_VORBIS); + enumerateDecoders(MimeTypes.AUDIO_OPUS); + enumerateDecoders(MimeTypes.AUDIO_AMR_NB); + enumerateDecoders(MimeTypes.AUDIO_AMR_WB); + enumerateDecoders(MimeTypes.AUDIO_FLAC); + enumerateDecoders(MimeTypes.AUDIO_ALAC); + enumerateDecoders(MimeTypes.AUDIO_MSGSM); + } + + private void enumerateDecoders(String mimeType) throws DecoderQueryException { + logDecoderInfos(mimeType, /* secure= */ false); + logDecoderInfos(mimeType, /* secure= */ true); + } + + private void logDecoderInfos(String mimeType, boolean secure) throws DecoderQueryException { + List mediaCodecInfos = MediaCodecUtil.getDecoderInfos(mimeType, secure); + for (MediaCodecInfo mediaCodecInfo : mediaCodecInfos) { + CodecCapabilities capabilities = Assertions.checkNotNull(mediaCodecInfo.capabilities); + metricsLogger.logMetric( + "capabilities_" + mediaCodecInfo.name, codecCapabilitiesToString(mimeType, capabilities)); + } + } + + private static String codecCapabilitiesToString( + String requestedMimeType, CodecCapabilities codecCapabilities) { + boolean isVideo = MimeTypes.isVideo(requestedMimeType); + boolean isAudio = MimeTypes.isAudio(requestedMimeType); + StringBuilder result = new StringBuilder(); + result.append("[requestedMimeType=").append(requestedMimeType); + if (Util.SDK_INT >= 21) { + result.append(", mimeType=").append(codecCapabilities.getMimeType()); + } + result.append(", profileLevels="); + appendProfileLevels(codecCapabilities.profileLevels, result); + if (Util.SDK_INT >= 23) { + result + .append(", maxSupportedInstances=") + .append(codecCapabilities.getMaxSupportedInstances()); + } + if (Util.SDK_INT >= 21) { + if (isVideo) { + result.append(", videoCapabilities="); + appendVideoCapabilities(codecCapabilities.getVideoCapabilities(), result); + result.append(", colorFormats=").append(Arrays.toString(codecCapabilities.colorFormats)); + } else if (isAudio) { + result.append(", audioCapabilities="); + appendAudioCapabilities(codecCapabilities.getAudioCapabilities(), result); + } + } + if (Util.SDK_INT >= 19 + && isVideo + && codecCapabilities.isFeatureSupported(CodecCapabilities.FEATURE_AdaptivePlayback)) { + result.append(", FEATURE_AdaptivePlayback"); + } + if (Util.SDK_INT >= 21 + && isVideo + && codecCapabilities.isFeatureSupported(CodecCapabilities.FEATURE_SecurePlayback)) { + result.append(", FEATURE_SecurePlayback"); + } + if (Util.SDK_INT >= 26 + && isVideo + && codecCapabilities.isFeatureSupported(CodecCapabilities.FEATURE_PartialFrame)) { + result.append(", FEATURE_PartialFrame"); + } + if (Util.SDK_INT >= 21 + && (isVideo || isAudio) + && codecCapabilities.isFeatureSupported(CodecCapabilities.FEATURE_TunneledPlayback)) { + result.append(", FEATURE_TunneledPlayback"); + } + result.append(']'); + return result.toString(); + } + + private static void appendAudioCapabilities( + AudioCapabilities audioCapabilities, StringBuilder result) { + result + .append("[bitrateRange=") + .append(audioCapabilities.getBitrateRange()) + .append(", maxInputChannelCount=") + .append(audioCapabilities.getMaxInputChannelCount()) + .append(", supportedSampleRateRanges=") + .append(Arrays.toString(audioCapabilities.getSupportedSampleRateRanges())) + .append(']'); + } + + private static void appendVideoCapabilities( + VideoCapabilities videoCapabilities, StringBuilder result) { + result + .append("[bitrateRange=") + .append(videoCapabilities.getBitrateRange()) + .append(", heightAlignment=") + .append(videoCapabilities.getHeightAlignment()) + .append(", widthAlignment=") + .append(videoCapabilities.getWidthAlignment()) + .append(", supportedWidths=") + .append(videoCapabilities.getSupportedWidths()) + .append(", supportedHeights=") + .append(videoCapabilities.getSupportedHeights()) + .append(", supportedFrameRates=") + .append(videoCapabilities.getSupportedFrameRates()) + .append(']'); + } + + private static void appendProfileLevels(CodecProfileLevel[] profileLevels, StringBuilder result) { + result.append('['); + int count = profileLevels.length; + for (int i = 0; i < count; i++) { + CodecProfileLevel profileLevel = profileLevels[i]; + if (i != 0) { + result.append(", "); + } + result + .append("[profile=") + .append(profileLevel.profile) + .append(", level=") + .append(profileLevel.level) + .append(']'); + } + result.append(']'); + } +} diff --git a/publish.gradle b/publish.gradle index ca1a2cfd8b..85cf87aa85 100644 --- a/publish.gradle +++ b/publish.gradle @@ -16,8 +16,8 @@ if (project.ext.has("exoplayerPublishEnabled") apply plugin: 'bintray-release' publish { artifactId = releaseArtifact - description = releaseDescription - version = releaseVersion + desc = releaseDescription + publishVersion = releaseVersion repoName = getBintrayRepo() userOrg = 'google' groupId = 'com.google.android.exoplayer' diff --git a/settings.gradle b/settings.gradle index fb31055f5e..d4530d67b7 100644 --- a/settings.gradle +++ b/settings.gradle @@ -19,8 +19,12 @@ if (gradle.ext.has('exoplayerModulePrefix')) { } include modulePrefix + 'demo' +include modulePrefix + 'demo-cast' +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-cast').projectDir = new File(rootDir, 'demos/cast') +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/build.gradle b/testutils/build.gradle index db8462b1fd..2ef377ba5d 100644 --- a/testutils/build.gradle +++ b/testutils/build.gradle @@ -18,13 +18,30 @@ android { compileSdkVersion project.ext.compileSdkVersion buildToolsVersion project.ext.buildToolsVersion + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + defaultConfig { minSdkVersion project.ext.minSdkVersion targetSdkVersion project.ext.targetSdkVersion } + + lintOptions { + // Truth depends on JUnit, which depends on java.lang.management, which + // is not part of Android. Remove this when JUnit 4.13 or later is used. + // See: https://github.com/junit-team/junit4/pull/1187. + disable 'InvalidPackage' + } } dependencies { - compile project(modulePrefix + 'library-core') - compile 'org.mockito:mockito-core:' + mockitoVersion + api 'org.mockito:mockito-core:' + mockitoVersion + api 'com.google.truth:truth:' + truthVersion + implementation 'com.android.support:support-annotations:' + supportLibraryVersion + implementation project(modulePrefix + 'library-core') + implementation 'com.google.auto.value:auto-value-annotations:' + autoValueVersion + annotationProcessor 'com.google.auto.value:auto-value:' + autoValueVersion + testImplementation project(modulePrefix + 'testutils-robolectric') } 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 b1c6f081cf..c988c0c172 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,11 +15,27 @@ */ package com.google.android.exoplayer2.testutil; -import android.util.Log; +import android.os.Handler; +import android.support.annotation.Nullable; import android.view.Surface; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.PlayerMessage; +import com.google.android.exoplayer2.PlayerMessage.Target; import com.google.android.exoplayer2.SimpleExoPlayer; -import com.google.android.exoplayer2.trackselection.MappingTrackSelector; +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.testutil.ActionSchedule.PlayerRunnable; +import com.google.android.exoplayer2.testutil.ActionSchedule.PlayerTarget; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.Parameters; +import com.google.android.exoplayer2.util.ConditionVariable; +import com.google.android.exoplayer2.util.HandlerWrapper; +import com.google.android.exoplayer2.util.Log; /** * Base class for actions to perform during playback tests. @@ -27,87 +43,162 @@ import com.google.android.exoplayer2.trackselection.MappingTrackSelector; public abstract class Action { private final String tag; - private final String description; + private final @Nullable String description; /** * @param tag A tag to use for logging. - * @param description A description to be logged when the action is executed. + * @param description A description to be logged when the action is executed, or null if no + * logging is required. */ - public Action(String tag, String description) { + public Action(String tag, @Nullable String description) { this.tag = tag; this.description = 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) { - Log.i(tag, description); - doActionImpl(player, trackSelector, surface); + public final void doActionAndScheduleNext( + SimpleExoPlayer player, + DefaultTrackSelector trackSelector, + Surface surface, + HandlerWrapper handler, + ActionNode nextAction) { + if (description != null) { + Log.i(tag, description); + } + doActionAndScheduleNextImpl(player, trackSelector, surface, handler, nextAction); } /** - * Called by {@link #doAction(SimpleExoPlayer, MappingTrackSelector, Surface)} do perform the - * action. + * Called by {@link #doActionAndScheduleNext(SimpleExoPlayer, DefaultTrackSelector, Surface, + * HandlerWrapper, 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, + DefaultTrackSelector trackSelector, + Surface surface, + HandlerWrapper handler, + ActionNode nextAction) { + doActionImpl(player, trackSelector, surface); + if (nextAction != null) { + nextAction.schedule(player, trackSelector, surface, handler); + } + } + + /** + * Called by {@link #doActionAndScheduleNextImpl(SimpleExoPlayer, DefaultTrackSelector, Surface, + * HandlerWrapper, 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. * @param surface The surface to use when applying actions. */ - protected abstract void doActionImpl(SimpleExoPlayer player, MappingTrackSelector trackSelector, - Surface surface); + protected abstract void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface); /** - * Calls {@link ExoPlayer#seekTo(long)}. + * Calls {@link Player#seekTo(long)} or {@link Player#seekTo(int, long)}. */ public static final class Seek extends Action { + private final Integer windowIndex; private final long positionMs; /** + * Action calls {@link Player#seekTo(long)}. + * * @param tag A tag to use for logging. * @param positionMs The seek position. */ public Seek(String tag, long positionMs) { super(tag, "Seek:" + positionMs); + this.windowIndex = null; + this.positionMs = positionMs; + } + + /** + * Action calls {@link Player#seekTo(int, long)}. + * + * @param tag A tag to use for logging. + * @param windowIndex The window to seek to. + * @param positionMs The seek position. + */ + public Seek(String tag, int windowIndex, long positionMs) { + super(tag, "Seek:" + positionMs); + this.windowIndex = windowIndex; this.positionMs = positionMs; } @Override - protected void doActionImpl(SimpleExoPlayer player, MappingTrackSelector trackSelector, - Surface surface) { - player.seekTo(positionMs); + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { + if (windowIndex == null) { + player.seekTo(positionMs); + } else { + player.seekTo(windowIndex, positionMs); + } } } /** - * Calls {@link ExoPlayer#stop()}. + * Calls {@link Player#stop()} or {@link Player#stop(boolean)}. */ public static final class Stop extends Action { + private static final String STOP_ACTION_TAG = "Stop"; + + private final Boolean reset; + /** + * Action will call {@link Player#stop()}. + * * @param tag A tag to use for logging. */ public Stop(String tag) { - super(tag, "Stop"); + super(tag, STOP_ACTION_TAG); + this.reset = null; + } + + /** + * Action will call {@link Player#stop(boolean)}. + * + * @param tag A tag to use for logging. + * @param reset The value to pass to {@link Player#stop(boolean)}. + */ + public Stop(String tag, boolean reset) { + super(tag, STOP_ACTION_TAG); + this.reset = reset; } @Override - protected void doActionImpl(SimpleExoPlayer player, MappingTrackSelector trackSelector, - Surface surface) { - player.stop(); + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { + if (reset == null) { + player.stop(); + } else { + player.stop(reset); + } + } } /** - * Calls {@link ExoPlayer#setPlayWhenReady(boolean)}. + * Calls {@link Player#setPlayWhenReady(boolean)}. */ public static final class SetPlayWhenReady extends Action { @@ -123,15 +214,16 @@ public abstract class Action { } @Override - protected void doActionImpl(SimpleExoPlayer player, MappingTrackSelector trackSelector, - Surface surface) { + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { player.setPlayWhenReady(playWhenReady); } } /** - * Calls {@link MappingTrackSelector#setRendererDisabled(int, boolean)}. + * Updates the {@link Parameters} of a {@link DefaultTrackSelector} to specify whether the + * renderer at a given index should be disabled. */ public static final class SetRendererDisabled extends Action { @@ -150,9 +242,10 @@ public abstract class Action { } @Override - protected void doActionImpl(SimpleExoPlayer player, MappingTrackSelector trackSelector, - Surface surface) { - trackSelector.setRendererDisabled(rendererIndex, disabled); + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { + trackSelector.setParameters( + trackSelector.buildUponParameters().setRendererDisabled(rendererIndex, disabled)); } } @@ -170,8 +263,8 @@ public abstract class Action { } @Override - protected void doActionImpl(SimpleExoPlayer player, MappingTrackSelector trackSelector, - Surface surface) { + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { player.clearVideoSurface(); } @@ -190,12 +283,522 @@ public abstract class Action { } @Override - protected void doActionImpl(SimpleExoPlayer player, MappingTrackSelector trackSelector, - Surface surface) { + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { player.setVideoSurface(surface); } } + /** + * 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, DefaultTrackSelector 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, DefaultTrackSelector 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, DefaultTrackSelector trackSelector, Surface surface) { + player.setShuffleModeEnabled(shuffleModeEnabled); + } + } + + /** Calls {@link ExoPlayer#createMessage(Target)} and {@link PlayerMessage#send()}. */ + public static final class SendMessages extends Action { + + private final Target target; + private final int windowIndex; + private final long positionMs; + private final boolean deleteAfterDelivery; + + /** + * @param tag A tag to use for logging. + * @param target A message target. + * @param positionMs The position at which the message should be sent, in milliseconds. + */ + public SendMessages(String tag, Target target, long positionMs) { + this( + tag, + target, + /* windowIndex= */ C.INDEX_UNSET, + positionMs, + /* deleteAfterDelivery= */ true); + } + + /** + * @param tag A tag to use for logging. + * @param target A message target. + * @param windowIndex The window index at which the message should be sent, or {@link + * C#INDEX_UNSET} for the current window. + * @param positionMs The position at which the message should be sent, in milliseconds. + * @param deleteAfterDelivery Whether the message will be deleted after delivery. + */ + public SendMessages( + String tag, Target target, int windowIndex, long positionMs, boolean deleteAfterDelivery) { + super(tag, "SendMessages"); + this.target = target; + this.windowIndex = windowIndex; + this.positionMs = positionMs; + this.deleteAfterDelivery = deleteAfterDelivery; + } + + @Override + protected void doActionImpl( + final SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { + if (target instanceof PlayerTarget) { + ((PlayerTarget) target).setPlayer(player); + } + PlayerMessage message = player.createMessage(target); + if (windowIndex != C.INDEX_UNSET) { + message.setPosition(windowIndex, positionMs); + } else { + message.setPosition(positionMs); + } + message.setHandler(new Handler()); + message.setDeleteAfterDelivery(deleteAfterDelivery); + message.send(); + } + } + + /** + * Calls {@link Player#setPlaybackParameters(PlaybackParameters)}. + */ + public static final class SetPlaybackParameters extends Action { + + private final PlaybackParameters playbackParameters; + + /** + * @param tag A tag to use for logging. + * @param playbackParameters The playback parameters. + */ + public SetPlaybackParameters(String tag, PlaybackParameters playbackParameters) { + super(tag, "SetPlaybackParameters:" + playbackParameters); + this.playbackParameters = playbackParameters; + } + + @Override + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { + player.setPlaybackParameters(playbackParameters); + } + + } + + /** Throws a playback exception on the playback thread. */ + public static final class ThrowPlaybackException extends Action { + + private final ExoPlaybackException exception; + + /** + * @param tag A tag to use for logging. + * @param exception The exception to throw. + */ + public ThrowPlaybackException(String tag, ExoPlaybackException exception) { + super(tag, "ThrowPlaybackException:" + exception); + this.exception = exception; + } + + @Override + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { + player + .createMessage( + (messageType, payload) -> { + throw exception; + }) + .send(); + } + } + + /** + * Schedules a play action to be executed, waits until the player reaches the specified position, + * and pauses the player again. + */ + public static final class PlayUntilPosition extends Action { + + private final int windowIndex; + private final long positionMs; + + /** + * @param tag A tag to use for logging. + * @param windowIndex The window index at which the player should be paused again. + * @param positionMs The position in that window at which the player should be paused again. + */ + public PlayUntilPosition(String tag, int windowIndex, long positionMs) { + super(tag, "PlayUntilPosition:" + windowIndex + "," + positionMs); + this.windowIndex = windowIndex; + this.positionMs = positionMs; + } + + @Override + protected void doActionAndScheduleNextImpl( + final SimpleExoPlayer player, + final DefaultTrackSelector trackSelector, + final Surface surface, + final HandlerWrapper handler, + final ActionNode nextAction) { + Handler testThreadHandler = new Handler(); + // Schedule a message on the playback thread to ensure the player is paused immediately. + player + .createMessage( + (messageType, payload) -> { + // Block playback thread until pause command has been sent from test thread. + ConditionVariable blockPlaybackThreadCondition = new ConditionVariable(); + testThreadHandler.post( + () -> { + player.setPlayWhenReady(/* playWhenReady= */ false); + blockPlaybackThreadCondition.open(); + }); + try { + blockPlaybackThreadCondition.block(); + } catch (InterruptedException e) { + // Ignore. + } + }) + .setPosition(windowIndex, positionMs) + .send(); + // Schedule another message on this test thread to continue action schedule. + player + .createMessage( + (messageType, payload) -> + nextAction.schedule(player, trackSelector, surface, handler)) + .setPosition(windowIndex, positionMs) + .setHandler(testThreadHandler) + .send(); + player.setPlayWhenReady(true); + } + + @Override + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { + // Not triggered. + } + } + + /** + * Waits for {@link Player.EventListener#onTimelineChanged(Timeline, Object, int)}. + */ + public static final class WaitForTimelineChanged extends Action { + + private final @Nullable Timeline expectedTimeline; + + /** + * Creates action waiting for a timeline change. + * + * @param tag A tag to use for logging. + * @param expectedTimeline The expected timeline to wait for. If null, wait for any timeline + * change. + */ + public WaitForTimelineChanged(String tag, @Nullable Timeline expectedTimeline) { + super(tag, "WaitForTimelineChanged"); + this.expectedTimeline = expectedTimeline; + } + + @Override + protected void doActionAndScheduleNextImpl( + final SimpleExoPlayer player, + final DefaultTrackSelector trackSelector, + final Surface surface, + final HandlerWrapper handler, + final ActionNode nextAction) { + if (nextAction == null) { + return; + } + Player.EventListener listener = + new Player.EventListener() { + @Override + public void onTimelineChanged( + Timeline timeline, + @Nullable Object manifest, + @Player.TimelineChangeReason int reason) { + if (expectedTimeline == null || timeline.equals(expectedTimeline)) { + player.removeListener(this); + nextAction.schedule(player, trackSelector, surface, handler); + } + } + }; + player.addListener(listener); + if (expectedTimeline != null && player.getCurrentTimeline().equals(expectedTimeline)) { + player.removeListener(listener); + nextAction.schedule(player, trackSelector, surface, handler); + } + } + + @Override + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector 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 DefaultTrackSelector trackSelector, + final Surface surface, + final HandlerWrapper handler, + final ActionNode nextAction) { + if (nextAction == null) { + return; + } + player.addListener( + new Player.EventListener() { + @Override + public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { + player.removeListener(this); + nextAction.schedule(player, trackSelector, surface, handler); + } + }); + } + + @Override + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector 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 DefaultTrackSelector trackSelector, + final Surface surface, + final HandlerWrapper handler, + final ActionNode nextAction) { + if (nextAction == null) { + return; + } + if (targetPlaybackState == player.getPlaybackState()) { + nextAction.schedule(player, trackSelector, surface, handler); + } else { + player.addListener( + new Player.EventListener() { + @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, DefaultTrackSelector trackSelector, Surface surface) { + // Not triggered. + } + } + + /** + * Waits for a specified loading state, returning either immediately or after a call to {@link + * Player.EventListener#onLoadingChanged(boolean)}. + */ + public static final class WaitForIsLoading extends Action { + + private final boolean targetIsLoading; + + /** + * @param tag A tag to use for logging. + * @param targetIsLoading The loading state to wait for. + */ + public WaitForIsLoading(String tag, boolean targetIsLoading) { + super(tag, "WaitForIsLoading"); + this.targetIsLoading = targetIsLoading; + } + + @Override + protected void doActionAndScheduleNextImpl( + final SimpleExoPlayer player, + final DefaultTrackSelector trackSelector, + final Surface surface, + final HandlerWrapper handler, + final ActionNode nextAction) { + if (nextAction == null) { + return; + } + if (targetIsLoading == player.isLoading()) { + nextAction.schedule(player, trackSelector, surface, handler); + } else { + player.addListener( + new Player.EventListener() { + @Override + public void onLoadingChanged(boolean isLoading) { + if (targetIsLoading == isLoading) { + player.removeListener(this); + nextAction.schedule(player, trackSelector, surface, handler); + } + } + }); + } + } + + @Override + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector 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 DefaultTrackSelector trackSelector, + final Surface surface, + final HandlerWrapper handler, + final ActionNode nextAction) { + if (nextAction == null) { + return; + } + player.addListener( + new Player.EventListener() { + @Override + public void onSeekProcessed() { + player.removeListener(this); + nextAction.schedule(player, trackSelector, surface, handler); + } + }); + } + + @Override + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector 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, DefaultTrackSelector trackSelector, Surface surface) { + if (runnable instanceof PlayerRunnable) { + ((PlayerRunnable) runnable).setPlayer(player); + } + 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 66f7ebca95..71f5fdeae1 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 @@ -15,30 +15,68 @@ */ package com.google.android.exoplayer2.testutil; -import android.os.Handler; +import android.os.Looper; +import android.support.annotation.Nullable; import android.view.Surface; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.PlayerMessage; +import com.google.android.exoplayer2.PlayerMessage.Target; 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.PlayUntilPosition; +import com.google.android.exoplayer2.testutil.Action.PrepareSource; import com.google.android.exoplayer2.testutil.Action.Seek; +import com.google.android.exoplayer2.testutil.Action.SendMessages; import com.google.android.exoplayer2.testutil.Action.SetPlayWhenReady; +import com.google.android.exoplayer2.testutil.Action.SetPlaybackParameters; 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.trackselection.MappingTrackSelector; +import com.google.android.exoplayer2.testutil.Action.ThrowPlaybackException; +import com.google.android.exoplayer2.testutil.Action.WaitForIsLoading; +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.DefaultTrackSelector; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.HandlerWrapper; /** * Schedules a sequence of {@link Action}s for execution during a test. */ public final class ActionSchedule { + /** + * Callback to notify listener that the action schedule has finished. + */ + public interface Callback { + + /** + * Called when action schedule finished executing all its actions. + */ + void onActionScheduleFinished(); + + } + private final ActionNode rootNode; + private final CallbackAction callbackAction; /** * @param rootNode The first node in the sequence. + * @param callbackAction The final action which can be used to trigger a callback. */ - private ActionSchedule(ActionNode rootNode) { + private ActionSchedule(ActionNode rootNode, CallbackAction callbackAction) { this.rootNode = rootNode; + this.callbackAction = callbackAction; } /** @@ -48,9 +86,16 @@ public final class ActionSchedule { * @param trackSelector The track selector to which actions should be applied. * @param surface The surface to use when applying actions. * @param mainHandler A handler associated with the main thread of the host activity. + * @param callback A {@link Callback} to notify when the action schedule finishes, or null if no + * notification is needed. */ - /* package */ void start(SimpleExoPlayer player, MappingTrackSelector trackSelector, - Surface surface, Handler mainHandler) { + /* package */ void start( + SimpleExoPlayer player, + DefaultTrackSelector trackSelector, + Surface surface, + HandlerWrapper mainHandler, + @Nullable Callback callback) { + callbackAction.setCallback(callback); rootNode.schedule(player, trackSelector, surface, mainHandler); } @@ -61,8 +106,8 @@ public final class ActionSchedule { private final String tag; private final ActionNode rootNode; - private long currentDelayMs; + private long currentDelayMs; private ActionNode previousNode; /** @@ -116,6 +161,49 @@ public final class ActionSchedule { return apply(new Seek(tag, positionMs)); } + /** + * Schedules a seek action to be executed. + * + * @param windowIndex The window to seek to. + * @param positionMs The seek position. + * @return The builder, for convenience. + */ + public Builder seek(int windowIndex, long positionMs) { + return apply(new Seek(tag, windowIndex, 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 delay until the player indicates that a seek has been processed. + * + * @return The builder, for convenience. + */ + public Builder waitForSeekProcessed() { + return apply(new WaitForSeekProcessed(tag)); + } + + /** + * Schedules a playback parameters setting action to be executed. + * + * @param playbackParameters The playback parameters to set. + * @return The builder, for convenience. + * @see Player#setPlaybackParameters(PlaybackParameters) + */ + public Builder setPlaybackParameters(PlaybackParameters playbackParameters) { + return apply(new SetPlaybackParameters(tag, playbackParameters)); + } + /** * Schedules a stop action to be executed. * @@ -125,6 +213,16 @@ public final class ActionSchedule { return apply(new Stop(tag)); } + /** + * Schedules a stop action to be executed. + * + * @param reset Whether the player should be reset. + * @return The builder, for convenience. + */ + public Builder stop(boolean reset) { + return apply(new Stop(tag, reset)); + } + /** * Schedules a play action to be executed. * @@ -134,6 +232,29 @@ public final class ActionSchedule { return apply(new SetPlayWhenReady(tag, true)); } + /** + * Schedules a play action to be executed, waits until the player reaches the specified + * position, and pauses the player again. + * + * @param windowIndex The window index at which the player should be paused again. + * @param positionMs The position in that window at which the player should be paused again. + * @return The builder, for convenience. + */ + public Builder playUntilPosition(int windowIndex, long positionMs) { + return apply(new PlayUntilPosition(tag, windowIndex, positionMs)); + } + + /** + * Schedules a play action to be executed, waits until the player reaches the start of the + * specified window, and pauses the player again. + * + * @param windowIndex The window index at which the player should be paused again. + * @return The builder, for convenience. + */ + public Builder playUntilStartOfWindow(int windowIndex) { + return apply(new PlayUntilPosition(tag, windowIndex, /* positionMs= */ 0)); + } + /** * Schedules a pause action to be executed. * @@ -179,8 +300,154 @@ public final class ActionSchedule { 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 sending a {@link PlayerMessage}. + * + * @param positionMs The position in the current window at which the message should be sent, in + * milliseconds. + * @return The builder, for convenience. + */ + public Builder sendMessage(Target target, long positionMs) { + return apply(new SendMessages(tag, target, positionMs)); + } + + /** + * Schedules sending a {@link PlayerMessage}. + * + * @param target A message target. + * @param windowIndex The window index at which the message should be sent. + * @param positionMs The position at which the message should be sent, in milliseconds. + * @return The builder, for convenience. + */ + public Builder sendMessage(Target target, int windowIndex, long positionMs) { + return apply( + new SendMessages(tag, target, windowIndex, positionMs, /* deleteAfterDelivery= */ true)); + } + + /** + * Schedules to send a {@link PlayerMessage}. + * + * @param target A message target. + * @param windowIndex The window index at which the message should be sent. + * @param positionMs The position at which the message should be sent, in milliseconds. + * @param deleteAfterDelivery Whether the message will be deleted after delivery. + * @return The builder, for convenience. + */ + public Builder sendMessage( + Target target, int windowIndex, long positionMs, boolean deleteAfterDelivery) { + return apply(new SendMessages(tag, target, windowIndex, positionMs, deleteAfterDelivery)); + } + + /** + * Schedules a delay until any timeline change. + * + * @return The builder, for convenience. + */ + public Builder waitForTimelineChanged() { + return apply(new WaitForTimelineChanged(tag, /* expectedTimeline= */ null)); + } + + /** + * Schedules a delay until the timeline changed to a specified expected timeline. + * + * @param expectedTimeline The expected timeline to wait for. If null, wait for any timeline + * change. + * @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 delay until {@code player.isLoading()} changes to the specified value. + * + * @param targetIsLoading The target value of {@code player.isLoading()}. + * @return The builder, for convenience. + */ + public Builder waitForIsLoading(boolean targetIsLoading) { + return apply(new WaitForIsLoading(tag, targetIsLoading)); + } + + /** + * Schedules a {@link Runnable} to be executed. + * + * @return The builder, for convenience. + */ + public Builder executeRunnable(Runnable runnable) { + return apply(new ExecuteRunnable(tag, runnable)); + } + + /** + * Schedules to throw a playback exception on the playback thread. + * + * @param exception The exception to throw. + * @return The builder, for convenience. + */ + public Builder throwPlaybackException(ExoPlaybackException exception) { + return apply(new ThrowPlaybackException(tag, exception)); + } + public ActionSchedule build() { - return new ActionSchedule(rootNode); + CallbackAction callbackAction = new CallbackAction(tag); + apply(callbackAction); + return new ActionSchedule(rootNode, callbackAction); } private Builder appendActionNode(ActionNode actionNode) { @@ -189,13 +456,58 @@ public final class ActionSchedule { currentDelayMs = 0; return this; } + } + /** + * Provides a wrapper for a {@link Target} which has access to the player when handling messages. + * Can be used with {@link Builder#sendMessage(Target, long)}. + */ + public abstract static class PlayerTarget implements Target { + + private SimpleExoPlayer player; + + /** Handles the message send to the component and additionally provides access to the player. */ + public abstract void handleMessage( + SimpleExoPlayer player, int messageType, @Nullable Object message); + + /** Sets the player to be passed to {@link #handleMessage(SimpleExoPlayer, int, Object)}. */ + /* package */ void setPlayer(SimpleExoPlayer player) { + this.player = player; + } + + @Override + public final void handleMessage(int messageType, @Nullable Object message) + throws ExoPlaybackException { + handleMessage(player, messageType, message); + } + } + + /** + * Provides a wrapper for a {@link Runnable} which has access to the player. Can be used with + * {@link Builder#executeRunnable(Runnable)}. + */ + public abstract static class PlayerRunnable implements Runnable { + + private SimpleExoPlayer player; + + /** Executes Runnable with reference to player. */ + public abstract void run(SimpleExoPlayer player); + + /** Sets the player to be passed to {@link #run(SimpleExoPlayer)} . */ + /* package */ void setPlayer(SimpleExoPlayer player) { + this.player = player; + } + + @Override + public final void run() { + run(player); + } } /** * 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 long delayMs; @@ -204,9 +516,9 @@ public final class ActionSchedule { private ActionNode next; private SimpleExoPlayer player; - private MappingTrackSelector trackSelector; + private DefaultTrackSelector trackSelector; private Surface surface; - private Handler mainHandler; + private HandlerWrapper mainHandler; /** * @param action The wrapped action. @@ -238,31 +550,43 @@ public final class ActionSchedule { } /** - * Schedules {@link #action} to be executed after {@link #delayMs}. The {@link #next} node - * will be scheduled immediately after {@link #action} is executed. + * Schedules {@link #action} to be executed after {@link #delayMs}. The {@link #next} node will + * be scheduled immediately after {@link #action} is executed. * * @param player The player to which actions should be applied. * @param trackSelector The track selector to which actions should be applied. * @param surface The surface to use when applying actions. * @param mainHandler A handler associated with the main thread of the host activity. */ - public void schedule(SimpleExoPlayer player, MappingTrackSelector trackSelector, - Surface surface, Handler mainHandler) { + public void schedule( + SimpleExoPlayer player, + DefaultTrackSelector trackSelector, + Surface surface, + HandlerWrapper mainHandler) { this.player = player; this.trackSelector = trackSelector; this.surface = surface; this.mainHandler = mainHandler; - mainHandler.postDelayed(this, delayMs); + if (delayMs == 0 && Looper.myLooper() == mainHandler.getLooper()) { + run(); + } else { + mainHandler.postDelayed(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); + mainHandler.postDelayed( + new Runnable() { + @Override + public void run() { + action.doActionAndScheduleNext(player, trackSelector, surface, mainHandler, null); + mainHandler.postDelayed(this, repeatIntervalMs); + } + }, + repeatIntervalMs); } } @@ -278,11 +602,45 @@ public final class ActionSchedule { } @Override - protected void doActionImpl(SimpleExoPlayer player, MappingTrackSelector trackSelector, - Surface surface) { + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { // Do nothing. } + } + /** + * An action calling a specified {@link ActionSchedule.Callback}. + */ + private static final class CallbackAction extends Action { + + private @Nullable Callback callback; + + public CallbackAction(String tag) { + super(tag, "FinishedCallback"); + } + + public void setCallback(@Nullable Callback callback) { + this.callback = callback; + } + + @Override + protected void doActionAndScheduleNextImpl( + SimpleExoPlayer player, + DefaultTrackSelector trackSelector, + Surface surface, + HandlerWrapper handler, + ActionNode nextAction) { + Assertions.checkArgument(nextAction == null); + if (callback != null) { + handler.post(() -> callback.onActionScheduleFinished()); + } + } + + @Override + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { + // Not triggered. + } } } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/AutoAdvancingFakeClock.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/AutoAdvancingFakeClock.java new file mode 100644 index 0000000000..1d25429a67 --- /dev/null +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/AutoAdvancingFakeClock.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.testutil; + +import com.google.android.exoplayer2.util.HandlerWrapper; + +/** + * {@link FakeClock} extension which automatically advances time whenever an empty message is + * enqueued at a future time. The clock time is advanced to the time of the message. Only the first + * Handler sending messages at a future time will be allowed to advance time to ensure there is only + * one "time master". This should usually be the Handler of the internal playback loop. + */ +public final class AutoAdvancingFakeClock extends FakeClock { + + private HandlerWrapper autoAdvancingHandler; + + public AutoAdvancingFakeClock() { + super(/* initialTimeMs= */ 0); + } + + @Override + protected synchronized boolean addHandlerMessageAtTime( + HandlerWrapper handler, int message, long timeMs) { + boolean result = super.addHandlerMessageAtTime(handler, message, timeMs); + if (autoAdvancingHandler == null || autoAdvancingHandler == handler) { + autoAdvancingHandler = handler; + long currentTimeMs = elapsedRealtime(); + if (currentTimeMs < timeMs) { + advanceTime(timeMs - currentTimeMs); + } + } + return result; + } +} 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 deleted file mode 100644 index c8ead5dcba..0000000000 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/CacheAsserts.java +++ /dev/null @@ -1,109 +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 static junit.framework.Assert.assertEquals; - -import android.net.Uri; -import android.test.MoreAsserts; -import com.google.android.exoplayer2.testutil.FakeDataSet.FakeData; -import com.google.android.exoplayer2.upstream.DataSourceInputStream; -import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.upstream.DummyDataSource; -import com.google.android.exoplayer2.upstream.cache.Cache; -import com.google.android.exoplayer2.upstream.cache.CacheDataSource; -import com.google.android.exoplayer2.upstream.cache.CacheUtil; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.util.ArrayList; -import junit.framework.Assert; - -/** - * Assertion methods for {@link Cache}. - */ -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()]; - for (int i = 0; i < allData.size(); i++) { - uriStrings[i] = allData.get(i).uri; - } - assertCachedData(cache, fakeDataSet, uriStrings); - } - - /** - * 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, String... uriStrings) - throws IOException { - int totalLength = 0; - for (String uriString : uriStrings) { - byte[] data = fakeDataSet.getData(uriString).getData(); - assertDataCached(cache, uriString, 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) - throws IOException { - for (String uriString : uriStrings) { - assertDataCached(cache, uriString, fakeDataSet.getData(uriString).getData()); - } - } - - /** Asserts that the cache contains the given data for {@code uriString}. */ - public static void assertDataCached(Cache cache, String uriString, 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)); - try { - inputStream.open(); - byte[] buffer = new byte[1024]; - int bytesRead; - while ((bytesRead = inputStream.read(buffer)) != -1) { - outputStream.write(buffer, 0, bytesRead); - } - } catch (IOException e) { - // Ignore - } finally { - inputStream.close(); - } - MoreAsserts.assertEquals("Cached data doesn't match expected for '" + uriString + "',", - expected, outputStream.toByteArray()); - } - - /** Asserts that there is no cache content for the given {@code uriStrings}. */ - public static void assertDataNotCached(Cache cache, String... uriStrings) { - for (String uriString : uriStrings) { - Assert.assertNull("There is cached data for '" + uriString + "',", - cache.getCachedSpans(CacheUtil.generateKey(Uri.parse(uriString)))); - } - } - - /** Asserts that the cache is empty. */ - public static void assertCacheEmpty(Cache cache) { - assertEquals(0, cache.getCacheSpace()); - } - - private CacheAsserts() {} - -} 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 af7c1a3e2a..39194d48fe 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 @@ package com.google.android.exoplayer2.testutil; 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.Renderer; 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; /** @@ -37,9 +42,8 @@ import java.util.ArrayList; @TargetApi(16) public class DebugRenderersFactory extends DefaultRenderersFactory { - public DebugRenderersFactory(Context context, - DrmSessionManager drmSessionManager) { - super(context, drmSessionManager, DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF, 0); + public DebugRenderersFactory(Context context) { + super(context, DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF, 0); } @Override @@ -66,6 +70,7 @@ public class DebugRenderersFactory extends DefaultRenderersFactory { private int queueSize; private int bufferCount; private int minimumInsertIndex; + private boolean skipToPositionBeforeRenderingFirstFrame; public DebugMediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector, long allowedJoiningTimeMs, DrmSessionManager drmSessionManager, @@ -76,15 +81,36 @@ public class DebugRenderersFactory extends DefaultRenderersFactory { } @Override - protected void releaseCodec() { - super.releaseCodec(); - clearTimestamps(); + protected void configureCodec( + MediaCodecInfo codecInfo, + MediaCodec codec, + Format format, + MediaCrypto crypto, + float operatingRate) + 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, operatingRate); } @Override - protected void flushCodec() throws ExoPlaybackException { - super.flushCodec(); + protected void releaseCodec() { + super.releaseCodec(); clearTimestamps(); + skipToPositionBeforeRenderingFirstFrame = false; + } + + @Override + protected boolean flushOrReleaseCodec() { + try { + return super.flushOrReleaseCodec(); + } finally { + clearTimestamps(); + } } @Override @@ -102,6 +128,50 @@ public class DebugRenderersFactory extends DefaultRenderersFactory { maybeShiftTimestampsList(); } + @Override + protected boolean processOutputBuffer( + long positionUs, + long elapsedRealtimeUs, + MediaCodec codec, + ByteBuffer buffer, + int bufferIndex, + int bufferFlags, + long bufferPresentationTimeUs, + boolean shouldSkip, + Format format) + 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, + format); + } + + @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 448ec79c2d..307443718c 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 @@ -15,8 +15,9 @@ */ package com.google.android.exoplayer2.testutil; +import static com.google.common.truth.Truth.assertWithMessage; + import com.google.android.exoplayer2.decoder.DecoderCounters; -import junit.framework.TestCase; /** * Assertions for {@link DecoderCounters}. @@ -31,8 +32,9 @@ public final class 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; } @@ -40,32 +42,60 @@ public final class DecoderCountersUtil { int expected) { counters.ensureUpdated(); int actual = counters.skippedOutputBufferCount; - TestCase.assertEquals("Codec(" + name + ") skipped " + actual + " buffers. Expected " - + expected + ".", expected, actual); + assertWithMessage( + "Codec(" + name + ") skipped " + actual + " buffers. Expected " + expected + ".") + .that(actual) + .isEqualTo(expected); } - public static void assertTotalOutputBufferCount(String name, DecoderCounters counters, - int minCount, int maxCount) { + public static void assertTotalBufferCount(String name, DecoderCounters counters, int minCount, + int maxCount) { + int actual = getTotalBufferCount(counters); + assertWithMessage( + "Codec(" + + name + + ") output " + + actual + + " buffers. Expected in range [" + + minCount + + ", " + + maxCount + + "].") + .that(minCount <= actual && actual <= maxCount) + .isTrue(); + } + + public static void assertDroppedBufferLimit(String name, DecoderCounters counters, int limit) { counters.ensureUpdated(); - int actual = getTotalOutputBuffers(counters); - TestCase.assertTrue("Codec(" + name + ") output " + actual + " buffers. Expected in range [" - + minCount + ", " + maxCount + "].", minCount <= actual && actual <= maxCount); + int actual = counters.droppedBufferCount; + assertWithMessage( + "Codec(" + + name + + ") was late decoding: " + + actual + + " buffers. " + + "Limit: " + + limit + + ".") + .that(actual) + .isAtMost(limit); } - public static void assertDroppedOutputBufferLimit(String name, DecoderCounters counters, + public static void assertConsecutiveDroppedBufferLimit(String name, DecoderCounters counters, int limit) { counters.ensureUpdated(); - int actual = counters.droppedOutputBufferCount; - TestCase.assertTrue("Codec(" + name + ") was late decoding: " + actual + " buffers. " - + "Limit: " + limit + ".", actual <= limit); - } - - public static void assertConsecutiveDroppedOutputBufferLimit(String name, - DecoderCounters counters, int limit) { - counters.ensureUpdated(); - int actual = counters.maxConsecutiveDroppedOutputBufferCount; - TestCase.assertTrue("Codec(" + name + ") was late decoding: " + actual - + " buffers consecutively. " + "Limit: " + limit + ".", actual <= limit); + int actual = counters.maxConsecutiveDroppedBufferCount; + assertWithMessage( + "Codec(" + + name + + ") was late decoding: " + + actual + + " buffers consecutively. " + + "Limit: " + + limit + + ".") + .that(actual) + .isAtMost(limit); } } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/DummyMainThread.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/DummyMainThread.java new file mode 100644 index 0000000000..858d287196 --- /dev/null +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/DummyMainThread.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.testutil; + +import static com.google.common.truth.Truth.assertThat; + +import android.os.ConditionVariable; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; + +/** Helper class to simulate main/UI thread in tests. */ +public final class DummyMainThread { + + /** Default timeout value used for {@link #runOnMainThread(Runnable)}. */ + public static final int TIMEOUT_MS = 10000; + + private final HandlerThread thread; + private final Handler handler; + + public DummyMainThread() { + thread = new HandlerThread("DummyMainThread"); + thread.start(); + handler = new Handler(thread.getLooper()); + } + + /** + * Runs the provided {@link Runnable} on the main thread, blocking until execution completes or + * until {@link #TIMEOUT_MS} milliseconds have passed. + * + * @param runnable The {@link Runnable} to run. + */ + public void runOnMainThread(final Runnable runnable) { + runOnMainThread(TIMEOUT_MS, runnable); + } + + /** + * Runs the provided {@link Runnable} on the main thread, blocking until execution completes or + * until timeout milliseconds have passed. + * + * @param timeoutMs the maximum time to wait in milliseconds. + * @param runnable The {@link Runnable} to run. + */ + public void runOnMainThread(int timeoutMs, final Runnable runnable) { + if (Looper.myLooper() == handler.getLooper()) { + runnable.run(); + } else { + final ConditionVariable finishedCondition = new ConditionVariable(); + handler.post( + () -> { + runnable.run(); + finishedCondition.open(); + }); + assertThat(finishedCondition.block(timeoutMs)).isTrue(); + } + } + + public void release() { + thread.quit(); + } +} 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 610b5d1a84..74c0d4bb43 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,50 +15,45 @@ */ package com.google.android.exoplayer2.testutil; -import android.os.Handler; +import static com.google.common.truth.Truth.assertWithMessage; + +import android.os.ConditionVariable; +import android.os.Looper; import android.os.SystemClock; -import android.util.Log; import android.view.Surface; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.DefaultLoadControl; import com.google.android.exoplayer2.DefaultRenderersFactory; import com.google.android.exoplayer2.ExoPlaybackException; 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.analytics.AnalyticsListener; +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; -import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.Clock; +import com.google.android.exoplayer2.util.EventLogger; +import com.google.android.exoplayer2.util.HandlerWrapper; +import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; -import com.google.android.exoplayer2.video.VideoRendererEventListener; -import junit.framework.Assert; -/** - * A {@link HostedTest} for {@link ExoPlayer} playback tests. - */ -public abstract class ExoHostedTest implements HostedTest, ExoPlayer.EventListener, - AudioRendererEventListener, VideoRendererEventListener { +/** A {@link HostedTest} for {@link ExoPlayer} playback tests. */ +public abstract class ExoHostedTest implements AnalyticsListener, HostedTest { 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; @@ -71,16 +66,16 @@ public abstract class ExoHostedTest implements HostedTest, ExoPlayer.EventListen private final long expectedPlayingTimeMs; private final DecoderCounters videoDecoderCounters; private final DecoderCounters audioDecoderCounters; + private final ConditionVariable testFinished; private ActionSchedule pendingSchedule; - private Handler actionHandler; - private MappingTrackSelector trackSelector; + private HandlerWrapper actionHandler; + private DefaultTrackSelector trackSelector; private SimpleExoPlayer player; private Surface surface; private ExoPlaybackException playerError; - private ExoPlayer.EventListener playerEventListener; private boolean playerWasPrepared; - private boolean playerFinished; + private boolean playing; private long totalPlayingTimeMs; private long lastPlayingStartTimeMs; @@ -113,8 +108,9 @@ public abstract class ExoHostedTest implements HostedTest, ExoPlayer.EventListen 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(); } /** @@ -126,17 +122,7 @@ public abstract class ExoHostedTest implements HostedTest, ExoPlayer.EventListen if (player == null) { pendingSchedule = schedule; } else { - schedule.start(player, trackSelector, surface, actionHandler); - } - } - - /** - * Sets an {@link ExoPlayer.EventListener} to listen for ExoPlayer events during the test. - */ - public final void setEventListener(ExoPlayer.EventListener eventListener) { - this.playerEventListener = eventListener; - if (player != null) { - player.addListener(eventListener); + schedule.start(player, trackSelector, surface, actionHandler, /* callback= */ null); } } @@ -146,81 +132,65 @@ public abstract class ExoHostedTest implements HostedTest, ExoPlayer.EventListen public final void onStart(HostActivity host, Surface surface) { this.surface = surface; // Build the player. - DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter(); - trackSelector = buildTrackSelector(host, bandwidthMeter); + trackSelector = buildTrackSelector(host); String userAgent = "ExoPlayerPlaybackTests"; DrmSessionManager drmSessionManager = buildDrmSessionManager(userAgent); player = buildExoPlayer(host, surface, trackSelector, drmSessionManager); - player.prepare(buildSource(host, Util.getUserAgent(host, userAgent), bandwidthMeter)); - if (playerEventListener != null) { - player.addListener(playerEventListener); - } - player.addListener(this); - player.setAudioDebugListener(this); - player.setVideoDebugListener(this); player.setPlayWhenReady(true); - actionHandler = new Handler(); + player.addAnalyticsListener(this); + player.addAnalyticsListener(new EventLogger(trackSelector, tag)); // Schedule any pending actions. + actionHandler = Clock.DEFAULT.createHandler(Looper.myLooper(), /* callback= */ null); if (pendingSchedule != null) { - pendingSchedule.start(player, trackSelector, surface, actionHandler); + pendingSchedule.start(player, trackSelector, surface, actionHandler, /* callback= */ null); pendingSchedule = null; } + player.prepare(buildSource(host, Util.getUserAgent(host, userAgent))); } @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 public final void onFinished() { + onTestFinished(audioDecoderCounters, videoDecoderCounters); if (failOnPlayerError && playerError != null) { throw new Error(playerError); } - logMetrics(audioDecoderCounters, videoDecoderCounters); if (expectedPlayingTimeMs != EXPECTED_PLAYING_TIME_UNSET) { long playingTimeToAssertMs = expectedPlayingTimeMs == EXPECTED_PLAYING_TIME_MEDIA_DURATION_MS ? sourceDurationMs : expectedPlayingTimeMs; // Assert that the playback spanned the correct duration of time. long minAllowedActualPlayingTimeMs = playingTimeToAssertMs - MAX_PLAYING_TIME_DISCREPANCY_MS; long maxAllowedActualPlayingTimeMs = playingTimeToAssertMs + MAX_PLAYING_TIME_DISCREPANCY_MS; - Assert.assertTrue("Total playing time: " + totalPlayingTimeMs + ". Expected: " - + playingTimeToAssertMs, minAllowedActualPlayingTimeMs <= totalPlayingTimeMs - && totalPlayingTimeMs <= maxAllowedActualPlayingTimeMs); + assertWithMessage( + "Total playing time: " + totalPlayingTimeMs + ". Expected: " + playingTimeToAssertMs) + .that( + minAllowedActualPlayingTimeMs <= totalPlayingTimeMs + && totalPlayingTimeMs <= maxAllowedActualPlayingTimeMs) + .isTrue(); } - // Make any additional assertions. - assertPassed(audioDecoderCounters, videoDecoderCounters); } - // ExoPlayer.EventListener + // AnalyticsListener @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) { + public final void onPlayerStateChanged( + EventTime eventTime, boolean playWhenReady, int playbackState) { Log.d(tag, "state [" + playWhenReady + ", " + playbackState + "]"); - playerWasPrepared |= playbackState != ExoPlayer.STATE_IDLE; - if (playbackState == ExoPlayer.STATE_ENDED - || (playbackState == ExoPlayer.STATE_IDLE && playerWasPrepared)) { - playerFinished = true; + playerWasPrepared |= playbackState != Player.STATE_IDLE; + if (playbackState == Player.STATE_ENDED + || (playbackState == Player.STATE_IDLE && playerWasPrepared)) { + stopTest(); } - boolean playing = playWhenReady && playbackState == ExoPlayer.STATE_READY; + boolean playing = playWhenReady && playbackState == Player.STATE_READY; if (!this.playing && playing) { lastPlayingStartTimeMs = SystemClock.elapsedRealtime(); } else if (this.playing && !playing) { @@ -230,146 +200,71 @@ public abstract class ExoHostedTest implements HostedTest, ExoPlayer.EventListen } @Override - public void onRepeatModeChanged(int repeatMode) { - // Do nothing. - } - - @Override - public final void onPlayerError(ExoPlaybackException error) { + public final void onPlayerError(EventTime eventTime, ExoPlaybackException error) { playerWasPrepared = true; playerError = 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 - public void onAudioEnabled(DecoderCounters counters) { - Log.d(tag, "audioEnabled"); - } - - @Override - public void onAudioSessionId(int audioSessionId) { - Log.d(tag, "audioSessionId [" + audioSessionId + "]"); - } - - @Override - public void onAudioDecoderInitialized(String decoderName, long elapsedRealtimeMs, - long initializationDurationMs) { - Log.d(tag, "audioDecoderInitialized [" + decoderName + "]"); - } - - @Override - public void onAudioInputFormatChanged(Format format) { - Log.d(tag, "audioFormatChanged [" + Format.toLogString(format) + "]"); - } - - @Override - public void onAudioDisabled(DecoderCounters counters) { - Log.d(tag, "audioDisabled"); - audioDecoderCounters.merge(counters); - } - - @Override - public void onAudioTrackUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { - Log.e(tag, "audioTrackUnderrun [" + bufferSize + ", " + bufferSizeMs + ", " - + elapsedSinceLastFeedMs + "]", null); - } - - // VideoRendererEventListener - - @Override - public void onVideoEnabled(DecoderCounters counters) { - Log.d(tag, "videoEnabled"); - } - - @Override - public void onVideoDecoderInitialized(String decoderName, long elapsedRealtimeMs, - long initializationDurationMs) { - Log.d(tag, "videoDecoderInitialized [" + decoderName + "]"); - } - - @Override - public void onVideoInputFormatChanged(Format format) { - Log.d(tag, "videoFormatChanged [" + Format.toLogString(format) + "]"); - } - - @Override - public void onVideoDisabled(DecoderCounters counters) { - Log.d(tag, "videoDisabled"); - videoDecoderCounters.merge(counters); - } - - @Override - public void onDroppedFrames(int count, long elapsed) { - Log.d(tag, "droppedFrames [" + count + "]"); - } - - @Override - public void onVideoSizeChanged(int width, int height, int unappliedRotationDegrees, - float pixelWidthHeightRatio) { - // Do nothing. - } - - @Override - public void onRenderedFirstFrame(Surface surface) { - // Do nothing. + public void onDecoderDisabled( + EventTime eventTime, int trackType, DecoderCounters decoderCounters) { + if (trackType == C.TRACK_TYPE_AUDIO) { + audioDecoderCounters.merge(decoderCounters); + } else if (trackType == C.TRACK_TYPE_VIDEO) { + videoDecoderCounters.merge(decoderCounters); + } } // 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(testFinished::open); + return true; + } + protected DrmSessionManager buildDrmSessionManager(String userAgent) { // Do nothing. Interested subclasses may override. return null; } - @SuppressWarnings("unused") - protected MappingTrackSelector buildTrackSelector(HostActivity host, - BandwidthMeter bandwidthMeter) { - return new DefaultTrackSelector(new AdaptiveTrackSelection.Factory(bandwidthMeter)); + protected DefaultTrackSelector buildTrackSelector(HostActivity host) { + return new DefaultTrackSelector(new AdaptiveTrackSelection.Factory()); } - @SuppressWarnings("unused") - protected SimpleExoPlayer buildExoPlayer(HostActivity host, Surface surface, + protected SimpleExoPlayer buildExoPlayer( + HostActivity host, + Surface surface, MappingTrackSelector trackSelector, DrmSessionManager drmSessionManager) { - RenderersFactory renderersFactory = new DefaultRenderersFactory(host, drmSessionManager, - DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF, 0); - SimpleExoPlayer player = ExoPlayerFactory.newSimpleInstance(renderersFactory, trackSelector); + RenderersFactory renderersFactory = + new DefaultRenderersFactory( + host, + DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF, + /* allowedVideoJoiningTimeMs= */ 0); + SimpleExoPlayer player = + ExoPlayerFactory.newSimpleInstance( + host, renderersFactory, trackSelector, new DefaultLoadControl(), drmSessionManager); player.setVideoSurface(surface); return player; } - @SuppressWarnings("unused") - protected abstract MediaSource buildSource(HostActivity host, String userAgent, - TransferListener mediaTransferListener); + protected abstract MediaSource buildSource(HostActivity host, String userAgent); - @SuppressWarnings("unused") protected void onPlayerErrorInternal(ExoPlaybackException error) { // Do nothing. Interested subclasses may override. } - protected void logMetrics(DecoderCounters audioCounters, DecoderCounters videoCounters) { - // Do nothing. Subclasses may override to log metrics. + protected void onTestFinished(DecoderCounters audioCounters, DecoderCounters videoCounters) { + // Do nothing. Subclasses may override to add clean-up and assertions. } - - protected void assertPassed(DecoderCounters audioCounters, DecoderCounters videoCounters) { - // Do nothing. Subclasses may override to add additional assertions. - } - } 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 0000000000..401a2fae1b --- /dev/null +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoPlayerTestRunner.java @@ -0,0 +1,646 @@ +/* + * 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 static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import android.os.HandlerThread; +import android.os.Looper; +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.DefaultLoadControl; +import com.google.android.exoplayer2.ExoPlaybackException; +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.analytics.AnalyticsCollector; +import com.google.android.exoplayer2.analytics.AnalyticsListener; +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 com.google.android.exoplayer2.trackselection.TrackSelector; +import com.google.android.exoplayer2.upstream.BandwidthMeter; +import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; +import com.google.android.exoplayer2.util.Clock; +import com.google.android.exoplayer2.util.HandlerWrapper; +import com.google.android.exoplayer2.util.MimeTypes; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** Helper class to run an ExoPlayer test. */ +public final class ExoPlayerTestRunner implements Player.EventListener, ActionSchedule.Callback { + + /** + * Builder to set-up a {@link ExoPlayerTestRunner}. Default fake implementations will be used for + * unset test properties. + */ + public static final class Builder { + + /** + * A generic video {@link Format} which can be used to set up media sources and renderers. + */ + 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); + + /** + * A generic audio {@link Format} which can be used to set up media sources and renderers. + */ + 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 Clock clock; + private Timeline timeline; + private Object manifest; + private MediaSource mediaSource; + private DefaultTrackSelector trackSelector; + private LoadControl loadControl; + private BandwidthMeter bandwidthMeter; + private Format[] supportedFormats; + private Renderer[] renderers; + private RenderersFactory renderersFactory; + private ActionSchedule actionSchedule; + private Player.EventListener eventListener; + private AnalyticsListener analyticsListener; + private Integer expectedPlayerEndedCount; + + /** + * Sets a {@link Timeline} to be used by a {@link FakeMediaSource} in the test runner. The + * default value is a seekable, non-dynamic {@link FakeTimeline} with a duration of + * {@link FakeTimeline.TimelineWindowDefinition#DEFAULT_WINDOW_DURATION_US}. Setting the + * timeline is not allowed after a call to {@link #setMediaSource(MediaSource)}. + * + * @param timeline A {@link Timeline} to be used by a {@link FakeMediaSource} in the test + * runner. + * @return This builder. + */ + public Builder setTimeline(Timeline timeline) { + assertThat(mediaSource).isNull(); + this.timeline = timeline; + return this; + } + + /** + * Sets a manifest to be used by a {@link FakeMediaSource} in the test runner. The default value + * is null. Setting the manifest is not allowed after a call to + * {@link #setMediaSource(MediaSource)}. + * + * @param manifest A manifest to be used by a {@link FakeMediaSource} in the test runner. + * @return This builder. + */ + public Builder setManifest(Object manifest) { + assertThat(mediaSource).isNull(); + this.manifest = manifest; + return this; + } + + /** + * Sets a {@link MediaSource} to be used by the test runner. The default value is a + * {@link FakeMediaSource} with the timeline and manifest provided by + * {@link #setTimeline(Timeline)} and {@link #setManifest(Object)}. Setting the media source is + * not allowed after calls to {@link #setTimeline(Timeline)} and/or + * {@link #setManifest(Object)}. + * + * @param mediaSource A {@link MediaSource} to be used by the test runner. + * @return This builder. + */ + public Builder setMediaSource(MediaSource mediaSource) { + assertThat(timeline).isNull(); + assertThat(manifest).isNull(); + this.mediaSource = mediaSource; + return this; + } + + /** + * Sets a {@link DefaultTrackSelector} to be used by the test runner. The default value is a + * {@link DefaultTrackSelector} in its initial configuration. + * + * @param trackSelector A {@link DefaultTrackSelector} to be used by the test runner. + * @return This builder. + */ + public Builder setTrackSelector(DefaultTrackSelector trackSelector) { + this.trackSelector = trackSelector; + return this; + } + + /** + * Sets a {@link LoadControl} to be used by the test runner. The default value is a + * {@link DefaultLoadControl}. + * + * @param loadControl A {@link LoadControl} to be used by the test runner. + * @return This builder. + */ + public Builder setLoadControl(LoadControl loadControl) { + this.loadControl = loadControl; + return this; + } + + /** + * Sets the {@link BandwidthMeter} to be used by the test runner. The default value is a {@link + * DefaultBandwidthMeter} in its default configuration. + * + * @param bandwidthMeter The {@link BandwidthMeter} to be used by the test runner. + * @return This builder. + */ + public Builder setBandwidthMeter(BandwidthMeter bandwidthMeter) { + this.bandwidthMeter = bandwidthMeter; + return this; + } + + /** + * Sets a list of {@link Format}s to be used by a {@link FakeMediaSource} to create media + * periods and for setting up a {@link FakeRenderer}. The default value is a single + * {@link #VIDEO_FORMAT}. Note that this parameter doesn't have any influence if both a media + * source with {@link #setMediaSource(MediaSource)} and renderers with + * {@link #setRenderers(Renderer...)} or {@link #setRenderersFactory(RenderersFactory)} are set. + * + * @param supportedFormats A list of supported {@link Format}s. + * @return This builder. + */ + public Builder setSupportedFormats(Format... supportedFormats) { + this.supportedFormats = supportedFormats; + return this; + } + + /** + * Sets the {@link Renderer}s to be used by the test runner. The default value is a single + * {@link FakeRenderer} supporting the formats set by {@link #setSupportedFormats(Format...)}. + * Setting the renderers is not allowed after a call to + * {@link #setRenderersFactory(RenderersFactory)}. + * + * @param renderers A list of {@link Renderer}s to be used by the test runner. + * @return This builder. + */ + public Builder setRenderers(Renderer... renderers) { + assertThat(renderersFactory).isNull(); + this.renderers = renderers; + return this; + } + + /** + * Sets the {@link RenderersFactory} to be used by the test runner. The default factory creates + * all renderers set by {@link #setRenderers(Renderer...)}. Setting the renderer factory is not + * allowed after a call to {@link #setRenderers(Renderer...)}. + * + * @param renderersFactory A {@link RenderersFactory} to be used by the test runner. + * @return This builder. + */ + public Builder setRenderersFactory(RenderersFactory renderersFactory) { + assertThat(renderers).isNull(); + this.renderersFactory = renderersFactory; + return this; + } + + /** + * Sets the {@link Clock} to be used by the test runner. The default value is a {@link + * AutoAdvancingFakeClock}. + * + * @param clock A {@link Clock} to be used by the test runner. + * @return This builder. + */ + public Builder setClock(Clock clock) { + this.clock = clock; + return this; + } + + /** + * Sets an {@link ActionSchedule} to be run by the test runner. The first action will be + * executed immediately before {@link SimpleExoPlayer#prepare(MediaSource)}. + * + * @param actionSchedule An {@link ActionSchedule} to be used by the test runner. + * @return This builder. + */ + public Builder setActionSchedule(ActionSchedule actionSchedule) { + this.actionSchedule = actionSchedule; + return this; + } + + /** + * Sets an {@link Player.EventListener} to be registered to listen to player events. + * + * @param eventListener A {@link Player.EventListener} to be registered by the test runner to + * listen to player events. + * @return This builder. + */ + public Builder setEventListener(Player.EventListener eventListener) { + this.eventListener = eventListener; + return this; + } + + /** + * Sets an {@link AnalyticsListener} to be registered. + * + * @param analyticsListener An {@link AnalyticsListener} to be registered. + * @return This builder. + */ + public Builder setAnalyticsListener(AnalyticsListener analyticsListener) { + this.analyticsListener = analyticsListener; + return this; + } + + /** + * Sets the number of times the test runner is expected to reach the {@link Player#STATE_ENDED} + * or {@link Player#STATE_IDLE}. The default is 1. This affects how long + * {@link ExoPlayerTestRunner#blockUntilEnded(long)} waits. + * + * @param expectedPlayerEndedCount The number of times the player is expected to reach the ended + * or idle state. + * @return This builder. + */ + public Builder setExpectedPlayerEndedCount(int expectedPlayerEndedCount) { + this.expectedPlayerEndedCount = expectedPlayerEndedCount; + return this; + } + + /** + * Builds an {@link ExoPlayerTestRunner} using the provided values or their defaults. + * + * @param context The context. + * @return The built {@link ExoPlayerTestRunner}. + */ + public ExoPlayerTestRunner build(Context context) { + if (supportedFormats == null) { + supportedFormats = new Format[] {VIDEO_FORMAT}; + } + if (trackSelector == null) { + trackSelector = new DefaultTrackSelector(); + } + if (bandwidthMeter == null) { + bandwidthMeter = new DefaultBandwidthMeter.Builder(context).build(); + } + if (renderersFactory == null) { + if (renderers == null) { + renderers = new Renderer[] {new FakeRenderer(supportedFormats)}; + } + renderersFactory = + (eventHandler, + videoRendererEventListener, + audioRendererEventListener, + textRendererOutput, + metadataRendererOutput, + drmSessionManager) -> renderers; + } + if (loadControl == null) { + loadControl = new DefaultLoadControl(); + } + if (clock == null) { + clock = new AutoAdvancingFakeClock(); + } + if (mediaSource == null) { + if (timeline == null) { + timeline = new FakeTimeline(1); + } + mediaSource = new FakeMediaSource(timeline, manifest, supportedFormats); + } + if (expectedPlayerEndedCount == null) { + expectedPlayerEndedCount = 1; + } + return new ExoPlayerTestRunner( + context, + clock, + mediaSource, + renderersFactory, + trackSelector, + loadControl, + bandwidthMeter, + actionSchedule, + eventListener, + analyticsListener, + expectedPlayerEndedCount); + } + } + + private final Context context; + private final Clock clock; + private final MediaSource mediaSource; + private final RenderersFactory renderersFactory; + private final DefaultTrackSelector trackSelector; + private final LoadControl loadControl; + private final BandwidthMeter bandwidthMeter; + private final @Nullable ActionSchedule actionSchedule; + private final @Nullable Player.EventListener eventListener; + private final @Nullable AnalyticsListener analyticsListener; + + private final HandlerThread playerThread; + private final HandlerWrapper handler; + private final CountDownLatch endedCountDownLatch; + private final CountDownLatch actionScheduleFinishedCountDownLatch; + private final ArrayList timelines; + private final ArrayList manifests; + private final ArrayList timelineChangeReasons; + private final ArrayList periodIndices; + private final ArrayList discontinuityReasons; + + private SimpleExoPlayer player; + private Exception exception; + private TrackGroupArray trackGroups; + private boolean playerWasPrepared; + + private ExoPlayerTestRunner( + Context context, + Clock clock, + MediaSource mediaSource, + RenderersFactory renderersFactory, + DefaultTrackSelector trackSelector, + LoadControl loadControl, + BandwidthMeter bandwidthMeter, + @Nullable ActionSchedule actionSchedule, + @Nullable Player.EventListener eventListener, + @Nullable AnalyticsListener analyticsListener, + int expectedPlayerEndedCount) { + this.context = context; + this.clock = clock; + this.mediaSource = mediaSource; + this.renderersFactory = renderersFactory; + this.trackSelector = trackSelector; + this.loadControl = loadControl; + this.bandwidthMeter = bandwidthMeter; + this.actionSchedule = actionSchedule; + this.eventListener = eventListener; + this.analyticsListener = analyticsListener; + this.timelines = new ArrayList<>(); + this.manifests = new ArrayList<>(); + this.timelineChangeReasons = new ArrayList<>(); + this.periodIndices = new ArrayList<>(); + this.discontinuityReasons = new ArrayList<>(); + this.endedCountDownLatch = new CountDownLatch(expectedPlayerEndedCount); + this.actionScheduleFinishedCountDownLatch = new CountDownLatch(actionSchedule != null ? 1 : 0); + this.playerThread = new HandlerThread("ExoPlayerTest thread"); + playerThread.start(); + this.handler = clock.createHandler(playerThread.getLooper(), /* callback= */ null); + } + + // Called on the test thread to run the test. + + /** + * Starts the test runner on its own thread. This will trigger the creation of the player, the + * listener registration, the start of the action schedule, and the preparation of the player + * with the provided media source. + * + * @return This test runner. + */ + public ExoPlayerTestRunner start() { + handler.post( + () -> { + try { + player = + new TestSimpleExoPlayer( + context, renderersFactory, trackSelector, loadControl, bandwidthMeter, clock); + player.addListener(ExoPlayerTestRunner.this); + if (eventListener != null) { + player.addListener(eventListener); + } + if (analyticsListener != null) { + player.addAnalyticsListener(analyticsListener); + } + player.setPlayWhenReady(true); + if (actionSchedule != null) { + actionSchedule.start(player, trackSelector, null, handler, ExoPlayerTestRunner.this); + } + player.prepare(mediaSource); + } catch (Exception e) { + handleException(e); + } + }); + return this; + } + + /** + * Blocks the current thread until the test runner finishes. A test is deemed to be finished when + * the playback state transitioned to {@link Player#STATE_ENDED} or {@link Player#STATE_IDLE} for + * the specified number of times. The test also finishes when an {@link ExoPlaybackException} is + * thrown. + * + * @param timeoutMs The maximum time to wait for the test runner to finish. If this time elapsed + * the method will throw a {@link TimeoutException}. + * @return This test runner. + * @throws Exception If any exception occurred during playback, release, or due to a timeout. + */ + 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; + } + + /** + * Blocks the current thread until the action schedule finished. This does not release the test + * runner and the test must still call {@link #blockUntilEnded(long)}. + * + * @param timeoutMs The maximum time to wait for the action schedule to finish. + * @return This test runner. + * @throws TimeoutException If the action schedule did not finish within the specified timeout. + * @throws InterruptedException If the test thread gets interrupted while waiting. + */ + public ExoPlayerTestRunner blockUntilActionScheduleFinished(long timeoutMs) + throws TimeoutException, InterruptedException { + if (!actionScheduleFinishedCountDownLatch.await(timeoutMs, TimeUnit.MILLISECONDS)) { + throw new TimeoutException("Test playback timed out waiting for action schedule to finish."); + } + return this; + } + + // Assertions called on the test thread after test finished. + + /** + * Asserts that the timelines reported by + * {@link Player.EventListener#onTimelineChanged(Timeline, Object, int)} are equal to the provided + * timelines. + * + * @param timelines A list of expected {@link Timeline}s. + */ + public void assertTimelinesEqual(Timeline... timelines) { + assertThat(this.timelines).containsExactlyElementsIn(Arrays.asList(timelines)).inOrder(); + } + + /** + * Asserts that the manifests reported by + * {@link Player.EventListener#onTimelineChanged(Timeline, Object, int)} are equal to the provided + * manifest. + * + * @param manifests A list of expected manifests. + */ + public void assertManifestsEqual(Object... manifests) { + assertThat(this.manifests).containsExactlyElementsIn(Arrays.asList(manifests)).inOrder(); + } + + /** + * Asserts that the timeline change reasons reported by {@link + * Player.EventListener#onTimelineChanged(Timeline, Object, int)} are equal to the provided + * timeline change reasons. + */ + public void assertTimelineChangeReasonsEqual(Integer... reasons) { + assertThat(timelineChangeReasons).containsExactlyElementsIn(Arrays.asList(reasons)).inOrder(); + } + + /** + * Asserts that the last track group array reported by + * {@link Player.EventListener#onTracksChanged(TrackGroupArray, TrackSelectionArray)} is equal to + * the provided track group array. + * + * @param trackGroupArray The expected {@link TrackGroupArray}. + */ + public void assertTrackGroupsEqual(TrackGroupArray trackGroupArray) { + assertThat(this.trackGroups).isEqualTo(trackGroupArray); + } + + /** + * Asserts that {@link Player.EventListener#onPositionDiscontinuity(int)} was not called. + */ + public void assertNoPositionDiscontinuities() { + assertThat(discontinuityReasons).isEmpty(); + } + + /** + * Asserts that the discontinuity reasons reported by {@link + * Player.EventListener#onPositionDiscontinuity(int)} are equal to the provided values. + * + * @param discontinuityReasons The expected discontinuity reasons. + */ + public void assertPositionDiscontinuityReasonsEqual(Integer... discontinuityReasons) { + assertThat(this.discontinuityReasons) + .containsExactlyElementsIn(Arrays.asList(discontinuityReasons)) + .inOrder(); + } + + /** + * Asserts that the indices of played periods is equal to the provided list of periods. A period + * is considered to be played if it was the current period after a position discontinuity or a + * media source preparation. When the same period is repeated automatically due to enabled repeat + * modes, it is reported twice. Seeks within the current period are not reported. + * + * @param periodIndices A list of expected period indices. + */ + public void assertPlayedPeriodIndices(Integer... periodIndices) { + assertThat(this.periodIndices) + .containsExactlyElementsIn(Arrays.asList(periodIndices)) + .inOrder(); + } + + // Private implementation details. + + private void release() throws InterruptedException { + handler.post( + () -> { + 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; + } + while (endedCountDownLatch.getCount() > 0) { + endedCountDownLatch.countDown(); + } + } + + // Player.EventListener + + @Override + public void onTimelineChanged( + Timeline timeline, @Nullable Object manifest, @Player.TimelineChangeReason int reason) { + timelines.add(timeline); + manifests.add(manifest); + timelineChangeReasons.add(reason); + if (reason == Player.TIMELINE_CHANGE_REASON_PREPARED) { + periodIndices.add(player.getCurrentPeriodIndex()); + } + } + + @Override + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + this.trackGroups = trackGroups; + } + + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + playerWasPrepared |= playbackState != Player.STATE_IDLE; + if (playbackState == Player.STATE_ENDED + || (playbackState == Player.STATE_IDLE && playerWasPrepared)) { + endedCountDownLatch.countDown(); + } + } + + @Override + public void onPlayerError(ExoPlaybackException error) { + handleException(error); + } + + @Override + public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { + discontinuityReasons.add(reason); + int currentIndex = player.getCurrentPeriodIndex(); + if (reason == Player.DISCONTINUITY_REASON_PERIOD_TRANSITION + || periodIndices.isEmpty() + || periodIndices.get(periodIndices.size() - 1) != currentIndex) { + // Ignore seek or internal discontinuities within a period. + periodIndices.add(currentIndex); + } + } + + // ActionSchedule.Callback + + @Override + public void onActionScheduleFinished() { + actionScheduleFinishedCountDownLatch.countDown(); + } + + /** SimpleExoPlayer implementation using a custom Clock. */ + private static final class TestSimpleExoPlayer extends SimpleExoPlayer { + + public TestSimpleExoPlayer( + Context context, + RenderersFactory renderersFactory, + TrackSelector trackSelector, + LoadControl loadControl, + BandwidthMeter bandwidthMeter, + Clock clock) { + super( + context, + renderersFactory, + trackSelector, + loadControl, + /* drmSessionManager= */ null, + bandwidthMeter, + new AnalyticsCollector.Factory(), + clock, + Looper.myLooper()); + } + } +} 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 ff819d722e..0000000000 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoPlayerWrapper.java +++ /dev/null @@ -1,191 +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.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 ExoPlayer.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()); - } - } - - // ExoPlayer.EventListener implementation. - - @Override - public void onLoadingChanged(boolean isLoading) { - // Do nothing. - } - - @Override - public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { - if (playbackState == ExoPlayer.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/ExtractorAsserts.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java index fb78b3a634..1e4811aadf 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExtractorAsserts.java @@ -15,22 +15,42 @@ */ package com.google.android.exoplayer2.testutil; -import android.app.Instrumentation; +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.testutil.FakeExtractorInput.SimulatedIOException; import com.google.android.exoplayer2.util.Assertions; import java.io.IOException; +import java.lang.reflect.Field; import java.util.Arrays; -import junit.framework.Assert; /** * Assertion methods for {@link Extractor}. */ public final class ExtractorAsserts { + private static Context robolectricContext; + + static { + try { + Class runtimeEnvironmentClass = Class.forName("org.robolectric.RuntimeEnvironment"); + Field applicationField = runtimeEnvironmentClass.getDeclaredField("application"); + robolectricContext = (Context) applicationField.get(null); + } catch (ClassNotFoundException e) { + // Keep Robolectric context at null if not found. + } catch (NoSuchFieldException e) { + // Keep Robolectric context at null if not found. + } catch (IllegalAccessException e) { + // Keep Robolectric context at null if not found. + } + } + /** * A factory for {@link Extractor} instances. */ @@ -42,46 +62,87 @@ public final class ExtractorAsserts { private static final String UNKNOWN_LENGTH_EXTENSION = ".unklen" + DUMP_EXTENSION; /** - * Calls {@link #assertOutput(Extractor, String, byte[], Instrumentation, boolean, boolean, - * boolean)} with all possible combinations of "simulate" parameters. + * Asserts that an extractor behaves correctly given valid input data. Can only be used from + * Robolectric tests. + * + *
    + *
  • Calls {@link Extractor#seek(long, long)} and {@link Extractor#release()} without calling + * {@link Extractor#init(ExtractorOutput)} to check these calls do not fail. + *
  • Calls {@link #assertOutput(Extractor, String, byte[], Context, boolean, boolean, boolean, + * boolean)} with all possible combinations of "simulate" parameters. + *
* * @param factory An {@link ExtractorFactory} which creates instances of the {@link Extractor} * class which is to be tested. - * @param sampleFile The path to the input sample. - * @param instrumentation To be used to load the sample file. + * @param file The path to the input sample. * @throws IOException If reading from the input fails. * @throws InterruptedException If interrupted while reading from the input. - * @see #assertOutput(Extractor, String, byte[], Instrumentation, boolean, boolean, boolean) */ - public static void assertOutput(ExtractorFactory factory, String sampleFile, - Instrumentation instrumentation) throws IOException, InterruptedException { - byte[] fileData = TestUtil.getByteArray(instrumentation, sampleFile); - assertOutput(factory, sampleFile, fileData, instrumentation); + public static void assertBehavior(ExtractorFactory factory, String file) + throws IOException, InterruptedException { + // Check behavior prior to initialization. + Extractor extractor = factory.create(); + extractor.seek(0, 0); + extractor.release(); + // Assert output. + byte[] fileData = TestUtil.getByteArray(robolectricContext, file); + assertOutput(factory, file, fileData, robolectricContext); } /** - * Calls {@link #assertOutput(Extractor, String, byte[], Instrumentation, boolean, boolean, - * boolean)} with all possible combinations of "simulate" parameters. + * Asserts that an extractor behaves correctly given valid input data: + * + *
    + *
  • Calls {@link Extractor#seek(long, long)} and {@link Extractor#release()} without calling + * {@link Extractor#init(ExtractorOutput)} to check these calls do not fail. + *
  • Calls {@link #assertOutput(Extractor, String, byte[], Context, boolean, boolean, boolean, + * boolean)} with all possible combinations of "simulate" parameters. + *
* * @param factory An {@link ExtractorFactory} which creates instances of the {@link Extractor} * class which is to be tested. - * @param sampleFile The path to the input sample. - * @param fileData Content of the input file. - * @param instrumentation To be used to load the sample file. + * @param file The path to the input sample. + * @param context To be used to load the sample file. * @throws IOException If reading from the input fails. * @throws InterruptedException If interrupted while reading from the input. - * @see #assertOutput(Extractor, String, byte[], Instrumentation, boolean, boolean, boolean) */ - public static void assertOutput(ExtractorFactory factory, String sampleFile, byte[] fileData, - Instrumentation instrumentation) throws IOException, InterruptedException { - assertOutput(factory.create(), sampleFile, fileData, instrumentation, false, false, false); - assertOutput(factory.create(), sampleFile, fileData, instrumentation, true, false, false); - assertOutput(factory.create(), sampleFile, fileData, instrumentation, false, true, false); - assertOutput(factory.create(), sampleFile, fileData, instrumentation, true, true, false); - assertOutput(factory.create(), sampleFile, fileData, instrumentation, false, false, true); - assertOutput(factory.create(), sampleFile, fileData, instrumentation, true, false, true); - assertOutput(factory.create(), sampleFile, fileData, instrumentation, false, true, true); - assertOutput(factory.create(), sampleFile, fileData, instrumentation, true, true, true); + public static void assertBehavior(ExtractorFactory factory, String file, Context context) + throws IOException, InterruptedException { + // Check behavior prior to initialization. + Extractor extractor = factory.create(); + extractor.seek(0, 0); + extractor.release(); + // Assert output. + byte[] fileData = TestUtil.getByteArray(context, file); + assertOutput(factory, file, fileData, context); + } + + /** + * Calls {@link #assertOutput(Extractor, String, byte[], Context, boolean, boolean, boolean, + * boolean)} with all possible combinations of "simulate" parameters with {@code sniffFirst} set + * to true, and makes one additional call with the "simulate" and {@code sniffFirst} parameters + * all set to false. + * + * @param factory An {@link ExtractorFactory} which creates instances of the {@link Extractor} + * class which is to be tested. + * @param file The path to the input sample. + * @param data Content of the input file. + * @param context To be used to load the sample file. + * @throws IOException If reading from the input fails. + * @throws InterruptedException If interrupted while reading from the input. + */ + public static void assertOutput( + ExtractorFactory factory, String file, byte[] data, Context context) + throws IOException, InterruptedException { + assertOutput(factory.create(), file, data, context, true, false, false, false); + assertOutput(factory.create(), file, data, context, true, false, false, true); + assertOutput(factory.create(), file, data, context, true, false, true, false); + assertOutput(factory.create(), file, data, context, true, false, true, true); + assertOutput(factory.create(), file, data, context, true, true, false, false); + assertOutput(factory.create(), file, data, context, true, true, false, true); + assertOutput(factory.create(), file, data, context, true, true, true, false); + assertOutput(factory.create(), file, data, context, true, true, true, true); + assertOutput(factory.create(), file, data, context, false, false, false, false); } /** @@ -91,34 +152,43 @@ public final class ExtractorAsserts { * #UNKNOWN_LENGTH_EXTENSION}" exists, it's preferred. * * @param extractor The {@link Extractor} to be tested. - * @param sampleFile The path to the input sample. - * @param fileData Content of the input file. - * @param instrumentation To be used to load the sample file. - * @param simulateIOErrors If true simulates IOErrors. - * @param simulateUnknownLength If true simulates unknown input length. - * @param simulatePartialReads If true simulates partial reads. + * @param file The path to the input sample. + * @param data Content of the input file. + * @param context To be used to load the sample file. + * @param sniffFirst Whether to sniff the data by calling {@link Extractor#sniff(ExtractorInput)} + * prior to consuming it. + * @param simulateIOErrors Whether to simulate IO errors. + * @param simulateUnknownLength Whether to simulate unknown input length. + * @param simulatePartialReads Whether to simulate partial reads. * @return The {@link FakeExtractorOutput} used in the test. * @throws IOException If reading from the input fails. * @throws InterruptedException If interrupted while reading from the input. */ - public static FakeExtractorOutput assertOutput(Extractor extractor, String sampleFile, - byte[] fileData, Instrumentation instrumentation, boolean simulateIOErrors, - boolean simulateUnknownLength, boolean simulatePartialReads) throws IOException, - InterruptedException { - FakeExtractorInput input = new FakeExtractorInput.Builder().setData(fileData) + private static FakeExtractorOutput assertOutput( + Extractor extractor, + String file, + byte[] data, + Context context, + boolean sniffFirst, + boolean simulateIOErrors, + boolean simulateUnknownLength, + boolean simulatePartialReads) + throws IOException, InterruptedException { + FakeExtractorInput input = new FakeExtractorInput.Builder().setData(data) .setSimulateIOErrors(simulateIOErrors) .setSimulateUnknownLength(simulateUnknownLength) .setSimulatePartialReads(simulatePartialReads).build(); - Assert.assertTrue(TestUtil.sniffTestData(extractor, input)); - input.resetPeekPosition(); - FakeExtractorOutput extractorOutput = consumeTestData(extractor, input, 0, true); + if (sniffFirst) { + assertThat(TestUtil.sniffTestData(extractor, input)).isTrue(); + input.resetPeekPosition(); + } - if (simulateUnknownLength - && assetExists(instrumentation, sampleFile + UNKNOWN_LENGTH_EXTENSION)) { - extractorOutput.assertOutput(instrumentation, sampleFile + UNKNOWN_LENGTH_EXTENSION); + FakeExtractorOutput extractorOutput = consumeTestData(extractor, input, 0, true); + if (simulateUnknownLength && assetExists(context, file + UNKNOWN_LENGTH_EXTENSION)) { + extractorOutput.assertOutput(context, file + UNKNOWN_LENGTH_EXTENSION); } else { - extractorOutput.assertOutput(instrumentation, sampleFile + ".0" + DUMP_EXTENSION); + extractorOutput.assertOutput(context, file + ".0" + DUMP_EXTENSION); } SeekMap seekMap = extractorOutput.seekMap; @@ -126,14 +196,14 @@ public final class ExtractorAsserts { long durationUs = seekMap.getDurationUs(); for (int j = 0; j < 4; j++) { long timeUs = (durationUs * j) / 3; - long position = seekMap.getPosition(timeUs); + long position = seekMap.getSeekPoints(timeUs).first.position; input.setPosition((int) position); for (int i = 0; i < extractorOutput.numberOfTracks; i++) { extractorOutput.trackOutputs.valueAt(i).clear(); } consumeTestData(extractor, input, timeUs, extractorOutput, false); - extractorOutput.assertOutput(instrumentation, sampleFile + '.' + j + DUMP_EXTENSION); + extractorOutput.assertOutput(context, file + '.' + j + DUMP_EXTENSION); } } @@ -147,16 +217,19 @@ public final class ExtractorAsserts { * @param factory An {@link ExtractorFactory} which creates instances of the {@link Extractor} * class which is to be tested. * @param sampleFile The path to the input sample. - * @param instrumentation To be used to load the sample file. + * @param context To be used to load the sample file. * @param expectedThrowable Expected {@link Throwable} class. * @throws IOException If reading from the input fails. * @throws InterruptedException If interrupted while reading from the input. * @see #assertThrows(Extractor, byte[], Class, boolean, boolean, boolean) */ - public static void assertThrows(ExtractorFactory factory, String sampleFile, - Instrumentation instrumentation, Class expectedThrowable) + public static void assertThrows( + ExtractorFactory factory, + String sampleFile, + Context context, + Class expectedThrowable) throws IOException, InterruptedException { - byte[] fileData = TestUtil.getByteArray(instrumentation, sampleFile); + byte[] fileData = TestUtil.getByteArray(context, sampleFile); assertThrows(factory, fileData, expectedThrowable); } @@ -172,8 +245,9 @@ public final class ExtractorAsserts { * @throws InterruptedException If interrupted while reading from the input. * @see #assertThrows(Extractor, byte[], Class, boolean, boolean, boolean) */ - public static void assertThrows(ExtractorFactory factory, byte[] fileData, - Class expectedThrowable) throws IOException, InterruptedException { + private static void assertThrows( + ExtractorFactory factory, byte[] fileData, Class expectedThrowable) + throws IOException, InterruptedException { assertThrows(factory.create(), fileData, expectedThrowable, false, false, false); assertThrows(factory.create(), fileData, expectedThrowable, true, false, false); assertThrows(factory.create(), fileData, expectedThrowable, false, true, false); @@ -196,10 +270,14 @@ public final class ExtractorAsserts { * @throws IOException If reading from the input fails. * @throws InterruptedException If interrupted while reading from the input. */ - public static void assertThrows(Extractor extractor, byte[] fileData, - Class expectedThrowable, boolean simulateIOErrors, - boolean simulateUnknownLength, boolean simulatePartialReads) throws IOException, - InterruptedException { + private static void assertThrows( + Extractor extractor, + byte[] fileData, + Class expectedThrowable, + boolean simulateIOErrors, + boolean simulateUnknownLength, + boolean simulatePartialReads) + throws IOException, InterruptedException { FakeExtractorInput input = new FakeExtractorInput.Builder().setData(fileData) .setSimulateIOErrors(simulateIOErrors) .setSimulateUnknownLength(simulateUnknownLength) @@ -260,13 +338,11 @@ public final class ExtractorAsserts { } } - private static boolean assetExists(Instrumentation instrumentation, String fileName) - throws IOException { + private static boolean assetExists(Context context, String fileName) throws IOException { int i = fileName.lastIndexOf('/'); String path = i >= 0 ? fileName.substring(0, i) : ""; String file = i >= 0 ? fileName.substring(i + 1) : fileName; - return Arrays.asList(instrumentation.getContext().getResources().getAssets().list(path)) - .contains(file); + return Arrays.asList(context.getResources().getAssets().list(path)).contains(file); } } 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 f4476ddf93..0fef8db78e 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 @@ -15,13 +15,19 @@ */ 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.trackselection.TrackSelection; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.chunk.BaseMediaChunkIterator; +import com.google.android.exoplayer2.source.chunk.MediaChunkIterator; +import com.google.android.exoplayer2.testutil.FakeDataSet.FakeData.Segment; +import com.google.android.exoplayer2.upstream.DataSpec; +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 +36,129 @@ public final class FakeAdaptiveDataSet extends FakeDataSet { */ public static final class Factory { - private final long chunkDurationUs; + private static final Random random = new Random(); - public Factory(long chunkDurationUs) { + private final long chunkDurationUs; + private final double bitratePercentStdDev; + + /** + * 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; + /** {@link MediaChunkIterator} for the chunks defined by a fake adaptive data set. */ + public static final class Iterator extends BaseMediaChunkIterator { + + private final FakeAdaptiveDataSet dataSet; + private final int trackGroupIndex; + + /** + * Create iterator. + * + * @param dataSet The data set to iterate over. + * @param trackGroupIndex The index of the track group to iterate over. + * @param chunkIndex The chunk index to which the iterator points initially. + */ + public Iterator(FakeAdaptiveDataSet dataSet, int trackGroupIndex, int chunkIndex) { + super(/* fromIndex= */ chunkIndex, /* toIndex= */ dataSet.getChunkCount() - 1); + this.dataSet = dataSet; + this.trackGroupIndex = trackGroupIndex; + } + + @Override + public DataSpec getDataSpec() { + checkInBounds(); + String uri = dataSet.getUri(trackGroupIndex); + int chunkIndex = (int) getCurrentIndex(); + Segment fakeDataChunk = dataSet.getData(uri).getSegments().get(chunkIndex); + return new DataSpec( + Uri.parse(uri), fakeDataChunk.byteOffset, fakeDataChunk.length, /* key= */ null); + } + + @Override + public long getChunkStartTimeUs() { + checkInBounds(); + return dataSet.getStartTime((int) getCurrentIndex()); + } + + @Override + public long getChunkEndTimeUs() { + checkInBounds(); + int chunkIndex = (int) getCurrentIndex(); + return dataSet.getStartTime(chunkIndex) + dataSet.getChunkDuration(chunkIndex); + } + } + + 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) { @@ -89,5 +172,4 @@ public final class FakeAdaptiveDataSet extends FakeDataSet { public int getChunkIndexByPosition(long positionUs) { return (int) (positionUs / chunkDurationUs); } - } 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 0000000000..f8bf950ef2 --- /dev/null +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaPeriod.java @@ -0,0 +1,165 @@ +/* + * 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.support.annotation.Nullable; +import com.google.android.exoplayer2.source.CompositeSequenceableLoader; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +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.upstream.DefaultLoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.TransferListener; +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 Allocator allocator; + private final FakeChunkSource.Factory chunkSourceFactory; + private final @Nullable TransferListener transferListener; + 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, + @Nullable TransferListener transferListener) { + super(trackGroupArray, eventDispatcher); + this.allocator = allocator; + this.chunkSourceFactory = chunkSourceFactory; + this.transferListener = transferListener; + this.durationUs = durationUs; + this.sampleStreams = newSampleStreamArray(0); + } + + @Override + public void release() { + for (ChunkSampleStream sampleStream : sampleStreams) { + sampleStream.release(); + } + super.release(); + } + + @Override + public synchronized 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(newSampleStreamArray(validStreams.size())); + this.sequenceableLoader = new CompositeSequenceableLoader(sampleStreams); + return returnPositionUs; + } + + @Override + public void discardBuffer(long positionUs, boolean toKeyframe) { + super.discardBuffer(positionUs, toKeyframe); + for (ChunkSampleStream sampleStream : sampleStreams) { + sampleStream.discardBuffer(positionUs, toKeyframe); + } + } + + @Override + public void reevaluateBuffer(long positionUs) { + super.reevaluateBuffer(positionUs); + sequenceableLoader.reevaluateBuffer(positionUs); + } + + @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, transferListener); + return new ChunkSampleStream<>( + MimeTypes.getTrackType(trackSelection.getSelectedFormat().sampleMimeType), + /* embeddedTrackTypes= */ null, + /* embeddedTrackFormats= */ null, + chunkSource, + /* callback= */ this, + allocator, + /* positionUs= */ 0, + new DefaultLoadErrorHandlingPolicy(/* minimumLoadableRetryCount= */ 3), + eventDispatcher); + } + + @Override + public void onContinueLoadingRequested(ChunkSampleStream source) { + callback.onContinueLoadingRequested(this); + } + + @SuppressWarnings("unchecked") + private static ChunkSampleStream[] newSampleStreamArray(int length) { + return new ChunkSampleStream[length]; + } +} 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 0000000000..089528bfde --- /dev/null +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeAdaptiveMediaSource.java @@ -0,0 +1,61 @@ +/* + * 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.support.annotation.Nullable; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.Timeline.Period; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.upstream.TransferListener; + +/** + * 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 FakeChunkSource.Factory chunkSourceFactory; + + public FakeAdaptiveMediaSource( + Timeline timeline, + Object manifest, + TrackGroupArray trackGroupArray, + FakeChunkSource.Factory chunkSourceFactory) { + super(timeline, manifest, trackGroupArray); + this.chunkSourceFactory = chunkSourceFactory; + } + + @Override + protected FakeMediaPeriod createFakeMediaPeriod( + MediaPeriodId id, + TrackGroupArray trackGroupArray, + Allocator allocator, + EventDispatcher eventDispatcher, + @Nullable TransferListener transferListener) { + Period period = timeline.getPeriodByUid(id.periodUid, new Period()); + return new FakeAdaptiveMediaPeriod( + trackGroupArray, + eventDispatcher, + allocator, + chunkSourceFactory, + period.durationUs, + transferListener); + } + +} 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 0c970caa15..b5db0dc489 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,17 +16,23 @@ 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.Format; +import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.source.chunk.Chunk; import com.google.android.exoplayer2.source.chunk.ChunkHolder; import com.google.android.exoplayer2.source.chunk.ChunkSource; import com.google.android.exoplayer2.source.chunk.MediaChunk; +import com.google.android.exoplayer2.source.chunk.MediaChunkIterator; import com.google.android.exoplayer2.source.chunk.SingleSampleMediaChunk; import com.google.android.exoplayer2.testutil.FakeDataSet.FakeData.Segment; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.util.List; @@ -49,10 +55,17 @@ public final class FakeChunkSource implements ChunkSource { this.dataSourceFactory = dataSourceFactory; } - public FakeChunkSource createChunkSource(TrackSelection trackSelection, long durationUs) { - FakeAdaptiveDataSet dataSet = dataSetFactory.createDataSet(trackSelection, durationUs); + public FakeChunkSource createChunkSource( + TrackSelection trackSelection, + long durationUs, + @Nullable TransferListener transferListener) { + FakeAdaptiveDataSet dataSet = + dataSetFactory.createDataSet(trackSelection.getTrackGroup(), durationUs); dataSourceFactory.setFakeDataSet(dataSet); DataSource dataSource = dataSourceFactory.createDataSource(); + if (transferListener != null) { + dataSource.addTransferListener(transferListener); + } return new FakeChunkSource(trackSelection, dataSource, dataSet); } @@ -69,6 +82,17 @@ public final class FakeChunkSource implements ChunkSource { this.dataSet = dataSet; } + @Override + public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { + int chunkIndex = dataSet.getChunkIndexByPosition(positionUs); + long firstSyncUs = dataSet.getStartTime(chunkIndex); + long secondSyncUs = + firstSyncUs < positionUs && chunkIndex < dataSet.getChunkCount() - 1 + ? dataSet.getStartTime(chunkIndex + 1) + : firstSyncUs; + return Util.resolveSeekPositionUs(positionUs, seekParameters, firstSyncUs, secondSyncUs); + } + @Override public void maybeThrowError() throws IOException { // Do nothing. @@ -80,18 +104,31 @@ public final class FakeChunkSource implements ChunkSource { } @Override - public void getNextChunk(MediaChunk previous, long playbackPositionUs, ChunkHolder out) { - long bufferedDurationUs = previous != null ? (previous.endTimeUs - playbackPositionUs) : 0; - trackSelection.updateSelectedTrack(bufferedDurationUs); - int chunkIndex = previous == null ? dataSet.getChunkIndexByPosition(playbackPositionUs) - : previous.getNextChunkIndex(); + public void getNextChunk( + long playbackPositionUs, + long loadPositionUs, + List queue, + ChunkHolder out) { + long bufferedDurationUs = loadPositionUs - playbackPositionUs; + int chunkIndex = + queue.isEmpty() + ? dataSet.getChunkIndexByPosition(playbackPositionUs) + : (int) queue.get(queue.size() - 1).getNextChunkIndex(); + MediaChunkIterator[] chunkIterators = new MediaChunkIterator[trackSelection.length()]; + for (int i = 0; i < chunkIterators.length; i++) { + int trackGroupIndex = trackSelection.getIndexInTrackGroup(i); + chunkIterators[i] = new FakeAdaptiveDataSet.Iterator(dataSet, trackGroupIndex, chunkIndex); + } + trackSelection.updateSelectedTrack( + playbackPositionUs, bufferedDurationUs, C.TIME_UNSET, queue, chunkIterators); if (chunkIndex >= dataSet.getChunkCount()) { out.endOfStream = true; } else { 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); @@ -108,7 +145,8 @@ public final class FakeChunkSource implements ChunkSource { } @Override - public boolean onChunkLoadError(Chunk chunk, boolean cancelable, Exception e) { + public boolean onChunkLoadError( + Chunk chunk, boolean cancelable, Exception e, long blacklistDurationMs) { return false; } 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 36ce4b5c3e..a591546613 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,17 +15,21 @@ */ package com.google.android.exoplayer2.testutil; +import android.os.Handler.Callback; +import android.os.Looper; +import android.os.Message; import com.google.android.exoplayer2.util.Clock; +import com.google.android.exoplayer2.util.HandlerWrapper; import java.util.ArrayList; import java.util.List; -/** - * Fake {@link Clock} implementation independent of {@link android.os.SystemClock}. - */ -public final class FakeClock implements Clock { +/** Fake {@link Clock} implementation independent of {@link android.os.SystemClock}. */ +public class FakeClock implements Clock { + + private final List wakeUpTimes; + private final List handlerMessages; private long currentTimeMs; - private final List wakeUpTimes; /** * Create {@link FakeClock} with an arbitrary initial timestamp. @@ -35,6 +39,7 @@ public final class FakeClock implements Clock { public FakeClock(long initialTimeMs) { this.currentTimeMs = initialTimeMs; this.wakeUpTimes = new ArrayList<>(); + this.handlerMessages = new ArrayList<>(); } /** @@ -50,13 +55,23 @@ public final class FakeClock implements Clock { break; } } + for (int i = handlerMessages.size() - 1; i >= 0; i--) { + if (handlerMessages.get(i).maybeSendToTarget(currentTimeMs)) { + handlerMessages.remove(i); + } + } } @Override - public long elapsedRealtime() { + public synchronized long elapsedRealtime() { return currentTimeMs; } + @Override + public long uptimeMillis() { + return elapsedRealtime(); + } + @Override public synchronized void sleep(long sleepTimeMs) { if (sleepTimeMs <= 0) { @@ -74,5 +89,130 @@ public final class FakeClock implements Clock { wakeUpTimes.remove(wakeUpTimeMs); } + @Override + public HandlerWrapper createHandler(Looper looper, Callback callback) { + return new ClockHandler(looper, callback); + } + + /** Adds a handler post to list of pending messages. */ + protected synchronized boolean addHandlerMessageAtTime( + HandlerWrapper handler, Runnable runnable, long timeMs) { + if (timeMs <= currentTimeMs) { + return handler.post(runnable); + } + handlerMessages.add(new HandlerMessageData(timeMs, handler, runnable)); + return true; + } + + /** Adds an empty handler message to list of pending messages. */ + protected synchronized boolean addHandlerMessageAtTime( + HandlerWrapper handler, int message, long timeMs) { + if (timeMs <= currentTimeMs) { + return handler.sendEmptyMessage(message); + } + handlerMessages.add(new HandlerMessageData(timeMs, handler, message)); + return true; + } + + /** Message data saved to send messages or execute runnables at a later time on a Handler. */ + private static final class HandlerMessageData { + + private final long postTime; + private final HandlerWrapper handler; + private final Runnable runnable; + private final int message; + + public HandlerMessageData(long postTime, HandlerWrapper handler, Runnable runnable) { + this.postTime = postTime; + this.handler = handler; + this.runnable = runnable; + this.message = 0; + } + + public HandlerMessageData(long postTime, HandlerWrapper handler, int message) { + this.postTime = postTime; + this.handler = handler; + this.runnable = null; + this.message = message; + } + + /** Sends the message and returns whether the message was sent to its target. */ + public boolean maybeSendToTarget(long currentTimeMs) { + if (postTime <= currentTimeMs) { + if (runnable != null) { + handler.post(runnable); + } else { + handler.sendEmptyMessage(message); + } + return true; + } + return false; + } + } + + /** HandlerWrapper implementation using the enclosing Clock to schedule delayed messages. */ + private final class ClockHandler implements HandlerWrapper { + + private final android.os.Handler handler; + + public ClockHandler(Looper looper, Callback callback) { + handler = new android.os.Handler(looper, callback); + } + + @Override + public Looper getLooper() { + return handler.getLooper(); + } + + @Override + public Message obtainMessage(int what) { + return handler.obtainMessage(what); + } + + @Override + public Message obtainMessage(int what, Object obj) { + return handler.obtainMessage(what, obj); + } + + @Override + public Message obtainMessage(int what, int arg1, int arg2) { + return handler.obtainMessage(what, arg1, arg2); + } + + @Override + public Message obtainMessage(int what, int arg1, int arg2, Object obj) { + return handler.obtainMessage(what, arg1, arg2, obj); + } + + @Override + public boolean sendEmptyMessage(int what) { + return handler.sendEmptyMessage(what); + } + + @Override + public boolean sendEmptyMessageAtTime(int what, long uptimeMs) { + return addHandlerMessageAtTime(this, what, uptimeMs); + } + + @Override + public void removeMessages(int what) { + handler.removeMessages(what); + } + + @Override + public void removeCallbacksAndMessages(Object token) { + handler.removeCallbacksAndMessages(token); + } + + @Override + public boolean post(Runnable runnable) { + return handler.post(runnable); + } + + @Override + public boolean postDelayed(Runnable runnable, long delayMs) { + return addHandlerMessageAtTime(this, runnable, uptimeMillis() + delayMs); + } + } } 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 2580205361..e77e0714e7 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 @@ import java.util.List; /** * 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 @@ public class FakeDataSet { 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 class FakeDataSet { } /** 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 class FakeDataSet { } /** - * 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 @@ public class FakeDataSet { } - private final HashMap dataMap; + private final HashMap dataMap; private FakeData defaultData; public FakeDataSet() { @@ -234,16 +235,31 @@ public class FakeDataSet { /** 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 class FakeDataSet { /** 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 6180a8aa77..9f6fdc9d49 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 @@ -16,14 +16,13 @@ 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.testutil.FakeDataSet.FakeData; import com.google.android.exoplayer2.testutil.FakeDataSet.FakeData.Segment; +import com.google.android.exoplayer2.upstream.BaseDataSource; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSourceException; import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; import java.io.IOException; import java.util.ArrayList; @@ -32,38 +31,38 @@ import java.util.ArrayList; * A fake {@link DataSource} capable of simulating various scenarios. It uses a {@link FakeDataSet} * instance which determines the response to data access calls. */ -public class FakeDataSource implements DataSource { +public class FakeDataSource extends BaseDataSource { /** * Factory to create a {@link FakeDataSource}. */ public static class Factory implements DataSource.Factory { - protected final TransferListener transferListener; protected FakeDataSet fakeDataSet; - - public Factory(@Nullable TransferListener transferListener) { - this.transferListener = transferListener; - } + protected boolean isNetwork; public final Factory setFakeDataSet(FakeDataSet fakeDataSet) { this.fakeDataSet = fakeDataSet; return this; } - @Override - public DataSource createDataSource() { - return new FakeDataSource(fakeDataSet, transferListener); + public final Factory setIsNetwork(boolean isNetwork) { + this.isNetwork = isNetwork; + return this; } + @Override + public DataSource createDataSource() { + return new FakeDataSource(fakeDataSet, isNetwork); + } } private final FakeDataSet fakeDataSet; - private final TransferListener transferListener; private final ArrayList openedDataSpecs; private Uri uri; - private boolean opened; + private boolean openCalled; + private boolean sourceOpened; private FakeData fakeData; private int currentSegmentIndex; private long bytesRemaining; @@ -73,14 +72,13 @@ public class FakeDataSource implements DataSource { } public FakeDataSource(FakeDataSet fakeDataSet) { - this(fakeDataSet, null); + this(fakeDataSet, /* isNetwork= */ false); } - public FakeDataSource(FakeDataSet fakeDataSet, - @Nullable TransferListener transferListener) { + public FakeDataSource(FakeDataSet fakeDataSet, boolean isNetwork) { + super(isNetwork); Assertions.checkNotNull(fakeDataSet); this.fakeDataSet = fakeDataSet; - this.transferListener = transferListener; this.openedDataSpecs = new ArrayList<>(); } @@ -90,12 +88,14 @@ public class FakeDataSource implements DataSource { @Override public final long open(DataSpec dataSpec) throws IOException { - Assertions.checkState(!opened); + Assertions.checkState(!openCalled); + openCalled = true; + // DataSpec requires a matching close call even if open fails. - opened = true; uri = dataSpec.uri; openedDataSpecs.add(dataSpec); + transferInitializing(dataSpec); fakeData = fakeDataSet.getData(uri.toString()); if (fakeData == null) { throw new IOException("Data not found: " + dataSpec.uri); @@ -129,9 +129,8 @@ public class FakeDataSource implements DataSource { currentSegmentIndex++; } } - if (transferListener != null) { - transferListener.onTransferStart(this, dataSpec); - } + sourceOpened = true; + transferStarted(dataSpec); // Configure bytesRemaining, and return. if (dataSpec.length == C.LENGTH_UNSET) { bytesRemaining = totalLength - dataSpec.position; @@ -144,7 +143,7 @@ public class FakeDataSource implements DataSource { @Override public final int read(byte[] buffer, int offset, int readLength) throws IOException { - Assertions.checkState(opened); + Assertions.checkState(sourceOpened); while (true) { if (currentSegmentIndex == fakeData.getSegments().size() || bytesRemaining == 0) { return C.RESULT_END_OF_INPUT; @@ -166,13 +165,12 @@ public class FakeDataSource implements DataSource { // 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); } onDataRead(readLength); - if (transferListener != null) { - transferListener.onBytesTransferred(this, readLength); - } + bytesTransferred(readLength); bytesRemaining -= readLength; current.bytesRead += readLength; if (current.bytesRead == current.length) { @@ -190,8 +188,8 @@ public class FakeDataSource implements DataSource { @Override public final void close() throws IOException { - Assertions.checkState(opened); - opened = false; + Assertions.checkState(openCalled); + openCalled = false; uri = null; if (fakeData != null && currentSegmentIndex < fakeData.getSegments().size()) { Segment current = fakeData.getSegments().get(currentSegmentIndex); @@ -199,8 +197,9 @@ public class FakeDataSource implements DataSource { current.exceptionCleared = true; } } - if (transferListener != null) { - transferListener.onTransferEnd(this); + if (sourceOpened) { + sourceOpened = false; + transferEnded(); } fakeData = null; } @@ -216,7 +215,12 @@ public class FakeDataSource implements DataSource { return dataSpecs; } - protected void onDataRead(int bytesRead) { + /** Returns whether the data source is currently opened. */ + public final boolean isOpened() { + return sourceOpened; + } + + protected void onDataRead(int bytesRead) throws IOException { // Do nothing. Can be overridden. } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorInput.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorInput.java index 5cb11fdd81..c467bd36af 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorInput.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorInput.java @@ -15,12 +15,14 @@ */ package com.google.android.exoplayer2.testutil; +import static com.google.common.truth.Truth.assertThat; + import android.util.SparseBooleanArray; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.util.Util; import java.io.EOFException; import java.io.IOException; -import junit.framework.Assert; /** * A fake {@link ExtractorInput} capable of simulating various scenarios. @@ -84,30 +86,23 @@ public final class FakeExtractorInput implements ExtractorInput { * @param position The position to set. */ public void setPosition(int position) { - Assert.assertTrue(0 <= position && position <= data.length); + assertThat(0 <= position && position <= data.length).isTrue(); readPosition = position; peekPosition = position; } @Override public int read(byte[] target, int offset, int length) throws IOException { + checkIOException(readPosition, failedReadPositions); length = getReadLength(length); - if (readFully(target, offset, length, true)) { - return length; - } - return C.RESULT_END_OF_INPUT; + return readFullyInternal(target, offset, length, true) ? length : C.RESULT_END_OF_INPUT; } @Override public boolean readFully(byte[] target, int offset, int length, boolean allowEndOfInput) throws IOException { - if (!checkXFully(allowEndOfInput, readPosition, length, failedReadPositions)) { - return false; - } - System.arraycopy(data, readPosition, target, offset, length); - readPosition += length; - peekPosition = readPosition; - return true; + checkIOException(readPosition, failedReadPositions); + return readFullyInternal(target, offset, length, allowEndOfInput); } @Override @@ -117,21 +112,15 @@ public final class FakeExtractorInput implements ExtractorInput { @Override public int skip(int length) throws IOException { + checkIOException(readPosition, failedReadPositions); length = getReadLength(length); - if (skipFully(length, true)) { - return length; - } - return C.RESULT_END_OF_INPUT; + return skipFullyInternal(length, true) ? length : C.RESULT_END_OF_INPUT; } @Override public boolean skipFully(int length, boolean allowEndOfInput) throws IOException { - if (!checkXFully(allowEndOfInput, readPosition, length, failedReadPositions)) { - return false; - } - readPosition += length; - peekPosition = readPosition; - return true; + checkIOException(readPosition, failedReadPositions); + return skipFullyInternal(length, allowEndOfInput); } @Override @@ -142,7 +131,8 @@ public final class FakeExtractorInput implements ExtractorInput { @Override public boolean peekFully(byte[] target, int offset, int length, boolean allowEndOfInput) throws IOException { - if (!checkXFully(allowEndOfInput, peekPosition, length, failedPeekPositions)) { + checkIOException(peekPosition, failedPeekPositions); + if (!checkXFully(allowEndOfInput, peekPosition, length)) { return false; } System.arraycopy(data, peekPosition, target, offset, length); @@ -157,7 +147,8 @@ public final class FakeExtractorInput implements ExtractorInput { @Override public boolean advancePeekPosition(int length, boolean allowEndOfInput) throws IOException { - if (!checkXFully(allowEndOfInput, peekPosition, length, failedPeekPositions)) { + checkIOException(peekPosition, failedPeekPositions); + if (!checkXFully(allowEndOfInput, peekPosition, length)) { return false; } peekPosition += length; @@ -191,18 +182,22 @@ public final class FakeExtractorInput implements ExtractorInput { @Override public void setRetryPosition(long position, E e) throws E { - Assert.assertTrue(position >= 0); + assertThat(position >= 0).isTrue(); readPosition = (int) position; throw e; } - private boolean checkXFully(boolean allowEndOfInput, int position, int length, - SparseBooleanArray failedPositions) throws IOException { + private void checkIOException(int position, SparseBooleanArray failedPositions) + throws SimulatedIOException { if (simulateIOErrors && !failedPositions.get(position)) { failedPositions.put(position, true); peekPosition = readPosition; throw new SimulatedIOException("Simulated IO error at position: " + position); } + } + + private boolean checkXFully(boolean allowEndOfInput, int position, int length) + throws EOFException { if (length > 0 && position == data.length) { if (allowEndOfInput) { return false; @@ -230,6 +225,26 @@ public final class FakeExtractorInput implements ExtractorInput { return Math.min(requestedLength, data.length - readPosition); } + private boolean readFullyInternal(byte[] target, int offset, int length, boolean allowEndOfInput) + throws EOFException { + if (!checkXFully(allowEndOfInput, readPosition, length)) { + return false; + } + System.arraycopy(data, readPosition, target, offset, length); + readPosition += length; + peekPosition = readPosition; + return true; + } + + private boolean skipFullyInternal(int length, boolean allowEndOfInput) throws EOFException { + if (!checkXFully(allowEndOfInput, readPosition, length)) { + return false; + } + readPosition += length; + peekPosition = readPosition; + return true; + } + /** * Builder of {@link FakeExtractorInput} instances. */ @@ -241,7 +256,7 @@ public final class FakeExtractorInput implements ExtractorInput { private boolean simulateIOErrors; public Builder() { - data = new byte[0]; + data = Util.EMPTY_BYTE_ARRAY; } public Builder setData(byte[] data) { diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorOutput.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorOutput.java index ee8927ea21..c6543bd7a5 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorOutput.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeExtractorOutput.java @@ -15,14 +15,16 @@ */ package com.google.android.exoplayer2.testutil; -import android.app.Instrumentation; +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import android.content.Context; import android.util.SparseArray; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.SeekMap; import java.io.File; import java.io.IOException; import java.io.PrintWriter; -import junit.framework.Assert; /** * A fake {@link ExtractorOutput}. @@ -30,9 +32,9 @@ import junit.framework.Assert; public final class FakeExtractorOutput implements ExtractorOutput, Dumper.Dumpable { /** - * If true, makes {@link #assertOutput(Instrumentation, String)} method write dump result to - * {@code /sdcard/Android/data/apk_package/ + dumpfile} file instead of comparing it with an - * existing file. + * If true, makes {@link #assertOutput(Context, String)} method write dump result to {@code + * /sdcard/Android/data/apk_package/ + dumpfile} file instead of comparing it with an existing + * file. */ private static final boolean WRITE_DUMP = false; @@ -50,7 +52,7 @@ public final class FakeExtractorOutput implements ExtractorOutput, Dumper.Dumpab public FakeTrackOutput track(int id, int type) { FakeTrackOutput output = trackOutputs.get(id); if (output == null) { - Assert.assertFalse(tracksEnded); + assertThat(tracksEnded).isFalse(); numberOfTracks++; output = new FakeTrackOutput(); trackOutputs.put(id, output); @@ -69,19 +71,19 @@ public final class FakeExtractorOutput implements ExtractorOutput, Dumper.Dumpab } public void assertEquals(FakeExtractorOutput expected) { - Assert.assertEquals(expected.numberOfTracks, numberOfTracks); - Assert.assertEquals(expected.tracksEnded, tracksEnded); + assertThat(numberOfTracks).isEqualTo(expected.numberOfTracks); + assertThat(tracksEnded).isEqualTo(expected.tracksEnded); if (expected.seekMap == null) { - Assert.assertNull(seekMap); + assertThat(seekMap).isNull(); } else { // TODO: Bulk up this check if possible. - Assert.assertNotNull(seekMap); - Assert.assertEquals(expected.seekMap.getClass(), seekMap.getClass()); - Assert.assertEquals(expected.seekMap.isSeekable(), seekMap.isSeekable()); - Assert.assertEquals(expected.seekMap.getPosition(0), seekMap.getPosition(0)); + assertThat(seekMap).isNotNull(); + assertThat(seekMap.getClass()).isEqualTo(expected.seekMap.getClass()); + assertThat(seekMap.isSeekable()).isEqualTo(expected.seekMap.isSeekable()); + assertThat(seekMap.getSeekPoints(0)).isEqualTo(expected.seekMap.getSeekPoints(0)); } for (int i = 0; i < numberOfTracks; i++) { - Assert.assertEquals(expected.trackOutputs.keyAt(i), trackOutputs.keyAt(i)); + assertThat(trackOutputs.keyAt(i)).isEqualTo(expected.trackOutputs.keyAt(i)); trackOutputs.valueAt(i).assertEquals(expected.trackOutputs.valueAt(i)); } } @@ -95,29 +97,30 @@ public final class FakeExtractorOutput implements ExtractorOutput, Dumper.Dumpab * actual dump will be written to {@code dumpFile}. This new dump file needs to be copied to the * project, {@code library/src/androidTest/assets} folder manually. */ - public void assertOutput(Instrumentation instrumentation, String dumpFile) throws IOException { + public void assertOutput(Context context, String dumpFile) throws IOException { String actual = new Dumper().add(this).toString(); if (WRITE_DUMP) { - File directory = instrumentation.getContext().getExternalFilesDir(null); + File directory = context.getExternalFilesDir(null); File file = new File(directory, dumpFile); file.getParentFile().mkdirs(); PrintWriter out = new PrintWriter(file); out.print(actual); out.close(); } else { - String expected = TestUtil.getString(instrumentation, dumpFile); - Assert.assertEquals(dumpFile, expected, actual); + String expected = TestUtil.getString(context, dumpFile); + assertWithMessage(dumpFile).that(actual).isEqualTo(expected); } } @Override public void dump(Dumper dumper) { if (seekMap != null) { - dumper.startBlock("seekMap") + dumper + .startBlock("seekMap") .add("isSeekable", seekMap.isSeekable()) .addTime("duration", seekMap.getDurationUs()) - .add("getPosition(0)", seekMap.getPosition(0)) + .add("getPosition(0)", seekMap.getSeekPoints(0)) .endBlock(); } dumper.add("numberOfTracks", numberOfTracks); 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 d8e501a298..f2739f2b4d 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 @@ -15,112 +15,229 @@ */ package com.google.android.exoplayer2.testutil; +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; +import android.os.Handler; +import android.os.SystemClock; +import android.support.annotation.Nullable; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.source.SampleStream; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.upstream.DataSpec; import java.io.IOException; -import junit.framework.Assert; +import java.util.Collections; /** - * 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. Loading data completes immediately after + * the period has finished preparing. */ -public final class FakeMediaPeriod implements MediaPeriod { +public class FakeMediaPeriod implements MediaPeriod { + + public static final DataSpec FAKE_DATA_SPEC = new DataSpec(Uri.parse("http://fake.uri")); private final TrackGroupArray trackGroupArray; + protected final EventDispatcher eventDispatcher; - private boolean preparedPeriod; + @Nullable private Handler playerHandler; + @Nullable private Callback prepareCallback; - public FakeMediaPeriod(TrackGroupArray trackGroupArray) { + private boolean deferOnPrepared; + private boolean notifiedReadingStarted; + private boolean prepared; + private long seekOffsetUs; + private long discontinuityPositionUs; + + /** + * @param trackGroupArray The track group array. + * @param eventDispatcher A dispatcher for media source events. + */ + public FakeMediaPeriod(TrackGroupArray trackGroupArray, EventDispatcher eventDispatcher) { + this(trackGroupArray, eventDispatcher, /* deferOnPrepared */ false); + } + + /** + * @param trackGroupArray The track group array. + * @param eventDispatcher A dispatcher for media source events. + * @param deferOnPrepared Whether {@link MediaPeriod.Callback#onPrepared(MediaPeriod)} should be + * called only after {@link #setPreparationComplete()} has been called. If {@code false} + * preparation completes immediately. + */ + public FakeMediaPeriod( + TrackGroupArray trackGroupArray, EventDispatcher eventDispatcher, boolean deferOnPrepared) { this.trackGroupArray = trackGroupArray; + this.eventDispatcher = eventDispatcher; + this.deferOnPrepared = deferOnPrepared; + discontinuityPositionUs = C.TIME_UNSET; + eventDispatcher.mediaPeriodCreated(); + } + + /** + * Sets a discontinuity position to be returned from the next call to + * {@link #readDiscontinuity()}. + * + * @param discontinuityPositionUs The position to be returned, in microseconds. + */ + public void setDiscontinuityPositionUs(long discontinuityPositionUs) { + this.discontinuityPositionUs = discontinuityPositionUs; + } + + /** + * Allows the fake media period to complete preparation. May be called on any thread. + */ + public synchronized void setPreparationComplete() { + deferOnPrepared = false; + if (playerHandler != null && prepareCallback != null) { + playerHandler.post(this::finishPreparation); + } + } + + /** + * Sets an offset to be applied to positions returned by {@link #seekToUs(long)}. + * + * @param seekOffsetUs The offset to be applied, in microseconds. + */ + public void setSeekToUsOffset(long seekOffsetUs) { + this.seekOffsetUs = seekOffsetUs; } public void release() { - preparedPeriod = false; + prepared = false; + eventDispatcher.mediaPeriodReleased(); } @Override - public void prepare(Callback callback, long positionUs) { - Assert.assertFalse(preparedPeriod); - Assert.assertEquals(0, positionUs); - preparedPeriod = true; - callback.onPrepared(this); + public synchronized void prepare(Callback callback, long positionUs) { + eventDispatcher.loadStarted( + FAKE_DATA_SPEC, + C.DATA_TYPE_MEDIA, + C.TRACK_TYPE_UNKNOWN, + /* trackFormat= */ null, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ 0, + /* mediaEndTimeUs = */ C.TIME_UNSET, + SystemClock.elapsedRealtime()); + prepareCallback = callback; + if (deferOnPrepared) { + playerHandler = new Handler(); + } else { + finishPreparation(); + } } @Override public void maybeThrowPrepareError() throws IOException { - Assert.assertTrue(preparedPeriod); + // Do nothing. } @Override public TrackGroupArray getTrackGroups() { - Assert.assertTrue(preparedPeriod); + assertThat(prepared).isTrue(); return trackGroupArray; } @Override public long selectTracks(TrackSelection[] selections, boolean[] mayRetainStreamFlags, SampleStream[] streams, boolean[] streamResetFlags, long positionUs) { - Assert.assertTrue(preparedPeriod); + assertThat(prepared).isTrue(); int rendererCount = selections.length; for (int i = 0; i < rendererCount; i++) { 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()); + assertThat(selection.length()).isAtLeast(1); TrackGroup trackGroup = selection.getTrackGroup(); - Assert.assertTrue(trackGroupArray.indexOf(trackGroup) != C.INDEX_UNSET); + assertThat(trackGroupArray.indexOf(trackGroup) != C.INDEX_UNSET).isTrue(); int indexInTrackGroup = selection.getIndexInTrackGroup(selection.getSelectedIndex()); - Assert.assertTrue(0 <= indexInTrackGroup); - Assert.assertTrue(indexInTrackGroup < trackGroup.length); - streams[i] = new FakeSampleStream(selection.getSelectedFormat()); + assertThat(indexInTrackGroup).isAtLeast(0); + assertThat(indexInTrackGroup).isLessThan(trackGroup.length); + streams[i] = createSampleStream(selection); streamResetFlags[i] = true; } } - return 0; + return positionUs; } @Override - public void discardBuffer(long positionUs) { + public void discardBuffer(long positionUs, boolean toKeyframe) { + // Do nothing. + } + + @Override + public void reevaluateBuffer(long positionUs) { // Do nothing. } @Override public long readDiscontinuity() { - Assert.assertTrue(preparedPeriod); - return C.TIME_UNSET; + assertThat(prepared).isTrue(); + if (!notifiedReadingStarted) { + eventDispatcher.readingStarted(); + notifiedReadingStarted = true; + } + long positionDiscontinuityUs = this.discontinuityPositionUs; + this.discontinuityPositionUs = C.TIME_UNSET; + return positionDiscontinuityUs; } @Override public long getBufferedPositionUs() { - Assert.assertTrue(preparedPeriod); + assertThat(prepared).isTrue(); return C.TIME_END_OF_SOURCE; } @Override public long seekToUs(long positionUs) { - Assert.assertTrue(preparedPeriod); + assertThat(prepared).isTrue(); + return positionUs + seekOffsetUs; + } + + @Override + public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { return positionUs; } @Override public long getNextLoadPositionUs() { - Assert.assertTrue(preparedPeriod); + assertThat(prepared).isTrue(); return C.TIME_END_OF_SOURCE; } @Override public boolean continueLoading(long positionUs) { - Assert.assertTrue(preparedPeriod); return false; } + protected SampleStream createSampleStream(TrackSelection selection) { + return new FakeSampleStream( + selection.getSelectedFormat(), eventDispatcher, /* shouldOutputSample= */ true); + } + + private void finishPreparation() { + prepared = true; + prepareCallback.onPrepared(this); + eventDispatcher.loadCompleted( + FAKE_DATA_SPEC, + FAKE_DATA_SPEC.uri, + /* responseHeaders= */ Collections.emptyMap(), + C.DATA_TYPE_MEDIA, + C.TRACK_TYPE_UNKNOWN, + /* trackFormat= */ null, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ 0, + /* mediaEndTimeUs = */ C.TIME_UNSET, + SystemClock.elapsedRealtime(), + /* loadDurationMs= */ 0, + /* bytesLoaded= */ 100); + } } 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 a2c1e9879e..2fca4f42c7 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 @@ -15,93 +15,232 @@ */ package com.google.android.exoplayer2.testutil; +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; +import android.os.Handler; +import android.os.SystemClock; +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.Timeline.Period; +import com.google.android.exoplayer2.source.BaseMediaSource; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import com.google.android.exoplayer2.source.MediaSourceEventListener.LoadEventInfo; +import com.google.android.exoplayer2.source.MediaSourceEventListener.MediaLoadData; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; import java.io.IOException; import java.util.ArrayList; -import junit.framework.Assert; +import java.util.Collections; +import java.util.List; /** - * Fake {@link MediaSource} that provides a given timeline. Creating the period will return a - * {@link FakeMediaPeriod} with a {@link TrackGroupArray} using the given {@link Format}s. + * Fake {@link MediaSource} that provides a given timeline. Creating the period will return a {@link + * FakeMediaPeriod} with a {@link TrackGroupArray} using the given {@link Format}s. */ -public class FakeMediaSource implements MediaSource { +public class FakeMediaSource extends BaseMediaSource { + + private static final DataSpec FAKE_DATA_SPEC = new DataSpec(Uri.parse("http://manifest.uri")); + private static final int MANIFEST_LOAD_BYTES = 100; - private final Timeline timeline; - private final Object manifest; private final TrackGroupArray trackGroupArray; private final ArrayList activeMediaPeriods; + private final ArrayList createdMediaPeriods; + protected Timeline timeline; + private Object manifest; private boolean preparedSource; private boolean releasedSource; + private Handler sourceInfoRefreshHandler; + private @Nullable TransferListener transferListener; /** * Creates a {@link FakeMediaSource}. This media source creates {@link FakeMediaPeriod}s with a - * {@link TrackGroupArray} using the given {@link Format}s. + * {@link TrackGroupArray} using the given {@link Format}s. The provided {@link Timeline} may be + * null to prevent an immediate source info refresh message when preparing the media source. It + * can be manually set later using {@link #setNewSourceInfo(Timeline, Object)}. */ - public FakeMediaSource(Timeline timeline, Object manifest, Format... formats) { + public FakeMediaSource(@Nullable Timeline timeline, Object manifest, Format... formats) { this(timeline, manifest, buildTrackGroupArray(formats)); } /** * Creates a {@link FakeMediaSource}. This media source creates {@link FakeMediaPeriod}s with the - * given {@link TrackGroupArray}. + * given {@link TrackGroupArray}. The provided {@link Timeline} may be null to prevent an + * immediate source info refresh message when preparing the media source. It can be manually set + * later using {@link #setNewSourceInfo(Timeline, Object)}. */ - public FakeMediaSource(Timeline timeline, Object manifest, TrackGroupArray trackGroupArray) { + public FakeMediaSource(@Nullable Timeline timeline, Object manifest, + TrackGroupArray trackGroupArray) { 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); + public synchronized void prepareSourceInternal( + ExoPlayer player, + boolean isTopLevelSource, + @Nullable TransferListener mediaTransferListener) { + assertThat(preparedSource).isFalse(); + transferListener = mediaTransferListener; preparedSource = true; - listener.onSourceInfoRefreshed(timeline, manifest); + releasedSource = false; + sourceInfoRefreshHandler = new Handler(); + if (timeline != null) { + finishSourcePreparation(); + } } @Override public void maybeThrowSourceInfoRefreshError() throws IOException { - Assert.assertTrue(preparedSource); + assertThat(preparedSource).isTrue(); } @Override 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); + assertThat(preparedSource).isTrue(); + assertThat(releasedSource).isFalse(); + int periodIndex = timeline.getIndexOfPeriod(id.periodUid); + Assertions.checkArgument(periodIndex != C.INDEX_UNSET); + Period period = timeline.getPeriod(periodIndex, new Period()); + EventDispatcher eventDispatcher = + createEventDispatcher(period.windowIndex, id, period.getPositionInWindowMs()); + FakeMediaPeriod mediaPeriod = + createFakeMediaPeriod(id, trackGroupArray, allocator, eventDispatcher, transferListener); activeMediaPeriods.add(mediaPeriod); + createdMediaPeriods.add(id); return mediaPeriod; } @Override public void releasePeriod(MediaPeriod mediaPeriod) { - Assert.assertTrue(preparedSource); - Assert.assertFalse(releasedSource); + assertThat(preparedSource).isTrue(); + assertThat(releasedSource).isFalse(); FakeMediaPeriod fakeMediaPeriod = (FakeMediaPeriod) mediaPeriod; - Assert.assertTrue(activeMediaPeriods.remove(fakeMediaPeriod)); + assertThat(activeMediaPeriods.remove(fakeMediaPeriod)).isTrue(); fakeMediaPeriod.release(); } @Override - public void releaseSource() { - Assert.assertTrue(preparedSource); - Assert.assertFalse(releasedSource); - Assert.assertTrue(activeMediaPeriods.isEmpty()); + public void releaseSourceInternal() { + assertThat(preparedSource).isTrue(); + assertThat(releasedSource).isFalse(); + assertThat(activeMediaPeriods.isEmpty()).isTrue(); releasedSource = true; + preparedSource = false; + sourceInfoRefreshHandler.removeCallbacksAndMessages(null); + sourceInfoRefreshHandler = null; + } + + /** + * Sets a new timeline and manifest. If the source is already prepared, this triggers a source + * info refresh message being sent to the listener. + */ + public synchronized void setNewSourceInfo(final Timeline newTimeline, final Object newManifest) { + if (sourceInfoRefreshHandler != null) { + sourceInfoRefreshHandler.post( + () -> { + assertThat(releasedSource).isFalse(); + assertThat(preparedSource).isTrue(); + timeline = newTimeline; + manifest = newManifest; + finishSourcePreparation(); + }); + } else { + timeline = newTimeline; + manifest = newManifest; + } + } + + /** Returns whether the source is currently prepared. */ + public boolean isPrepared() { + return preparedSource; + } + + /** + * Assert that the source and all periods have been released. + */ + public void assertReleased() { + assertThat(releasedSource || !preparedSource).isTrue(); + } + + /** + * Assert that a media period for the given id has been created. + */ + public void assertMediaPeriodCreated(MediaPeriodId mediaPeriodId) { + assertThat(createdMediaPeriods).contains(mediaPeriodId); + } + + /** Returns a list of {@link MediaPeriodId}s, with one element for each created media period. */ + public List getCreatedMediaPeriods() { + return createdMediaPeriods; + } + + /** + * Creates a {@link FakeMediaPeriod} for this media source. + * + * @param id The identifier of the period. + * @param trackGroupArray The {@link TrackGroupArray} supported by the media period. + * @param allocator An {@link Allocator} from which to obtain media buffer allocations. + * @param eventDispatcher An {@link EventDispatcher} to dispatch media source events. + * @param transferListener The transfer listener which should be informed of any data transfers. + * May be null if no listener is available. + * @return A new {@link FakeMediaPeriod}. + */ + protected FakeMediaPeriod createFakeMediaPeriod( + MediaPeriodId id, + TrackGroupArray trackGroupArray, + Allocator allocator, + EventDispatcher eventDispatcher, + @Nullable TransferListener transferListener) { + return new FakeMediaPeriod(trackGroupArray, eventDispatcher); + } + + private void finishSourcePreparation() { + refreshSourceInfo(timeline, manifest); + if (!timeline.isEmpty()) { + MediaLoadData mediaLoadData = + new MediaLoadData( + C.DATA_TYPE_MANIFEST, + C.TRACK_TYPE_UNKNOWN, + /* trackFormat= */ null, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeMs= */ C.TIME_UNSET, + /* mediaEndTimeMs = */ C.TIME_UNSET); + long elapsedRealTimeMs = SystemClock.elapsedRealtime(); + EventDispatcher eventDispatcher = createEventDispatcher(/* mediaPeriodId= */ null); + eventDispatcher.loadStarted( + new LoadEventInfo( + FAKE_DATA_SPEC, + FAKE_DATA_SPEC.uri, + /* responseHeaders= */ Collections.emptyMap(), + elapsedRealTimeMs, + /* loadDurationMs= */ 0, + /* bytesLoaded= */ 0), + mediaLoadData); + eventDispatcher.loadCompleted( + new LoadEventInfo( + FAKE_DATA_SPEC, + FAKE_DATA_SPEC.uri, + /* responseHeaders= */ Collections.emptyMap(), + elapsedRealTimeMs, + /* loadDurationMs= */ 0, + /* bytesLoaded= */ MANIFEST_LOAD_BYTES), + mediaLoadData); + } } private static TrackGroupArray buildTrackGroupArray(Format... formats) { @@ -111,4 +250,5 @@ public class FakeMediaSource implements MediaSource { } return new TrackGroupArray(trackGroups); } + } 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 a66043b77f..0d65d7fcc7 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 @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.testutil; +import static com.google.common.truth.Truth.assertThat; + import com.google.android.exoplayer2.BaseRenderer; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; @@ -22,11 +24,11 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; import java.util.Arrays; import java.util.Collections; import java.util.List; -import junit.framework.Assert; /** * Fake {@link Renderer} that supports any format with the matching MIME type. The renderer @@ -34,50 +36,74 @@ import junit.framework.Assert; */ public class FakeRenderer extends BaseRenderer { + /** + * The amount of time ahead of the current playback position that the renderer reads from the + * source. A real renderer will typically read ahead by a small amount due to pipelining through + * decoders and the media output path. + */ + private static final long SOURCE_READAHEAD_US = 250000; + private final List expectedFormats; private final DecoderInputBuffer buffer; + private final FormatHolder formatHolder; + private long playbackPositionUs; + private long lastSamplePositionUs; + + public boolean isEnded; public int positionResetCount; public int formatReadCount; - public int bufferReadCount; - public boolean isEnded; - public boolean isReady; + public int sampleBufferReadCount; public FakeRenderer(Format... expectedFormats) { super(expectedFormats.length == 0 ? C.TRACK_TYPE_UNKNOWN : MimeTypes.getTrackType(expectedFormats[0].sampleMimeType)); this.expectedFormats = Collections.unmodifiableList(Arrays.asList(expectedFormats)); - this.buffer = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); + buffer = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); + formatHolder = new FormatHolder(); + lastSamplePositionUs = Long.MIN_VALUE; } @Override protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { + playbackPositionUs = positionUs; + lastSamplePositionUs = Long.MIN_VALUE; positionResetCount++; isEnded = false; } @Override public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { - if (!isEnded) { - // Verify the format matches the expected format. - FormatHolder formatHolder = new FormatHolder(); + if (isEnded) { + return; + } + playbackPositionUs = positionUs; + while (lastSamplePositionUs < positionUs + SOURCE_READAHEAD_US) { + formatHolder.format = null; + buffer.clear(); int result = readSource(formatHolder, buffer, false); if (result == C.RESULT_FORMAT_READ) { formatReadCount++; - Assert.assertTrue(expectedFormats.contains(formatHolder.format)); + assertThat(expectedFormats).contains(formatHolder.format); + onFormatChanged(formatHolder.format); } else if (result == C.RESULT_BUFFER_READ) { - bufferReadCount++; if (buffer.isEndOfStream()) { isEnded = true; + return; } + lastSamplePositionUs = buffer.timeUs; + sampleBufferReadCount++; + onBufferRead(); + } else { + Assertions.checkState(result == C.RESULT_NOTHING_READ); + return; } } - isReady = buffer.timeUs >= positionUs; } @Override public boolean isReady() { - return isReady || isSourceReady(); + return lastSamplePositionUs >= playbackPositionUs || isSourceReady(); } @Override @@ -91,4 +117,9 @@ public class FakeRenderer extends BaseRenderer { ? (FORMAT_HANDLED | ADAPTIVE_SEAMLESS) : FORMAT_UNSUPPORTED_TYPE; } + /** Called when the renderer reads a new format. */ + protected void onFormatChanged(Format format) {} + + /** Called when the renderer read a sample from the buffer. */ + protected void onBufferRead() {} } 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 4e1e32980f..8b653f6642 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 @@ -15,25 +15,41 @@ */ package com.google.android.exoplayer2.testutil; +import android.support.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.source.SampleStream; import java.io.IOException; /** - * Fake {@link SampleStream} that outputs a given {@link Format} then sets the end of stream flag - * on its input buffer. + * Fake {@link SampleStream} that outputs a given {@link Format}, an optional sample containing a + * single zero byte, then end of stream. */ public final class FakeSampleStream implements SampleStream { private final Format format; + private final @Nullable EventDispatcher eventDispatcher; + private boolean notifiedDownstreamFormat; private boolean readFormat; + private boolean readSample; - public FakeSampleStream(Format format) { + /** + * Creates fake sample stream which outputs the given {@link Format}, optionally one sample with + * zero bytes, then end of stream. + * + * @param format The {@link Format} to output. + * @param eventDispatcher An {@link EventDispatcher} to notify of read events. + * @param shouldOutputSample Whether the sample stream should output a sample. + */ + public FakeSampleStream( + Format format, @Nullable EventDispatcher eventDispatcher, boolean shouldOutputSample) { this.format = format; + this.eventDispatcher = eventDispatcher; + readSample = !shouldOutputSample; } @Override @@ -44,10 +60,26 @@ public final class FakeSampleStream implements SampleStream { @Override public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, boolean formatRequired) { + if (eventDispatcher != null && !notifiedDownstreamFormat) { + eventDispatcher.downstreamFormatChanged( + C.TRACK_TYPE_UNKNOWN, + format, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaTimeUs= */ 0); + notifiedDownstreamFormat = true; + } if (formatRequired || !readFormat) { formatHolder.format = format; readFormat = true; return C.RESULT_FORMAT_READ; + } else if (!readSample) { + buffer.timeUs = 0; + buffer.ensureSpaceForWrite(1); + buffer.data.put((byte) 0); + buffer.flip(); + readSample = true; + return C.RESULT_BUFFER_READ; } else { buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); return C.RESULT_BUFFER_READ; @@ -60,8 +92,8 @@ public final class FakeSampleStream implements SampleStream { } @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/FakeTimeline.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java index 040782264b..56438a51ef 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 @@ -15,9 +15,13 @@ */ package com.google.android.exoplayer2.testutil; +import android.util.Pair; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.ads.AdPlaybackState; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; +import java.util.Arrays; /** * Fake {@link Timeline} which can be setup to return custom {@link TimelineWindowDefinition}s. @@ -29,36 +33,123 @@ public final class FakeTimeline extends Timeline { */ public static final class TimelineWindowDefinition { - private static final int WINDOW_DURATION_US = 100000; + /** Default test window duration in microseconds. */ + public static final long DEFAULT_WINDOW_DURATION_US = 10 * C.MICROS_PER_SECOND; public final int periodCount; public final Object id; public final boolean isSeekable; public final boolean isDynamic; public final long durationUs; + public final AdPlaybackState adPlaybackState; + /** + * Creates a seekable, non-dynamic window definition with a duration of + * {@link #DEFAULT_WINDOW_DURATION_US}. + * + * @param periodCount The number of periods in the window. Each period get an equal slice of the + * total window duration. + * @param id The UID of the window. + */ public TimelineWindowDefinition(int periodCount, Object id) { - this(periodCount, id, true, false, WINDOW_DURATION_US); + this(periodCount, id, true, false, DEFAULT_WINDOW_DURATION_US); } + /** + * Creates a window definition with one period. + * + * @param isSeekable Whether the window is seekable. + * @param isDynamic Whether the window is dynamic. + * @param durationUs The duration of the window in microseconds. + */ public TimelineWindowDefinition(boolean isSeekable, boolean isDynamic, long durationUs) { this(1, 0, isSeekable, isDynamic, durationUs); } + /** + * Creates a window definition. + * + * @param periodCount The number of periods in the window. Each period get an equal slice of the + * total window duration. + * @param id The UID of the window. + * @param isSeekable Whether the window is seekable. + * @param isDynamic Whether the window is dynamic. + * @param durationUs The duration of the window in microseconds. + */ public TimelineWindowDefinition(int periodCount, Object id, boolean isSeekable, boolean isDynamic, long durationUs) { + this(periodCount, id, isSeekable, isDynamic, durationUs, AdPlaybackState.NONE); + } + + /** + * Creates a window definition with ad groups. + * + * @param periodCount The number of periods in the window. Each period get an equal slice of the + * total window duration. + * @param id The UID of the window. + * @param isSeekable Whether the window is seekable. + * @param isDynamic Whether the window is dynamic. + * @param durationUs The duration of the window in microseconds. + * @param adPlaybackState The ad playback state. + */ + public TimelineWindowDefinition( + int periodCount, + Object id, + boolean isSeekable, + boolean isDynamic, + long durationUs, + AdPlaybackState adPlaybackState) { this.periodCount = periodCount; this.id = id; this.isSeekable = isSeekable; this.isDynamic = isDynamic; this.durationUs = durationUs; + this.adPlaybackState = adPlaybackState; } } + private static final long AD_DURATION_US = 10 * C.MICROS_PER_SECOND; + private final TimelineWindowDefinition[] windowDefinitions; private final int[] periodOffsets; + /** + * Returns an ad playback state with the specified number of ads in each of the specified ad + * groups, each ten seconds long. + * + * @param adsPerAdGroup The number of ads per ad group. + * @param adGroupTimesUs The times of ad groups, in microseconds. + * @return The ad playback state. + */ + public static AdPlaybackState createAdPlaybackState(int adsPerAdGroup, long... adGroupTimesUs) { + int adGroupCount = adGroupTimesUs.length; + AdPlaybackState adPlaybackState = new AdPlaybackState(adGroupTimesUs); + long[][] adDurationsUs = new long[adGroupCount][]; + for (int i = 0; i < adGroupCount; i++) { + adPlaybackState = adPlaybackState.withAdCount(i, adsPerAdGroup); + adDurationsUs[i] = new long[adsPerAdGroup]; + Arrays.fill(adDurationsUs[i], AD_DURATION_US); + } + adPlaybackState = adPlaybackState.withAdDurationsUs(adDurationsUs); + return adPlaybackState; + } + + /** + * Creates a fake timeline with the given number of seekable, non-dynamic windows with one period + * with a duration of {@link TimelineWindowDefinition#DEFAULT_WINDOW_DURATION_US} each. + * + * @param windowCount The number of windows. + */ + public FakeTimeline(int windowCount) { + this(createDefaultWindowDefinitions(windowCount)); + } + + /** + * Creates a fake timeline with the given window definitions. + * + * @param windowDefinitions A list of {@link TimelineWindowDefinition}s. + */ public FakeTimeline(TimelineWindowDefinition... windowDefinitions) { this.windowDefinitions = windowDefinitions; periodOffsets = new int[windowDefinitions.length + 1]; @@ -74,13 +165,21 @@ public final class FakeTimeline extends Timeline { } @Override - public Window getWindow(int windowIndex, Window window, boolean setIds, - long defaultPositionProjectionUs) { + public Window getWindow( + int windowIndex, Window window, boolean setTag, long defaultPositionProjectionUs) { TimelineWindowDefinition windowDefinition = windowDefinitions[windowIndex]; - Object id = setIds ? windowDefinition.id : null; - return window.set(id, C.TIME_UNSET, C.TIME_UNSET, windowDefinition.isSeekable, - windowDefinition.isDynamic, 0, windowDefinition.durationUs, periodOffsets[windowIndex], - periodOffsets[windowIndex + 1] - 1, 0); + Object tag = setTag ? windowDefinition.id : null; + return window.set( + tag, + /* presentationStartTimeMs= */ C.TIME_UNSET, + /* windowStartTimeMs= */ C.TIME_UNSET, + windowDefinition.isSeekable, + windowDefinition.isDynamic, + /* defaultPositionUs= */ 0, + windowDefinition.durationUs, + periodOffsets[windowIndex], + periodOffsets[windowIndex + 1] - 1, + /* positionInFirstPeriodUs= */ 0); } @Override @@ -94,18 +193,45 @@ public final class FakeTimeline extends Timeline { int windowPeriodIndex = periodIndex - periodOffsets[windowIndex]; TimelineWindowDefinition windowDefinition = windowDefinitions[windowIndex]; Object id = setIds ? windowPeriodIndex : null; - Object uid = setIds ? periodIndex : null; + Object uid = setIds ? Pair.create(windowDefinition.id, windowPeriodIndex) : null; long periodDurationUs = windowDefinition.durationUs / windowDefinition.periodCount; - return period.set(id, uid, windowIndex, periodDurationUs, periodDurationUs * windowPeriodIndex); + long positionInWindowUs = periodDurationUs * windowPeriodIndex; + return period.set( + id, + uid, + windowIndex, + periodDurationUs, + positionInWindowUs, + windowDefinition.adPlaybackState); } @Override public int getIndexOfPeriod(Object uid) { - if (!(uid instanceof Integer)) { - return C.INDEX_UNSET; + for (int i = 0; i < getPeriodCount(); i++) { + if (getUidOfPeriod(i).equals(uid)) { + return i; + } } - int index = (Integer) uid; - return index >= 0 && index < getPeriodCount() ? index : C.INDEX_UNSET; + return C.INDEX_UNSET; + } + + @Override + public Object getUidOfPeriod(int periodIndex) { + Assertions.checkIndex(periodIndex, 0, getPeriodCount()); + int windowIndex = + Util.binarySearchFloor( + periodOffsets, periodIndex, /* inclusive= */ true, /* stayInBounds= */ false); + int windowPeriodIndex = periodIndex - periodOffsets[windowIndex]; + TimelineWindowDefinition windowDefinition = windowDefinitions[windowIndex]; + return Pair.create(windowDefinition.id, windowPeriodIndex); + } + + private static TimelineWindowDefinition[] createDefaultWindowDefinitions(int windowCount) { + TimelineWindowDefinition[] windowDefinitions = new TimelineWindowDefinition[windowCount]; + for (int i = 0; i < windowCount; i++) { + windowDefinitions[i] = new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ i); + } + return windowDefinitions; } } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java index b14e6f60ef..4dd00557ae 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackOutput.java @@ -15,17 +15,20 @@ */ package com.google.android.exoplayer2.testutil; -import android.test.MoreAsserts; +import static com.google.common.truth.Truth.assertThat; + import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.android.exoplayer2.util.Util; import java.io.EOFException; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; -import junit.framework.Assert; +import java.util.Collections; +import java.util.List; /** * A fake {@link TrackOutput}. @@ -42,7 +45,7 @@ public final class FakeTrackOutput implements TrackOutput, Dumper.Dumpable { public Format format; public FakeTrackOutput() { - sampleData = new byte[0]; + sampleData = Util.EMPTY_BYTE_ARRAY; sampleTimesUs = new ArrayList<>(); sampleFlags = new ArrayList<>(); sampleStartOffsets = new ArrayList<>(); @@ -51,7 +54,7 @@ public final class FakeTrackOutput implements TrackOutput, Dumper.Dumpable { } public void clear() { - sampleData = new byte[0]; + sampleData = Util.EMPTY_BYTE_ARRAY; sampleTimesUs.clear(); sampleFlags.clear(); sampleStartOffsets.clear(); @@ -98,15 +101,15 @@ public final class FakeTrackOutput implements TrackOutput, Dumper.Dumpable { } public void assertSampleCount(int count) { - Assert.assertEquals(count, sampleTimesUs.size()); + assertThat(sampleTimesUs).hasSize(count); } public void assertSample(int index, byte[] data, long timeUs, int flags, CryptoData cryptoData) { byte[] actualData = getSampleData(index); - MoreAsserts.assertEquals(data, actualData); - Assert.assertEquals(timeUs, (long) sampleTimesUs.get(index)); - Assert.assertEquals(flags, (int) sampleFlags.get(index)); - Assert.assertEquals(cryptoData, cryptoDatas.get(index)); + assertThat(actualData).isEqualTo(data); + assertThat(sampleTimesUs.get(index)).isEqualTo(timeUs); + assertThat(sampleFlags.get(index)).isEqualTo(flags); + assertThat(cryptoDatas.get(index)).isEqualTo(cryptoData); } public byte[] getSampleData(int index) { @@ -114,19 +117,39 @@ public final class FakeTrackOutput implements TrackOutput, Dumper.Dumpable { sampleEndOffsets.get(index)); } + public long getSampleTimeUs(int index) { + return sampleTimesUs.get(index); + } + + public int getSampleFlags(int index) { + return sampleFlags.get(index); + } + + public CryptoData getSampleCryptoData(int index) { + return cryptoDatas.get(index); + } + + public int getSampleCount() { + return sampleTimesUs.size(); + } + + public List getSampleTimesUs() { + return Collections.unmodifiableList(sampleTimesUs); + } + public void assertEquals(FakeTrackOutput expected) { - Assert.assertEquals(expected.format, format); - Assert.assertEquals(expected.sampleTimesUs.size(), sampleTimesUs.size()); - MoreAsserts.assertEquals(expected.sampleData, sampleData); + assertThat(format).isEqualTo(expected.format); + assertThat(sampleTimesUs).hasSize(expected.sampleTimesUs.size()); + assertThat(sampleData).isEqualTo(expected.sampleData); for (int i = 0; i < sampleTimesUs.size(); i++) { - Assert.assertEquals(expected.sampleTimesUs.get(i), sampleTimesUs.get(i)); - Assert.assertEquals(expected.sampleFlags.get(i), sampleFlags.get(i)); - Assert.assertEquals(expected.sampleStartOffsets.get(i), sampleStartOffsets.get(i)); - Assert.assertEquals(expected.sampleEndOffsets.get(i), sampleEndOffsets.get(i)); + assertThat(sampleTimesUs.get(i)).isEqualTo(expected.sampleTimesUs.get(i)); + assertThat(sampleFlags.get(i)).isEqualTo(expected.sampleFlags.get(i)); + assertThat(sampleStartOffsets.get(i)).isEqualTo(expected.sampleStartOffsets.get(i)); + assertThat(sampleEndOffsets.get(i)).isEqualTo(expected.sampleEndOffsets.get(i)); if (expected.cryptoDatas.get(i) == null) { - Assert.assertNull(cryptoDatas.get(i)); + assertThat(cryptoDatas.get(i)).isNull(); } else { - Assert.assertEquals(expected.cryptoDatas.get(i), cryptoDatas.get(i)); + assertThat(cryptoDatas.get(i)).isEqualTo(expected.cryptoDatas.get(i)); } } } @@ -160,6 +183,7 @@ public final class FakeTrackOutput implements TrackOutput, Dumper.Dumpable { } dumper.endBlock().endBlock(); + dumper.add("total output bytes", sampleData.length); dumper.add("sample count", sampleTimesUs.size()); for (int i = 0; i < sampleTimesUs.size(); i++) { 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 831344aa8b..8f2b977f08 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 @@ -15,24 +15,22 @@ */ package com.google.android.exoplayer2.testutil; -import static junit.framework.Assert.fail; +import static org.junit.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; import android.view.Surface; import android.view.SurfaceHolder; import android.view.SurfaceView; import android.view.Window; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; /** @@ -57,19 +55,20 @@ public final class HostActivity extends Activity implements SurfaceHolder.Callba 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. @@ -81,17 +80,16 @@ public final class HostActivity extends Activity implements SurfaceHolder.Callba } private static final String TAG = "HostActivity"; + private static final long START_TIMEOUT_MS = 5000; 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,8 +98,8 @@ public final class HostActivity extends Activity implements SurfaceHolder.Callba * @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) { - runTest(hostedTest, timeoutMs, true); + public void runTest(HostedTest hostedTest, long timeoutMs) { + runTest(hostedTest, timeoutMs, /* failOnTimeoutOrForceStop= */ true); } /** @@ -109,45 +107,56 @@ public final class HostActivity extends Activity implements SurfaceHolder.Callba * * @param hostedTest The test to execute. * @param timeoutMs The number of milliseconds to wait for the test to finish. - * @param failOnTimeout Whether the test fails when the timeout is exceeded. + * @param failOnTimeoutOrForceStop Whether the test fails when a timeout is exceeded or the test + * is stopped forcefully. */ - public void runTest(final HostedTest hostedTest, long timeoutMs, boolean failOnTimeout) { + public void runTest( + final HostedTest hostedTest, long timeoutMs, boolean failOnTimeoutOrForceStop) { 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() { - maybeStartHostedTest(); + runOnUiThread( + () -> { + HostActivity.this.hostedTest = hostedTest; + maybeStartHostedTest(); + }); + + if (!hostedTestStartedCondition.block(START_TIMEOUT_MS)) { + String message = + "Test failed to start. Display may be turned off or keyguard may be present."; + Log.e(TAG, message); + if (failOnTimeoutOrForceStop) { + fail(message); } - }); + } - 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); + if (failOnTimeoutOrForceStop) { + fail(message); + } } } else { + runOnUiThread(hostedTest::forceStop); String message = "Test timed out after " + timeoutMs + " ms."; Log.e(TAG, message); - if (failOnTimeout) { + if (failOnTimeoutOrForceStop) { fail(message); } - maybeStopHostedTest(); - hostedTestStoppedCondition.block(); } + this.hostedTest = null; } // Activity lifecycle @@ -157,18 +166,16 @@ public final class HostActivity extends Activity implements SurfaceHolder.Callba 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 +183,20 @@ public final class HostActivity extends Activity implements SurfaceHolder.Callba 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 +231,13 @@ public final class HostActivity extends Activity implements SurfaceHolder.Callba 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(); - } - }); + if (hostedTest != null && hostedTestStarted && !forcedStopped) { + forcedStopped = hostedTest.forceStop(); } } - - @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); - } - } - - } - } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/LogcatMetricsLogger.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/LogcatMetricsLogger.java index fdff47dd2c..f3432749d0 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/LogcatMetricsLogger.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/LogcatMetricsLogger.java @@ -15,7 +15,7 @@ */ package com.google.android.exoplayer2.testutil; -import android.util.Log; +import com.google.android.exoplayer2.util.Log; /** * Implementation of {@link MetricsLogger} that prints the metrics to logcat. 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 5819a4b711..3351e2db8d 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 @@ -15,14 +15,20 @@ */ package com.google.android.exoplayer2.testutil; -import android.app.Instrumentation; -import android.test.InstrumentationTestCase; -import android.test.MoreAsserts; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.net.Uri; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.extractor.DefaultExtractorInput; import com.google.android.exoplayer2.extractor.Extractor; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.MediaSource.Listener; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.extractor.PositionHolder; +import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.testutil.FakeExtractorInput.SimulatedIOException; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; @@ -32,8 +38,6 @@ import java.io.IOException; import java.io.InputStream; import java.util.Arrays; import java.util.Random; -import junit.framework.Assert; -import org.mockito.MockitoAnnotations; /** * Utility methods for tests. @@ -69,6 +73,20 @@ public class TestUtil { return Arrays.copyOf(data, position); } + public static byte[] readExactly(DataSource dataSource, int length) throws IOException { + byte[] data = new byte[length]; + int position = 0; + while (position < length) { + int bytesRead = dataSource.read(data, position, data.length - position); + if (bytesRead == C.RESULT_END_OF_INPUT) { + fail("Not enough data could be read: " + position + " < " + length); + } else { + position += bytesRead; + } + } + return data; + } + public static byte[] buildTestData(int length) { return buildTestData(length, length); } @@ -121,52 +139,20 @@ public class TestUtil { 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(Context context, String fileName) throws IOException { + return Util.toByteArray(getInputStream(context, fileName)); } - public static byte[] getByteArray(Instrumentation instrumentation, String fileName) - throws IOException { - return Util.toByteArray(getInputStream(instrumentation, fileName)); + public static InputStream getInputStream(Context context, String fileName) throws IOException { + return context.getResources().getAssets().open(fileName); } - public static InputStream getInputStream(Instrumentation instrumentation, String fileName) - throws IOException { - return instrumentation.getContext().getResources().getAssets().open(fileName); + public static String getString(Context context, String fileName) throws IOException { + return Util.fromUtf8Bytes(getByteArray(context, fileName)); } - public static String getString(Instrumentation instrumentation, String fileName) - throws IOException { - return new String(getByteArray(instrumentation, fileName)); - } - - /** - * Extracts the timeline from a media source. - */ - public static Timeline extractTimelineFromMediaSource(MediaSource mediaSource) { - class TimelineListener implements Listener { - private Timeline timeline; - @Override - public synchronized void onSourceInfoRefreshed(Timeline timeline, Object manifest) { - this.timeline = timeline; - this.notify(); - } - } - TimelineListener listener = new TimelineListener(); - mediaSource.prepareSource(null, true, listener); - synchronized (listener) { - while (listener.timeline == null) { - try { - listener.wait(); - } catch (InterruptedException e) { - Assert.fail(e.getMessage()); - } - } - } - return listener.timeline; + public static Bitmap readBitmapFromFile(Context context, String fileName) throws IOException { + return BitmapFactory.decodeStream(getInputStream(context, fileName)); } /** @@ -175,18 +161,220 @@ public class TestUtil { * @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 { + public static void assertDataSourceContent( + DataSource dataSource, DataSpec dataSpec, byte[] expectedData, boolean expectKnownLength) + throws IOException { try { long length = dataSource.open(dataSpec); - Assert.assertEquals(length, expectedData.length); - byte[] readData = TestUtil.readToEnd(dataSource); - MoreAsserts.assertEquals(expectedData, readData); + assertThat(length).isEqualTo(expectKnownLength ? expectedData.length : C.LENGTH_UNSET); + byte[] readData = readToEnd(dataSource); + assertThat(readData).isEqualTo(expectedData); } finally { dataSource.close(); } } + /** + * Asserts whether actual bitmap is very similar to the expected bitmap at some quality level. + * + *

This is defined as their PSNR value is greater than or equal to the threshold. The higher + * the threshold, the more similar they are. + * + * @param expectedBitmap The expected bitmap. + * @param actualBitmap The actual bitmap. + * @param psnrThresholdDb The PSNR threshold (in dB), at or above which bitmaps are considered + * very similar. + */ + public static void assertBitmapsAreSimilar( + Bitmap expectedBitmap, Bitmap actualBitmap, double psnrThresholdDb) { + assertThat(getPsnr(expectedBitmap, actualBitmap)).isAtLeast(psnrThresholdDb); + } + + /** + * Calculates the Peak-Signal-to-Noise-Ratio value for 2 bitmaps. + * + *

This is the logarithmic decibel(dB) value of the average mean-squared-error of normalized + * (0.0-1.0) R/G/B values from the two bitmaps. The higher the value, the more similar they are. + * + * @param firstBitmap The first bitmap. + * @param secondBitmap The second bitmap. + * @return The PSNR value calculated from these 2 bitmaps. + */ + private static double getPsnr(Bitmap firstBitmap, Bitmap secondBitmap) { + assertThat(firstBitmap.getWidth()).isEqualTo(secondBitmap.getWidth()); + assertThat(firstBitmap.getHeight()).isEqualTo(secondBitmap.getHeight()); + long mse = 0; + for (int i = 0; i < firstBitmap.getWidth(); i++) { + for (int j = 0; j < firstBitmap.getHeight(); j++) { + int firstColorInt = firstBitmap.getPixel(i, j); + int firstRed = Color.red(firstColorInt); + int firstGreen = Color.green(firstColorInt); + int firstBlue = Color.blue(firstColorInt); + int secondColorInt = secondBitmap.getPixel(i, j); + int secondRed = Color.red(secondColorInt); + int secondGreen = Color.green(secondColorInt); + int secondBlue = Color.blue(secondColorInt); + mse += + ((firstRed - secondRed) * (firstRed - secondRed) + + (firstGreen - secondGreen) * (firstGreen - secondGreen) + + (firstBlue - secondBlue) * (firstBlue - secondBlue)); + } + } + double normalizedMse = + mse / (255.0 * 255.0 * 3.0 * firstBitmap.getWidth() * firstBitmap.getHeight()); + return 10 * Math.log10(1.0 / normalizedMse); + } + + /** Returns the {@link Uri} for the given asset path. */ + public static Uri buildAssetUri(String assetPath) { + return Uri.parse("asset:///" + assetPath); + } + + /** + * Reads from the given input using the given {@link Extractor}, until it can produce the {@link + * SeekMap} and all of the tracks have been identified, or until the extractor encounters EOF. + * + * @param extractor The {@link Extractor} to extractor from input. + * @param output The {@link FakeTrackOutput} to store the extracted {@link SeekMap} and track. + * @param dataSource The {@link DataSource} that will be used to read from the input. + * @param uri The Uri of the input. + * @return The extracted {@link SeekMap}. + * @throws IOException If an error occurred reading from the input, or if the extractor finishes + * reading from input without extracting any {@link SeekMap}. + * @throws InterruptedException If the thread was interrupted. + */ + public static SeekMap extractSeekMap( + Extractor extractor, FakeExtractorOutput output, DataSource dataSource, Uri uri) + throws IOException, InterruptedException { + ExtractorInput input = getExtractorInputFromPosition(dataSource, /* position= */ 0, uri); + extractor.init(output); + PositionHolder positionHolder = new PositionHolder(); + int readResult = Extractor.RESULT_CONTINUE; + while (true) { + try { + // Keep reading until we can get the seek map + while (readResult == Extractor.RESULT_CONTINUE + && (output.seekMap == null || !output.tracksEnded)) { + readResult = extractor.read(input, positionHolder); + } + } finally { + Util.closeQuietly(dataSource); + } + + if (readResult == Extractor.RESULT_SEEK) { + input = getExtractorInputFromPosition(dataSource, positionHolder.position, uri); + readResult = Extractor.RESULT_CONTINUE; + } else if (readResult == Extractor.RESULT_END_OF_INPUT) { + throw new IOException("EOF encountered without seekmap"); + } + if (output.seekMap != null) { + return output.seekMap; + } + } + } + + /** + * Extracts all samples from the given file into a {@link FakeTrackOutput}. + * + * @param extractor The {@link Extractor} to extractor from input. + * @param context A {@link Context}. + * @param fileName The name of the input file. + * @return The {@link FakeTrackOutput} containing the extracted samples. + * @throws IOException If an error occurred reading from the input, or if the extractor finishes + * reading from input without extracting any {@link SeekMap}. + * @throws InterruptedException If the thread was interrupted. + */ + public static FakeExtractorOutput extractAllSamplesFromFile( + Extractor extractor, Context context, String fileName) + throws IOException, InterruptedException { + byte[] data = TestUtil.getByteArray(context, fileName); + FakeExtractorOutput expectedOutput = new FakeExtractorOutput(); + extractor.init(expectedOutput); + FakeExtractorInput input = new FakeExtractorInput.Builder().setData(data).build(); + + PositionHolder positionHolder = new PositionHolder(); + int readResult = Extractor.RESULT_CONTINUE; + while (readResult != Extractor.RESULT_END_OF_INPUT) { + while (readResult == Extractor.RESULT_CONTINUE) { + readResult = extractor.read(input, positionHolder); + } + if (readResult == Extractor.RESULT_SEEK) { + input.setPosition((int) positionHolder.position); + readResult = Extractor.RESULT_CONTINUE; + } + } + return expectedOutput; + } + + /** + * Seeks to the given seek time of the stream from the given input, and keeps reading from the + * input until we can extract at least one sample following the seek position, or until + * end-of-input is reached. + * + * @param extractor The {@link Extractor} to extractor from input. + * @param seekMap The {@link SeekMap} of the stream from the given input. + * @param seekTimeUs The seek time, in micro-seconds. + * @param trackOutput The {@link FakeTrackOutput} to store the extracted samples. + * @param dataSource The {@link DataSource} that will be used to read from the input. + * @param uri The Uri of the input. + * @return The index of the first extracted sample written to the given {@code trackOutput} after + * the seek is completed, or -1 if the seek is completed without any extracted sample. + */ + public static int seekToTimeUs( + Extractor extractor, + SeekMap seekMap, + long seekTimeUs, + DataSource dataSource, + FakeTrackOutput trackOutput, + Uri uri) + throws IOException, InterruptedException { + int numSampleBeforeSeek = trackOutput.getSampleCount(); + SeekMap.SeekPoints seekPoints = seekMap.getSeekPoints(seekTimeUs); + + long initialSeekLoadPosition = seekPoints.first.position; + extractor.seek(initialSeekLoadPosition, seekTimeUs); + + PositionHolder positionHolder = new PositionHolder(); + positionHolder.position = C.POSITION_UNSET; + ExtractorInput extractorInput = + TestUtil.getExtractorInputFromPosition(dataSource, initialSeekLoadPosition, uri); + int extractorReadResult = Extractor.RESULT_CONTINUE; + while (true) { + try { + // Keep reading until we can read at least one sample after seek + while (extractorReadResult == Extractor.RESULT_CONTINUE + && trackOutput.getSampleCount() == numSampleBeforeSeek) { + extractorReadResult = extractor.read(extractorInput, positionHolder); + } + } finally { + Util.closeQuietly(dataSource); + } + + if (extractorReadResult == Extractor.RESULT_SEEK) { + extractorInput = + TestUtil.getExtractorInputFromPosition(dataSource, positionHolder.position, uri); + extractorReadResult = Extractor.RESULT_CONTINUE; + } else if (extractorReadResult == Extractor.RESULT_END_OF_INPUT) { + return -1; + } else if (trackOutput.getSampleCount() > numSampleBeforeSeek) { + // First index after seek = num sample before seek. + return numSampleBeforeSeek; + } + } + } + + /** Returns an {@link ExtractorInput} to read from the given input at given position. */ + public static ExtractorInput getExtractorInputFromPosition( + DataSource dataSource, long position, Uri uri) throws IOException { + DataSpec dataSpec = new DataSpec(uri, position, C.LENGTH_UNSET, /* key= */ null); + long length = dataSource.open(dataSpec); + if (length != C.LENGTH_UNSET) { + length += position; + } + return new DefaultExtractorInput(dataSource, position, length); + } } 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 deleted file mode 100644 index afbfbb59db..0000000000 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TimelineAsserts.java +++ /dev/null @@ -1,151 +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 static junit.framework.Assert.assertEquals; - -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.ExoPlayer; -import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.Timeline.Period; -import com.google.android.exoplayer2.Timeline.Window; - -/** - * Unit test for {@link Timeline}. - */ -public final class TimelineAsserts { - - private TimelineAsserts() {} - - /** - * Assert that timeline is empty (i.e. has no windows or periods). - */ - public static void assertEmpty(Timeline timeline) { - assertWindowIds(timeline); - assertPeriodCounts(timeline); - } - - /** - * Asserts that window IDs are set correctly. - * - * @param expectedWindowIds A list of expected window IDs. If an ID is unknown or not important - * {@code null} can be passed to skip this window. - */ - public static void assertWindowIds(Timeline timeline, Object... expectedWindowIds) { - Window window = new Window(); - assertEquals(expectedWindowIds.length, timeline.getWindowCount()); - for (int i = 0; i < timeline.getWindowCount(); i++) { - timeline.getWindow(i, window, true); - if (expectedWindowIds[i] != null) { - assertEquals(expectedWindowIds[i], window.id); - } - } - } - - /** - * Asserts that window properties {@link Window}.isDynamic are set correctly.. - */ - public static void assertWindowIsDynamic(Timeline timeline, boolean... windowIsDynamic) { - Window window = new Window(); - for (int i = 0; i < timeline.getWindowCount(); i++) { - timeline.getWindow(i, window, true); - assertEquals(windowIsDynamic[i], window.isDynamic); - } - } - - /** - * Asserts that previous window indices for each window are set correctly depending on the repeat - * mode. - */ - public static void assertPreviousWindowIndices(Timeline timeline, - @ExoPlayer.RepeatMode int repeatMode, int... expectedPreviousWindowIndices) { - for (int i = 0; i < timeline.getWindowCount(); i++) { - assertEquals(expectedPreviousWindowIndices[i], - timeline.getPreviousWindowIndex(i, repeatMode)); - } - } - - /** - * Asserts that next window indices for each window are set correctly depending on the repeat - * mode. - */ - public static void assertNextWindowIndices(Timeline timeline, - @ExoPlayer.RepeatMode int repeatMode, int... expectedNextWindowIndices) { - for (int i = 0; i < timeline.getWindowCount(); i++) { - assertEquals(expectedNextWindowIndices[i], - timeline.getNextWindowIndex(i, repeatMode)); - } - } - - /** - * 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)}. - */ - public static void assertPeriodCounts(Timeline timeline, int... expectedPeriodCounts) { - int windowCount = timeline.getWindowCount(); - int[] accumulatedPeriodCounts = new int[windowCount + 1]; - accumulatedPeriodCounts[0] = 0; - for (int i = 0; i < windowCount; i++) { - accumulatedPeriodCounts[i + 1] = accumulatedPeriodCounts[i] + expectedPeriodCounts[i]; - } - assertEquals(accumulatedPeriodCounts[accumulatedPeriodCounts.length - 1], - timeline.getPeriodCount()); - Window window = new Window(); - Period period = new Period(); - for (int i = 0; i < windowCount; i++) { - timeline.getWindow(i, window, true); - assertEquals(accumulatedPeriodCounts[i], window.firstPeriodIndex); - assertEquals(accumulatedPeriodCounts[i + 1] - 1, window.lastPeriodIndex); - } - int expectedWindowIndex = 0; - for (int i = 0; i < timeline.getPeriodCount(); i++) { - timeline.getPeriod(i, period, true); - while (i >= accumulatedPeriodCounts[expectedWindowIndex + 1]) { - expectedWindowIndex++; - } - assertEquals(expectedWindowIndex, period.windowIndex); - if (i < accumulatedPeriodCounts[expectedWindowIndex + 1] - 1) { - assertEquals(i + 1, timeline.getNextPeriodIndex(i, period, window, - ExoPlayer.REPEAT_MODE_OFF)); - assertEquals(i + 1, timeline.getNextPeriodIndex(i, period, window, - ExoPlayer.REPEAT_MODE_ONE)); - assertEquals(i + 1, timeline.getNextPeriodIndex(i, period, window, - ExoPlayer.REPEAT_MODE_ALL)); - } else { - int nextWindowOff = timeline.getNextWindowIndex(expectedWindowIndex, - ExoPlayer.REPEAT_MODE_OFF); - int nextWindowOne = timeline.getNextWindowIndex(expectedWindowIndex, - ExoPlayer.REPEAT_MODE_ONE); - int nextWindowAll = timeline.getNextWindowIndex(expectedWindowIndex, - ExoPlayer.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, - ExoPlayer.REPEAT_MODE_OFF)); - assertEquals(nextPeriodOne, timeline.getNextPeriodIndex(i, period, window, - ExoPlayer.REPEAT_MODE_ONE)); - assertEquals(nextPeriodAll, timeline.getNextPeriodIndex(i, period, window, - ExoPlayer.REPEAT_MODE_ALL)); - } - } - } - -} diff --git a/testutils/src/test/AndroidManifest.xml b/testutils/src/test/AndroidManifest.xml new file mode 100644 index 0000000000..e30ea1c3ca --- /dev/null +++ b/testutils/src/test/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + diff --git a/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeAdaptiveDataSetTest.java b/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeAdaptiveDataSetTest.java new file mode 100644 index 0000000000..7fd84f6287 --- /dev/null +++ b/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeAdaptiveDataSetTest.java @@ -0,0 +1,147 @@ +/* + * 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 static com.google.common.truth.Truth.assertThat; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.testutil.FakeDataSet.FakeData; +import com.google.android.exoplayer2.testutil.FakeDataSet.FakeData.Segment; +import com.google.android.exoplayer2.util.MimeTypes; +import java.util.List; +import java.util.Random; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Unit test for {@link FakeAdaptiveDataSet}. */ +@RunWith(RobolectricTestRunner.class) +public final class FakeAdaptiveDataSetTest { + + private static final Format[] TEST_FORMATS = { + Format.createVideoSampleFormat( + null, + MimeTypes.VIDEO_H264, + null, + 1000000, + Format.NO_VALUE, + 1280, + 720, + Format.NO_VALUE, + null, + null), + Format.createVideoSampleFormat( + null, + MimeTypes.VIDEO_H264, + null, + 300000, + Format.NO_VALUE, + 640, + 360, + Format.NO_VALUE, + null, + null) + }; + private static final TrackGroup TRACK_GROUP = new TrackGroup(TEST_FORMATS); + + @Test + public void testAdaptiveDataSet() { + long chunkDuration = 2 * C.MICROS_PER_SECOND; + FakeAdaptiveDataSet dataSet = + new FakeAdaptiveDataSet( + TRACK_GROUP, 10 * C.MICROS_PER_SECOND, chunkDuration, 0.0, new Random(0)); + assertThat(dataSet.getAllData().size()).isEqualTo(TEST_FORMATS.length); + assertThat(dataSet.getUri(0).equals(dataSet.getUri(1))).isFalse(); + assertThat(dataSet.getChunkCount()).isEqualTo(5); + assertThat(dataSet.getChunkIndexByPosition(4 * C.MICROS_PER_SECOND)).isEqualTo(2); + assertThat(dataSet.getChunkIndexByPosition(9 * C.MICROS_PER_SECOND)).isEqualTo(4); + for (int i = 0; i < dataSet.getChunkCount(); i++) { + assertThat(dataSet.getChunkDuration(i)).isEqualTo(chunkDuration); + } + assertChunkData(dataSet, chunkDuration); + } + + @Test + public void testAdaptiveDataSetTrailingSmallChunk() { + long chunkDuration = 3 * C.MICROS_PER_SECOND; + FakeAdaptiveDataSet dataSet = + new FakeAdaptiveDataSet( + TRACK_GROUP, 10 * C.MICROS_PER_SECOND, chunkDuration, 0.0, new Random(0)); + assertThat(dataSet.getAllData().size()).isEqualTo(TEST_FORMATS.length); + assertThat(dataSet.getUri(0).equals(dataSet.getUri(1))).isFalse(); + assertThat(dataSet.getChunkCount()).isEqualTo(4); + assertThat(dataSet.getChunkIndexByPosition(4 * C.MICROS_PER_SECOND)).isEqualTo(1); + assertThat(dataSet.getChunkIndexByPosition(9 * C.MICROS_PER_SECOND)).isEqualTo(3); + for (int i = 0; i < dataSet.getChunkCount() - 1; i++) { + assertThat(dataSet.getChunkDuration(i)).isEqualTo(chunkDuration); + } + assertThat(dataSet.getChunkDuration(3)).isEqualTo(1 * C.MICROS_PER_SECOND); + assertChunkData(dataSet, chunkDuration); + } + + @Test + public void testAdaptiveDataSetChunkSizeDistribution() { + double expectedStdDev = 4.0; + FakeAdaptiveDataSet dataSet = + new FakeAdaptiveDataSet( + TRACK_GROUP, + 100000 * C.MICROS_PER_SECOND, + 1 * C.MICROS_PER_SECOND, + expectedStdDev, + new Random(0)); + for (int i = 0; i < TEST_FORMATS.length; i++) { + FakeData data = dataSet.getData(dataSet.getUri(i)); + double mean = computeSegmentSizeMean(data.getSegments()); + double stddev = computeSegmentSizeStdDev(data.getSegments(), mean); + double relativePercentStdDev = stddev / mean * 100.0; + assertThat(relativePercentStdDev).isWithin(0.02).of(expectedStdDev); + assertThat(mean * 8 / TEST_FORMATS[i].bitrate).isWithin(0.01).of(1.0); + } + } + + private void assertChunkData(FakeAdaptiveDataSet dataSet, long chunkDuration) { + for (int i = 0; i < dataSet.getChunkCount(); i++) { + assertThat(dataSet.getStartTime(i)).isEqualTo(chunkDuration * i); + } + for (int s = 0; s < TEST_FORMATS.length; s++) { + FakeData data = dataSet.getData(dataSet.getUri(s)); + assertThat(data.getSegments().size()).isEqualTo(dataSet.getChunkCount()); + for (int i = 0; i < data.getSegments().size(); i++) { + long expectedLength = + TEST_FORMATS[s].bitrate * dataSet.getChunkDuration(i) / (8 * C.MICROS_PER_SECOND); + assertThat(data.getSegments().get(i).length).isEqualTo(expectedLength); + } + } + } + + private static double computeSegmentSizeMean(List segments) { + double totalSize = 0.0; + for (Segment segment : segments) { + totalSize += segment.length; + } + return totalSize / segments.size(); + } + + private static double computeSegmentSizeStdDev(List segments, double mean) { + double totalSquaredSize = 0.0; + for (Segment segment : segments) { + totalSquaredSize += (double) segment.length * segment.length; + } + return Math.sqrt(totalSquaredSize / segments.size() - mean * mean); + } +} diff --git a/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeClockTest.java b/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeClockTest.java new file mode 100644 index 0000000000..90e70e4538 --- /dev/null +++ b/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeClockTest.java @@ -0,0 +1,195 @@ +/* + * 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 static com.google.common.truth.Truth.assertThat; + +import android.os.ConditionVariable; +import android.os.HandlerThread; +import com.google.android.exoplayer2.util.Clock; +import com.google.android.exoplayer2.util.HandlerWrapper; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** Unit test for {@link FakeClock}. */ +@RunWith(RobolectricTestRunner.class) +@Config(shadows = {RobolectricUtil.CustomLooper.class, RobolectricUtil.CustomMessageQueue.class}) +public final class FakeClockTest { + + private static final long TIMEOUT_MS = 10000; + + @Test + public void testAdvanceTime() { + FakeClock fakeClock = new FakeClock(2000); + assertThat(fakeClock.elapsedRealtime()).isEqualTo(2000); + fakeClock.advanceTime(500); + assertThat(fakeClock.elapsedRealtime()).isEqualTo(2500); + fakeClock.advanceTime(0); + assertThat(fakeClock.elapsedRealtime()).isEqualTo(2500); + } + + @Test + public void testSleep() throws InterruptedException { + FakeClock fakeClock = new FakeClock(0); + SleeperThread sleeperThread = new SleeperThread(fakeClock, 1000); + sleeperThread.start(); + assertThat(sleeperThread.waitUntilAsleep(TIMEOUT_MS)).isTrue(); + assertThat(sleeperThread.isSleeping()).isTrue(); + fakeClock.advanceTime(1000); + sleeperThread.join(TIMEOUT_MS); + assertThat(sleeperThread.isSleeping()).isFalse(); + + sleeperThread = new SleeperThread(fakeClock, 0); + sleeperThread.start(); + sleeperThread.join(); + assertThat(sleeperThread.isSleeping()).isFalse(); + + SleeperThread[] sleeperThreads = new SleeperThread[5]; + sleeperThreads[0] = new SleeperThread(fakeClock, 1000); + sleeperThreads[1] = new SleeperThread(fakeClock, 1000); + sleeperThreads[2] = new SleeperThread(fakeClock, 2000); + sleeperThreads[3] = new SleeperThread(fakeClock, 3000); + sleeperThreads[4] = new SleeperThread(fakeClock, 4000); + for (SleeperThread thread : sleeperThreads) { + thread.start(); + assertThat(thread.waitUntilAsleep(TIMEOUT_MS)).isTrue(); + } + assertSleepingStates(new boolean[] {true, true, true, true, true}, sleeperThreads); + fakeClock.advanceTime(1500); + assertThat(sleeperThreads[0].waitUntilAwake(TIMEOUT_MS)).isTrue(); + assertThat(sleeperThreads[1].waitUntilAwake(TIMEOUT_MS)).isTrue(); + assertSleepingStates(new boolean[] {false, false, true, true, true}, sleeperThreads); + fakeClock.advanceTime(2000); + assertThat(sleeperThreads[2].waitUntilAwake(TIMEOUT_MS)).isTrue(); + assertThat(sleeperThreads[3].waitUntilAwake(TIMEOUT_MS)).isTrue(); + assertSleepingStates(new boolean[] {false, false, false, false, true}, sleeperThreads); + fakeClock.advanceTime(2000); + for (SleeperThread thread : sleeperThreads) { + thread.join(TIMEOUT_MS); + } + assertSleepingStates(new boolean[] {false, false, false, false, false}, sleeperThreads); + } + + @Test + public void testPostDelayed() { + HandlerThread handlerThread = new HandlerThread("FakeClockTest thread"); + handlerThread.start(); + FakeClock fakeClock = new FakeClock(0); + HandlerWrapper handler = + fakeClock.createHandler(handlerThread.getLooper(), /* callback= */ null); + + TestRunnable[] testRunnables = { + new TestRunnable(), + new TestRunnable(), + new TestRunnable(), + new TestRunnable(), + new TestRunnable() + }; + handler.postDelayed(testRunnables[0], 0); + handler.postDelayed(testRunnables[1], 100); + handler.postDelayed(testRunnables[2], 200); + waitForHandler(handler); + assertTestRunnableStates(new boolean[] {true, false, false, false, false}, testRunnables); + + fakeClock.advanceTime(150); + handler.postDelayed(testRunnables[3], 50); + handler.postDelayed(testRunnables[4], 100); + waitForHandler(handler); + assertTestRunnableStates(new boolean[] {true, true, false, false, false}, testRunnables); + + fakeClock.advanceTime(50); + waitForHandler(handler); + assertTestRunnableStates(new boolean[] {true, true, true, true, false}, testRunnables); + + fakeClock.advanceTime(1000); + waitForHandler(handler); + assertTestRunnableStates(new boolean[] {true, true, true, true, true}, testRunnables); + } + + private static void assertSleepingStates(boolean[] states, SleeperThread[] sleeperThreads) { + for (int i = 0; i < sleeperThreads.length; i++) { + assertThat(sleeperThreads[i].isSleeping()).isEqualTo(states[i]); + } + } + + private static void waitForHandler(HandlerWrapper handler) { + final ConditionVariable handlerFinished = new ConditionVariable(); + handler.post(handlerFinished::open); + handlerFinished.block(); + } + + private static void assertTestRunnableStates(boolean[] states, TestRunnable[] testRunnables) { + for (int i = 0; i < testRunnables.length; i++) { + assertThat(testRunnables[i].hasRun).isEqualTo(states[i]); + } + } + + private static final class SleeperThread extends Thread { + + private final Clock clock; + private final long sleepDurationMs; + private final CountDownLatch fallAsleepCountDownLatch; + private final CountDownLatch wakeUpCountDownLatch; + + private volatile boolean isSleeping; + + public SleeperThread(Clock clock, long sleepDurationMs) { + this.clock = clock; + this.sleepDurationMs = sleepDurationMs; + this.fallAsleepCountDownLatch = new CountDownLatch(1); + this.wakeUpCountDownLatch = new CountDownLatch(1); + } + + public boolean waitUntilAsleep(long timeoutMs) throws InterruptedException { + return fallAsleepCountDownLatch.await(timeoutMs, TimeUnit.MILLISECONDS); + } + + public boolean waitUntilAwake(long timeoutMs) throws InterruptedException { + return wakeUpCountDownLatch.await(timeoutMs, TimeUnit.MILLISECONDS); + } + + public boolean isSleeping() { + return isSleeping; + } + + @Override + public void run() { + // This relies on the FakeClock's methods synchronizing on its own monitor to ensure that + // any interactions with it occur only after sleep() has called wait() or returned. + synchronized (clock) { + isSleeping = true; + fallAsleepCountDownLatch.countDown(); + clock.sleep(sleepDurationMs); + isSleeping = false; + wakeUpCountDownLatch.countDown(); + } + } + } + + private static final class TestRunnable implements Runnable { + + public boolean hasRun; + + @Override + public void run() { + hasRun = true; + } + } +} diff --git a/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeDataSetTest.java b/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeDataSetTest.java new file mode 100644 index 0000000000..99469295bb --- /dev/null +++ b/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeDataSetTest.java @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.testutil; + +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; +import com.google.android.exoplayer2.testutil.FakeDataSet.FakeData.Segment; +import java.io.IOException; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Unit test for {@link FakeDataSet} */ +@RunWith(RobolectricTestRunner.class) +public final class FakeDataSetTest { + + @Test + public void testMultipleDataSets() { + byte[][] testData = new byte[4][]; + Uri[] uris = new Uri[3]; + for (int i = 0; i < 4; i++) { + testData[i] = TestUtil.buildTestData(10, i); + if (i > 0) { + uris[i - 1] = Uri.parse("test_uri_" + i); + } + } + FakeDataSet fakeDataSet = + new FakeDataSet() + .newDefaultData() + .appendReadData(testData[0]) + .endData() + .setData(uris[0], testData[1]) + .newData(uris[1]) + .appendReadData(testData[2]) + .endData() + .setData(uris[2], testData[3]); + + assertThat(fakeDataSet.getAllData().size()).isEqualTo(4); + assertThat(fakeDataSet.getData("unseen_uri")).isEqualTo(fakeDataSet.getData((Uri) null)); + for (int i = 0; i < 3; i++) { + assertThat(fakeDataSet.getData(uris[i]).uri).isEqualTo(uris[i]); + } + assertThat(fakeDataSet.getData((Uri) null).getData()).isEqualTo(testData[0]); + for (int i = 1; i < 4; i++) { + assertThat(fakeDataSet.getData(uris[i - 1]).getData()).isEqualTo(testData[i]); + } + } + + @Test + public void testSegmentTypes() { + byte[] testData = TestUtil.buildTestData(3); + Runnable runnable = + () -> { + // Do nothing. + }; + IOException exception = new IOException(); + FakeDataSet fakeDataSet = + new FakeDataSet() + .newDefaultData() + .appendReadData(testData) + .appendReadData(testData) + .appendReadData(50) + .appendReadAction(runnable) + .appendReadError(exception) + .endData(); + + List segments = fakeDataSet.getData((Uri) null).getSegments(); + assertThat(segments.size()).isEqualTo(5); + assertSegment(segments.get(0), testData, 3, 0, null, null); + assertSegment(segments.get(1), testData, 3, 3, null, null); + assertSegment(segments.get(2), null, 50, 6, null, null); + assertSegment(segments.get(3), null, 0, 56, runnable, null); + assertSegment(segments.get(4), null, 0, 56, null, exception); + + byte[] allData = new byte[6]; + System.arraycopy(testData, 0, allData, 0, 3); + System.arraycopy(testData, 0, allData, 3, 3); + assertThat(fakeDataSet.getData((Uri) null).getData()).isEqualTo(allData); + } + + private static void assertSegment( + Segment segment, + byte[] data, + int length, + long byteOffset, + Runnable runnable, + IOException exception) { + if (data != null) { + assertThat(segment.data).isEqualTo(data); + assertThat(data).hasLength(length); + } else { + assertThat(segment.data).isNull(); + } + assertThat(segment.length).isEqualTo(length); + assertThat(segment.byteOffset).isEqualTo(byteOffset); + assertThat(segment.action).isEqualTo(runnable); + assertThat(segment.isActionSegment()).isEqualTo(runnable != null); + assertThat(segment.exception).isEqualTo(exception); + assertThat(segment.isErrorSegment()).isEqualTo(exception != null); + } +} diff --git a/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeDataSourceTest.java b/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeDataSourceTest.java new file mode 100644 index 0000000000..c88aba4e08 --- /dev/null +++ b/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeDataSourceTest.java @@ -0,0 +1,251 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.testutil; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import android.net.Uri; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.upstream.DataSpec; +import java.io.IOException; +import java.util.Arrays; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Unit test for {@link FakeDataSource}. */ +@RunWith(RobolectricTestRunner.class) +public final class FakeDataSourceTest { + + private static final String URI_STRING = "test://test.test"; + private static final byte[] BUFFER = new byte[500]; + private static final byte[] TEST_DATA = TestUtil.buildTestData(15); + private static final byte[] TEST_DATA_PART_1 = Arrays.copyOf(TEST_DATA, 10); + private static final byte[] TEST_DATA_PART_2 = Arrays.copyOfRange(TEST_DATA, 10, 15); + + private static Uri uri; + private static FakeDataSet fakeDataSet; + + @Before + public void setUp() { + uri = Uri.parse(URI_STRING); + fakeDataSet = + new FakeDataSet() + .newData(uri.toString()) + .appendReadData(TEST_DATA_PART_1) + .appendReadData(TEST_DATA_PART_2) + .endData(); + } + + @Test + public void testReadFull() throws IOException { + FakeDataSource dataSource = new FakeDataSource(fakeDataSet); + assertThat(dataSource.open(new DataSpec(uri))).isEqualTo(15); + assertThat(dataSource.read(BUFFER, 0, BUFFER.length)).isEqualTo(10); + assertBuffer(TEST_DATA_PART_1); + assertThat(dataSource.read(BUFFER, 10, BUFFER.length)).isEqualTo(5); + assertBuffer(TEST_DATA); + assertThat(dataSource.read(BUFFER, 15, BUFFER.length)).isEqualTo(C.RESULT_END_OF_INPUT); + assertBuffer(TEST_DATA); + assertThat(dataSource.read(BUFFER, 20, BUFFER.length)).isEqualTo(C.RESULT_END_OF_INPUT); + dataSource.close(); + } + + @Test + public void testReadPartialOpenEnded() throws IOException { + FakeDataSource dataSource = new FakeDataSource(fakeDataSet); + assertThat(dataSource.open(new DataSpec(uri, 7, C.LENGTH_UNSET, null))).isEqualTo(8); + assertThat(dataSource.read(BUFFER, 0, BUFFER.length)).isEqualTo(3); + assertBuffer(TEST_DATA_PART_1, 7, 3); + assertThat(dataSource.read(BUFFER, 0, BUFFER.length)).isEqualTo(5); + assertBuffer(TEST_DATA_PART_2); + assertThat(dataSource.read(BUFFER, 15, BUFFER.length)).isEqualTo(C.RESULT_END_OF_INPUT); + dataSource.close(); + } + + @Test + public void testReadPartialBounded() throws IOException { + FakeDataSource dataSource = new FakeDataSource(fakeDataSet); + assertThat(dataSource.open(new DataSpec(uri, 9, 3, null))).isEqualTo(3); + assertThat(dataSource.read(BUFFER, 0, BUFFER.length)).isEqualTo(1); + assertBuffer(TEST_DATA_PART_1, 9, 1); + assertThat(dataSource.read(BUFFER, 0, BUFFER.length)).isEqualTo(2); + assertBuffer(TEST_DATA_PART_2, 0, 2); + assertThat(dataSource.read(BUFFER, 0, BUFFER.length)).isEqualTo(C.RESULT_END_OF_INPUT); + dataSource.close(); + + assertThat(dataSource.open(new DataSpec(uri, 11, 4, null))).isEqualTo(4); + assertThat(dataSource.read(BUFFER, 0, BUFFER.length)).isEqualTo(4); + assertBuffer(TEST_DATA_PART_2, 1, 4); + assertThat(dataSource.read(BUFFER, 0, BUFFER.length)).isEqualTo(C.RESULT_END_OF_INPUT); + dataSource.close(); + } + + @Test + public void testDummyData() throws IOException { + FakeDataSource dataSource = + new FakeDataSource( + new FakeDataSet() + .newData(uri.toString()) + .appendReadData(100) + .appendReadData(TEST_DATA) + .appendReadData(200) + .endData()); + assertThat(dataSource.open(new DataSpec(uri))).isEqualTo(315); + assertThat(dataSource.read(BUFFER, 0, BUFFER.length)).isEqualTo(100); + assertThat(dataSource.read(BUFFER, 0, BUFFER.length)).isEqualTo(15); + assertBuffer(TEST_DATA); + assertThat(dataSource.read(BUFFER, 0, BUFFER.length)).isEqualTo(200); + assertThat(dataSource.read(BUFFER, 0, BUFFER.length)).isEqualTo(C.RESULT_END_OF_INPUT); + dataSource.close(); + } + + @Test + public void testException() throws IOException { + String errorMessage = "error, error, error"; + IOException exception = new IOException(errorMessage); + FakeDataSource dataSource = + new FakeDataSource( + new FakeDataSet() + .newData(uri.toString()) + .appendReadData(TEST_DATA) + .appendReadError(exception) + .appendReadData(TEST_DATA) + .endData()); + assertThat(dataSource.open(new DataSpec(uri))).isEqualTo(30); + assertThat(dataSource.read(BUFFER, 0, BUFFER.length)).isEqualTo(15); + assertBuffer(TEST_DATA); + try { + dataSource.read(BUFFER, 0, BUFFER.length); + fail("IOException expected."); + } catch (IOException e) { + assertThat(e).hasMessageThat().isEqualTo(errorMessage); + } + try { + dataSource.read(BUFFER, 0, BUFFER.length); + fail("IOException expected."); + } catch (IOException e) { + assertThat(e).hasMessageThat().isEqualTo(errorMessage); + } + dataSource.close(); + assertThat(dataSource.open(new DataSpec(uri, 15, 15, null))).isEqualTo(15); + assertThat(dataSource.read(BUFFER, 0, BUFFER.length)).isEqualTo(15); + assertBuffer(TEST_DATA); + assertThat(dataSource.read(BUFFER, 0, BUFFER.length)).isEqualTo(C.RESULT_END_OF_INPUT); + dataSource.close(); + } + + @Test + public void testRunnable() throws IOException { + TestRunnable[] runnables = new TestRunnable[3]; + for (int i = 0; i < 3; i++) { + runnables[i] = new TestRunnable(); + } + FakeDataSource dataSource = + new FakeDataSource( + new FakeDataSet() + .newData(uri.toString()) + .appendReadData(TEST_DATA) + .appendReadAction(runnables[0]) + .appendReadData(TEST_DATA) + .appendReadAction(runnables[1]) + .appendReadAction(runnables[2]) + .appendReadData(TEST_DATA) + .endData()); + assertThat(dataSource.open(new DataSpec(uri))).isEqualTo(45); + assertThat(dataSource.read(BUFFER, 0, BUFFER.length)).isEqualTo(15); + assertBuffer(TEST_DATA); + for (int i = 0; i < 3; i++) { + assertThat(runnables[i].ran).isFalse(); + } + assertThat(dataSource.read(BUFFER, 0, BUFFER.length)).isEqualTo(15); + assertBuffer(TEST_DATA); + assertThat(runnables[0].ran).isTrue(); + assertThat(runnables[1].ran).isFalse(); + assertThat(runnables[2].ran).isFalse(); + assertThat(dataSource.read(BUFFER, 0, BUFFER.length)).isEqualTo(15); + assertBuffer(TEST_DATA); + for (int i = 0; i < 3; i++) { + assertThat(runnables[i].ran).isTrue(); + } + assertThat(dataSource.read(BUFFER, 0, BUFFER.length)).isEqualTo(C.RESULT_END_OF_INPUT); + dataSource.close(); + } + + @Test + public void testOpenSourceFailures() throws IOException { + // Empty data. + FakeDataSource dataSource = + new FakeDataSource(new FakeDataSet().newData(uri.toString()).endData()); + try { + dataSource.open(new DataSpec(uri)); + fail("IOException expected."); + } catch (IOException e) { + // Expected. + } finally { + dataSource.close(); + } + + // Non-existent data + dataSource = new FakeDataSource(new FakeDataSet()); + try { + dataSource.open(new DataSpec(uri)); + fail("IOException expected."); + } catch (IOException e) { + // Expected. + } finally { + dataSource.close(); + } + + // DataSpec out of bounds. + dataSource = + new FakeDataSource( + new FakeDataSet() + .newDefaultData() + .appendReadData(TestUtil.buildTestData(10)) + .endData()); + try { + dataSource.open(new DataSpec(uri, 5, 10, null)); + fail("IOException expected."); + } catch (IOException e) { + // Expected. + } finally { + dataSource.close(); + } + } + + private static void assertBuffer(byte[] expected) { + assertBuffer(expected, 0, expected.length); + } + + private static void assertBuffer(byte[] expected, int expectedStart, int expectedLength) { + for (int i = 0; i < expectedLength; i++) { + assertThat(BUFFER[i]).isEqualTo(expected[i + expectedStart]); + } + } + + private static final class TestRunnable implements Runnable { + + public boolean ran; + + @Override + public void run() { + ran = true; + } + } +} diff --git a/testutils/src/test/resources/robolectric.properties b/testutils/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..2f3210368e --- /dev/null +++ b/testutils/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +manifest=src/test/AndroidManifest.xml diff --git a/testutils_robolectric/build.gradle b/testutils_robolectric/build.gradle new file mode 100644 index 0000000000..2d3317934b --- /dev/null +++ b/testutils_robolectric/build.gradle @@ -0,0 +1,43 @@ +// Copyright (C) 2018 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +apply from: '../constants.gradle' +apply plugin: 'com.android.library' + +android { + compileSdkVersion project.ext.compileSdkVersion + buildToolsVersion project.ext.buildToolsVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + defaultConfig { + minSdkVersion project.ext.minSdkVersion + targetSdkVersion project.ext.targetSdkVersion + } + + lintOptions { + // Robolectric depends on BouncyCastle, which depends on javax.naming, + // which is not part of Android. + disable 'InvalidPackage' + } +} + +dependencies { + api 'org.robolectric:robolectric:' + robolectricVersion + api project(modulePrefix + 'testutils') + implementation project(modulePrefix + 'library-core') + implementation 'com.android.support:support-annotations:' + supportLibraryVersion +} diff --git a/testutils_robolectric/src/main/AndroidManifest.xml b/testutils_robolectric/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..057caad867 --- /dev/null +++ b/testutils_robolectric/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + diff --git a/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/CacheAsserts.java b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/CacheAsserts.java new file mode 100644 index 0000000000..9d6fbe37e7 --- /dev/null +++ b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/CacheAsserts.java @@ -0,0 +1,136 @@ +/* + * 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 static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import android.net.Uri; +import com.google.android.exoplayer2.testutil.FakeDataSet.FakeData; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSourceInputStream; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.DummyDataSource; +import com.google.android.exoplayer2.upstream.cache.Cache; +import com.google.android.exoplayer2.upstream.cache.CacheDataSource; +import com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.ArrayList; + +/** Assertion methods for {@link Cache}. */ +public final class CacheAsserts { + + /** + * Asserts that the cache content is equal to the data in the {@code fakeDataSet}. + * + * @throws IOException If an error occurred reading from the Cache. + */ + public static void assertCachedData(Cache cache, FakeDataSet fakeDataSet) throws IOException { + ArrayList allData = fakeDataSet.getAllData(); + Uri[] uris = new Uri[allData.size()]; + for (int i = 0; i < allData.size(); i++) { + uris[i] = allData.get(i).uri; + } + assertCachedData(cache, fakeDataSet, uris); + } + + /** + * Asserts that the cache content is equal to the given subset of data in the {@code fakeDataSet}. + * + * @throws IOException If an error occurred reading from the Cache. + */ + 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}. + * + * @throws IOException If an error occurred reading from the Cache. + */ + public static void assertCachedData(Cache cache, FakeDataSet fakeDataSet, Uri... uris) + throws IOException { + int totalLength = 0; + for (Uri uri : uris) { + byte[] data = fakeDataSet.getData(uri).getData(); + assertDataCached(cache, uri, data); + totalLength += data.length; + } + assertThat(cache.getCacheSpace()).isEqualTo(totalLength); + } + + /** + * Asserts that the cache contains the given data for {@code uriString}. + * + * @throws IOException If an error occurred reading from the Cache. + */ + public static void assertDataCached(Cache cache, Uri uri, byte[] expected) throws IOException { + DataSpec dataSpec = new DataSpec(uri, DataSpec.FLAG_ALLOW_CACHING_UNKNOWN_LENGTH); + assertDataCached(cache, dataSpec, expected); + } + + /** + * Asserts that the cache contains the given data for {@code dataSpec}. + * + * @throws IOException If an error occurred reading from the Cache. + */ + public static void assertDataCached(Cache cache, DataSpec dataSpec, byte[] expected) + throws IOException { + DataSource dataSource = new CacheDataSource(cache, DummyDataSource.INSTANCE, 0); + dataSource.open(dataSpec); + try { + byte[] bytes = TestUtil.readToEnd(dataSource); + assertWithMessage("Cached data doesn't match expected for '" + dataSpec.uri + "',") + .that(bytes) + .isEqualTo(expected); + } finally { + dataSource.close(); + } + } + + /** + * Asserts that the read data from {@code dataSource} specified by {@code dataSpec} is equal to + * {@code expected} or not. + * + * @throws IOException If an error occurred reading from the Cache. + */ + public static void assertReadData(DataSource dataSource, DataSpec dataSpec, byte[] expected) + throws IOException { + DataSourceInputStream inputStream = new DataSourceInputStream(dataSource, dataSpec); + byte[] bytes = null; + try { + bytes = Util.toByteArray(inputStream); + } catch (IOException e) { + // Ignore + } finally { + inputStream.close(); + } + assertThat(bytes).isEqualTo(expected); + } + + /** Asserts that the cache is empty. */ + public static void assertCacheEmpty(Cache cache) { + assertThat(cache.getCacheSpace()).isEqualTo(0); + assertThat(cache.getKeys()).isEmpty(); + } + + private CacheAsserts() {} +} diff --git a/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaChunk.java b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaChunk.java new file mode 100644 index 0000000000..6669504c07 --- /dev/null +++ b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaChunk.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.testutil; + +import android.net.Uri; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.source.chunk.MediaChunk; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; +import java.io.IOException; + +/** Fake {@link MediaChunk}. */ +public final class FakeMediaChunk extends MediaChunk { + + private static final DataSource DATA_SOURCE = new DefaultHttpDataSource("TEST_AGENT", null); + + public FakeMediaChunk(Format trackFormat, long startTimeUs, long endTimeUs) { + this(new DataSpec(Uri.EMPTY), trackFormat, startTimeUs, endTimeUs); + } + + public FakeMediaChunk(DataSpec dataSpec, Format trackFormat, long startTimeUs, long endTimeUs) { + super( + DATA_SOURCE, + dataSpec, + trackFormat, + C.SELECTION_REASON_ADAPTIVE, + /* trackSelectionData= */ null, + startTimeUs, + endTimeUs, + /* chunkIndex= */ 0); + } + + @Override + public void cancelLoad() { + // Do nothing. + } + + @Override + public void load() throws IOException, InterruptedException { + // Do nothing. + } + + @Override + public boolean isLoadCompleted() { + return true; + } +} diff --git a/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaChunkIterator.java b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaChunkIterator.java new file mode 100644 index 0000000000..6dce2b5428 --- /dev/null +++ b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaChunkIterator.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.exoplayer2.testutil; + +import android.net.Uri; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.source.chunk.BaseMediaChunkIterator; +import com.google.android.exoplayer2.upstream.DataSpec; + +/** Fake {@link com.google.android.exoplayer2.source.chunk.MediaChunkIterator}. */ +public final class FakeMediaChunkIterator extends BaseMediaChunkIterator { + + private final long[] chunkTimeBoundariesSec; + private final long[] chunkLengths; + + /** + * Creates a fake {@link com.google.android.exoplayer2.source.chunk.MediaChunkIterator}. + * + * @param chunkTimeBoundariesSec An array containing the time boundaries where one chunk ends and + * the next one starts. The first value is the start time of the first chunk and the last + * value is the end time of the last chunk. The array should be of length (chunk-count + 1). + * @param chunkLengths An array which contains the length of each chunk, should be of length + * (chunk-count). + */ + public FakeMediaChunkIterator(long[] chunkTimeBoundariesSec, long[] chunkLengths) { + super(/* fromIndex= */ 0, /* toIndex= */ chunkTimeBoundariesSec.length - 2); + this.chunkTimeBoundariesSec = chunkTimeBoundariesSec; + this.chunkLengths = chunkLengths; + } + + @Override + public DataSpec getDataSpec() { + checkInBounds(); + return new DataSpec( + Uri.EMPTY, + /* absoluteStreamPosition= */ 0, + chunkLengths[(int) getCurrentIndex()], + /* key= */ null); + } + + @Override + public long getChunkStartTimeUs() { + checkInBounds(); + return chunkTimeBoundariesSec[(int) getCurrentIndex()] * C.MICROS_PER_SECOND; + } + + @Override + public long getChunkEndTimeUs() { + checkInBounds(); + return chunkTimeBoundariesSec[(int) getCurrentIndex() + 1] * C.MICROS_PER_SECOND; + } +} diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaClockRenderer.java b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaClockRenderer.java similarity index 93% rename from testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaClockRenderer.java rename to testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaClockRenderer.java index 4d118f9288..009afd1ff7 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaClockRenderer.java +++ b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaClockRenderer.java @@ -19,9 +19,7 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.util.MediaClock; -/** - * Fake abstract {@link Renderer} which is also a {@link MediaClock}. - */ +/** Fake abstract {@link Renderer} which is also a {@link MediaClock}. */ public abstract class FakeMediaClockRenderer extends FakeRenderer implements MediaClock { public FakeMediaClockRenderer(Format... expectedFormats) { @@ -32,5 +30,4 @@ public abstract class FakeMediaClockRenderer extends FakeRenderer implements Med public MediaClock getMediaClock() { return this; } - } diff --git a/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/FakeShuffleOrder.java b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/FakeShuffleOrder.java new file mode 100644 index 0000000000..4b3a0d5051 --- /dev/null +++ b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/FakeShuffleOrder.java @@ -0,0 +1,72 @@ +/* + * 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 indexFrom, int indexToExclusive) { + return new FakeShuffleOrder(length - indexToExclusive + indexFrom); + } + + @Override + public ShuffleOrder cloneAndClear() { + return new FakeShuffleOrder(/* length= */ 0); + } +} diff --git a/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackSelection.java b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackSelection.java new file mode 100644 index 0000000000..b479ebed29 --- /dev/null +++ b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackSelection.java @@ -0,0 +1,142 @@ +/* + * 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 static com.google.common.truth.Truth.assertThat; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.chunk.MediaChunk; +import com.google.android.exoplayer2.source.chunk.MediaChunkIterator; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import java.util.List; + +/** + * A fake {@link TrackSelection} that only returns 1 fixed track, and allows querying the number of + * calls to its methods. + */ +public final class FakeTrackSelection implements TrackSelection { + + private final TrackGroup rendererTrackGroup; + + public int enableCount; + public int releaseCount; + public boolean isEnabled; + + public FakeTrackSelection(TrackGroup rendererTrackGroup) { + this.rendererTrackGroup = rendererTrackGroup; + } + + @Override + public void enable() { + // assert that track selection is in disabled state before this call. + assertThat(isEnabled).isFalse(); + enableCount++; + isEnabled = true; + } + + @Override + public void disable() { + // assert that track selection is in enabled state before this call. + assertThat(isEnabled).isTrue(); + releaseCount++; + isEnabled = false; + } + + @Override + public TrackGroup getTrackGroup() { + return rendererTrackGroup; + } + + @Override + public int length() { + return rendererTrackGroup.length; + } + + @Override + public Format getFormat(int index) { + return rendererTrackGroup.getFormat(0); + } + + @Override + public int getIndexInTrackGroup(int index) { + return 0; + } + + @Override + public int indexOf(Format format) { + assertThat(isEnabled).isTrue(); + return 0; + } + + @Override + public int indexOf(int indexInTrackGroup) { + return 0; + } + + @Override + public Format getSelectedFormat() { + return rendererTrackGroup.getFormat(0); + } + + @Override + public int getSelectedIndexInTrackGroup() { + return 0; + } + + @Override + public int getSelectedIndex() { + return 0; + } + + @Override + public int getSelectionReason() { + return C.SELECTION_REASON_UNKNOWN; + } + + @Override + public Object getSelectionData() { + return null; + } + + @Override + public void onPlaybackSpeed(float speed) { + // Do nothing. + } + + @Override + public void updateSelectedTrack( + long playbackPositionUs, + long bufferedDurationUs, + long availableDurationUs, + List queue, + MediaChunkIterator[] mediaChunkIterators) { + assertThat(isEnabled).isTrue(); + } + + @Override + public int evaluateQueueSize(long playbackPositionUs, List queue) { + assertThat(isEnabled).isTrue(); + return 0; + } + + @Override + public boolean blacklist(int index, long blacklistDurationMs) { + assertThat(isEnabled).isTrue(); + return false; + } +} diff --git a/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackSelector.java b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackSelector.java new file mode 100644 index 0000000000..6d37961005 --- /dev/null +++ b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackSelector.java @@ -0,0 +1,109 @@ +/* + * 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.TrackGroup; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.MappingTrackSelector; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.upstream.BandwidthMeter; +import java.util.ArrayList; +import java.util.List; + +/** A fake {@link MappingTrackSelector} that returns {@link FakeTrackSelection}s. */ +public class FakeTrackSelector extends DefaultTrackSelector { + + private final FakeTrackSelectionFactory fakeTrackSelectionFactory; + + public FakeTrackSelector() { + this(false); + } + + /** + * @param mayReuseTrackSelection Whether this {@link FakeTrackSelector} will reuse {@link + * TrackSelection}s during track selection, when it finds previously-selected track selection + * using the same {@link TrackGroup}. + */ + public FakeTrackSelector(boolean mayReuseTrackSelection) { + this(new FakeTrackSelectionFactory(mayReuseTrackSelection)); + } + + private FakeTrackSelector(FakeTrackSelectionFactory fakeTrackSelectionFactory) { + super(fakeTrackSelectionFactory); + this.fakeTrackSelectionFactory = fakeTrackSelectionFactory; + } + + @Override + protected TrackSelection.Definition[] selectAllTracks( + MappedTrackInfo mappedTrackInfo, + int[][][] rendererFormatSupports, + int[] rendererMixedMimeTypeAdaptationSupports, + Parameters params) { + int rendererCount = mappedTrackInfo.getRendererCount(); + TrackSelection.Definition[] definitions = new TrackSelection.Definition[rendererCount]; + for (int i = 0; i < rendererCount; i++) { + TrackGroupArray trackGroupArray = mappedTrackInfo.getTrackGroups(i); + boolean hasTracks = trackGroupArray.length > 0; + definitions[i] = hasTracks ? new TrackSelection.Definition(trackGroupArray.get(0)) : null; + } + return definitions; + } + + /** Returns list of all {@link FakeTrackSelection}s that this track selector has made so far. */ + public List getAllTrackSelections() { + return fakeTrackSelectionFactory.trackSelections; + } + + private static class FakeTrackSelectionFactory implements TrackSelection.Factory { + + private final List trackSelections; + private final boolean mayReuseTrackSelection; + + public FakeTrackSelectionFactory(boolean mayReuseTrackSelection) { + this.mayReuseTrackSelection = mayReuseTrackSelection; + trackSelections = new ArrayList<>(); + } + + @Override + public TrackSelection createTrackSelection( + TrackGroup trackGroup, BandwidthMeter bandwidthMeter, int... tracks) { + if (mayReuseTrackSelection) { + for (FakeTrackSelection trackSelection : trackSelections) { + if (trackSelection.getTrackGroup().equals(trackGroup)) { + return trackSelection; + } + } + } + FakeTrackSelection trackSelection = new FakeTrackSelection(trackGroup); + trackSelections.add(trackSelection); + return trackSelection; + } + + @Override + public TrackSelection[] createTrackSelections( + TrackSelection.Definition[] definitions, BandwidthMeter bandwidthMeter) { + TrackSelection[] selections = new TrackSelection[definitions.length]; + for (int i = 0; i < definitions.length; i++) { + TrackSelection.Definition definition = definitions[i]; + if (definition != null) { + selections[i] = createTrackSelection(definition.group, bandwidthMeter, definition.tracks); + } + } + return selections; + } + } +} diff --git a/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/MediaSourceTestRunner.java b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/MediaSourceTestRunner.java new file mode 100644 index 0000000000..70e7669dfb --- /dev/null +++ b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/MediaSourceTestRunner.java @@ -0,0 +1,459 @@ +/* + * 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 static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; +import static org.junit.Assert.fail; + +import android.os.ConditionVariable; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; +import android.support.annotation.Nullable; +import android.util.Pair; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.PlayerMessage; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import com.google.android.exoplayer2.source.MediaSourceEventListener; +import com.google.android.exoplayer2.source.MediaSourceEventListener.LoadEventInfo; +import com.google.android.exoplayer2.source.MediaSourceEventListener.MediaLoadData; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** A runner for {@link MediaSource} tests. */ +public class MediaSourceTestRunner { + + public static final int TIMEOUT_MS = 10000; + + private final StubExoPlayer player; + private final MediaSource mediaSource; + private final MediaSourceListener mediaSourceListener; + private final HandlerThread playbackThread; + private final Handler playbackHandler; + private final Allocator allocator; + + private final LinkedBlockingDeque timelines; + private final CopyOnWriteArrayList> completedLoads; + private final AtomicReference lastCreatedMediaPeriod; + private final AtomicReference lastReleasedMediaPeriod; + + private Timeline timeline; + + /** + * @param mediaSource The source under test. + * @param allocator The allocator to use during the test run. + */ + public MediaSourceTestRunner(MediaSource mediaSource, Allocator allocator) { + this.mediaSource = mediaSource; + this.allocator = allocator; + playbackThread = new HandlerThread("PlaybackThread"); + playbackThread.start(); + Looper playbackLooper = playbackThread.getLooper(); + playbackHandler = new Handler(playbackLooper); + player = new EventHandlingExoPlayer(playbackLooper); + mediaSourceListener = new MediaSourceListener(); + timelines = new LinkedBlockingDeque<>(); + completedLoads = new CopyOnWriteArrayList<>(); + lastCreatedMediaPeriod = new AtomicReference<>(); + lastReleasedMediaPeriod = new AtomicReference<>(); + mediaSource.addEventListener(playbackHandler, mediaSourceListener); + } + + /** + * Runs the provided {@link Runnable} on the playback thread, blocking until execution completes. + * + * @param runnable The {@link Runnable} to run. + */ + public void runOnPlaybackThread(final Runnable runnable) { + final Throwable[] throwable = new Throwable[1]; + final ConditionVariable finishedCondition = new ConditionVariable(); + playbackHandler.post( + () -> { + try { + runnable.run(); + } catch (Throwable e) { + throwable[0] = e; + } finally { + finishedCondition.open(); + } + }); + assertThat(finishedCondition.block(TIMEOUT_MS)).isTrue(); + if (throwable[0] != null) { + Util.sneakyThrow(throwable[0]); + } + } + + /** + * Prepares the source on the playback thread, asserting that it provides an initial timeline. + * + * @return The initial {@link Timeline}. + */ + public Timeline prepareSource() throws IOException { + final IOException[] prepareError = new IOException[1]; + runOnPlaybackThread( + () -> { + mediaSource.prepareSource( + player, + /* isTopLevelSource= */ true, + mediaSourceListener, + /* mediaTransferListener= */ null); + try { + // TODO: This only catches errors that are set synchronously in prepareSource. To + // capture async errors we'll need to poll maybeThrowSourceInfoRefreshError until the + // first call to onSourceInfoRefreshed. + mediaSource.maybeThrowSourceInfoRefreshError(); + } catch (IOException e) { + prepareError[0] = e; + } + }); + if (prepareError[0] != null) { + throw prepareError[0]; + } + return assertTimelineChangeBlocking(); + } + + /** + * Calls {@link MediaSource#createPeriod(MediaSource.MediaPeriodId, Allocator)} on the playback + * thread, asserting that a non-null {@link MediaPeriod} is returned. + * + * @param periodId The id of the period to create. + * @return The created {@link MediaPeriod}. + */ + public MediaPeriod createPeriod(final MediaPeriodId periodId) { + final MediaPeriod[] holder = new MediaPeriod[1]; + runOnPlaybackThread(() -> holder[0] = mediaSource.createPeriod(periodId, allocator)); + assertThat(holder[0]).isNotNull(); + return holder[0]; + } + + /** + * Calls {@link MediaPeriod#prepare(MediaPeriod.Callback, long)} on the playback thread and blocks + * until the method has been called. + * + * @param mediaPeriod The {@link MediaPeriod} to prepare. + * @param positionUs The position at which to prepare. + * @return A {@link CountDownLatch} that will be counted down when preparation completes. + */ + public CountDownLatch preparePeriod(final MediaPeriod mediaPeriod, final long positionUs) { + final ConditionVariable prepareCalled = new ConditionVariable(); + final CountDownLatch preparedCountDown = new CountDownLatch(1); + runOnPlaybackThread( + () -> { + mediaPeriod.prepare( + new MediaPeriod.Callback() { + @Override + public void onPrepared(MediaPeriod mediaPeriod1) { + preparedCountDown.countDown(); + } + + @Override + public void onContinueLoadingRequested(MediaPeriod source) { + // Do nothing. + } + }, + positionUs); + prepareCalled.open(); + }); + prepareCalled.block(); + return preparedCountDown; + } + + /** + * Calls {@link MediaSource#releasePeriod(MediaPeriod)} on the playback thread. + * + * @param mediaPeriod The {@link MediaPeriod} to release. + */ + public void releasePeriod(final MediaPeriod mediaPeriod) { + runOnPlaybackThread(() -> mediaSource.releasePeriod(mediaPeriod)); + } + + /** + * Calls {@link MediaSource#releaseSource(MediaSource.SourceInfoRefreshListener)} on the playback + * thread. + */ + public void releaseSource() { + runOnPlaybackThread(() -> mediaSource.releaseSource(mediaSourceListener)); + } + + /** + * Asserts that the source has not notified its listener of a timeline change since the last call + * to {@link #assertTimelineChangeBlocking()} or {@link #assertTimelineChange()} (or since the + * runner was created if neither method has been called). + */ + public void assertNoTimelineChange() { + assertThat(timelines.isEmpty()).isTrue(); + } + + /** + * Asserts that the source has notified its listener of a single timeline change. + * + * @return The new {@link Timeline}. + */ + public Timeline assertTimelineChange() { + timeline = timelines.removeFirst(); + assertNoTimelineChange(); + return timeline; + } + + /** + * Asserts that the source notifies its listener of a single timeline change. If the source has + * not yet notified its listener, it has up to the timeout passed to the constructor to do so. + * + * @return The new {@link Timeline}. + */ + public Timeline assertTimelineChangeBlocking() { + try { + timeline = timelines.poll(TIMEOUT_MS, TimeUnit.MILLISECONDS); + assertThat(timeline).isNotNull(); // Null indicates the poll timed out. + assertNoTimelineChange(); + return timeline; + } catch (InterruptedException e) { + // Should never happen. + throw new RuntimeException(e); + } + } + + /** + * Creates and releases all periods (including ad periods) defined in the last timeline to be + * returned from {@link #prepareSource()}, {@link #assertTimelineChange()} or {@link + * #assertTimelineChangeBlocking()}. The {@link MediaPeriodId#windowSequenceNumber} is set to the + * index of the window. + */ + public void assertPrepareAndReleaseAllPeriods() throws InterruptedException { + Timeline.Period period = new Timeline.Period(); + for (int i = 0; i < timeline.getPeriodCount(); i++) { + timeline.getPeriod(i, period, /* setIds= */ true); + assertPrepareAndReleasePeriod(new MediaPeriodId(period.uid, period.windowIndex)); + for (int adGroupIndex = 0; adGroupIndex < period.getAdGroupCount(); adGroupIndex++) { + for (int adIndex = 0; adIndex < period.getAdCountInAdGroup(adGroupIndex); adIndex++) { + assertPrepareAndReleasePeriod( + new MediaPeriodId(period.uid, adGroupIndex, adIndex, period.windowIndex)); + } + } + } + } + + private void assertPrepareAndReleasePeriod(MediaPeriodId mediaPeriodId) + throws InterruptedException { + MediaPeriod mediaPeriod = createPeriod(mediaPeriodId); + assertThat(lastCreatedMediaPeriod.getAndSet(/* newValue= */ null)).isEqualTo(mediaPeriodId); + CountDownLatch preparedCondition = preparePeriod(mediaPeriod, 0); + assertThat(preparedCondition.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)).isTrue(); + // MediaSource is supposed to support multiple calls to createPeriod without an intervening call + // to releasePeriod. + MediaPeriodId secondMediaPeriodId = + new MediaPeriodId( + mediaPeriodId.periodUid, + mediaPeriodId.adGroupIndex, + mediaPeriodId.adIndexInAdGroup, + mediaPeriodId.windowSequenceNumber + 1000); + MediaPeriod secondMediaPeriod = createPeriod(secondMediaPeriodId); + assertThat(lastCreatedMediaPeriod.getAndSet(/* newValue= */ null)) + .isEqualTo(secondMediaPeriodId); + CountDownLatch secondPreparedCondition = preparePeriod(secondMediaPeriod, 0); + assertThat(secondPreparedCondition.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)).isTrue(); + // Release the periods. + releasePeriod(mediaPeriod); + assertThat(lastReleasedMediaPeriod.getAndSet(/* newValue= */ null)).isEqualTo(mediaPeriodId); + releasePeriod(secondMediaPeriod); + assertThat(lastReleasedMediaPeriod.getAndSet(/* newValue= */ null)) + .isEqualTo(secondMediaPeriodId); + } + + /** + * Asserts that the media source reported completed loads via {@link + * MediaSourceEventListener#onLoadCompleted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)} for + * each specified window index and a null period id. Also asserts that no other loads with media + * period id null are reported. + */ + public void assertCompletedManifestLoads(Integer... windowIndices) { + List expectedWindowIndices = new ArrayList<>(Arrays.asList(windowIndices)); + for (Pair windowIndexAndMediaPeriodId : completedLoads) { + if (windowIndexAndMediaPeriodId.second == null) { + boolean loadExpected = expectedWindowIndices.remove(windowIndexAndMediaPeriodId.first); + assertThat(loadExpected).isTrue(); + } + } + assertWithMessage("Not all expected media source loads have been completed.") + .that(expectedWindowIndices) + .isEmpty(); + } + + /** + * Asserts that the media source reported completed loads via {@link + * MediaSourceEventListener#onLoadCompleted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)} for + * each specified media period id, and asserts that the associated window index matches the one in + * the last known timeline returned from {@link #prepareSource()}, {@link #assertTimelineChange()} + * or {@link #assertTimelineChangeBlocking()}. + */ + public void assertCompletedMediaPeriodLoads(MediaPeriodId... mediaPeriodIds) { + Timeline.Period period = new Timeline.Period(); + HashSet expectedLoads = new HashSet<>(Arrays.asList(mediaPeriodIds)); + for (Pair windowIndexAndMediaPeriodId : completedLoads) { + int windowIndex = windowIndexAndMediaPeriodId.first; + MediaPeriodId mediaPeriodId = windowIndexAndMediaPeriodId.second; + if (expectedLoads.remove(mediaPeriodId)) { + int periodIndex = timeline.getIndexOfPeriod(mediaPeriodId.periodUid); + assertThat(windowIndex).isEqualTo(timeline.getPeriod(periodIndex, period).windowIndex); + } + } + assertWithMessage("Not all expected media source loads have been completed.") + .that(expectedLoads) + .isEmpty(); + } + + /** Releases the runner. Should be called when the runner is no longer required. */ + public void release() { + playbackThread.quit(); + } + + private class MediaSourceListener + implements MediaSource.SourceInfoRefreshListener, MediaSourceEventListener { + + // SourceInfoRefreshListener methods. + + @Override + public void onSourceInfoRefreshed(MediaSource source, Timeline timeline, Object manifest) { + Assertions.checkState(Looper.myLooper() == playbackThread.getLooper()); + timelines.addLast(timeline); + } + + // MediaSourceEventListener methods. + + @Override + public void onMediaPeriodCreated(int windowIndex, MediaPeriodId mediaPeriodId) { + Assertions.checkState(Looper.myLooper() == playbackThread.getLooper()); + lastCreatedMediaPeriod.set(mediaPeriodId); + } + + @Override + public void onMediaPeriodReleased(int windowIndex, MediaPeriodId mediaPeriodId) { + Assertions.checkState(Looper.myLooper() == playbackThread.getLooper()); + lastReleasedMediaPeriod.set(mediaPeriodId); + } + + @Override + public void onLoadStarted( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData) { + Assertions.checkState(Looper.myLooper() == playbackThread.getLooper()); + } + + @Override + public void onLoadCompleted( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData) { + Assertions.checkState(Looper.myLooper() == playbackThread.getLooper()); + completedLoads.add(Pair.create(windowIndex, mediaPeriodId)); + } + + @Override + public void onLoadCanceled( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData) { + Assertions.checkState(Looper.myLooper() == playbackThread.getLooper()); + } + + @Override + public void onLoadError( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData, + IOException error, + boolean wasCanceled) { + Assertions.checkState(Looper.myLooper() == playbackThread.getLooper()); + } + + @Override + public void onReadingStarted(int windowIndex, MediaPeriodId mediaPeriodId) { + Assertions.checkState(Looper.myLooper() == playbackThread.getLooper()); + } + + @Override + public void onUpstreamDiscarded( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { + Assertions.checkState(Looper.myLooper() == playbackThread.getLooper()); + } + + @Override + public void onDownstreamFormatChanged( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { + Assertions.checkState(Looper.myLooper() == playbackThread.getLooper()); + } + } + + private static class EventHandlingExoPlayer extends StubExoPlayer + implements Handler.Callback, PlayerMessage.Sender { + + private final Handler handler; + + public EventHandlingExoPlayer(Looper looper) { + this.handler = new Handler(looper, this); + } + + @Override + public Looper getApplicationLooper() { + return handler.getLooper(); + } + + @Override + public PlayerMessage createMessage(PlayerMessage.Target target) { + return new PlayerMessage( + /* sender= */ this, target, Timeline.EMPTY, /* defaultWindowIndex= */ 0, handler); + } + + @Override + public void sendMessage(PlayerMessage message) { + handler.obtainMessage(0, message).sendToTarget(); + } + + @Override + @SuppressWarnings("unchecked") + public boolean handleMessage(Message msg) { + PlayerMessage message = (PlayerMessage) msg.obj; + try { + message.getTarget().handleMessage(message.getType(), message.getPayload()); + message.markAsProcessed(/* isDelivered= */ true); + } catch (ExoPlaybackException e) { + fail("Unexpected ExoPlaybackException."); + } + return true; + } + } +} diff --git a/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/OggTestData.java b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/OggTestData.java new file mode 100644 index 0000000000..8dd0cd16b1 --- /dev/null +++ b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/OggTestData.java @@ -0,0 +1,1070 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.testutil; + +/** Provides ogg/vorbis test data in bytes for unit tests. */ +public final class OggTestData { + + public static FakeExtractorInput createInput(byte[] data, boolean simulateUnknownLength) { + return new FakeExtractorInput.Builder() + .setData(data) + .setSimulateIOErrors(true) + .setSimulateUnknownLength(simulateUnknownLength) + .setSimulatePartialReads(true) + .build(); + } + + public static byte[] buildOggHeader( + int headerType, long granule, int pageSequenceCounter, int pageSegmentCount) { + return TestUtil.createByteArray( + 0x4F, + 0x67, + 0x67, + 0x53, // Oggs. + 0x00, // Stream revision. + headerType, + (int) (granule) & 0xFF, + (int) (granule >> 8) & 0xFF, + (int) (granule >> 16) & 0xFF, + (int) (granule >> 24) & 0xFF, + (int) (granule >> 32) & 0xFF, + (int) (granule >> 40) & 0xFF, + (int) (granule >> 48) & 0xFF, + (int) (granule >> 56) & 0xFF, + 0x00, // LSB of data serial number. + 0x10, + 0x00, + 0x00, // MSB of data serial number. + (pageSequenceCounter) & 0xFF, + (pageSequenceCounter >> 8) & 0xFF, + (pageSequenceCounter >> 16) & 0xFF, + (pageSequenceCounter >> 24) & 0xFF, + 0x00, // LSB of page checksum. + 0x00, + 0x10, + 0x00, // MSB of page checksum. + pageSegmentCount); + } + + /** + * Returns the initial two pages of bytes which by spec contain the three vorbis header packets: + * identification, comment and setup header. + */ + public static byte[] getVorbisHeaderPages() { + byte[] data = new byte[VORBIS_HEADER_PAGES.length]; + System.arraycopy(VORBIS_HEADER_PAGES, 0, data, 0, VORBIS_HEADER_PAGES.length); + return data; + } + + /** Returns a valid vorbis identification header in bytes. */ + public static byte[] getIdentificationHeaderData() { + int idHeaderStart = 28; + int idHeaderLength = 30; + byte[] idHeaderData = new byte[idHeaderLength]; + System.arraycopy(VORBIS_HEADER_PAGES, idHeaderStart, idHeaderData, 0, idHeaderLength); + return idHeaderData; + } + + /** Returns a valid vorbis comment header with 3 comments including utf8 chars in bytes. */ + public static byte[] getCommentHeaderDataUTF8() { + byte[] commentHeaderData = new byte[COMMENT_HEADER_WITH_UTF8.length]; + System.arraycopy( + COMMENT_HEADER_WITH_UTF8, 0, commentHeaderData, 0, COMMENT_HEADER_WITH_UTF8.length); + return commentHeaderData; + } + + /** Returns a valid vorbis setup header in bytes. */ + public static byte[] getSetupHeaderData() { + int setupHeaderStart = 146; + int setupHeaderLength = VORBIS_HEADER_PAGES.length - setupHeaderStart; + byte[] setupHeaderData = new byte[setupHeaderLength]; + System.arraycopy(VORBIS_HEADER_PAGES, setupHeaderStart, setupHeaderData, 0, setupHeaderLength); + return setupHeaderData; + } + + private static final byte[] COMMENT_HEADER_WITH_UTF8 = { + (byte) 0x03, (byte) 0x76, (byte) 0x6f, (byte) 0x72, // 3, v, o, r, + (byte) 0x62, (byte) 0x69, (byte) 0x73, (byte) 0x2b, // b, i, s, . + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x58, + (byte) 0x69, (byte) 0x70, (byte) 0x68, (byte) 0x2e, + (byte) 0x4f, (byte) 0x72, (byte) 0x67, (byte) 0x20, + (byte) 0x6c, (byte) 0x69, (byte) 0x62, (byte) 0x56, + (byte) 0x6f, (byte) 0x72, (byte) 0x62, (byte) 0x69, + (byte) 0x73, (byte) 0x20, (byte) 0x49, (byte) 0x20, + (byte) 0x32, (byte) 0x30, (byte) 0x31, (byte) 0x32, + (byte) 0x30, (byte) 0x32, (byte) 0x30, (byte) 0x33, + (byte) 0x20, (byte) 0x28, (byte) 0x4f, (byte) 0x6d, + (byte) 0x6e, (byte) 0x69, (byte) 0x70, (byte) 0x72, + (byte) 0x65, (byte) 0x73, (byte) 0x65, (byte) 0x6e, + (byte) 0x74, (byte) 0x29, (byte) 0x03, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x0a, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x41, (byte) 0x4c, + (byte) 0x42, (byte) 0x55, (byte) 0x4d, (byte) 0x3d, + (byte) 0xc3, (byte) 0xa4, (byte) 0xc3, (byte) 0xb6, + (byte) 0x13, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x54, (byte) 0x49, (byte) 0x54, (byte) 0x4c, + (byte) 0x45, (byte) 0x3d, (byte) 0x41, (byte) 0x20, + (byte) 0x73, (byte) 0x61, (byte) 0x6d, (byte) 0x70, + (byte) 0x6c, (byte) 0x65, (byte) 0x20, (byte) 0x73, + (byte) 0x6f, (byte) 0x6e, (byte) 0x67, (byte) 0x0d, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x41, + (byte) 0x52, (byte) 0x54, (byte) 0x49, (byte) 0x53, + (byte) 0x54, (byte) 0x3d, (byte) 0x47, (byte) 0x6f, + (byte) 0x6f, (byte) 0x67, (byte) 0x6c, (byte) 0x65, + (byte) 0x01 + }; + + // two OGG pages with 3 packets (id, comment and setup header) + // length: 3743 bytes + private static final byte[] VORBIS_HEADER_PAGES = { /* capture pattern ogg header 1 */ + (byte) 0x4f, (byte) 0x67, (byte) 0x67, (byte) 0x53, // O,g,g,S : start pos 0 + (byte) 0x00, (byte) 0x02, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x5e, (byte) 0x5f, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x83, (byte) 0x36, + (byte) 0xe3, (byte) 0x49, (byte) 0x01, (byte) 0x1e, /* capture pattern vorbis id header */ + (byte) 0x01, (byte) 0x76, (byte) 0x6f, (byte) 0x72, // 1,v,o,r : start pos 28 + (byte) 0x62, (byte) 0x69, (byte) 0x73, (byte) 0x00, // b,i,s,. + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x02, + (byte) 0x22, (byte) 0x56, (byte) 0x00, (byte) 0x00, + (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, + (byte) 0x6a, (byte) 0x04, (byte) 0x01, (byte) 0x00, + (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, /* capture pattern ogg header 2 */ + (byte) 0xa9, (byte) 0x01, (byte) 0x4f, (byte) 0x67, // .,.,O,g : start pos 86 + (byte) 0x67, (byte) 0x53, (byte) 0x00, (byte) 0x00, // g,S,.,. + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x5e, (byte) 0x5f, (byte) 0x00, (byte) 0x00, + (byte) 0x01, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x69, (byte) 0xf8, (byte) 0xeb, (byte) 0xe1, + (byte) 0x10, (byte) 0x2d, (byte) 0xff, (byte) 0xff, + (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, + (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, + (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, /* capture pattern vorbis comment header*/ + (byte) 0x1b, (byte) 0x03, (byte) 0x76, (byte) 0x6f, // .,3,v,o : start pos 101 + (byte) 0x72, (byte) 0x62, (byte) 0x69, (byte) 0x73, // r,b,i,s + (byte) 0x1d, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x58, (byte) 0x69, (byte) 0x70, (byte) 0x68, + (byte) 0x2e, (byte) 0x4f, (byte) 0x72, (byte) 0x67, + (byte) 0x20, (byte) 0x6c, (byte) 0x69, (byte) 0x62, + (byte) 0x56, (byte) 0x6f, (byte) 0x72, (byte) 0x62, + (byte) 0x69, (byte) 0x73, (byte) 0x20, (byte) 0x49, + (byte) 0x20, (byte) 0x32, (byte) 0x30, (byte) 0x30, + (byte) 0x33, (byte) 0x30, (byte) 0x39, (byte) 0x30, + (byte) 0x39, (byte) 0x00, (byte) 0x00, (byte) 0x00, /* capture pattern vorbis setup header */ + (byte) 0x00, (byte) 0x01, (byte) 0x05, (byte) 0x76, // .,.,5,v : start pos 146 + (byte) 0x6f, (byte) 0x72, (byte) 0x62, (byte) 0x69, // o,r,b,i + (byte) 0x73, (byte) 0x22, (byte) 0x42, (byte) 0x43, // s,. + (byte) 0x56, (byte) 0x01, (byte) 0x00, (byte) 0x40, + (byte) 0x00, (byte) 0x00, (byte) 0x18, (byte) 0x42, + (byte) 0x10, (byte) 0x2a, (byte) 0x05, (byte) 0xad, + (byte) 0x63, (byte) 0x8e, (byte) 0x3a, (byte) 0xc8, + (byte) 0x15, (byte) 0x21, (byte) 0x8c, (byte) 0x19, + (byte) 0xa2, (byte) 0xa0, (byte) 0x42, (byte) 0xca, + (byte) 0x29, (byte) 0xc7, (byte) 0x1d, (byte) 0x42, + (byte) 0xd0, (byte) 0x21, (byte) 0xa3, (byte) 0x24, + (byte) 0x43, (byte) 0x88, (byte) 0x3a, (byte) 0xc6, + (byte) 0x35, (byte) 0xc7, (byte) 0x18, (byte) 0x63, + (byte) 0x47, (byte) 0xb9, (byte) 0x64, (byte) 0x8a, + (byte) 0x42, (byte) 0xc9, (byte) 0x81, (byte) 0xd0, + (byte) 0x90, (byte) 0x55, (byte) 0x00, (byte) 0x00, + (byte) 0x40, (byte) 0x00, (byte) 0x00, (byte) 0xa4, + (byte) 0x1c, (byte) 0x57, (byte) 0x50, (byte) 0x72, + (byte) 0x49, (byte) 0x2d, (byte) 0xe7, (byte) 0x9c, + (byte) 0x73, (byte) 0xa3, (byte) 0x18, (byte) 0x57, + (byte) 0xcc, (byte) 0x71, (byte) 0xe8, (byte) 0x20, + (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0xe5, + (byte) 0x20, (byte) 0x67, (byte) 0xcc, (byte) 0x71, + (byte) 0x09, (byte) 0x25, (byte) 0xe7, (byte) 0x9c, + (byte) 0x73, (byte) 0x8e, (byte) 0x39, (byte) 0xe7, + (byte) 0x92, (byte) 0x72, (byte) 0x8e, (byte) 0x31, + (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0xa3, + (byte) 0x18, (byte) 0x57, (byte) 0x0e, (byte) 0x72, + (byte) 0x29, (byte) 0x2d, (byte) 0xe7, (byte) 0x9c, + (byte) 0x73, (byte) 0x81, (byte) 0x14, (byte) 0x47, + (byte) 0x8a, (byte) 0x71, (byte) 0xa7, (byte) 0x18, + (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0xa4, + (byte) 0x1c, (byte) 0x47, (byte) 0x8a, (byte) 0x71, + (byte) 0xa8, (byte) 0x18, (byte) 0xe7, (byte) 0x9c, + (byte) 0x73, (byte) 0x6d, (byte) 0x31, (byte) 0xb7, + (byte) 0x92, (byte) 0x72, (byte) 0xce, (byte) 0x39, + (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0xe6, + (byte) 0x20, (byte) 0x87, (byte) 0x52, (byte) 0x72, + (byte) 0xae, (byte) 0x35, (byte) 0xe7, (byte) 0x9c, + (byte) 0x73, (byte) 0xa4, (byte) 0x18, (byte) 0x67, + (byte) 0x0e, (byte) 0x72, (byte) 0x0b, (byte) 0x25, + (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0xc6, + (byte) 0x20, (byte) 0x67, (byte) 0xcc, (byte) 0x71, + (byte) 0xeb, (byte) 0x20, (byte) 0xe7, (byte) 0x9c, + (byte) 0x73, (byte) 0x8c, (byte) 0x35, (byte) 0xb7, + (byte) 0xd4, (byte) 0x72, (byte) 0xce, (byte) 0x39, + (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0xce, + (byte) 0x39, (byte) 0xe7, (byte) 0x9c, (byte) 0x73, + (byte) 0xce, (byte) 0x39, (byte) 0xe7, (byte) 0x9c, + (byte) 0x73, (byte) 0x8c, (byte) 0x31, (byte) 0xe7, + (byte) 0x9c, (byte) 0x73, (byte) 0xce, (byte) 0x39, + (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0x6e, + (byte) 0x31, (byte) 0xe7, (byte) 0x16, (byte) 0x73, + (byte) 0xae, (byte) 0x39, (byte) 0xe7, (byte) 0x9c, + (byte) 0x73, (byte) 0xce, (byte) 0x39, (byte) 0xe7, + (byte) 0x1c, (byte) 0x73, (byte) 0xce, (byte) 0x39, + (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0x20, + (byte) 0x34, (byte) 0x64, (byte) 0x15, (byte) 0x00, + (byte) 0x90, (byte) 0x00, (byte) 0x00, (byte) 0xa0, + (byte) 0xa1, (byte) 0x28, (byte) 0x8a, (byte) 0xe2, + (byte) 0x28, (byte) 0x0e, (byte) 0x10, (byte) 0x1a, + (byte) 0xb2, (byte) 0x0a, (byte) 0x00, (byte) 0xc8, + (byte) 0x00, (byte) 0x00, (byte) 0x10, (byte) 0x40, + (byte) 0x71, (byte) 0x14, (byte) 0x47, (byte) 0x91, + (byte) 0x14, (byte) 0x4b, (byte) 0xb1, (byte) 0x1c, + (byte) 0xcb, (byte) 0xd1, (byte) 0x24, (byte) 0x0d, + (byte) 0x08, (byte) 0x0d, (byte) 0x59, (byte) 0x05, + (byte) 0x00, (byte) 0x00, (byte) 0x01, (byte) 0x00, + (byte) 0x08, (byte) 0x00, (byte) 0x00, (byte) 0xa0, + (byte) 0x48, (byte) 0x86, (byte) 0xa4, (byte) 0x48, + (byte) 0x8a, (byte) 0xa5, (byte) 0x58, (byte) 0x8e, + (byte) 0x66, (byte) 0x69, (byte) 0x9e, (byte) 0x26, + (byte) 0x7a, (byte) 0xa2, (byte) 0x28, (byte) 0x9a, + (byte) 0xa2, (byte) 0x2a, (byte) 0xab, (byte) 0xb2, + (byte) 0x69, (byte) 0xca, (byte) 0xb2, (byte) 0x2c, + (byte) 0xcb, (byte) 0xb2, (byte) 0xeb, (byte) 0xba, + (byte) 0x2e, (byte) 0x10, (byte) 0x1a, (byte) 0xb2, + (byte) 0x0a, (byte) 0x00, (byte) 0x48, (byte) 0x00, + (byte) 0x00, (byte) 0x50, (byte) 0x51, (byte) 0x14, + (byte) 0xc5, (byte) 0x70, (byte) 0x14, (byte) 0x07, + (byte) 0x08, (byte) 0x0d, (byte) 0x59, (byte) 0x05, + (byte) 0x00, (byte) 0x64, (byte) 0x00, (byte) 0x00, + (byte) 0x08, (byte) 0x60, (byte) 0x28, (byte) 0x8a, + (byte) 0xa3, (byte) 0x38, (byte) 0x8e, (byte) 0xe4, + (byte) 0x58, (byte) 0x92, (byte) 0xa5, (byte) 0x59, + (byte) 0x9e, (byte) 0x07, (byte) 0x84, (byte) 0x86, + (byte) 0xac, (byte) 0x02, (byte) 0x00, (byte) 0x80, + (byte) 0x00, (byte) 0x00, (byte) 0x04, (byte) 0x00, + (byte) 0x00, (byte) 0x50, (byte) 0x0c, (byte) 0x47, + (byte) 0xb1, (byte) 0x14, (byte) 0x4d, (byte) 0xf1, + (byte) 0x24, (byte) 0xcf, (byte) 0xf2, (byte) 0x3c, + (byte) 0xcf, (byte) 0xf3, (byte) 0x3c, (byte) 0xcf, + (byte) 0xf3, (byte) 0x3c, (byte) 0xcf, (byte) 0xf3, + (byte) 0x3c, (byte) 0xcf, (byte) 0xf3, (byte) 0x3c, + (byte) 0xcf, (byte) 0xf3, (byte) 0x3c, (byte) 0xcf, + (byte) 0xf3, (byte) 0x3c, (byte) 0x0d, (byte) 0x08, + (byte) 0x0d, (byte) 0x59, (byte) 0x05, (byte) 0x00, + (byte) 0x20, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x82, (byte) 0x28, (byte) 0x64, (byte) 0x18, + (byte) 0x03, (byte) 0x42, (byte) 0x43, (byte) 0x56, + (byte) 0x01, (byte) 0x00, (byte) 0x40, (byte) 0x00, + (byte) 0x00, (byte) 0x08, (byte) 0x21, (byte) 0x1a, + (byte) 0x19, (byte) 0x43, (byte) 0x9d, (byte) 0x52, + (byte) 0x12, (byte) 0x5c, (byte) 0x0a, (byte) 0x16, + (byte) 0x42, (byte) 0x1c, (byte) 0x11, (byte) 0x43, + (byte) 0x1d, (byte) 0x42, (byte) 0xce, (byte) 0x43, + (byte) 0xa9, (byte) 0xa5, (byte) 0x83, (byte) 0xe0, + (byte) 0x29, (byte) 0x85, (byte) 0x25, (byte) 0x63, + (byte) 0xd2, (byte) 0x53, (byte) 0xac, (byte) 0x41, + (byte) 0x08, (byte) 0x21, (byte) 0x7c, (byte) 0xef, + (byte) 0x3d, (byte) 0xf7, (byte) 0xde, (byte) 0x7b, + (byte) 0xef, (byte) 0x81, (byte) 0xd0, (byte) 0x90, + (byte) 0x55, (byte) 0x00, (byte) 0x00, (byte) 0x10, + (byte) 0x00, (byte) 0x00, (byte) 0x61, (byte) 0x14, + (byte) 0x38, (byte) 0x88, (byte) 0x81, (byte) 0xc7, + (byte) 0x24, (byte) 0x08, (byte) 0x21, (byte) 0x84, + (byte) 0x62, (byte) 0x14, (byte) 0x27, (byte) 0x44, + (byte) 0x71, (byte) 0xa6, (byte) 0x20, (byte) 0x08, + (byte) 0x21, (byte) 0x84, (byte) 0xe5, (byte) 0x24, + (byte) 0x58, (byte) 0xca, (byte) 0x79, (byte) 0xe8, + (byte) 0x24, (byte) 0x08, (byte) 0xdd, (byte) 0x83, + (byte) 0x10, (byte) 0x42, (byte) 0xb8, (byte) 0x9c, + (byte) 0x7b, (byte) 0xcb, (byte) 0xb9, (byte) 0xf7, + (byte) 0xde, (byte) 0x7b, (byte) 0x20, (byte) 0x34, + (byte) 0x64, (byte) 0x15, (byte) 0x00, (byte) 0x00, + (byte) 0x08, (byte) 0x00, (byte) 0xc0, (byte) 0x20, + (byte) 0x84, (byte) 0x10, (byte) 0x42, (byte) 0x08, + (byte) 0x21, (byte) 0x84, (byte) 0x10, (byte) 0x42, + (byte) 0x08, (byte) 0x29, (byte) 0xa4, (byte) 0x94, + (byte) 0x52, (byte) 0x48, (byte) 0x29, (byte) 0xa6, + (byte) 0x98, (byte) 0x62, (byte) 0x8a, (byte) 0x29, + (byte) 0xc7, (byte) 0x1c, (byte) 0x73, (byte) 0xcc, + (byte) 0x31, (byte) 0xc7, (byte) 0x20, (byte) 0x83, + (byte) 0x0c, (byte) 0x32, (byte) 0xe8, (byte) 0xa0, + (byte) 0x93, (byte) 0x4e, (byte) 0x3a, (byte) 0xc9, + (byte) 0xa4, (byte) 0x92, (byte) 0x4e, (byte) 0x3a, + (byte) 0xca, (byte) 0x24, (byte) 0xa3, (byte) 0x8e, + (byte) 0x52, (byte) 0x6b, (byte) 0x29, (byte) 0xb5, + (byte) 0x14, (byte) 0x53, (byte) 0x4c, (byte) 0xb1, + (byte) 0xe5, (byte) 0x16, (byte) 0x63, (byte) 0xad, + (byte) 0xb5, (byte) 0xd6, (byte) 0x9c, (byte) 0x73, + (byte) 0xaf, (byte) 0x41, (byte) 0x29, (byte) 0x63, + (byte) 0x8c, (byte) 0x31, (byte) 0xc6, (byte) 0x18, + (byte) 0x63, (byte) 0x8c, (byte) 0x31, (byte) 0xc6, + (byte) 0x18, (byte) 0x63, (byte) 0x8c, (byte) 0x31, + (byte) 0xc6, (byte) 0x18, (byte) 0x23, (byte) 0x08, + (byte) 0x0d, (byte) 0x59, (byte) 0x05, (byte) 0x00, + (byte) 0x80, (byte) 0x00, (byte) 0x00, (byte) 0x10, + (byte) 0x06, (byte) 0x19, (byte) 0x64, (byte) 0x90, + (byte) 0x41, (byte) 0x08, (byte) 0x21, (byte) 0x84, + (byte) 0x14, (byte) 0x52, (byte) 0x48, (byte) 0x29, + (byte) 0xa6, (byte) 0x98, (byte) 0x72, (byte) 0xcc, + (byte) 0x31, (byte) 0xc7, (byte) 0x1c, (byte) 0x03, + (byte) 0x42, (byte) 0x43, (byte) 0x56, (byte) 0x01, + (byte) 0x00, (byte) 0x80, (byte) 0x00, (byte) 0x00, + (byte) 0x02, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x1c, (byte) 0x45, (byte) 0x52, (byte) 0x24, + (byte) 0x47, (byte) 0x72, (byte) 0x24, (byte) 0x47, + (byte) 0x92, (byte) 0x24, (byte) 0xc9, (byte) 0x92, + (byte) 0x2c, (byte) 0x49, (byte) 0x93, (byte) 0x3c, + (byte) 0xcb, (byte) 0xb3, (byte) 0x3c, (byte) 0xcb, + (byte) 0xb3, (byte) 0x3c, (byte) 0x4d, (byte) 0xd4, + (byte) 0x44, (byte) 0x4d, (byte) 0x15, (byte) 0x55, + (byte) 0xd5, (byte) 0x55, (byte) 0x6d, (byte) 0xd7, + (byte) 0xf6, (byte) 0x6d, (byte) 0x5f, (byte) 0xf6, + (byte) 0x6d, (byte) 0xdf, (byte) 0xd5, (byte) 0x65, + (byte) 0xdf, (byte) 0xf6, (byte) 0x65, (byte) 0xdb, + (byte) 0xd5, (byte) 0x65, (byte) 0x5d, (byte) 0x96, + (byte) 0x65, (byte) 0xdd, (byte) 0xb5, (byte) 0x6d, + (byte) 0x5d, (byte) 0xd6, (byte) 0x5d, (byte) 0x5d, + (byte) 0xd7, (byte) 0x75, (byte) 0x5d, (byte) 0xd7, + (byte) 0x75, (byte) 0x5d, (byte) 0xd7, (byte) 0x75, + (byte) 0x5d, (byte) 0xd7, (byte) 0x75, (byte) 0x5d, + (byte) 0xd7, (byte) 0x75, (byte) 0x5d, (byte) 0xd7, + (byte) 0x81, (byte) 0xd0, (byte) 0x90, (byte) 0x55, + (byte) 0x00, (byte) 0x80, (byte) 0x04, (byte) 0x00, + (byte) 0x80, (byte) 0x8e, (byte) 0xe4, (byte) 0x38, + (byte) 0x8e, (byte) 0xe4, (byte) 0x38, (byte) 0x8e, + (byte) 0xe4, (byte) 0x48, (byte) 0x8e, (byte) 0xa4, + (byte) 0x48, (byte) 0x0a, (byte) 0x10, (byte) 0x1a, + (byte) 0xb2, (byte) 0x0a, (byte) 0x00, (byte) 0x90, + (byte) 0x01, (byte) 0x00, (byte) 0x10, (byte) 0x00, + (byte) 0x80, (byte) 0xa3, (byte) 0x38, (byte) 0x8a, + (byte) 0xe3, (byte) 0x48, (byte) 0x8e, (byte) 0xe4, + (byte) 0x58, (byte) 0x8e, (byte) 0x25, (byte) 0x59, + (byte) 0x92, (byte) 0x26, (byte) 0x69, (byte) 0x96, + (byte) 0x67, (byte) 0x79, (byte) 0x96, (byte) 0xa7, + (byte) 0x79, (byte) 0x9a, (byte) 0xa8, (byte) 0x89, + (byte) 0x1e, (byte) 0x10, (byte) 0x1a, (byte) 0xb2, + (byte) 0x0a, (byte) 0x00, (byte) 0x00, (byte) 0x04, + (byte) 0x00, (byte) 0x10, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x80, + (byte) 0xa2, (byte) 0x28, (byte) 0x8a, (byte) 0xa3, + (byte) 0x38, (byte) 0x8e, (byte) 0x24, (byte) 0x59, + (byte) 0x96, (byte) 0xa6, (byte) 0x69, (byte) 0x9e, + (byte) 0xa7, (byte) 0x7a, (byte) 0xa2, (byte) 0x28, + (byte) 0x9a, (byte) 0xaa, (byte) 0xaa, (byte) 0x8a, + (byte) 0xa6, (byte) 0xa9, (byte) 0xaa, (byte) 0xaa, + (byte) 0x6a, (byte) 0x9a, (byte) 0xa6, (byte) 0x69, + (byte) 0x9a, (byte) 0xa6, (byte) 0x69, (byte) 0x9a, + (byte) 0xa6, (byte) 0x69, (byte) 0x9a, (byte) 0xa6, + (byte) 0x69, (byte) 0x9a, (byte) 0xa6, (byte) 0x69, + (byte) 0x9a, (byte) 0xa6, (byte) 0x69, (byte) 0x9a, + (byte) 0xa6, (byte) 0x69, (byte) 0x9a, (byte) 0xa6, + (byte) 0x69, (byte) 0x9a, (byte) 0xa6, (byte) 0x69, + (byte) 0x9a, (byte) 0xa6, (byte) 0x69, (byte) 0x9a, + (byte) 0xa6, (byte) 0x69, (byte) 0x02, (byte) 0xa1, + (byte) 0x21, (byte) 0xab, (byte) 0x00, (byte) 0x00, + (byte) 0x09, (byte) 0x00, (byte) 0x00, (byte) 0x1d, + (byte) 0xc7, (byte) 0x71, (byte) 0x1c, (byte) 0x47, + (byte) 0x71, (byte) 0x1c, (byte) 0xc7, (byte) 0x71, + (byte) 0x24, (byte) 0x47, (byte) 0x92, (byte) 0x24, + (byte) 0x20, (byte) 0x34, (byte) 0x64, (byte) 0x15, + (byte) 0x00, (byte) 0x20, (byte) 0x03, (byte) 0x00, + (byte) 0x20, (byte) 0x00, (byte) 0x00, (byte) 0x43, + (byte) 0x51, (byte) 0x1c, (byte) 0x45, (byte) 0x72, + (byte) 0x2c, (byte) 0xc7, (byte) 0x92, (byte) 0x34, + (byte) 0x4b, (byte) 0xb3, (byte) 0x3c, (byte) 0xcb, + (byte) 0xd3, (byte) 0x44, (byte) 0xcf, (byte) 0xf4, + (byte) 0x5c, (byte) 0x51, (byte) 0x36, (byte) 0x75, + (byte) 0x53, (byte) 0x57, (byte) 0x6d, (byte) 0x20, + (byte) 0x34, (byte) 0x64, (byte) 0x15, (byte) 0x00, + (byte) 0x00, (byte) 0x08, (byte) 0x00, (byte) 0x20, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0xc7, (byte) 0x73, + (byte) 0x3c, (byte) 0xc7, (byte) 0x73, (byte) 0x3c, + (byte) 0xc9, (byte) 0x93, (byte) 0x3c, (byte) 0xcb, + (byte) 0x73, (byte) 0x3c, (byte) 0xc7, (byte) 0x93, + (byte) 0x3c, (byte) 0x49, (byte) 0xd3, (byte) 0x34, + (byte) 0x4d, (byte) 0xd3, (byte) 0x34, (byte) 0x4d, + (byte) 0xd3, (byte) 0x34, (byte) 0x4d, (byte) 0xd3, + (byte) 0x34, (byte) 0x4d, (byte) 0xd3, (byte) 0x34, + (byte) 0x4d, (byte) 0xd3, (byte) 0x34, (byte) 0x4d, + (byte) 0xd3, (byte) 0x34, (byte) 0x4d, (byte) 0xd3, + (byte) 0x34, (byte) 0x4d, (byte) 0xd3, (byte) 0x34, + (byte) 0x4d, (byte) 0xd3, (byte) 0x34, (byte) 0x4d, + (byte) 0xd3, (byte) 0x34, (byte) 0x4d, (byte) 0xd3, + (byte) 0x34, (byte) 0x4d, (byte) 0xd3, (byte) 0x34, + (byte) 0x4d, (byte) 0xd3, (byte) 0x34, (byte) 0x4d, + (byte) 0x03, (byte) 0x42, (byte) 0x43, (byte) 0x56, + (byte) 0x02, (byte) 0x00, (byte) 0x64, (byte) 0x00, + (byte) 0x00, (byte) 0x90, (byte) 0x02, (byte) 0xcf, + (byte) 0x42, (byte) 0x29, (byte) 0x2d, (byte) 0x46, + (byte) 0x02, (byte) 0x1c, (byte) 0x88, (byte) 0x98, + (byte) 0xa3, (byte) 0xd8, (byte) 0x7b, (byte) 0xef, + (byte) 0xbd, (byte) 0xf7, (byte) 0xde, (byte) 0x7b, + (byte) 0x65, (byte) 0x3c, (byte) 0x92, (byte) 0x88, + (byte) 0x49, (byte) 0xed, (byte) 0x31, (byte) 0xf4, + (byte) 0xd4, (byte) 0x31, (byte) 0x07, (byte) 0xb1, + (byte) 0x67, (byte) 0xc6, (byte) 0x23, (byte) 0x66, + (byte) 0x94, (byte) 0xa3, (byte) 0xd8, (byte) 0x29, + (byte) 0xcf, (byte) 0x1c, (byte) 0x42, (byte) 0x0c, + (byte) 0x62, (byte) 0xe8, (byte) 0x3c, (byte) 0x74, + (byte) 0x4a, (byte) 0x31, (byte) 0x88, (byte) 0x29, + (byte) 0xf5, (byte) 0x52, (byte) 0x32, (byte) 0xc6, + (byte) 0x20, (byte) 0xc6, (byte) 0xd8, (byte) 0x63, + (byte) 0x0c, (byte) 0x21, (byte) 0x94, (byte) 0x18, + (byte) 0x08, (byte) 0x0d, (byte) 0x59, (byte) 0x21, + (byte) 0x00, (byte) 0x84, (byte) 0x66, (byte) 0x00, + (byte) 0x18, (byte) 0x24, (byte) 0x09, (byte) 0x90, + (byte) 0x34, (byte) 0x0d, (byte) 0x90, (byte) 0x34, + (byte) 0x0d, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x24, (byte) 0x4f, (byte) 0x03, (byte) 0x34, + (byte) 0x51, (byte) 0x04, (byte) 0x34, (byte) 0x4f, + (byte) 0x04, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x49, (byte) 0xf3, (byte) 0x00, (byte) 0x4d, + (byte) 0xf4, (byte) 0x00, (byte) 0x4d, (byte) 0x14, + (byte) 0x01, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x90, (byte) 0x3c, (byte) 0x0d, (byte) 0xf0, + (byte) 0x44, (byte) 0x11, (byte) 0xd0, (byte) 0x44, + (byte) 0x11, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x34, (byte) 0x51, (byte) 0x04, (byte) 0x44, + (byte) 0x51, (byte) 0x05, (byte) 0x44, (byte) 0xd5, + (byte) 0x04, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x4d, (byte) 0x14, (byte) 0x01, (byte) 0x4f, + (byte) 0x15, (byte) 0x01, (byte) 0xd1, (byte) 0x54, + (byte) 0x01, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x90, (byte) 0x34, (byte) 0x0f, (byte) 0xd0, + (byte) 0x44, (byte) 0x11, (byte) 0xf0, (byte) 0x44, + (byte) 0x11, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x34, (byte) 0x51, (byte) 0x04, (byte) 0x44, + (byte) 0xd5, (byte) 0x04, (byte) 0x3c, (byte) 0x51, + (byte) 0x05, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x4d, (byte) 0x14, (byte) 0x01, (byte) 0xd1, + (byte) 0x54, (byte) 0x01, (byte) 0x51, (byte) 0x15, + (byte) 0x01, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x04, + (byte) 0x00, (byte) 0x00, (byte) 0x04, (byte) 0x38, + (byte) 0x00, (byte) 0x00, (byte) 0x04, (byte) 0x58, + (byte) 0x08, (byte) 0x85, (byte) 0x86, (byte) 0xac, + (byte) 0x08, (byte) 0x00, (byte) 0xe2, (byte) 0x04, + (byte) 0x00, (byte) 0x04, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x10, + (byte) 0x00, (byte) 0x00, (byte) 0x30, (byte) 0xe0, + (byte) 0x00, (byte) 0x00, (byte) 0x10, (byte) 0x60, + (byte) 0x42, (byte) 0x19, (byte) 0x28, (byte) 0x34, + (byte) 0x64, (byte) 0x45, (byte) 0x00, (byte) 0x10, + (byte) 0x27, (byte) 0x00, (byte) 0x60, (byte) 0x70, + (byte) 0x1c, (byte) 0xcb, (byte) 0x02, (byte) 0x00, + (byte) 0x00, (byte) 0x47, (byte) 0x92, (byte) 0x34, + (byte) 0x0d, (byte) 0x00, (byte) 0x00, (byte) 0x1c, + (byte) 0x49, (byte) 0xd2, (byte) 0x34, (byte) 0x00, + (byte) 0x00, (byte) 0xd0, (byte) 0x34, (byte) 0x4d, + (byte) 0x14, (byte) 0x01, (byte) 0x00, (byte) 0xc0, + (byte) 0xd2, (byte) 0x34, (byte) 0x51, (byte) 0x04, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x10, (byte) 0x00, (byte) 0x00, (byte) 0x30, + (byte) 0xe0, (byte) 0x00, (byte) 0x00, (byte) 0x10, + (byte) 0x60, (byte) 0x42, (byte) 0x19, (byte) 0x28, + (byte) 0x34, (byte) 0x64, (byte) 0x25, (byte) 0x00, + (byte) 0x10, (byte) 0x05, (byte) 0x00, (byte) 0x60, + (byte) 0x30, (byte) 0x14, (byte) 0x4d, (byte) 0x03, + (byte) 0x58, (byte) 0x16, (byte) 0xc0, (byte) 0xb2, + (byte) 0x00, (byte) 0x9a, (byte) 0x06, (byte) 0xd0, + (byte) 0x34, (byte) 0x80, (byte) 0xe7, (byte) 0x01, + (byte) 0x3c, (byte) 0x11, (byte) 0x60, (byte) 0x9a, + (byte) 0x00, (byte) 0x40, (byte) 0x00, (byte) 0x00, + (byte) 0x40, (byte) 0x81, (byte) 0x03, (byte) 0x00, + (byte) 0x40, (byte) 0x80, (byte) 0x0d, (byte) 0x9a, + (byte) 0x12, (byte) 0x8b, (byte) 0x03, (byte) 0x14, + (byte) 0x1a, (byte) 0xb2, (byte) 0x12, (byte) 0x00, + (byte) 0x88, (byte) 0x02, (byte) 0x00, (byte) 0x30, + (byte) 0x28, (byte) 0x8a, (byte) 0x24, (byte) 0x59, + (byte) 0x96, (byte) 0xe7, (byte) 0x41, (byte) 0xd3, + (byte) 0x34, (byte) 0x4d, (byte) 0x14, (byte) 0xa1, + (byte) 0x69, (byte) 0x9a, (byte) 0x26, (byte) 0x8a, + (byte) 0xf0, (byte) 0x3c, (byte) 0xcf, (byte) 0x13, + (byte) 0x45, (byte) 0x78, (byte) 0x9e, (byte) 0xe7, + (byte) 0x99, (byte) 0x26, (byte) 0x44, (byte) 0xd1, + (byte) 0xf3, (byte) 0x4c, (byte) 0x13, (byte) 0xa2, + (byte) 0xe8, (byte) 0x79, (byte) 0xa6, (byte) 0x09, + (byte) 0xd3, (byte) 0x14, (byte) 0x45, (byte) 0xd3, + (byte) 0x04, (byte) 0xa2, (byte) 0x68, (byte) 0x9a, + (byte) 0x02, (byte) 0x00, (byte) 0x00, (byte) 0x0a, + (byte) 0x1c, (byte) 0x00, (byte) 0x00, (byte) 0x02, + (byte) 0x6c, (byte) 0xd0, (byte) 0x94, (byte) 0x58, + (byte) 0x1c, (byte) 0xa0, (byte) 0xd0, (byte) 0x90, + (byte) 0x95, (byte) 0x00, (byte) 0x40, (byte) 0x48, + (byte) 0x00, (byte) 0x80, (byte) 0x41, (byte) 0x51, + (byte) 0x2c, (byte) 0xcb, (byte) 0xf3, (byte) 0x44, + (byte) 0x51, (byte) 0x14, (byte) 0x4d, (byte) 0x53, + (byte) 0x55, (byte) 0x5d, (byte) 0x17, (byte) 0x9a, + (byte) 0xe6, (byte) 0x79, (byte) 0xa2, (byte) 0x28, + (byte) 0x8a, (byte) 0xa6, (byte) 0xa9, (byte) 0xaa, + (byte) 0xae, (byte) 0x0b, (byte) 0x4d, (byte) 0xf3, + (byte) 0x3c, (byte) 0x51, (byte) 0x14, (byte) 0x45, + (byte) 0xd3, (byte) 0x54, (byte) 0x55, (byte) 0xd7, + (byte) 0x85, (byte) 0xe7, (byte) 0x79, (byte) 0xa2, + (byte) 0x29, (byte) 0x9a, (byte) 0xa6, (byte) 0x69, + (byte) 0xaa, (byte) 0xaa, (byte) 0xeb, (byte) 0xc2, + (byte) 0xf3, (byte) 0x44, (byte) 0xd1, (byte) 0x34, + (byte) 0x4d, (byte) 0x53, (byte) 0x55, (byte) 0x55, + (byte) 0xd7, (byte) 0x75, (byte) 0xe1, (byte) 0x79, + (byte) 0xa2, (byte) 0x68, (byte) 0x9a, (byte) 0xa6, + (byte) 0xa9, (byte) 0xaa, (byte) 0xae, (byte) 0xeb, + (byte) 0xba, (byte) 0xf0, (byte) 0x3c, (byte) 0x51, + (byte) 0x34, (byte) 0x4d, (byte) 0xd3, (byte) 0x54, + (byte) 0x55, (byte) 0xd7, (byte) 0x95, (byte) 0x65, + (byte) 0x88, (byte) 0xa2, (byte) 0x28, (byte) 0x9a, + (byte) 0xa6, (byte) 0x69, (byte) 0xaa, (byte) 0xaa, + (byte) 0xeb, (byte) 0xca, (byte) 0x32, (byte) 0x10, + (byte) 0x45, (byte) 0xd3, (byte) 0x34, (byte) 0x4d, + (byte) 0x55, (byte) 0x75, (byte) 0x5d, (byte) 0x59, + (byte) 0x06, (byte) 0xa2, (byte) 0x68, (byte) 0x9a, + (byte) 0xaa, (byte) 0xea, (byte) 0xba, (byte) 0xae, + (byte) 0x2b, (byte) 0xcb, (byte) 0x40, (byte) 0x14, + (byte) 0x4d, (byte) 0x53, (byte) 0x55, (byte) 0x5d, + (byte) 0xd7, (byte) 0x75, (byte) 0x65, (byte) 0x19, + (byte) 0x98, (byte) 0xa6, (byte) 0x6a, (byte) 0xaa, + (byte) 0xaa, (byte) 0xeb, (byte) 0xca, (byte) 0xb2, + (byte) 0x2c, (byte) 0x03, (byte) 0x4c, (byte) 0x53, + (byte) 0x55, (byte) 0x5d, (byte) 0x57, (byte) 0x96, + (byte) 0x65, (byte) 0x19, (byte) 0xa0, (byte) 0xaa, + (byte) 0xae, (byte) 0xeb, (byte) 0xba, (byte) 0xb2, + (byte) 0x6c, (byte) 0xdb, (byte) 0x00, (byte) 0x55, + (byte) 0x75, (byte) 0x5d, (byte) 0xd7, (byte) 0x95, + (byte) 0x65, (byte) 0xdb, (byte) 0x06, (byte) 0xb8, + (byte) 0xae, (byte) 0xeb, (byte) 0xca, (byte) 0xb2, + (byte) 0x2c, (byte) 0xdb, (byte) 0x36, (byte) 0x00, + (byte) 0xd7, (byte) 0x95, (byte) 0x65, (byte) 0x59, + (byte) 0xb6, (byte) 0x6d, (byte) 0x01, (byte) 0x00, + (byte) 0x00, (byte) 0x07, (byte) 0x0e, (byte) 0x00, + (byte) 0x00, (byte) 0x01, (byte) 0x46, (byte) 0xd0, + (byte) 0x49, (byte) 0x46, (byte) 0x95, (byte) 0x45, + (byte) 0xd8, (byte) 0x68, (byte) 0xc2, (byte) 0x85, + (byte) 0x07, (byte) 0xa0, (byte) 0xd0, (byte) 0x90, + (byte) 0x15, (byte) 0x01, (byte) 0x40, (byte) 0x14, + (byte) 0x00, (byte) 0x00, (byte) 0x60, (byte) 0x8c, + (byte) 0x52, (byte) 0x8a, (byte) 0x29, (byte) 0x65, + (byte) 0x18, (byte) 0x93, (byte) 0x50, (byte) 0x4a, + (byte) 0x09, (byte) 0x0d, (byte) 0x63, (byte) 0x52, + (byte) 0x4a, (byte) 0x2a, (byte) 0xa5, (byte) 0x92, + (byte) 0x92, (byte) 0x52, (byte) 0x4a, (byte) 0xa5, + (byte) 0x54, (byte) 0x12, (byte) 0x52, (byte) 0x4a, + (byte) 0xa9, (byte) 0x94, (byte) 0x4a, (byte) 0x4a, + (byte) 0x4a, (byte) 0x29, (byte) 0x95, (byte) 0x92, + (byte) 0x51, (byte) 0x4a, (byte) 0x29, (byte) 0xb5, + (byte) 0x96, (byte) 0x2a, (byte) 0x29, (byte) 0xa9, + (byte) 0x94, (byte) 0x94, (byte) 0x52, (byte) 0x25, + (byte) 0xa5, (byte) 0xa4, (byte) 0x92, (byte) 0x52, + (byte) 0x2a, (byte) 0x00, (byte) 0x00, (byte) 0xec, + (byte) 0xc0, (byte) 0x01, (byte) 0x00, (byte) 0xec, + (byte) 0xc0, (byte) 0x42, (byte) 0x28, (byte) 0x34, + (byte) 0x64, (byte) 0x25, (byte) 0x00, (byte) 0x90, + (byte) 0x07, (byte) 0x00, (byte) 0x40, (byte) 0x10, + (byte) 0x82, (byte) 0x14, (byte) 0x63, (byte) 0x8c, + (byte) 0x39, (byte) 0x27, (byte) 0xa5, (byte) 0x54, + (byte) 0x8a, (byte) 0x31, (byte) 0xe7, (byte) 0x9c, + (byte) 0x93, (byte) 0x52, (byte) 0x2a, (byte) 0xc5, + (byte) 0x98, (byte) 0x73, (byte) 0xce, (byte) 0x49, + (byte) 0x29, (byte) 0x19, (byte) 0x63, (byte) 0xcc, + (byte) 0x39, (byte) 0xe7, (byte) 0xa4, (byte) 0x94, + (byte) 0x8c, (byte) 0x31, (byte) 0xe6, (byte) 0x9c, + (byte) 0x73, (byte) 0x52, (byte) 0x4a, (byte) 0xc6, + (byte) 0x9c, (byte) 0x73, (byte) 0xce, (byte) 0x39, + (byte) 0x29, (byte) 0x25, (byte) 0x63, (byte) 0xce, + (byte) 0x39, (byte) 0xe7, (byte) 0x9c, (byte) 0x94, + (byte) 0xd2, (byte) 0x39, (byte) 0xe7, (byte) 0x9c, + (byte) 0x83, (byte) 0x50, (byte) 0x4a, (byte) 0x29, + (byte) 0xa5, (byte) 0x73, (byte) 0xce, (byte) 0x41, + (byte) 0x28, (byte) 0xa5, (byte) 0x94, (byte) 0x12, + (byte) 0x42, (byte) 0xe7, (byte) 0x20, (byte) 0x94, + (byte) 0x52, (byte) 0x4a, (byte) 0xe9, (byte) 0x9c, + (byte) 0x73, (byte) 0x10, (byte) 0x0a, (byte) 0x00, + (byte) 0x00, (byte) 0x2a, (byte) 0x70, (byte) 0x00, + (byte) 0x00, (byte) 0x08, (byte) 0xb0, (byte) 0x51, + (byte) 0x64, (byte) 0x73, (byte) 0x82, (byte) 0x91, + (byte) 0xa0, (byte) 0x42, (byte) 0x43, (byte) 0x56, + (byte) 0x02, (byte) 0x00, (byte) 0xa9, (byte) 0x00, + (byte) 0x00, (byte) 0x06, (byte) 0xc7, (byte) 0xb1, + (byte) 0x2c, (byte) 0x4d, (byte) 0xd3, (byte) 0x34, + (byte) 0xcf, (byte) 0x13, (byte) 0x45, (byte) 0x4b, + (byte) 0x92, (byte) 0x34, (byte) 0xcf, (byte) 0x13, + (byte) 0x3d, (byte) 0x4f, (byte) 0x14, (byte) 0x4d, + (byte) 0xd5, (byte) 0x92, (byte) 0x24, (byte) 0xcf, + (byte) 0x13, (byte) 0x45, (byte) 0xcf, (byte) 0x13, + (byte) 0x4d, (byte) 0x53, (byte) 0xe5, (byte) 0x79, + (byte) 0x9e, (byte) 0x28, (byte) 0x8a, (byte) 0xa2, + (byte) 0x68, (byte) 0x9a, (byte) 0xaa, (byte) 0x4a, + (byte) 0x14, (byte) 0x45, (byte) 0x4f, (byte) 0x14, + (byte) 0x45, (byte) 0xd1, (byte) 0x34, (byte) 0x55, + (byte) 0x95, (byte) 0x2c, (byte) 0x8b, (byte) 0xa2, + (byte) 0x69, (byte) 0x9a, (byte) 0xa6, (byte) 0xaa, + (byte) 0xba, (byte) 0x2e, (byte) 0x5b, (byte) 0x16, + (byte) 0x45, (byte) 0xd3, (byte) 0x34, (byte) 0x4d, + (byte) 0x55, (byte) 0x75, (byte) 0x5d, (byte) 0x98, + (byte) 0xa6, (byte) 0x28, (byte) 0xaa, (byte) 0xaa, + (byte) 0xeb, (byte) 0xca, (byte) 0x2e, (byte) 0x4c, + (byte) 0x53, (byte) 0x14, (byte) 0x4d, (byte) 0xd3, + (byte) 0x75, (byte) 0x65, (byte) 0x19, (byte) 0xb2, + (byte) 0xad, (byte) 0x9a, (byte) 0xaa, (byte) 0xea, + (byte) 0xba, (byte) 0xb2, (byte) 0x0d, (byte) 0xdb, + (byte) 0x36, (byte) 0x4d, (byte) 0x55, (byte) 0x75, + (byte) 0x5d, (byte) 0x59, (byte) 0x06, (byte) 0xae, + (byte) 0xeb, (byte) 0xba, (byte) 0xb2, (byte) 0x6c, + (byte) 0xeb, (byte) 0xc0, (byte) 0x75, (byte) 0x5d, + (byte) 0x57, (byte) 0x96, (byte) 0x6d, (byte) 0x5d, + (byte) 0x00, (byte) 0x00, (byte) 0x78, (byte) 0x82, + (byte) 0x03, (byte) 0x00, (byte) 0x50, (byte) 0x81, + (byte) 0x0d, (byte) 0xab, (byte) 0x23, (byte) 0x9c, + (byte) 0x14, (byte) 0x8d, (byte) 0x05, (byte) 0x16, + (byte) 0x1a, (byte) 0xb2, (byte) 0x12, (byte) 0x00, + (byte) 0xc8, (byte) 0x00, (byte) 0x00, (byte) 0x20, + (byte) 0x08, (byte) 0x41, (byte) 0x48, (byte) 0x29, + (byte) 0x85, (byte) 0x90, (byte) 0x52, (byte) 0x0a, + (byte) 0x21, (byte) 0xa5, (byte) 0x14, (byte) 0x42, + (byte) 0x4a, (byte) 0x29, (byte) 0x84, (byte) 0x04, + (byte) 0x00, (byte) 0x00, (byte) 0x0c, (byte) 0x38, + (byte) 0x00, (byte) 0x00, (byte) 0x04, (byte) 0x98, + (byte) 0x50, (byte) 0x06, (byte) 0x0a, (byte) 0x0d, + (byte) 0x59, (byte) 0x09, (byte) 0x00, (byte) 0xa4, + (byte) 0x02, (byte) 0x00, (byte) 0x00, (byte) 0x10, + (byte) 0x42, (byte) 0x08, (byte) 0x21, (byte) 0x84, + (byte) 0x10, (byte) 0x42, (byte) 0x08, (byte) 0x21, + (byte) 0x84, (byte) 0x10, (byte) 0x42, (byte) 0x08, + (byte) 0x21, (byte) 0x84, (byte) 0x10, (byte) 0x42, + (byte) 0x08, (byte) 0x21, (byte) 0x84, (byte) 0x10, + (byte) 0x42, (byte) 0x08, (byte) 0x21, (byte) 0x84, + (byte) 0x10, (byte) 0x42, (byte) 0x08, (byte) 0x21, + (byte) 0x84, (byte) 0x10, (byte) 0x42, (byte) 0x08, + (byte) 0x21, (byte) 0x84, (byte) 0x10, (byte) 0x42, + (byte) 0x08, (byte) 0x21, (byte) 0x84, (byte) 0x10, + (byte) 0x42, (byte) 0x08, (byte) 0x21, (byte) 0x84, + (byte) 0x10, (byte) 0x42, (byte) 0x08, (byte) 0x21, + (byte) 0x84, (byte) 0xce, (byte) 0x39, (byte) 0xe7, + (byte) 0x9c, (byte) 0x73, (byte) 0xce, (byte) 0x39, + (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0xce, + (byte) 0x39, (byte) 0xe7, (byte) 0x9c, (byte) 0x73, + (byte) 0xce, (byte) 0x39, (byte) 0xe7, (byte) 0x9c, + (byte) 0x73, (byte) 0xce, (byte) 0x39, (byte) 0xe7, + (byte) 0x9c, (byte) 0x73, (byte) 0xce, (byte) 0x39, + (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0xce, + (byte) 0x39, (byte) 0xe7, (byte) 0x9c, (byte) 0x73, + (byte) 0xce, (byte) 0x39, (byte) 0xe7, (byte) 0x9c, + (byte) 0x73, (byte) 0xce, (byte) 0x39, (byte) 0xe7, + (byte) 0x9c, (byte) 0x73, (byte) 0xce, (byte) 0x39, + (byte) 0xe7, (byte) 0x9c, (byte) 0x73, (byte) 0xce, + (byte) 0x39, (byte) 0xe7, (byte) 0x9c, (byte) 0x73, + (byte) 0x02, (byte) 0x00, (byte) 0xb1, (byte) 0x2b, + (byte) 0x1c, (byte) 0x00, (byte) 0x76, (byte) 0x22, + (byte) 0x6c, (byte) 0x58, (byte) 0x1d, (byte) 0xe1, + (byte) 0xa4, (byte) 0x68, (byte) 0x2c, (byte) 0xb0, + (byte) 0xd0, (byte) 0x90, (byte) 0x95, (byte) 0x00, + (byte) 0x40, (byte) 0x38, (byte) 0x00, (byte) 0x00, + (byte) 0x60, (byte) 0x8c, (byte) 0x31, (byte) 0xce, + (byte) 0x59, (byte) 0xac, (byte) 0xb5, (byte) 0xd6, + (byte) 0x5a, (byte) 0x2b, (byte) 0xa5, (byte) 0x94, + (byte) 0x92, (byte) 0x50, (byte) 0x6b, (byte) 0xad, + (byte) 0xb5, (byte) 0xd6, (byte) 0x9a, (byte) 0x29, + (byte) 0xa4, (byte) 0x94, (byte) 0x84, (byte) 0x16, + (byte) 0x63, (byte) 0x8c, (byte) 0x31, (byte) 0xc6, + (byte) 0x98, (byte) 0x31, (byte) 0x08, (byte) 0x29, + (byte) 0xb5, (byte) 0x18, (byte) 0x63, (byte) 0x8c, + (byte) 0x31, (byte) 0xc6, (byte) 0x8c, (byte) 0x39, + (byte) 0x47, (byte) 0x2d, (byte) 0xc6, (byte) 0x18, + (byte) 0x63, (byte) 0x8c, (byte) 0x31, (byte) 0xb6, + (byte) 0x56, (byte) 0x4a, (byte) 0x6c, (byte) 0x31, + (byte) 0xc6, (byte) 0x18, (byte) 0x63, (byte) 0x8c, + (byte) 0xb1, (byte) 0xb5, (byte) 0x52, (byte) 0x62, + (byte) 0x8c, (byte) 0x31, (byte) 0xc6, (byte) 0x18, + (byte) 0x63, (byte) 0x8c, (byte) 0x31, (byte) 0xc6, + (byte) 0x16, (byte) 0x5b, (byte) 0x8c, (byte) 0x31, + (byte) 0xc6, (byte) 0x18, (byte) 0x63, (byte) 0x8c, + (byte) 0x31, (byte) 0xb6, (byte) 0x18, (byte) 0x63, + (byte) 0x8c, (byte) 0x31, (byte) 0xc6, (byte) 0x18, + (byte) 0x63, (byte) 0x8c, (byte) 0x31, (byte) 0xc6, + (byte) 0x18, (byte) 0x63, (byte) 0x8c, (byte) 0x31, + (byte) 0xc6, (byte) 0x18, (byte) 0x63, (byte) 0x8c, + (byte) 0x31, (byte) 0xb6, (byte) 0x18, (byte) 0x63, + (byte) 0x8c, (byte) 0x31, (byte) 0xc6, (byte) 0x18, + (byte) 0x63, (byte) 0x8c, (byte) 0x31, (byte) 0xc6, + (byte) 0x18, (byte) 0x63, (byte) 0x8c, (byte) 0x31, + (byte) 0xc6, (byte) 0x18, (byte) 0x63, (byte) 0x8c, + (byte) 0x31, (byte) 0xc6, (byte) 0x18, (byte) 0x63, + (byte) 0x8c, (byte) 0x31, (byte) 0xc6, (byte) 0x18, + (byte) 0x63, (byte) 0x6c, (byte) 0x31, (byte) 0xc6, + (byte) 0x18, (byte) 0x63, (byte) 0x8c, (byte) 0x31, + (byte) 0xc6, (byte) 0x18, (byte) 0x63, (byte) 0x8c, + (byte) 0x31, (byte) 0xc6, (byte) 0x18, (byte) 0x63, + (byte) 0x2c, (byte) 0x00, (byte) 0xc0, (byte) 0xe4, + (byte) 0xc1, (byte) 0x01, (byte) 0x00, (byte) 0x2a, + (byte) 0xc1, (byte) 0xc6, (byte) 0x19, (byte) 0x56, + (byte) 0x92, (byte) 0xce, (byte) 0x0a, (byte) 0x47, + (byte) 0x83, (byte) 0x0b, (byte) 0x0d, (byte) 0x59, + (byte) 0x09, (byte) 0x00, (byte) 0xe4, (byte) 0x06, + (byte) 0x00, (byte) 0x00, (byte) 0xc6, (byte) 0x28, + (byte) 0xc5, (byte) 0x98, (byte) 0x63, (byte) 0xce, + (byte) 0x41, (byte) 0x08, (byte) 0xa1, (byte) 0x94, + (byte) 0x12, (byte) 0x4a, (byte) 0x49, (byte) 0xad, + (byte) 0x75, (byte) 0xce, (byte) 0x39, (byte) 0x08, + (byte) 0x21, (byte) 0x94, (byte) 0x52, (byte) 0x4a, + (byte) 0x49, (byte) 0xa9, (byte) 0xb4, (byte) 0x94, + (byte) 0x62, (byte) 0xca, (byte) 0x98, (byte) 0x73, + (byte) 0xce, (byte) 0x41, (byte) 0x08, (byte) 0xa5, + (byte) 0x94, (byte) 0x12, (byte) 0x4a, (byte) 0x49, + (byte) 0xa9, (byte) 0xa5, (byte) 0xd4, (byte) 0x39, + (byte) 0xe7, (byte) 0x20, (byte) 0x94, (byte) 0x52, + (byte) 0x4a, (byte) 0x4a, (byte) 0x29, (byte) 0xa5, + (byte) 0x94, (byte) 0x5a, (byte) 0x6a, (byte) 0xad, + (byte) 0x73, (byte) 0x10, (byte) 0x42, (byte) 0x08, + (byte) 0xa5, (byte) 0x94, (byte) 0x52, (byte) 0x4a, + (byte) 0x4a, (byte) 0x29, (byte) 0xa5, (byte) 0xd4, + (byte) 0x52, (byte) 0x08, (byte) 0x21, (byte) 0x94, + (byte) 0x52, (byte) 0x4a, (byte) 0x2a, (byte) 0x29, + (byte) 0xa5, (byte) 0x94, (byte) 0x52, (byte) 0x6b, + (byte) 0xad, (byte) 0xa5, (byte) 0x10, (byte) 0x42, + (byte) 0x28, (byte) 0xa5, (byte) 0x94, (byte) 0x94, + (byte) 0x52, (byte) 0x4a, (byte) 0x29, (byte) 0xa5, + (byte) 0xd4, (byte) 0x5a, (byte) 0x8b, (byte) 0xa1, + (byte) 0x94, (byte) 0x90, (byte) 0x4a, (byte) 0x29, + (byte) 0x25, (byte) 0xa5, (byte) 0x94, (byte) 0x52, + (byte) 0x49, (byte) 0x2d, (byte) 0xb5, (byte) 0x96, + (byte) 0x5a, (byte) 0x2a, (byte) 0xa1, (byte) 0x94, + (byte) 0x54, (byte) 0x52, (byte) 0x4a, (byte) 0x29, + (byte) 0xa5, (byte) 0x94, (byte) 0x52, (byte) 0x6b, + (byte) 0xa9, (byte) 0xb5, (byte) 0x56, (byte) 0x4a, + (byte) 0x49, (byte) 0x25, (byte) 0xa5, (byte) 0x94, + (byte) 0x52, (byte) 0x4a, (byte) 0x29, (byte) 0xa5, + (byte) 0xd4, (byte) 0x62, (byte) 0x6b, (byte) 0x29, + (byte) 0x94, (byte) 0x92, (byte) 0x52, (byte) 0x49, + (byte) 0x29, (byte) 0xb5, (byte) 0x94, (byte) 0x52, + (byte) 0x4a, (byte) 0xad, (byte) 0xc5, (byte) 0xd8, + (byte) 0x62, (byte) 0x29, (byte) 0xad, (byte) 0xa4, + (byte) 0x94, (byte) 0x52, (byte) 0x4a, (byte) 0x29, + (byte) 0xa5, (byte) 0xd6, (byte) 0x52, (byte) 0x6c, + (byte) 0xad, (byte) 0xb5, (byte) 0xd8, (byte) 0x52, + (byte) 0x4a, (byte) 0x29, (byte) 0xa5, (byte) 0x96, + (byte) 0x5a, (byte) 0x4a, (byte) 0x29, (byte) 0xb5, + (byte) 0x16, (byte) 0x5b, (byte) 0x6a, (byte) 0x2d, + (byte) 0xa5, (byte) 0x94, (byte) 0x52, (byte) 0x4b, + (byte) 0x29, (byte) 0xa5, (byte) 0x96, (byte) 0x52, + (byte) 0x4b, (byte) 0x2d, (byte) 0xc6, (byte) 0xd6, + (byte) 0x5a, (byte) 0x4b, (byte) 0x29, (byte) 0xa5, + (byte) 0xd4, (byte) 0x52, (byte) 0x6a, (byte) 0xa9, + (byte) 0xa5, (byte) 0x94, (byte) 0x52, (byte) 0x6c, + (byte) 0xad, (byte) 0xb5, (byte) 0x98, (byte) 0x52, + (byte) 0x6a, (byte) 0x2d, (byte) 0xa5, (byte) 0xd4, + (byte) 0x52, (byte) 0x6b, (byte) 0x2d, (byte) 0xb5, + (byte) 0xd8, (byte) 0x52, (byte) 0x6a, (byte) 0x2d, + (byte) 0xb5, (byte) 0x94, (byte) 0x52, (byte) 0x6b, + (byte) 0xa9, (byte) 0xa5, (byte) 0x94, (byte) 0x5a, + (byte) 0x6b, (byte) 0x2d, (byte) 0xb6, (byte) 0xd8, + (byte) 0x5a, (byte) 0x6b, (byte) 0x29, (byte) 0xb5, + (byte) 0x94, (byte) 0x52, (byte) 0x4a, (byte) 0xa9, + (byte) 0xb5, (byte) 0x16, (byte) 0x5b, (byte) 0x8a, + (byte) 0xb1, (byte) 0xb5, (byte) 0xd4, (byte) 0x4a, + (byte) 0x4a, (byte) 0x29, (byte) 0xb5, (byte) 0xd4, + (byte) 0x5a, (byte) 0x6a, (byte) 0x2d, (byte) 0xb6, + (byte) 0x16, (byte) 0x5b, (byte) 0x6b, (byte) 0xad, + (byte) 0xa5, (byte) 0xd6, (byte) 0x5a, (byte) 0x6a, + (byte) 0x29, (byte) 0xa5, (byte) 0x16, (byte) 0x5b, + (byte) 0x8c, (byte) 0x31, (byte) 0xc6, (byte) 0x16, + (byte) 0x63, (byte) 0x6b, (byte) 0x31, (byte) 0xa5, + (byte) 0x94, (byte) 0x52, (byte) 0x4b, (byte) 0xa9, + (byte) 0xa5, (byte) 0x02, (byte) 0x00, (byte) 0x80, + (byte) 0x0e, (byte) 0x1c, (byte) 0x00, (byte) 0x00, + (byte) 0x02, (byte) 0x8c, (byte) 0xa8, (byte) 0xb4, + (byte) 0x10, (byte) 0x3b, (byte) 0xcd, (byte) 0xb8, + (byte) 0xf2, (byte) 0x08, (byte) 0x1c, (byte) 0x51, + (byte) 0xc8, (byte) 0x30, (byte) 0x01, (byte) 0x15, + (byte) 0x1a, (byte) 0xb2, (byte) 0x12, (byte) 0x00, + (byte) 0x20, (byte) 0x03, (byte) 0x00, (byte) 0x20, + (byte) 0x90, (byte) 0x69, (byte) 0x92, (byte) 0x39, + (byte) 0x49, (byte) 0xa9, (byte) 0x11, (byte) 0x26, + (byte) 0x39, (byte) 0xc5, (byte) 0xa0, (byte) 0x94, + (byte) 0xe6, (byte) 0x9c, (byte) 0x53, (byte) 0x4a, + (byte) 0x29, (byte) 0xa5, (byte) 0x34, (byte) 0x44, + (byte) 0x96, (byte) 0x64, (byte) 0x90, (byte) 0x62, + (byte) 0x50, (byte) 0x1d, (byte) 0x99, (byte) 0x8c, + (byte) 0x39, (byte) 0x49, (byte) 0x39, (byte) 0x43, + (byte) 0xa4, (byte) 0x31, (byte) 0xa4, (byte) 0x20, + (byte) 0xf5, (byte) 0x4c, (byte) 0x91, (byte) 0xc7, + (byte) 0x94, (byte) 0x62, (byte) 0x10, (byte) 0x43, + (byte) 0x48, (byte) 0x2a, (byte) 0x74, (byte) 0x8a, + (byte) 0x39, (byte) 0x6c, (byte) 0x35, (byte) 0xf9, + (byte) 0x58, (byte) 0x42, (byte) 0x07, (byte) 0xb1, + (byte) 0x06, (byte) 0x65, (byte) 0x8c, (byte) 0x70, + (byte) 0x29, (byte) 0xc5, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x08, (byte) 0x02, (byte) 0x00, + (byte) 0x04, (byte) 0x84, (byte) 0x04, (byte) 0x00, + (byte) 0x18, (byte) 0x20, (byte) 0x28, (byte) 0x98, + (byte) 0x01, (byte) 0x00, (byte) 0x06, (byte) 0x07, + (byte) 0x08, (byte) 0x23, (byte) 0x07, (byte) 0x02, + (byte) 0x1d, (byte) 0x01, (byte) 0x04, (byte) 0x0e, + (byte) 0x6d, (byte) 0x00, (byte) 0x80, (byte) 0x81, + (byte) 0x08, (byte) 0x99, (byte) 0x09, (byte) 0x0c, + (byte) 0x0a, (byte) 0xa1, (byte) 0xc1, (byte) 0x41, + (byte) 0x26, (byte) 0x00, (byte) 0x3c, (byte) 0x40, + (byte) 0x44, (byte) 0x48, (byte) 0x05, (byte) 0x00, + (byte) 0x89, (byte) 0x09, (byte) 0x8a, (byte) 0xd2, + (byte) 0x85, (byte) 0x2e, (byte) 0x08, (byte) 0x21, + (byte) 0x82, (byte) 0x74, (byte) 0x11, (byte) 0x64, + (byte) 0xf1, (byte) 0xc0, (byte) 0x85, (byte) 0x13, + (byte) 0x37, (byte) 0x9e, (byte) 0xb8, (byte) 0xe1, + (byte) 0x84, (byte) 0x0e, (byte) 0x6d, (byte) 0x20, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x20, (byte) 0x00, (byte) 0xf0, + (byte) 0x01, (byte) 0x00, (byte) 0x90, (byte) 0x50, + (byte) 0x00, (byte) 0x11, (byte) 0x11, (byte) 0xd1, + (byte) 0xcc, (byte) 0x55, (byte) 0x58, (byte) 0x5c, + (byte) 0x60, (byte) 0x64, (byte) 0x68, (byte) 0x6c, + (byte) 0x70, (byte) 0x74, (byte) 0x78, (byte) 0x7c, + (byte) 0x80, (byte) 0x84, (byte) 0x08, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x10, (byte) 0x00, (byte) 0x7c, (byte) 0x00, + (byte) 0x00, (byte) 0x24, (byte) 0x22, (byte) 0x40, + (byte) 0x44, (byte) 0x44, (byte) 0x34, (byte) 0x73, + (byte) 0x15, (byte) 0x16, (byte) 0x17, (byte) 0x18, + (byte) 0x19, (byte) 0x1a, (byte) 0x1b, (byte) 0x1c, + (byte) 0x1d, (byte) 0x1e, (byte) 0x1f, (byte) 0x20, + (byte) 0x21, (byte) 0x01, (byte) 0x00, (byte) 0x80, + (byte) 0x00, (byte) 0x02, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x20, (byte) 0x80, + (byte) 0x00, (byte) 0x04, (byte) 0x04, (byte) 0x04, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x02, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x04, (byte) 0x04 + }; +} diff --git a/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/RobolectricUtil.java b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/RobolectricUtil.java new file mode 100644 index 0000000000..dc7781fd90 --- /dev/null +++ b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/RobolectricUtil.java @@ -0,0 +1,220 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.testutil; + +import static org.robolectric.Shadows.shadowOf; +import static org.robolectric.util.ReflectionHelpers.callInstanceMethod; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.os.MessageQueue; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.util.Util; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.PriorityBlockingQueue; +import java.util.concurrent.atomic.AtomicLong; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.shadows.ShadowLooper; +import org.robolectric.shadows.ShadowMessageQueue; + +/** Collection of shadow classes used to run tests with Robolectric which require Loopers. */ +public final class RobolectricUtil { + + private static final AtomicLong sequenceNumberGenerator = new AtomicLong(0); + + private RobolectricUtil() {} + + /** + * A custom implementation of Robolectric's ShadowLooper which runs all scheduled messages in the + * loop method of the looper. Also ensures to correctly emulate the message order of the real + * message loop and to avoid blocking caused by Robolectric's default implementation. + * + *

Only works in conjunction with {@link CustomMessageQueue}. Note that the test's {@code + * SystemClock} is not advanced automatically. + */ + @Implements(Looper.class) + public static final class CustomLooper extends ShadowLooper { + + private final PriorityBlockingQueue pendingMessages; + private final CopyOnWriteArraySet removedMessages; + + public CustomLooper() { + pendingMessages = new PriorityBlockingQueue<>(); + removedMessages = new CopyOnWriteArraySet<>(); + } + + @Implementation + public static void loop() { + Looper looper = Looper.myLooper(); + if (shadowOf(looper) instanceof CustomLooper) { + ((CustomLooper) shadowOf(looper)).doLoop(); + } + } + + @Implementation + @Override + public void quitUnchecked() { + super.quitUnchecked(); + // Insert message at the front of the queue to quit loop as soon as possible. + addPendingMessage(/* message= */ null, /* when= */ Long.MIN_VALUE); + } + + private void addPendingMessage(@Nullable Message message, long when) { + pendingMessages.put(new PendingMessage(message, when)); + } + + private void removeMessages(Handler handler, int what, Object object) { + RemovedMessage newRemovedMessage = new RemovedMessage(handler, what, object); + removedMessages.add(newRemovedMessage); + for (RemovedMessage removedMessage : removedMessages) { + if (removedMessage != newRemovedMessage + && removedMessage.handler == handler + && removedMessage.what == what + && removedMessage.object == object) { + removedMessages.remove(removedMessage); + } + } + } + + private void doLoop() { + boolean wasInterrupted = false; + while (true) { + try { + PendingMessage pendingMessage = pendingMessages.take(); + if (pendingMessage.message == null) { + // Null message is signal to end message loop. + return; + } + // Call through to real {@code Message.markInUse()} and {@code Message.recycle()} to + // ensure message recycling works. This is also done in Robolectric's own implementation + // of the message queue. + callInstanceMethod(pendingMessage.message, "markInUse"); + Handler target = pendingMessage.message.getTarget(); + if (target != null) { + boolean isRemoved = false; + for (RemovedMessage removedMessage : removedMessages) { + if (removedMessage.handler == target + && removedMessage.what == pendingMessage.message.what + && (removedMessage.object == null + || removedMessage.object == pendingMessage.message.obj) + && pendingMessage.sequenceNumber < removedMessage.sequenceNumber) { + isRemoved = true; + } + } + if (!isRemoved) { + try { + if (wasInterrupted) { + wasInterrupted = false; + // Restore the interrupt status flag, so long-running messages will exit early. + Thread.currentThread().interrupt(); + } + target.dispatchMessage(pendingMessage.message); + } catch (Throwable t) { + // Interrupt the main thread to terminate the test. Robolectric's HandlerThread will + // print the rethrown error to standard output. + Looper.getMainLooper().getThread().interrupt(); + throw t; + } + } + } + if (Util.SDK_INT >= 21) { + callInstanceMethod(pendingMessage.message, "recycleUnchecked"); + } else { + callInstanceMethod(pendingMessage.message, "recycle"); + } + } catch (InterruptedException e) { + wasInterrupted = true; + } + } + } + } + + /** + * Custom implementation of Robolectric's ShadowMessageQueue which is needed to let {@link + * CustomLooper} work as intended. + */ + @Implements(MessageQueue.class) + public static final class CustomMessageQueue extends ShadowMessageQueue { + + private final Thread looperThread; + + public CustomMessageQueue() { + looperThread = Thread.currentThread(); + } + + @Implementation + @Override + public boolean enqueueMessage(Message msg, long when) { + Looper looper = ShadowLooper.getLooperForThread(looperThread); + if (shadowOf(looper) instanceof CustomLooper + && shadowOf(looper) != ShadowLooper.getShadowMainLooper()) { + ((CustomLooper) shadowOf(looper)).addPendingMessage(msg, when); + } else { + super.enqueueMessage(msg, when); + } + return true; + } + + @Implementation + public void removeMessages(Handler handler, int what, Object object) { + Looper looper = ShadowLooper.getLooperForThread(looperThread); + if (shadowOf(looper) instanceof CustomLooper + && shadowOf(looper) != ShadowLooper.getShadowMainLooper()) { + ((CustomLooper) shadowOf(looper)).removeMessages(handler, what, object); + } + } + } + + private static final class PendingMessage implements Comparable { + + public final @Nullable Message message; + public final long when; + public final long sequenceNumber; + + public PendingMessage(@Nullable Message message, long when) { + this.message = message; + this.when = when; + sequenceNumber = sequenceNumberGenerator.getAndIncrement(); + } + + @Override + public int compareTo(@NonNull PendingMessage other) { + int res = Util.compareLong(this.when, other.when); + if (res == 0 && this != other) { + res = Util.compareLong(this.sequenceNumber, other.sequenceNumber); + } + return res; + } + } + + private static final class RemovedMessage { + + public final Handler handler; + public final int what; + public final Object object; + public final long sequenceNumber; + + public RemovedMessage(Handler handler, int what, Object object) { + this.handler = handler; + this.what = what; + this.object = object; + this.sequenceNumber = sequenceNumberGenerator.get(); + } + } +} diff --git a/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java new file mode 100644 index 0000000000..1ac19591c0 --- /dev/null +++ b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java @@ -0,0 +1,275 @@ +/* + * 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.Looper; +import com.google.android.exoplayer2.BasePlayer; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.PlayerMessage; +import com.google.android.exoplayer2.SeekParameters; +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.TrackSelectionArray; + +/** + * An abstract {@link ExoPlayer} implementation that throws {@link UnsupportedOperationException} + * from every method. + */ +public abstract class StubExoPlayer extends BasePlayer implements ExoPlayer { + + @Override + public AudioComponent getAudioComponent() { + throw new UnsupportedOperationException(); + } + + @Override + public VideoComponent getVideoComponent() { + throw new UnsupportedOperationException(); + } + + @Override + public TextComponent getTextComponent() { + throw new UnsupportedOperationException(); + } + + @Override + public Looper getPlaybackLooper() { + throw new UnsupportedOperationException(); + } + + @Override + public Looper getApplicationLooper() { + throw new UnsupportedOperationException(); + } + + @Override + public void addListener(Player.EventListener listener) { + throw new UnsupportedOperationException(); + } + + @Override + public void removeListener(Player.EventListener listener) { + throw new UnsupportedOperationException(); + } + + @Override + public int getPlaybackState() { + throw new UnsupportedOperationException(); + } + + @Override + public ExoPlaybackException getPlaybackError() { + throw new UnsupportedOperationException(); + } + + @Override + public void retry() { + throw new UnsupportedOperationException(); + } + + @Override + public void prepare(MediaSource mediaSource) { + throw new UnsupportedOperationException(); + } + + @Override + public void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetState) { + throw new UnsupportedOperationException(); + } + + @Override + public void setPlayWhenReady(boolean playWhenReady) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean getPlayWhenReady() { + throw new UnsupportedOperationException(); + } + + @Override + public void setRepeatMode(@RepeatMode int repeatMode) { + throw new UnsupportedOperationException(); + } + + @Override + public int getRepeatMode() { + throw new UnsupportedOperationException(); + } + + @Override + public void setShuffleModeEnabled(boolean shuffleModeEnabled) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean getShuffleModeEnabled() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isLoading() { + throw new UnsupportedOperationException(); + } + + @Override + public void seekTo(int windowIndex, long positionMs) { + throw new UnsupportedOperationException(); + } + + @Override + public void setPlaybackParameters(PlaybackParameters playbackParameters) { + throw new UnsupportedOperationException(); + } + + @Override + public PlaybackParameters getPlaybackParameters() { + throw new UnsupportedOperationException(); + } + + @Override + public void setSeekParameters(SeekParameters seekParameters) { + throw new UnsupportedOperationException(); + } + + @Override + public SeekParameters getSeekParameters() { + throw new UnsupportedOperationException(); + } + + @Override + public void stop(boolean resetStateAndPosition) { + throw new UnsupportedOperationException(); + } + + @Override + public void release() { + throw new UnsupportedOperationException(); + } + + @Override + public PlayerMessage createMessage(PlayerMessage.Target target) { + throw new UnsupportedOperationException(); + } + + @Override + @Deprecated + @SuppressWarnings("deprecation") + public void sendMessages(ExoPlayerMessage... messages) { + throw new UnsupportedOperationException(); + } + + @Override + @Deprecated + @SuppressWarnings("deprecation") + public void blockingSendMessages(ExoPlayerMessage... messages) { + throw new UnsupportedOperationException(); + } + + @Override + public int getRendererCount() { + throw new UnsupportedOperationException(); + } + + @Override + public int getRendererType(int index) { + throw new UnsupportedOperationException(); + } + + @Override + public TrackGroupArray getCurrentTrackGroups() { + throw new UnsupportedOperationException(); + } + + @Override + public TrackSelectionArray getCurrentTrackSelections() { + throw new UnsupportedOperationException(); + } + + @Override + public Object getCurrentManifest() { + throw new UnsupportedOperationException(); + } + + @Override + public Timeline getCurrentTimeline() { + throw new UnsupportedOperationException(); + } + + @Override + public int getCurrentPeriodIndex() { + throw new UnsupportedOperationException(); + } + + @Override + public int getCurrentWindowIndex() { + throw new UnsupportedOperationException(); + } + + @Override + public long getDuration() { + throw new UnsupportedOperationException(); + } + + @Override + public long getCurrentPosition() { + throw new UnsupportedOperationException(); + } + + @Override + public long getBufferedPosition() { + throw new UnsupportedOperationException(); + } + + @Override + public long getTotalBufferedDuration() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isPlayingAd() { + throw new UnsupportedOperationException(); + } + + @Override + public int getCurrentAdGroupIndex() { + throw new UnsupportedOperationException(); + } + + @Override + public int getCurrentAdIndexInAdGroup() { + throw new UnsupportedOperationException(); + } + + @Override + public long getContentPosition() { + throw new UnsupportedOperationException(); + } + + @Override + public long getContentBufferedPosition() { + throw new UnsupportedOperationException(); + } + + @Override + public void setForegroundMode(boolean foregroundMode) { + throw new UnsupportedOperationException(); + } +} diff --git a/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/TestDownloadManagerListener.java b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/TestDownloadManagerListener.java new file mode 100644 index 0000000000..7e0ffc1772 --- /dev/null +++ b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/TestDownloadManagerListener.java @@ -0,0 +1,103 @@ +/* + * 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 static com.google.common.truth.Truth.assertThat; + +import com.google.android.exoplayer2.offline.DownloadAction; +import com.google.android.exoplayer2.offline.DownloadManager; +import java.util.HashMap; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** A {@link DownloadManager.Listener} for testing. */ +public final class TestDownloadManagerListener implements DownloadManager.Listener { + + private static final int TIMEOUT = 1000; + + private final DownloadManager downloadManager; + private final DummyMainThread dummyMainThread; + private final HashMap> actionStates; + + private CountDownLatch downloadFinishedCondition; + private Throwable downloadError; + + public TestDownloadManagerListener( + DownloadManager downloadManager, DummyMainThread dummyMainThread) { + this.downloadManager = downloadManager; + this.dummyMainThread = dummyMainThread; + actionStates = new HashMap<>(); + } + + public int pollStateChange(DownloadAction action, long timeoutMs) throws InterruptedException { + return getStateQueue(action).poll(timeoutMs, TimeUnit.MILLISECONDS); + } + + public void clearDownloadError() { + this.downloadError = null; + } + + @Override + public void onInitialized(DownloadManager downloadManager) { + // Do nothing. + } + + @Override + public void onTaskStateChanged( + DownloadManager downloadManager, DownloadManager.TaskState taskState) { + if (taskState.state == DownloadManager.TaskState.STATE_FAILED && downloadError == null) { + downloadError = taskState.error; + } + getStateQueue(taskState.action).add(taskState.state); + } + + @Override + public synchronized void onIdle(DownloadManager downloadManager) { + if (downloadFinishedCondition != null) { + downloadFinishedCondition.countDown(); + } + } + + /** + * Blocks until all remove and download tasks are complete and throws an exception if there was an + * error. + */ + public void blockUntilTasksCompleteAndThrowAnyDownloadError() throws Throwable { + synchronized (this) { + downloadFinishedCondition = new CountDownLatch(1); + } + dummyMainThread.runOnMainThread( + () -> { + if (downloadManager.isIdle()) { + downloadFinishedCondition.countDown(); + } + }); + assertThat(downloadFinishedCondition.await(TIMEOUT, TimeUnit.MILLISECONDS)).isTrue(); + if (downloadError != null) { + throw new Exception(downloadError); + } + } + + private ArrayBlockingQueue getStateQueue(DownloadAction action) { + synchronized (actionStates) { + if (!actionStates.containsKey(action)) { + actionStates.put(action, new ArrayBlockingQueue<>(10)); + } + return actionStates.get(action); + } + } +} diff --git a/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/TimelineAsserts.java b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/TimelineAsserts.java new file mode 100644 index 0000000000..1e3c9c61d9 --- /dev/null +++ b/testutils_robolectric/src/main/java/com/google/android/exoplayer2/testutil/TimelineAsserts.java @@ -0,0 +1,168 @@ +/* + * 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 static com.google.common.truth.Truth.assertThat; + +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; + +/** Unit test for {@link Timeline}. */ +public final class TimelineAsserts { + + private static final int[] REPEAT_MODES = { + Player.REPEAT_MODE_OFF, Player.REPEAT_MODE_ONE, Player.REPEAT_MODE_ALL + }; + + private TimelineAsserts() {} + + /** Assert that timeline is empty (i.e. has no windows or periods). */ + public static void assertEmpty(Timeline timeline) { + assertWindowTags(timeline); + assertPeriodCounts(timeline); + for (boolean shuffled : new boolean[] {false, true}) { + assertThat(timeline.getFirstWindowIndex(shuffled)).isEqualTo(C.INDEX_UNSET); + assertThat(timeline.getLastWindowIndex(shuffled)).isEqualTo(C.INDEX_UNSET); + } + } + + /** + * Asserts that window tags are set correctly. + * + * @param expectedWindowTags A list of expected window tags. If a tag is unknown or not important + * {@code null} can be passed to skip this window. + */ + public static void assertWindowTags(Timeline timeline, Object... expectedWindowTags) { + Window window = new Window(); + assertThat(timeline.getWindowCount()).isEqualTo(expectedWindowTags.length); + for (int i = 0; i < timeline.getWindowCount(); i++) { + timeline.getWindow(i, window, true); + if (expectedWindowTags[i] != null) { + assertThat(window.tag).isEqualTo(expectedWindowTags[i]); + } + } + } + + /** Asserts that window properties {@link Window}.isDynamic are set correctly. */ + public static void assertWindowIsDynamic(Timeline timeline, boolean... windowIsDynamic) { + Window window = new Window(); + for (int i = 0; i < timeline.getWindowCount(); i++) { + timeline.getWindow(i, window, true); + assertThat(window.isDynamic).isEqualTo(windowIsDynamic[i]); + } + } + + /** + * 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, + boolean shuffleModeEnabled, + int... expectedPreviousWindowIndices) { + for (int i = 0; i < timeline.getWindowCount(); i++) { + assertThat(timeline.getPreviousWindowIndex(i, repeatMode, shuffleModeEnabled)) + .isEqualTo(expectedPreviousWindowIndices[i]); + } + } + + /** + * 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, + boolean shuffleModeEnabled, + int... expectedNextWindowIndices) { + for (int i = 0; i < timeline.getWindowCount(); i++) { + assertThat(timeline.getNextWindowIndex(i, repeatMode, shuffleModeEnabled)) + .isEqualTo(expectedNextWindowIndices[i]); + } + } + + /** + * Asserts that the durations of the periods in the {@link Timeline} and the durations in the + * given sequence are equal. + */ + public static void assertPeriodDurations(Timeline timeline, long... durationsUs) { + int periodCount = timeline.getPeriodCount(); + assertThat(periodCount).isEqualTo(durationsUs.length); + Period period = new Period(); + for (int i = 0; i < periodCount; i++) { + assertThat(timeline.getPeriod(i, period).durationUs).isEqualTo(durationsUs[i]); + } + } + + /** + * 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, boolean)}. + */ + public static void assertPeriodCounts(Timeline timeline, int... expectedPeriodCounts) { + int windowCount = timeline.getWindowCount(); + assertThat(windowCount).isEqualTo(expectedPeriodCounts.length); + int[] accumulatedPeriodCounts = new int[windowCount + 1]; + accumulatedPeriodCounts[0] = 0; + for (int i = 0; i < windowCount; i++) { + accumulatedPeriodCounts[i + 1] = accumulatedPeriodCounts[i] + expectedPeriodCounts[i]; + } + assertThat(timeline.getPeriodCount()) + .isEqualTo(accumulatedPeriodCounts[accumulatedPeriodCounts.length - 1]); + Window window = new Window(); + Period period = new Period(); + for (int i = 0; i < windowCount; i++) { + timeline.getWindow(i, window, true); + assertThat(window.firstPeriodIndex).isEqualTo(accumulatedPeriodCounts[i]); + assertThat(window.lastPeriodIndex).isEqualTo(accumulatedPeriodCounts[i + 1] - 1); + } + int expectedWindowIndex = 0; + for (int i = 0; i < timeline.getPeriodCount(); i++) { + timeline.getPeriod(i, period, true); + while (i >= accumulatedPeriodCounts[expectedWindowIndex + 1]) { + expectedWindowIndex++; + } + assertThat(period.windowIndex).isEqualTo(expectedWindowIndex); + assertThat(timeline.getIndexOfPeriod(period.uid)).isEqualTo(i); + assertThat(timeline.getUidOfPeriod(i)).isEqualTo(period.uid); + for (int repeatMode : REPEAT_MODES) { + if (i < accumulatedPeriodCounts[expectedWindowIndex + 1] - 1) { + assertThat(timeline.getNextPeriodIndex(i, period, window, repeatMode, false)) + .isEqualTo(i + 1); + } else { + int nextWindow = timeline.getNextWindowIndex(expectedWindowIndex, repeatMode, false); + int nextPeriod = + nextWindow == C.INDEX_UNSET ? C.INDEX_UNSET : accumulatedPeriodCounts[nextWindow]; + assertThat(timeline.getNextPeriodIndex(i, period, window, repeatMode, false)) + .isEqualTo(nextPeriod); + } + } + } + } + + /** 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); + assertThat(period.getAdGroupCount()).isEqualTo(expectedAdGroupCounts[i]); + } + } +}