From 019f7df9bbce7ea0676fb7d51bd103f2a88b2409 Mon Sep 17 00:00:00 2001 From: Justin Sanford Date: Mon, 7 Dec 2020 14:01:43 -0800 Subject: [PATCH] v2.1.0 (#65) * expose CustomerViewData in proguard, and allow it to be updated * save * almost there * Fixed code to comply with automated tests. * Fixed some review issues. * Fixed soft dependency. * try additional requirement * version file * Fixed non ads crash, fixed pause after seeking, fixed viewstart not dispatched. * Reinstated necessary callback. * small cleanup * v2.1.0 Co-authored-by: Tomislav Kordic --- MuxExoPlayer/build.gradle | 19 +- MuxExoPlayer/libs/MuxCore.jar | Bin 122107 -> 121864 bytes MuxExoPlayer/libs/version-v5.0.0 | 1 - MuxExoPlayer/libs/version-v6.0.0 | 1 + MuxExoPlayer/proguard-rules.pro | 3 + .../stats/sdk/muxstats/AdsImaSDKListener.java | 8 +- .../stats/sdk/muxstats/MuxBaseExoPlayer.java | 191 +- .../stats/sdk/muxstats/MuxStatsExoPlayer.java | 22 +- .../stats/sdk/muxstats/MuxStatsExoPlayer.java | 21 +- .../stats/sdk/muxstats/MuxStatsExoPlayer.java | 386 ++++ .../stats/sdk/muxstats/MuxStatsExoPlayer.java | 21 +- build.gradle | 2 +- demo/build.gradle | 68 +- demo/src/r2_12_1/AndroidManifest.xml | 98 + demo/src/r2_12_1/assets/media.exolist.json | 477 ++++ .../exoplayer2/demo/DemoDownloadService.java | 123 ++ .../android/exoplayer2/demo/DemoUtil.java | 197 ++ .../exoplayer2/demo/DownloadTracker.java | 423 ++++ .../android/exoplayer2/demo/IntentUtil.java | 230 ++ .../exoplayer2/demo/PlayerActivity.java | 564 +++++ .../demo/SampleChooserActivity.java | 591 +++++ .../exoplayer2/demo/TrackSelectionDialog.java | 373 ++++ .../android/exoplayer2/demo/package-info.java | 19 + .../cronet/ByteArrayUploadDataProvider.java | 57 + .../ext/cronet/CronetDataSource.java | 1025 +++++++++ .../ext/cronet/CronetDataSourceFactory.java | 364 ++++ .../ext/cronet/CronetEngineWrapper.java | 251 +++ .../exoplayer2/ext/cronet/package-info.java | 19 + .../r2_12_1/res/drawable-hdpi/ic_download.png | Bin 0 -> 199 bytes .../res/drawable-hdpi/ic_download_done.png | Bin 0 -> 218 bytes .../r2_12_1/res/drawable-mdpi/ic_download.png | Bin 0 -> 163 bytes .../res/drawable-mdpi/ic_download_done.png | Bin 0 -> 182 bytes .../r2_12_1/res/drawable-xhdpi/ic_banner.png | Bin 0 -> 4299 bytes .../res/drawable-xhdpi/ic_download.png | Bin 0 -> 187 bytes .../res/drawable-xhdpi/ic_download_done.png | Bin 0 -> 304 bytes .../res/drawable-xxhdpi/ic_download.png | Bin 0 -> 261 bytes .../res/drawable-xxhdpi/ic_download_done.png | Bin 0 -> 450 bytes .../res/drawable-xxxhdpi/ic_download.png | Bin 0 -> 263 bytes .../res/drawable-xxxhdpi/ic_download_done.png | Bin 0 -> 575 bytes .../r2_12_1/res/layout/player_activity.xml | 60 + .../res/layout/sample_chooser_activity.xml | 25 + .../r2_12_1/res/layout/sample_list_item.xml | 38 + .../res/layout/track_selection_dialog.xml | 59 + .../r2_12_1/res/menu/sample_chooser_menu.xml | 22 + .../r2_12_1/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3394 bytes .../r2_12_1/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2184 bytes .../r2_12_1/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4886 bytes .../r2_12_1/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 7492 bytes .../res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 10801 bytes demo/src/r2_12_1/res/values/strings.xml | 64 + demo/src/r2_12_1/res/values/styles.xml | 26 + demo/src/r2_12_1_ads/AndroidManifest.xml | 98 + .../src/r2_12_1_ads/assets/media.exolist.json | 572 +++++ .../exoplayer2/demo/DemoDownloadService.java | 123 ++ .../android/exoplayer2/demo/DemoUtil.java | 197 ++ .../exoplayer2/demo/DownloadTracker.java | 423 ++++ .../android/exoplayer2/demo/IntentUtil.java | 230 ++ .../exoplayer2/demo/PlayerActivity.java | 607 ++++++ .../demo/SampleChooserActivity.java | 591 +++++ .../exoplayer2/demo/TrackSelectionDialog.java | 373 ++++ .../android/exoplayer2/demo/package-info.java | 19 + .../cronet/ByteArrayUploadDataProvider.java | 57 + .../ext/cronet/CronetDataSource.java | 1025 +++++++++ .../ext/cronet/CronetDataSourceFactory.java | 364 ++++ .../ext/cronet/CronetEngineWrapper.java | 251 +++ .../exoplayer2/ext/cronet/package-info.java | 19 + .../exoplayer2/ext/ima/ImaAdsLoader.java | 1938 +++++++++++++++++ .../android/exoplayer2/ext/ima/ImaUtil.java | 206 ++ .../exoplayer2/ext/ima/package-info.java | 19 + .../res/drawable-hdpi/ic_download.png | Bin 0 -> 199 bytes .../res/drawable-hdpi/ic_download_done.png | Bin 0 -> 218 bytes .../res/drawable-mdpi/ic_download.png | Bin 0 -> 163 bytes .../res/drawable-mdpi/ic_download_done.png | Bin 0 -> 182 bytes .../res/drawable-xhdpi/ic_banner.png | Bin 0 -> 4299 bytes .../res/drawable-xhdpi/ic_download.png | Bin 0 -> 187 bytes .../res/drawable-xhdpi/ic_download_done.png | Bin 0 -> 304 bytes .../res/drawable-xxhdpi/ic_download.png | Bin 0 -> 261 bytes .../res/drawable-xxhdpi/ic_download_done.png | Bin 0 -> 450 bytes .../res/drawable-xxxhdpi/ic_download.png | Bin 0 -> 263 bytes .../res/drawable-xxxhdpi/ic_download_done.png | Bin 0 -> 575 bytes .../res/layout/player_activity.xml | 60 + .../res/layout/sample_chooser_activity.xml | 25 + .../res/layout/sample_list_item.xml | 38 + .../res/layout/track_selection_dialog.xml | 59 + .../res/menu/sample_chooser_menu.xml | 22 + .../res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3394 bytes .../res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2184 bytes .../res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4886 bytes .../res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 7492 bytes .../res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 10801 bytes demo/src/r2_12_1_ads/res/values/strings.xml | 64 + demo/src/r2_12_1_ads/res/values/styles.xml | 26 + 92 files changed, 13168 insertions(+), 86 deletions(-) delete mode 100644 MuxExoPlayer/libs/version-v5.0.0 create mode 100644 MuxExoPlayer/libs/version-v6.0.0 create mode 100644 MuxExoPlayer/src/r2_12_1/java/com/mux/stats/sdk/muxstats/MuxStatsExoPlayer.java create mode 100644 demo/src/r2_12_1/AndroidManifest.xml create mode 100644 demo/src/r2_12_1/assets/media.exolist.json create mode 100644 demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/DemoDownloadService.java create mode 100644 demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/DemoUtil.java create mode 100644 demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/DownloadTracker.java create mode 100644 demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/IntentUtil.java create mode 100644 demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/PlayerActivity.java create mode 100644 demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java create mode 100644 demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/TrackSelectionDialog.java create mode 100644 demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/package-info.java create mode 100644 demo/src/r2_12_1/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProvider.java create mode 100644 demo/src/r2_12_1/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java create mode 100644 demo/src/r2_12_1/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java create mode 100644 demo/src/r2_12_1/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java create mode 100644 demo/src/r2_12_1/java/com/google/android/exoplayer2/ext/cronet/package-info.java create mode 100644 demo/src/r2_12_1/res/drawable-hdpi/ic_download.png create mode 100644 demo/src/r2_12_1/res/drawable-hdpi/ic_download_done.png create mode 100644 demo/src/r2_12_1/res/drawable-mdpi/ic_download.png create mode 100644 demo/src/r2_12_1/res/drawable-mdpi/ic_download_done.png create mode 100644 demo/src/r2_12_1/res/drawable-xhdpi/ic_banner.png create mode 100644 demo/src/r2_12_1/res/drawable-xhdpi/ic_download.png create mode 100644 demo/src/r2_12_1/res/drawable-xhdpi/ic_download_done.png create mode 100644 demo/src/r2_12_1/res/drawable-xxhdpi/ic_download.png create mode 100644 demo/src/r2_12_1/res/drawable-xxhdpi/ic_download_done.png create mode 100644 demo/src/r2_12_1/res/drawable-xxxhdpi/ic_download.png create mode 100644 demo/src/r2_12_1/res/drawable-xxxhdpi/ic_download_done.png create mode 100644 demo/src/r2_12_1/res/layout/player_activity.xml create mode 100644 demo/src/r2_12_1/res/layout/sample_chooser_activity.xml create mode 100644 demo/src/r2_12_1/res/layout/sample_list_item.xml create mode 100644 demo/src/r2_12_1/res/layout/track_selection_dialog.xml create mode 100644 demo/src/r2_12_1/res/menu/sample_chooser_menu.xml create mode 100644 demo/src/r2_12_1/res/mipmap-hdpi/ic_launcher.png create mode 100644 demo/src/r2_12_1/res/mipmap-mdpi/ic_launcher.png create mode 100644 demo/src/r2_12_1/res/mipmap-xhdpi/ic_launcher.png create mode 100644 demo/src/r2_12_1/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 demo/src/r2_12_1/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 demo/src/r2_12_1/res/values/strings.xml create mode 100644 demo/src/r2_12_1/res/values/styles.xml create mode 100644 demo/src/r2_12_1_ads/AndroidManifest.xml create mode 100644 demo/src/r2_12_1_ads/assets/media.exolist.json create mode 100644 demo/src/r2_12_1_ads/java/com/google/android/exoplayer2/demo/DemoDownloadService.java create mode 100644 demo/src/r2_12_1_ads/java/com/google/android/exoplayer2/demo/DemoUtil.java create mode 100644 demo/src/r2_12_1_ads/java/com/google/android/exoplayer2/demo/DownloadTracker.java create mode 100644 demo/src/r2_12_1_ads/java/com/google/android/exoplayer2/demo/IntentUtil.java create mode 100644 demo/src/r2_12_1_ads/java/com/google/android/exoplayer2/demo/PlayerActivity.java create mode 100644 demo/src/r2_12_1_ads/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java create mode 100644 demo/src/r2_12_1_ads/java/com/google/android/exoplayer2/demo/TrackSelectionDialog.java create mode 100644 demo/src/r2_12_1_ads/java/com/google/android/exoplayer2/demo/package-info.java create mode 100644 demo/src/r2_12_1_ads/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProvider.java create mode 100644 demo/src/r2_12_1_ads/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java create mode 100644 demo/src/r2_12_1_ads/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java create mode 100644 demo/src/r2_12_1_ads/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java create mode 100644 demo/src/r2_12_1_ads/java/com/google/android/exoplayer2/ext/cronet/package-info.java create mode 100644 demo/src/r2_12_1_ads/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java create mode 100644 demo/src/r2_12_1_ads/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java create mode 100644 demo/src/r2_12_1_ads/java/com/google/android/exoplayer2/ext/ima/package-info.java create mode 100644 demo/src/r2_12_1_ads/res/drawable-hdpi/ic_download.png create mode 100644 demo/src/r2_12_1_ads/res/drawable-hdpi/ic_download_done.png create mode 100644 demo/src/r2_12_1_ads/res/drawable-mdpi/ic_download.png create mode 100644 demo/src/r2_12_1_ads/res/drawable-mdpi/ic_download_done.png create mode 100644 demo/src/r2_12_1_ads/res/drawable-xhdpi/ic_banner.png create mode 100644 demo/src/r2_12_1_ads/res/drawable-xhdpi/ic_download.png create mode 100644 demo/src/r2_12_1_ads/res/drawable-xhdpi/ic_download_done.png create mode 100644 demo/src/r2_12_1_ads/res/drawable-xxhdpi/ic_download.png create mode 100644 demo/src/r2_12_1_ads/res/drawable-xxhdpi/ic_download_done.png create mode 100644 demo/src/r2_12_1_ads/res/drawable-xxxhdpi/ic_download.png create mode 100644 demo/src/r2_12_1_ads/res/drawable-xxxhdpi/ic_download_done.png create mode 100644 demo/src/r2_12_1_ads/res/layout/player_activity.xml create mode 100644 demo/src/r2_12_1_ads/res/layout/sample_chooser_activity.xml create mode 100644 demo/src/r2_12_1_ads/res/layout/sample_list_item.xml create mode 100644 demo/src/r2_12_1_ads/res/layout/track_selection_dialog.xml create mode 100644 demo/src/r2_12_1_ads/res/menu/sample_chooser_menu.xml create mode 100644 demo/src/r2_12_1_ads/res/mipmap-hdpi/ic_launcher.png create mode 100644 demo/src/r2_12_1_ads/res/mipmap-mdpi/ic_launcher.png create mode 100644 demo/src/r2_12_1_ads/res/mipmap-xhdpi/ic_launcher.png create mode 100644 demo/src/r2_12_1_ads/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 demo/src/r2_12_1_ads/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 demo/src/r2_12_1_ads/res/values/strings.xml create mode 100644 demo/src/r2_12_1_ads/res/values/styles.xml diff --git a/MuxExoPlayer/build.gradle b/MuxExoPlayer/build.gradle index 1f3679d7..7671c4ce 100644 --- a/MuxExoPlayer/build.gradle +++ b/MuxExoPlayer/build.gradle @@ -6,7 +6,7 @@ android { minSdkVersion project.ext.minSdkVersion targetSdkVersion project.ext.targetSdkVersion versionCode 9 - versionName "1.5.0" + versionName "2.1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -45,6 +45,12 @@ android { r2_11_1_ads { java.srcDirs = ['./src/r2_11_1/java'] } + r2_12_1 { + java.srcDirs = ['./src/r2_12_1/java'] + } + r2_12_1_ads { + java.srcDirs = ['./src/r2_12_1/java'] + } } flavorDimensions 'api' @@ -68,6 +74,12 @@ android { r2_11_1_ads { dimension 'api' } + r2_12_1 { + dimension 'api' + } + r2_12_1_ads { + dimension 'api' + } } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_7 @@ -106,7 +118,12 @@ dependencies { r2_11_1Api 'org.checkerframework:checker-qual:2.5.2' r2_11_1_adsApi 'com.google.android.exoplayer:exoplayer:2.11.1' r2_11_1_adsApi 'org.checkerframework:checker-qual:2.5.2' + r2_12_1Api 'com.google.android.exoplayer:exoplayer:2.12.1' + r2_12_1Api 'org.checkerframework:checker-qual:2.5.2' + r2_12_1_adsApi 'com.google.android.exoplayer:exoplayer:2.12.1' + r2_12_1_adsApi 'org.checkerframework:checker-qual:2.5.2' compileOnly 'com.google.ads.interactivemedia.v3:interactivemedia:3.9.0' compileOnly 'com.google.android.gms:play-services-ads:15.0.1' + compileOnly 'com.google.android.gms:play-services-ads-identifier:17.0.0' api files('libs/MuxCore.jar') } diff --git a/MuxExoPlayer/libs/MuxCore.jar b/MuxExoPlayer/libs/MuxCore.jar index 8d4734b85a4e66a557bd34d1ca962793e7b22207..57adf0464e25c6fc5b9ac7a733224c1eaf8db032 100644 GIT binary patch delta 30566 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 E0AgWuOySqbhclY4#!6mrde93?Ay=Sdnv!Aa0 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 player; protected WeakReference playerView; protected WeakReference contextRef; + protected AdsImaSDKListener adsImaSdkListener; protected int streamType = -1; public enum PlayerState { - BUFFERING, ERROR, PAUSED, PLAY, PLAYING, PLAYING_ADS, FINISHED_PLAYING_ADS, INIT, ENDED + BUFFERING, REBUFFERING, SEEKING, SEEKED, ERROR, PAUSED, PLAY, PLAYING, PLAYING_ADS, + FINISHED_PLAYING_ADS, INIT, ENDED } protected PlayerState state; protected MuxStats muxStats; @@ -88,28 +99,25 @@ public enum PlayerState { MuxBaseExoPlayer(Context ctx, ExoPlayer player, String playerName, CustomerPlayerData customerPlayerData, CustomerVideoData customerVideoData, - CustomerViewData customerViewData, boolean sentryEnabled) { + CustomerViewData customerViewData, boolean sentryEnabled, + INetworkRequest networkRequest) { super(); this.player = new WeakReference<>(player); this.contextRef = new WeakReference<>(ctx); state = PlayerState.INIT; MuxStats.setHostDevice(new MuxDevice(ctx)); - MuxStats.setHostNetworkApi(new MuxNetworkRequests()); + MuxStats.setHostNetworkApi(networkRequest); muxStats = new MuxStats(this, playerName, customerPlayerData, customerVideoData, customerViewData, sentryEnabled); addListener(muxStats); - Player.VideoComponent lDecCount = player.getVideoComponent(); playerHandler = new ExoPlayerHandler(player.getApplicationLooper(), player); - lDecCount.setVideoFrameMetadataListener(new VideoFrameMetadataListener() { - // As of r2.11.x, the signature for this callback has changed. These are not annotated as @Overrides in - // order to support both before r2.11.x and after r2.11.x at the same time. - public void onVideoFrameAboutToBeRendered(long presentationTimeUs, long releaseTimeNs, Format format) { - playerHandler.obtainMessage(ExoPlayerHandler.UPDATE_PLAYER_CURRENT_POSITION).sendToTarget(); - } - - public void onVideoFrameAboutToBeRendered(long presentationTimeUs, long releaseTimeNs, Format format, @Nullable MediaFormat mediaFormat) { - playerHandler.obtainMessage(ExoPlayerHandler.UPDATE_PLAYER_CURRENT_POSITION).sendToTarget(); - } - }); + frameRenderedListener = new FrameRenderedListener(playerHandler); + setPlaybackHeadUpdateInterval(false); + try { + adsImaSdkListener = new AdsImaSDKListener(this); + } catch (NoClassDefFoundError Err) { + // The ad modules are not included here, so we silently swallow the + // exception as the application can't be running ads anyway. + } } /** @@ -139,8 +147,12 @@ public AdsImaSDKListener getIMASdkListener() { /** * Monitor an instance of Google IMA SDK's AdsLoader * @param adsLoader + * + * + * For ExoPlayer 2.12 AdsLoader is initialized only when the add is requested, this makes + * this method impossible to use. */ - @SuppressWarnings("unused") + @SuppressWarnings("unused") public void monitorImaAdsLoader(AdsLoader adsLoader) { if (adsLoader == null) { Log.e(TAG, "Null AdsLoader provided to monitorImaAdsLoader"); @@ -161,10 +173,10 @@ public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) { // Set up the ad events that we want to use AdsManager adsManager = adsManagerLoadedEvent.getAdsManager(); - AdsImaSDKListener imaListener = new AdsImaSDKListener(baseExoPlayer); + // Attach mux event and error event listeners. - adsManager.addAdErrorListener(imaListener); - adsManager.addAdEventListener(imaListener); + adsManager.addAdErrorListener(adsImaSdkListener); + adsManager.addAdEventListener(adsImaSdkListener); } // TODO: probably need to handle some cleanup and things, like removing listeners on destroy @@ -174,19 +186,43 @@ public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) { } } + // ExoPlayer 2.12+ need this to hook add events + public AdErrorEvent.AdErrorListener getAdErrorEventListener() { + return adsImaSdkListener; + } + + // ExoPlayer 2.12+ need this to hook add events + public AdEvent.AdEventListener getAdEventListener() { + return adsImaSdkListener; + } + @SuppressWarnings("unused") public void updateCustomerData(CustomerPlayerData customPlayerData, CustomerVideoData customVideoData) { muxStats.updateCustomerData(customPlayerData, customVideoData); } + @SuppressWarnings("unused") + public void updateCustomerData(CustomerPlayerData customerPlayerData, + CustomerVideoData customerVideoData, + CustomerViewData customerViewData) { + muxStats.updateCustomerData(customerPlayerData, customerVideoData, customerViewData); + } + + @SuppressWarnings("unused") public CustomerVideoData getCustomerVideoData() { return muxStats.getCustomerVideoData(); } + @SuppressWarnings("unused") public CustomerPlayerData getCustomerPlayerData() { return muxStats.getCustomerPlayerData(); } + @SuppressWarnings("unused") + public CustomerViewData getCustomerViewData() { + return muxStats.getCustomerViewData(); + } + public void enableMuxCoreDebug(boolean enable, boolean verbose) { muxStats.allowLogcatOutput(enable, verbose); } @@ -287,6 +323,48 @@ public PlayerState getState() { return state; } + protected void configurePlaybackHeadUpdateInterval() { + if (player == null || player.get() == null) { + return; + } + + TrackGroupArray trackGroups = player.get().getCurrentTrackGroups(); + boolean haveVideo = false; + if (trackGroups.length > 0) { + for (int groupIndex = 0; groupIndex < trackGroups.length; groupIndex++) { + TrackGroup trackGroup = trackGroups.get(groupIndex); + if (0 < trackGroup.length) { + Format trackFormat = trackGroup.getFormat(0); + if (trackFormat.sampleMimeType != null && trackFormat.sampleMimeType.contains("video")) { + haveVideo = true; + break; + } + } + } + } + setPlaybackHeadUpdateInterval(haveVideo); + } + + protected void setPlaybackHeadUpdateInterval(boolean haveVideo) { + if (updatePlayheadPositionTimer != null) { + updatePlayheadPositionTimer.cancel(); + } + if (haveVideo) { + Player.VideoComponent videoComponent = player.get().getVideoComponent(); + videoComponent.setVideoFrameMetadataListener(frameRenderedListener); + } else { + // Schedule timer to execute, this is for audio only content. + updatePlayheadPositionTimer = new Timer(); + updatePlayheadPositionTimer.schedule(new TimerTask() { + @Override + public void run() { + playerHandler.obtainMessage(ExoPlayerHandler.UPDATE_PLAYER_CURRENT_POSITION) + .sendToTarget(); + } + }, 0, 15); + } + } + /* * This will be called by AdsImaSDKListener to set the player state to: PLAYING_ADS * and ADS_PLAYBACK_DONE accordingly @@ -328,16 +406,38 @@ public boolean isPaused() { } protected void buffering() { + if (state == PlayerState.REBUFFERING || state == PlayerState.SEEKING + || state == PlayerState.SEEKED ) { + // ignore + return; + } + // If we are going from playing to buffering then this is rebuffer event + if (state == PlayerState.PLAYING) { + rebufferingStarted(); + return; + } + // This is initial buffering event before playback starts state = PlayerState.BUFFERING; dispatch(new TimeUpdateEvent(null)); } protected void pause() { + if (state == PlayerState.REBUFFERING) { + rebufferingEnded(); + } + if (state == PlayerState.SEEKED) { + dispatch(new SeekedEvent(null)); + } state = PlayerState.PAUSED; dispatch(new PauseEvent(null)); } protected void play() { + if (state == PlayerState.REBUFFERING || state == PlayerState.SEEKING + || state == PlayerState.SEEKED ) { + // Ignore play event after rebuffering and Seeking + return; + } state = PlayerState.PLAY; dispatch(new PlayEvent(null)); } @@ -346,10 +446,43 @@ protected void playing() { if (state == PlayerState.PAUSED || state == PlayerState.FINISHED_PLAYING_ADS) { play(); } + if (state == PlayerState.REBUFFERING) { + rebufferingEnded(); + } + if (state == PlayerState.SEEKED) { + dispatch(new SeekedEvent(null)); + } state = PlayerState.PLAYING; dispatch(new PlayingEvent(null)); } + + protected void rebufferingStarted() { + state = PlayerState.REBUFFERING; + dispatch(new RebufferStartEvent(null)); + } + + protected void rebufferingEnded() { + dispatch(new RebufferEndEvent(null)); + } + + protected void seeking() { + if (state == PlayerState.PLAYING) { + dispatch(new PauseEvent(null)); + } + state = PlayerState.SEEKING; + dispatch(new SeekingEvent(null)); + } + + protected void seeked() { + /* + * Seeked event will be fired by the player immediately after seeking event + * This is not accurate, instead report the seeked event on first playing or pause + * event after seeked was reported by the player. + */ + state = PlayerState.SEEKED; + } + protected void ended() { dispatch(new PauseEvent(null)); dispatch(new EndedEvent(null)); @@ -378,6 +511,24 @@ protected void handleRenditionChange(Format format) { } } + static class FrameRenderedListener implements VideoFrameMetadataListener { + ExoPlayerHandler handler; + + public FrameRenderedListener(ExoPlayerHandler handler) { + this.handler = handler; + } + + // As of r2.11.x, the signature for this callback has changed. These are not annotated as @Overrides in + // order to support both before r2.11.x and after r2.11.x at the same time. + public void onVideoFrameAboutToBeRendered(long presentationTimeUs, long releaseTimeNs, Format format) { + handler.obtainMessage(ExoPlayerHandler.UPDATE_PLAYER_CURRENT_POSITION).sendToTarget(); + } + + public void onVideoFrameAboutToBeRendered(long presentationTimeUs, long releaseTimeNs, Format format, @Nullable MediaFormat mediaFormat) { + handler.obtainMessage(ExoPlayerHandler.UPDATE_PLAYER_CURRENT_POSITION).sendToTarget(); + } + }; + static class ExoPlayerHandler extends Handler { static final int UPDATE_PLAYER_CURRENT_POSITION = 1; diff --git a/MuxExoPlayer/src/r2_10_6/java/com/mux/stats/sdk/muxstats/MuxStatsExoPlayer.java b/MuxExoPlayer/src/r2_10_6/java/com/mux/stats/sdk/muxstats/MuxStatsExoPlayer.java index 40965e6f..26fcb1cd 100644 --- a/MuxExoPlayer/src/r2_10_6/java/com/mux/stats/sdk/muxstats/MuxStatsExoPlayer.java +++ b/MuxExoPlayer/src/r2_10_6/java/com/mux/stats/sdk/muxstats/MuxStatsExoPlayer.java @@ -54,7 +54,17 @@ public MuxStatsExoPlayer(Context ctx, ExoPlayer player, String playerName, CustomerPlayerData customerPlayerData, CustomerVideoData customerVideoData, CustomerViewData customerViewData, boolean sentryEnabled) { - super(ctx, player, playerName, customerPlayerData, customerVideoData, customerViewData, sentryEnabled); + this(ctx, player, playerName, customerPlayerData, customerVideoData, null, + sentryEnabled, new MuxNetworkRequests()); + } + + public MuxStatsExoPlayer(Context ctx, ExoPlayer player, String playerName, + CustomerPlayerData customerPlayerData, + CustomerVideoData customerVideoData, + CustomerViewData customerViewData, boolean sentryEnabled, + INetworkRequest networkRequest) { + super(ctx, player, playerName, customerPlayerData, customerVideoData, customerViewData, + sentryEnabled, networkRequest); if (player instanceof SimpleExoPlayer) { ((SimpleExoPlayer) player).addAnalyticsListener(this); @@ -75,7 +85,7 @@ public MuxStatsExoPlayer(Context ctx, ExoPlayer player, String playerName, @Override public void release() { - if (this.player.get() != null) { + if (player != null && this.player.get() != null) { ExoPlayer player = this.player.get(); if (player instanceof SimpleExoPlayer) { ((SimpleExoPlayer) player).removeAnalyticsListener(this); @@ -104,7 +114,7 @@ public void onPositionDiscontinuity(EventTime eventTime, int reason) { @Override public void onSeekStarted(EventTime eventTime) { - dispatch(new SeekingEvent(null)); + seeking(); } @Override @@ -307,6 +317,7 @@ public void onTimelineChanged(Timeline timeline, Object manifest, int reason) { @Override public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { bandwidthDispatcher.onTracksChanged(trackGroups); + configurePlaybackHeadUpdateInterval(); } @Override @@ -316,10 +327,9 @@ public void onLoadingChanged(boolean isLoading) { @Override public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { - this.playWhenReady = playWhenReady; PlayerState state = this.getState(); if (state == PlayerState.PLAYING_ADS) { - // Ignore all normal events while playing ads !!! + // Ignore all normal events while playing ads return; } switch (playbackState) { @@ -402,7 +412,7 @@ public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { @Override public void onSeekProcessed() { - dispatch(new SeekedEvent(null)); + seeked(); } @Override diff --git a/MuxExoPlayer/src/r2_11_1/java/com/mux/stats/sdk/muxstats/MuxStatsExoPlayer.java b/MuxExoPlayer/src/r2_11_1/java/com/mux/stats/sdk/muxstats/MuxStatsExoPlayer.java index 8f656b54..6791dfa0 100644 --- a/MuxExoPlayer/src/r2_11_1/java/com/mux/stats/sdk/muxstats/MuxStatsExoPlayer.java +++ b/MuxExoPlayer/src/r2_11_1/java/com/mux/stats/sdk/muxstats/MuxStatsExoPlayer.java @@ -54,8 +54,17 @@ public MuxStatsExoPlayer(Context ctx, ExoPlayer player, String playerName, CustomerPlayerData customerPlayerData, CustomerVideoData customerVideoData, CustomerViewData customerViewData, boolean sentryEnabled) { - super(ctx, player, playerName, customerPlayerData, customerVideoData, customerViewData, sentryEnabled); + this(ctx, player, playerName, customerPlayerData, customerVideoData, null, + sentryEnabled, new MuxNetworkRequests()); + } + public MuxStatsExoPlayer(Context ctx, ExoPlayer player, String playerName, + CustomerPlayerData customerPlayerData, + CustomerVideoData customerVideoData, + CustomerViewData customerViewData, boolean sentryEnabled, + INetworkRequest networkRequest) { + super(ctx, player, playerName, customerPlayerData, customerVideoData, customerViewData, + sentryEnabled, networkRequest); if (player instanceof SimpleExoPlayer) { ((SimpleExoPlayer) player).addAnalyticsListener(this); } else { @@ -75,7 +84,7 @@ public MuxStatsExoPlayer(Context ctx, ExoPlayer player, String playerName, @Override public void release() { - if (this.player.get() != null) { + if (player != null && this.player.get() != null) { ExoPlayer player = this.player.get(); if (player instanceof SimpleExoPlayer) { ((SimpleExoPlayer) player).removeAnalyticsListener(this); @@ -104,7 +113,7 @@ public void onPositionDiscontinuity(EventTime eventTime, int reason) { @Override public void onSeekStarted(EventTime eventTime) { - dispatch(new SeekingEvent(null)); + seeking(); } @Override @@ -307,6 +316,7 @@ public void onTimelineChanged(Timeline timeline, Object manifest, int reason) { @Override public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { bandwidthDispatcher.onTracksChanged(trackGroups); + configurePlaybackHeadUpdateInterval(); } @Override @@ -316,10 +326,9 @@ public void onLoadingChanged(boolean isLoading) { @Override public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { - this.playWhenReady = playWhenReady; PlayerState state = this.getState(); if (state == PlayerState.PLAYING_ADS) { - // Ignore all normal events while playing ads !!! + // Ignore all normal events while playing ads return; } switch (playbackState) { @@ -402,7 +411,7 @@ public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { @Override public void onSeekProcessed() { - dispatch(new SeekedEvent(null)); + seeked(); } @Override diff --git a/MuxExoPlayer/src/r2_12_1/java/com/mux/stats/sdk/muxstats/MuxStatsExoPlayer.java b/MuxExoPlayer/src/r2_12_1/java/com/mux/stats/sdk/muxstats/MuxStatsExoPlayer.java new file mode 100644 index 00000000..e525e251 --- /dev/null +++ b/MuxExoPlayer/src/r2_12_1/java/com/mux/stats/sdk/muxstats/MuxStatsExoPlayer.java @@ -0,0 +1,386 @@ +package com.mux.stats.sdk.muxstats; + +import android.content.Context; +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.MediaItem; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.analytics.AnalyticsListener; +import com.google.android.exoplayer2.audio.AudioAttributes; +import com.google.android.exoplayer2.decoder.DecoderCounters; +import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer; +import com.google.android.exoplayer2.mediacodec.MediaCodecUtil; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.source.LoadEventInfo; +import com.google.android.exoplayer2.source.MediaLoadData; +import com.google.android.exoplayer2.source.MediaSourceEventListener; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.source.LoadEventInfo; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.mux.stats.sdk.core.events.playback.SeekedEvent; +import com.mux.stats.sdk.core.events.playback.SeekingEvent; +import com.mux.stats.sdk.core.model.CustomerPlayerData; +import com.mux.stats.sdk.core.model.CustomerVideoData; +import com.mux.stats.sdk.core.model.CustomerViewData; + +import java.io.IOException; + +public class MuxStatsExoPlayer extends MuxBaseExoPlayer implements AnalyticsListener, Player.EventListener{ + + static final String TAG = "MuxStatsEventQueue"; + + public MuxStatsExoPlayer(Context ctx, ExoPlayer player, String playerName, + CustomerPlayerData customerPlayerData, + CustomerVideoData customerVideoData) { + this(ctx, player, playerName, customerPlayerData, + customerVideoData, null, true); + } + + public MuxStatsExoPlayer(Context ctx, ExoPlayer player, String playerName, + CustomerPlayerData customerPlayerData, + CustomerVideoData customerVideoData, + CustomerViewData customerViewData) { + this(ctx, player, playerName, customerPlayerData, customerVideoData, + customerViewData, true); + } + + public MuxStatsExoPlayer(Context ctx, ExoPlayer player, String playerName, + CustomerPlayerData customerPlayerData, + CustomerVideoData customerVideoData, + boolean sentryEnabled) { + this(ctx, player, playerName, customerPlayerData, customerVideoData, + null, sentryEnabled); + } + + public MuxStatsExoPlayer(Context ctx, ExoPlayer player, String playerName, + CustomerPlayerData customerPlayerData, + CustomerVideoData customerVideoData, + CustomerViewData customerViewData, boolean sentryEnabled) { + this(ctx, player, playerName, customerPlayerData, customerVideoData, + null, sentryEnabled, new MuxNetworkRequests()); + } + + public MuxStatsExoPlayer(Context ctx, ExoPlayer player, String playerName, + CustomerPlayerData customerPlayerData, + CustomerVideoData customerVideoData, + CustomerViewData customerViewData, boolean sentryEnabled, + INetworkRequest networkRequests ) { + super(ctx, player, playerName, customerPlayerData, customerVideoData, + customerViewData, sentryEnabled, networkRequests); + + if (player instanceof SimpleExoPlayer) { + ((SimpleExoPlayer) player).addAnalyticsListener(this); + } else { + player.addListener(this); + } + if (player.getPlaybackState() == Player.STATE_BUFFERING) { + // playback started before muxStats was initialized + play(); + buffering(); + } else if (player.getPlaybackState() == Player.STATE_READY) { + // We have to simulate all the events we expect to see here, even though not ideal + play(); + buffering(); + playing(); + } + } + + @Override + public void release() { + if (this.player != null && this.player.get() != null) { + ExoPlayer player = this.player.get(); + if (player instanceof SimpleExoPlayer) { + ((SimpleExoPlayer) player).removeAnalyticsListener(this); + } else { + player.removeListener(this); + } + } + super.release(); + } + + // ------BEGIN AnalyticsListener callbacks------ + @Override + public void onAudioAttributesChanged(AnalyticsListener.EventTime eventTime, + AudioAttributes audioAttributes) { } + + @Override + public void onAudioSessionId(AnalyticsListener.EventTime eventTime, int audioSessionId) { } + + @Override + public void onAudioUnderrun(AnalyticsListener.EventTime eventTime, int bufferSize, + long bufferSizeMs, long elapsedSinceLastFeedMs) { } + + @Override + public void onVideoInputFormatChanged(EventTime eventTime, Format format) { + handleRenditionChange(format); + } + + @Override + public void onDownstreamFormatChanged(AnalyticsListener.EventTime eventTime, + MediaLoadData mediaLoadData) { + if (mediaLoadData.trackFormat != null && mediaLoadData.trackFormat.containerMimeType != null) { + mimeType = mediaLoadData.trackFormat.containerMimeType; + } + } + + @Override + public void onDrmKeysLoaded(AnalyticsListener.EventTime eventTime) { } + + @Override + public void onDrmKeysRemoved(AnalyticsListener.EventTime eventTime) { } + + @Override + public void onDrmKeysRestored(AnalyticsListener.EventTime eventTime) { } + + @Override + public void onDrmSessionManagerError(AnalyticsListener.EventTime eventTime, Exception e) { + internalError(new MuxErrorException(ERROR_DRM, "DrmSessionManagerError - " + e.getMessage())); + } + + // Note: onLoadingChanged was deprecated and moved to onIsLoadingChanged in 2.12.0 + @Override + public void onIsLoadingChanged(AnalyticsListener.EventTime eventTime, boolean isLoading) { + onIsLoadingChanged(isLoading); + } + + @Override + public void onIsPlayingChanged(AnalyticsListener.EventTime eventTime, boolean isPlaying) { } + + @Override + public void onLoadCanceled(AnalyticsListener.EventTime eventTime, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData) { + bandwidthDispatcher.onLoadCanceled(loadEventInfo.dataSpec); + } + + @Override + public void onLoadCompleted(AnalyticsListener.EventTime eventTime, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData) { + bandwidthDispatcher.onLoadCompleted(loadEventInfo.dataSpec, mediaLoadData.dataType, + mediaLoadData.trackFormat, mediaLoadData.mediaStartTimeMs, + mediaLoadData.mediaEndTimeMs, loadEventInfo.elapsedRealtimeMs, + loadEventInfo.loadDurationMs, loadEventInfo.bytesLoaded, + loadEventInfo.responseHeaders); + } + + @Override + public void onLoadError(AnalyticsListener.EventTime eventTime, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData, IOException e, + boolean wasCanceled) { + bandwidthDispatcher.onLoadError(loadEventInfo.dataSpec, mediaLoadData.dataType, e); + } + + @Override + public void onLoadStarted(AnalyticsListener.EventTime eventTime, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData) { + bandwidthDispatcher.onLoadStarted(loadEventInfo.dataSpec, mediaLoadData.dataType, + mediaLoadData.trackFormat, mediaLoadData.mediaStartTimeMs, + mediaLoadData.mediaEndTimeMs, loadEventInfo.elapsedRealtimeMs); + } + + @Override + public void onMetadata(AnalyticsListener.EventTime eventTime, Metadata metadata) { } + + @Override + public void onPlaybackParametersChanged(AnalyticsListener.EventTime eventTime, + PlaybackParameters playbackParameters) { + onPlaybackParametersChanged(playbackParameters); + } + + @Override + public void onPlaybackStateChanged(EventTime eventTime, @Player.State int state) { + onPlaybackStateChanged(state); + } + + @Override + public void onPlaybackSuppressionReasonChanged(AnalyticsListener.EventTime eventTime, + int playbackSuppressionReason) { } + + @Override + public void onPlayerError(AnalyticsListener.EventTime eventTime, ExoPlaybackException error) { + onPlayerError(error); + } + + @Override + public void onPlayWhenReadyChanged(AnalyticsListener.EventTime eventTime, boolean playWhenReady, + int reason) { + onPlayWhenReadyChanged(playWhenReady, reason); + onPlaybackStateChanged(player.get().getPlaybackState()); + } + + @Override + public void onPositionDiscontinuity(AnalyticsListener.EventTime eventTime, int reason) { + onPositionDiscontinuity(reason); + } + + @Override + public void onRenderedFirstFrame(AnalyticsListener.EventTime eventTime, Surface surface) { } + + @Override + public void onRepeatModeChanged(AnalyticsListener.EventTime eventTime, int repeatMode) { + onRepeatModeChanged(repeatMode); + } + + // Note: onSeekProcessed was deprecated in 2.12.0 + + @Override + public void onSeekStarted(AnalyticsListener.EventTime eventTime) { + seeking(); + } + + @Override + public void onShuffleModeChanged(AnalyticsListener.EventTime eventTime, + boolean shuffleModeEnabled) { + onShuffleModeEnabledChanged(shuffleModeEnabled); + } + + @Override + public void onSurfaceSizeChanged(AnalyticsListener.EventTime eventTime, int width, + int height) { } + + @Override + public void onTimelineChanged(AnalyticsListener.EventTime eventTime, int reason) { + onTimelineChanged(eventTime.timeline, reason); + } + + @Override + public void onTracksChanged(AnalyticsListener.EventTime eventTime, TrackGroupArray trackGroups, + TrackSelectionArray trackSelections) { + onTracksChanged(trackGroups, trackSelections); + } + + @Override + public void onUpstreamDiscarded(EventTime eventTime, MediaLoadData mediaLoadData) { } + + @Override + public void onVideoSizeChanged(AnalyticsListener.EventTime eventTime, int width, int height, + int unappliedRotationDegrees, float pixelWidthHeightRatio) { + sourceWidth = width; + sourceHeight = height; + } + + @Override + public void onVolumeChanged(AnalyticsListener.EventTime eventTime, float volume) { } + // ------END AnalyticsListener callbacks------ + + // ------BEGIN Player.EventListener callbacks------ + @Override + public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { } + + @Override + public void onPlaybackStateChanged(int playbackState) { + /* + * Sometimes onPlaybackStateChanged callback will not be triggered, and it is + * prone to bugs to keep same value in two places, so we should always access + * playWhenReady via the player object. + */ + boolean playWhenReady = player.get().getPlayWhenReady(); + PlayerState state = this.getState(); + if (state == PlayerState.PLAYING_ADS) { + // Ignore all normal events while playing ads + return; + } + switch (playbackState) { + case Player.STATE_BUFFERING: + // We have entered buffering + buffering(); + // If we are expected to playWhenReady, signal the play event + if (playWhenReady) { + play(); + } else if (state != PlayerState.PAUSED) { + pause(); + } + break; + case Player.STATE_ENDED: + ended(); + break; + case Player.STATE_READY: + // By the time we get here, it depends on playWhenReady to know if we're playing + if (playWhenReady) { + playing(); + } else if (state != PlayerState.PAUSED) { + pause(); + } + break; + case Player.STATE_IDLE: + default: + // don't care + break; + } + } + + @Override + public void onPlayerError(ExoPlaybackException e) { + if (e.type == ExoPlaybackException.TYPE_RENDERER) { + Exception cause = e.getRendererException(); + if (cause instanceof MediaCodecRenderer.DecoderInitializationException) { + MediaCodecRenderer.DecoderInitializationException die = (MediaCodecRenderer.DecoderInitializationException) cause; + if (die.codecInfo != null && die.codecInfo.name == null) { + if (die.getCause() instanceof MediaCodecUtil.DecoderQueryException) { + internalError(new MuxErrorException(e.type, "Unable to query device decoders")); + } else if (die.secureDecoderRequired) { + internalError(new MuxErrorException(e.type, "No secure decoder for " + die.mimeType)); + } else { + internalError(new MuxErrorException(e.type, "No decoder for " + die.mimeType)); + } + } else { + internalError(new MuxErrorException(e.type, "Unable to instantiate decoder for " + die.mimeType)); + } + } + } else if (e.type == ExoPlaybackException.TYPE_SOURCE) { + Exception error = e.getSourceException(); + internalError(new MuxErrorException(e.type, error.getClass().getCanonicalName() + " - " + error.getMessage())); + } else if (e.type == ExoPlaybackException.TYPE_UNEXPECTED) { + Exception error = e.getUnexpectedException(); + internalError(new MuxErrorException(e.type, error.getClass().getCanonicalName() + " - " + error.getMessage())); + } else { + internalError(e); + } + } + + @Override + public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { + // Nothing to do here + } + + @Override + public void onPositionDiscontinuity(int reason) { + if (reason == Player.DISCONTINUITY_REASON_SEEK) { + seeked(); + } + } + + @Override + public void onRepeatModeChanged(int repeatMode) { } + + // Note, onSeekProcessed was deprecated in 2.12.0 + + @Override + public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { } + + @Override + public void onTimelineChanged(Timeline timeline, int reason) { + if (timeline != null && timeline.getWindowCount() > 0) { + Timeline.Window window = new Timeline.Window(); + timeline.getWindow(0, window); + sourceDuration = window.getDurationMs(); + } + } + + @Override + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + bandwidthDispatcher.onTracksChanged(trackGroups); + configurePlaybackHeadUpdateInterval(); + } +} diff --git a/MuxExoPlayer/src/r2_9_6/java/com/mux/stats/sdk/muxstats/MuxStatsExoPlayer.java b/MuxExoPlayer/src/r2_9_6/java/com/mux/stats/sdk/muxstats/MuxStatsExoPlayer.java index 4c20ddc8..836d8830 100644 --- a/MuxExoPlayer/src/r2_9_6/java/com/mux/stats/sdk/muxstats/MuxStatsExoPlayer.java +++ b/MuxExoPlayer/src/r2_9_6/java/com/mux/stats/sdk/muxstats/MuxStatsExoPlayer.java @@ -53,8 +53,17 @@ public MuxStatsExoPlayer(Context ctx, ExoPlayer player, String playerName, CustomerPlayerData customerPlayerData, CustomerVideoData customerVideoData, CustomerViewData customerViewData, boolean sentryEnabled) { - super(ctx, player, playerName, customerPlayerData, customerVideoData, customerViewData, sentryEnabled); + this(ctx, player, playerName, customerPlayerData, customerVideoData, null, + sentryEnabled, new MuxNetworkRequests()); + } + public MuxStatsExoPlayer(Context ctx, ExoPlayer player, String playerName, + CustomerPlayerData customerPlayerData, + CustomerVideoData customerVideoData, + CustomerViewData customerViewData, boolean sentryEnabled, + INetworkRequest networkRequest) { + super(ctx, player, playerName, customerPlayerData, customerVideoData, customerViewData, + sentryEnabled, networkRequest); if (player instanceof SimpleExoPlayer) { ((SimpleExoPlayer) player).addAnalyticsListener(this); } else { @@ -74,7 +83,7 @@ public MuxStatsExoPlayer(Context ctx, ExoPlayer player, String playerName, @Override public void release() { - if (this.player.get() != null) { + if (player != null && this.player.get() != null) { ExoPlayer player = this.player.get(); if (player instanceof SimpleExoPlayer) { ((SimpleExoPlayer) player).removeAnalyticsListener(this); @@ -103,7 +112,7 @@ public void onPositionDiscontinuity(EventTime eventTime, int reason) { @Override public void onSeekStarted(EventTime eventTime) { - dispatch(new SeekingEvent(null)); + seeking(); } @Override @@ -306,6 +315,7 @@ public void onTimelineChanged(Timeline timeline, Object manifest, int reason) { @Override public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { bandwidthDispatcher.onTracksChanged(trackGroups); + configurePlaybackHeadUpdateInterval(); } @Override @@ -315,10 +325,9 @@ public void onLoadingChanged(boolean isLoading) { @Override public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { - this.playWhenReady = playWhenReady; PlayerState state = this.getState(); if (state == PlayerState.PLAYING_ADS) { - // Ignore all normal events while playing ads !!! + // Ignore all normal events while playing ads return; } switch (playbackState) { @@ -401,6 +410,6 @@ public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { @Override public void onSeekProcessed() { - dispatch(new SeekedEvent(null)); + seeked(); } } diff --git a/build.gradle b/build.gradle index 9fd69209..9656503c 100644 --- a/build.gradle +++ b/build.gradle @@ -36,7 +36,7 @@ allprojects { } project.ext { - minSdkVersion=16 + minSdkVersion=24 compileSdkVersion=29 targetSdkVersion=29 releaseRepoName = 'exoplayer' diff --git a/demo/build.gradle b/demo/build.gradle index 7f384802..b9e834f0 100644 --- a/demo/build.gradle +++ b/demo/build.gradle @@ -71,6 +71,16 @@ android { res.srcDirs = ['./src/r2_11_1_ads/res'] assets.srcDirs = ['./src/r2_11_1_ads/assets'] } + r2_12_1 { + java.srcDirs = ['./src/r2_12_1/java'] + res.srcDirs = ['./src/r2_12_1/res'] + assets.srcDirs = ['./src/r2_12_1/assets'] + } + r2_12_1_ads { + java.srcDirs = ['./src/r2_12_1_ads/java'] + res.srcDirs = ['./src/r2_12_1_ads/res'] + assets.srcDirs = ['./src/r2_12_1_ads/assets'] + } } flavorDimensions 'api' @@ -78,64 +88,33 @@ android { productFlavors { r2_9_6 { dimension 'api' - minSdkVersion 24 - compileSdkVersion 29 - targetSdkVersion 29 - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } } r2_9_6_ads { dimension 'api' - minSdkVersion 24 - compileSdkVersion 29 - targetSdkVersion 29 - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } } r2_10_6 { dimension 'api' - minSdkVersion 24 - compileSdkVersion 29 - targetSdkVersion 29 - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } } r2_10_6_ads { dimension 'api' - minSdkVersion 24 - compileSdkVersion 29 - targetSdkVersion 29 - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } } r2_11_1 { dimension 'api' - minSdkVersion 24 - compileSdkVersion 29 - targetSdkVersion 29 - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } } r2_11_1_ads { dimension 'api' - minSdkVersion 24 - compileSdkVersion 29 - targetSdkVersion 29 - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } } + r2_12_1 { + dimension 'api' + } + r2_12_1_ads { + dimension 'api' + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 } } @@ -144,7 +123,10 @@ dependencies { implementation 'androidx.legacy:legacy-support-core-ui:1.0.0' implementation 'androidx.fragment:fragment:1.1.0' implementation 'com.google.android.material:material:1.0.0' + implementation 'androidx.multidex:multidex:2.0.0' implementation project(':MuxExoPlayer') + r2_12_1Api "com.google.android.gms:play-services-cronet:17.0.0" + r2_12_1_adsApi "com.google.android.gms:play-services-cronet:17.0.0" // Include the below to test ads for each product flavor that supports them r2_9_6_adsImplementation 'com.google.ads.interactivemedia.v3:interactivemedia:3.10.6' r2_9_6_adsImplementation 'com.google.android.gms:play-services-ads:15.0.1' @@ -152,4 +134,6 @@ dependencies { r2_10_6_adsImplementation 'com.google.android.gms:play-services-ads:15.0.1' r2_11_1_adsImplementation 'com.google.android.gms:play-services-ads-identifier:17.0.0' r2_11_1_adsImplementation 'com.google.ads.interactivemedia.v3:interactivemedia:3.11.3' + r2_12_1_adsImplementation 'com.google.android.gms:play-services-ads-identifier:17.0.0' + r2_12_1_adsImplementation 'com.google.ads.interactivemedia.v3:interactivemedia:3.20.1' } diff --git a/demo/src/r2_12_1/AndroidManifest.xml b/demo/src/r2_12_1/AndroidManifest.xml new file mode 100644 index 00000000..05366550 --- /dev/null +++ b/demo/src/r2_12_1/AndroidManifest.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demo/src/r2_12_1/assets/media.exolist.json b/demo/src/r2_12_1/assets/media.exolist.json new file mode 100644 index 00000000..9c1f762e --- /dev/null +++ b/demo/src/r2_12_1/assets/media.exolist.json @@ -0,0 +1,477 @@ +[ + { + "name": "YouTube DASH", + "samples": [ + { + "name": "Google Glass H264 (MP4)", + "uri": "https://www.youtube.com/api/manifest/dash/id/bf5bb2419360daf1/source/youtube?as=fmp4_audio_clear,fmp4_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=51AF5F39AB0CEC3E5497CD9C900EBFEAECCCB5C7.8506521BFC350652163895D4C26DEE124209AA9E&key=ik0", + "extension": "mpd" + }, + { + "name": "Google Play H264 (MP4)", + "uri": "https://www.youtube.com/api/manifest/dash/id/3aa39fa2cc27967f/source/youtube?as=fmp4_audio_clear,fmp4_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=A2716F75795F5D2AF0E88962FFCD10DB79384F29.84308FF04844498CE6FBCE4731507882B8307798&key=ik0", + "extension": "mpd" + }, + { + "name": "Google Glass VP9 (WebM)", + "uri": "https://www.youtube.com/api/manifest/dash/id/bf5bb2419360daf1/source/youtube?as=fmp4_audio_clear,webm2_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=249B04F79E984D7F86B4D8DB48AE6FAF41C17AB3.7B9F0EC0505E1566E59B8E488E9419F253DDF413&key=ik0", + "extension": "mpd" + }, + { + "name": "Google Play VP9 (WebM)", + "uri": "https://www.youtube.com/api/manifest/dash/id/3aa39fa2cc27967f/source/youtube?as=fmp4_audio_clear,webm2_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=B1C2A74783AC1CC4865EB312D7DD2D48230CC9FD.BD153B9882175F1F94BFE5141A5482313EA38E8D&key=ik0", + "extension": "mpd" + } + ] + }, + { + "name": "Widevine GTS policy tests", + "samples": [ + { + "name": "SW secure crypto (L3)", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO&provider=widevine_test" + }, + { + "name": "SW secure decode", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_DECODE&provider=widevine_test" + }, + { + "name": "HW secure crypto", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_HW_SECURE_CRYPTO&provider=widevine_test" + }, + { + "name": "HW secure decode", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_HW_SECURE_DECODE&provider=widevine_test" + }, + { + "name": "HW secure all (L1)", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_HW_SECURE_ALL&provider=widevine_test" + }, + { + "name": "30s license (fails at ~30s)", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_CAN_RENEW_FALSE_LICENSE_30S_PLAYBACK_30S&provider=widevine_test" + }, + { + "name": "HDCP not required", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_NONE&provider=widevine_test" + }, + { + "name": "HDCP 1.0 required", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_V1&provider=widevine_test" + }, + { + "name": "HDCP 2.0 required", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_V2&provider=widevine_test" + }, + { + "name": "HDCP 2.1 required", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_V2_1&provider=widevine_test" + }, + { + "name": "HDCP 2.2 required", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_V2_2&provider=widevine_test" + }, + { + "name": "HDCP no digital output", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_NO_DIGITAL_OUTPUT&provider=widevine_test" + } + ] + }, + { + "name": "Widevine DASH H264 (MP4)", + "samples": [ + { + "name": "Clear", + "uri": "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd" + }, + { + "name": "Clear UHD", + "uri": "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears_uhd.mpd" + }, + { + "name": "Secure (cenc)", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" + }, + { + "name": "Secure UHD (cenc)", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_uhd.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" + }, + { + "name": "Secure (cbcs)", + "uri": "https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" + }, + { + "name": "Secure UHD (cbcs)", + "uri": "https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs_uhd.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" + }, + { + "name": "Secure -> Clear -> Secure (cenc)", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/widevine/tears_enc_clear_enc.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test", + "drm_session_for_clear_content": true + } + ] + }, + { + "name": "Widevine DASH VP9 (WebM)", + "samples": [ + { + "name": "Clear", + "uri": "https://storage.googleapis.com/wvmedia/clear/vp9/tears/tears.mpd" + }, + { + "name": "Clear UHD", + "uri": "https://storage.googleapis.com/wvmedia/clear/vp9/tears/tears_uhd.mpd" + }, + { + "name": "Secure (full-sample)", + "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" + }, + { + "name": "Secure UHD (full-sample)", + "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/tears/tears_uhd.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" + }, + { + "name": "Secure (sub-sample)", + "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/subsample/24fps/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" + }, + { + "name": "Secure UHD (sub-sample)", + "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/subsample/24fps/tears/tears_uhd.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" + } + ] + }, + { + "name": "Widevine DASH H265 (MP4)", + "samples": [ + { + "name": "Clear", + "uri": "https://storage.googleapis.com/wvmedia/clear/hevc/tears/tears.mpd" + }, + { + "name": "Clear UHD", + "uri": "https://storage.googleapis.com/wvmedia/clear/hevc/tears/tears_uhd.mpd" + }, + { + "name": "Secure", + "uri": "https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" + }, + { + "name": "Secure UHD", + "uri": "https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears_uhd.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" + } + ] + }, + { + "name": "Widevine AV1 (WebM)", + "samples": [ + { + "name": "Clear", + "uri": "https://storage.googleapis.com/wvmedia/2019/clear/av1/24/webm/llama_av1_480p_400.webm" + }, + { + "name": "Secure L3", + "uri": "https://storage.googleapis.com/wvmedia/2019/cenc/av1/24/webm/llama_av1_480p_400.webm", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO&provider=widevine_test" + }, + { + "name": "Secure L1", + "uri": "https://storage.googleapis.com/wvmedia/2019/cenc/av1/24/webm/llama_av1_480p_400.webm", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_HW_SECURE_ALL&provider=widevine_test" + } + ] + }, + { + "name": "SmoothStreaming", + "samples": [ + { + "name": "Super speed", + "uri": "https://playready.directtaps.net/smoothstreaming/SSWSS720H264/SuperSpeedway_720.ism/Manifest" + }, + { + "name": "Super speed (PlayReady)", + "uri": "https://playready.directtaps.net/smoothstreaming/SSWSS720H264PR/SuperSpeedway_720.ism/Manifest", + "drm_scheme": "playready" + } + ] + }, + { + "name": "HLS", + "samples": [ + { + "name": "Apple 4x3 basic stream", + "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8" + }, + { + "name": "Apple 16x9 basic stream", + "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8" + }, + { + "name": "Apple master playlist advanced (TS)", + "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8" + }, + { + "name": "Apple master playlist advanced (FMP4)", + "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8" + }, + { + "name": "Apple TS media playlist", + "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/gear1/prog_index.m3u8" + }, + { + "name": "Apple AAC media playlist", + "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/gear0/prog_index.m3u8" + } + ] + }, + { + "name": "Misc", + "samples": [ + { + "name": "Dizzy (MP4)", + "uri": "https://html5demos.com/assets/dizzy.mp4" + }, + { + "name": "Apple 10s (AAC)", + "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/gear0/fileSequence0.aac" + }, + { + "name": "Apple 10s (TS)", + "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/gear1/fileSequence0.ts" + }, + { + "name": "Android screens (MKV)", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv" + }, + { + "name": "Screens 360p video (WebM,VP9)", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/video-vp9-360.webm" + }, + { + "name": "Screens 480p video (FMP4,H264)", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/video-avc-baseline-480.mp4" + }, + { + "name": "Screens 1080p video (FMP4,H264)", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/video-137.mp4" + }, + { + "name": "Screens audio (FMP4)", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/audio-141.mp4" + }, + { + "name": "Google Play (MP3)", + "uri": "https://storage.googleapis.com/exoplayer-test-media-0/play.mp3" + }, + { + "name": "Google Play (Ogg/Vorbis)", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/ogg/play.ogg" + }, + { + "name": "Google Play (FLAC)", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/flac/play.flac" + }, + { + "name": "Big Buck Bunny video (FLV)", + "uri": "https://vod.leasewebcdn.com/bbb.flv?ri=1024&rs=150&start=0" + }, + { + "name": "Big Buck Bunny 480p video (MP4,AV1)", + "uri": "https://storage.googleapis.com/downloads.webmproject.org/av1/exoplayer/bbb-av1-480p.mp4" + }, + { + "name": "One hour frame counter (MP4)", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mp4/frame-counter-one-hour.mp4" + } + ] + }, + { + "name": "Playlists", + "samples": [ + { + "name": "Cats -> Dogs", + "playlist": [ + { + "uri": "https://html5demos.com/assets/dizzy.mp4" + }, + { + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv" + } + ] + }, + { + "name": "Audio -> Video -> Audio", + "playlist": [ + { + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/audio-141.mp4" + }, + { + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv" + }, + { + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/audio-141.mp4" + } + ] + }, + { + "name": "Clear -> Enc -> Clear -> Enc -> Enc", + "playlist": [ + { + "uri": "https://html5demos.com/assets/dizzy.mp4" + }, + { + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_sd.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" + }, + { + "uri": "https://html5demos.com/assets/dizzy.mp4" + }, + { + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_sd.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" + }, + { + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_sd.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" + } + ] + }, + { + "name": "Manual ad insertion", + "playlist": [ + { + "uri": "https://html5demos.com/assets/dizzy.mp4", + "clip_end_position_ms": 10000 + }, + { + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mp4/frame-counter-one-hour.mp4", + "clip_end_position_ms": 5000 + }, + { + "uri": "https://html5demos.com/assets/dizzy.mp4", + "clip_start_position_ms": 10000 + } + ] + } + ] + }, + { + "name": "Subtitles", + "samples": [ + { + "name": "TTML", + "uri": "https://html5demos.com/assets/dizzy.mp4", + "subtitle_uri": "https://storage.googleapis.com/exoplayer-test-media-1/ttml/netflix_ttml_sample.xml", + "subtitle_mime_type": "application/ttml+xml", + "subtitle_language": "en" + }, + { + "name": "WebVTT line positioning", + "uri": "https://html5demos.com/assets/dizzy.mp4", + "subtitle_uri": "https://storage.googleapis.com/exoplayer-test-media-1/webvtt/numeric-lines.vtt", + "subtitle_mime_type": "text/vtt", + "subtitle_language": "en" + }, + { + "name": "SSA/ASS position & alignment", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/video-avc-baseline-480.mp4", + "subtitle_uri": "https://storage.googleapis.com/exoplayer-test-media-1/ssa/test-subs-position.ass", + "subtitle_mime_type": "text/x-ssa", + "subtitle_language": "en" + }, + { + "name": "MPEG-4 Timed Text (tx3g, mov_text)", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mp4/dizzy-with-tx3g.mp4" + }, + { + "name": "Japanese features (vertical + rubies) [TTML]", + "uri": "https://html5demos.com/assets/dizzy.mp4", + "subtitle_uri": "https://storage.googleapis.com/exoplayer-test-media-1/ttml/japanese-ttml.xml", + "subtitle_mime_type": "application/ttml+xml", + "subtitle_language": "ja" + }, + { + "name": "Japanese features (vertical + rubies) [WebVTT]", + "uri": "https://html5demos.com/assets/dizzy.mp4", + "subtitle_uri": "https://storage.googleapis.com/exoplayer-test-media-1/webvtt/japanese.vtt", + "subtitle_mime_type": "text/vtt", + "subtitle_language": "ja" + } + ] + }, + { + "name": "60fps", + "samples": [ + { + "name": "Big Buck Bunny (DASH,H264,1080p,Clear)", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/60fps/bbb-clear-1080/manifest.mpd" + }, + { + "name": "Big Buck Bunny (DASH,H264,4K,Clear)", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/60fps/bbb-clear-2160/manifest.mpd" + }, + { + "name": "Big Buck Bunny (DASH,H264,1080p,Widevine)", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/60fps/bbb-wv-1080/manifest.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" + }, + { + "name": "Big Buck Bunny (DASH,H264,4K,Widevine)", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/60fps/bbb-wv-2160/manifest.mpd", + "drm_scheme": "widevine", + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" + } + ] + } +] diff --git a/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/DemoDownloadService.java b/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/DemoDownloadService.java new file mode 100644 index 00000000..c462c14c --- /dev/null +++ b/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/DemoDownloadService.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.demo; + +import static com.google.android.exoplayer2.demo.DemoUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID; + +import android.app.Notification; +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.offline.Download; +import com.google.android.exoplayer2.offline.DownloadManager; +import com.google.android.exoplayer2.offline.DownloadService; +import com.google.android.exoplayer2.scheduler.PlatformScheduler; +import com.google.android.exoplayer2.ui.DownloadNotificationHelper; +import com.google.android.exoplayer2.util.NotificationUtil; +import com.google.android.exoplayer2.util.Util; +import java.util.List; + +/** A service for downloading media. */ +public class DemoDownloadService extends DownloadService { + + private static final int JOB_ID = 1; + private static final int FOREGROUND_NOTIFICATION_ID = 1; + + public DemoDownloadService() { + super( + FOREGROUND_NOTIFICATION_ID, + DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL, + DOWNLOAD_NOTIFICATION_CHANNEL_ID, + R.string.exo_download_notification_channel_name, + /* channelDescriptionResourceId= */ 0); + } + + @Override + @NonNull + protected DownloadManager getDownloadManager() { + // This will only happen once, because getDownloadManager is guaranteed to be called only once + // in the life cycle of the process. + DownloadManager downloadManager = DemoUtil.getDownloadManager(/* context= */ this); + DownloadNotificationHelper downloadNotificationHelper = + DemoUtil.getDownloadNotificationHelper(/* context= */ this); + downloadManager.addListener( + new TerminalStateNotificationHelper( + this, downloadNotificationHelper, FOREGROUND_NOTIFICATION_ID + 1)); + return downloadManager; + } + + @Override + protected PlatformScheduler getScheduler() { + return Util.SDK_INT >= 21 ? new PlatformScheduler(this, JOB_ID) : null; + } + + @Override + @NonNull + protected Notification getForegroundNotification(@NonNull List downloads) { + return DemoUtil.getDownloadNotificationHelper(/* context= */ this) + .buildProgressNotification( + /* context= */ this, + R.drawable.ic_download, + /* contentIntent= */ null, + /* message= */ null, + downloads); + } + + /** + * Creates and displays notifications for downloads when they complete or fail. + * + *

This helper will outlive the lifespan of a single instance of {@link DemoDownloadService}. + * It is static to avoid leaking the first {@link DemoDownloadService} instance. + */ + private static final class TerminalStateNotificationHelper implements DownloadManager.Listener { + + private final Context context; + private final DownloadNotificationHelper notificationHelper; + + private int nextNotificationId; + + public TerminalStateNotificationHelper( + Context context, DownloadNotificationHelper notificationHelper, int firstNotificationId) { + this.context = context.getApplicationContext(); + this.notificationHelper = notificationHelper; + nextNotificationId = firstNotificationId; + } + + @Override + public void onDownloadChanged( + DownloadManager downloadManager, Download download, @Nullable Exception finalException) { + Notification notification; + if (download.state == Download.STATE_COMPLETED) { + notification = + notificationHelper.buildDownloadCompletedNotification( + context, + R.drawable.ic_download_done, + /* contentIntent= */ null, + Util.fromUtf8Bytes(download.request.data)); + } else if (download.state == Download.STATE_FAILED) { + notification = + notificationHelper.buildDownloadFailedNotification( + context, + R.drawable.ic_download_done, + /* contentIntent= */ null, + Util.fromUtf8Bytes(download.request.data)); + } else { + return; + } + NotificationUtil.setNotification(context, nextNotificationId++, notification); + } + } +} diff --git a/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/DemoUtil.java b/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/DemoUtil.java new file mode 100644 index 00000000..c46e0865 --- /dev/null +++ b/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/DemoUtil.java @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.demo; + +import android.content.Context; +import com.google.android.exoplayer2.DefaultRenderersFactory; +import com.google.android.exoplayer2.RenderersFactory; +import com.google.android.exoplayer2.database.DatabaseProvider; +import com.google.android.exoplayer2.database.ExoDatabaseProvider; +import com.google.android.exoplayer2.ext.cronet.CronetDataSourceFactory; +import com.google.android.exoplayer2.ext.cronet.CronetEngineWrapper; +import com.google.android.exoplayer2.offline.ActionFileUpgradeUtil; +import com.google.android.exoplayer2.offline.DefaultDownloadIndex; +import com.google.android.exoplayer2.offline.DownloadManager; +import com.google.android.exoplayer2.ui.DownloadNotificationHelper; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.upstream.HttpDataSource; +import com.google.android.exoplayer2.upstream.cache.Cache; +import com.google.android.exoplayer2.upstream.cache.CacheDataSource; +import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor; +import com.google.android.exoplayer2.upstream.cache.SimpleCache; +import com.google.android.exoplayer2.util.Log; +import java.io.File; +import java.io.IOException; +import java.util.concurrent.Executors; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** Utility methods for the demo app. */ +public final class DemoUtil { + + public static final String DOWNLOAD_NOTIFICATION_CHANNEL_ID = "download_channel"; + + private static final String TAG = "DemoUtil"; + private static final String DOWNLOAD_ACTION_FILE = "actions"; + private static final String DOWNLOAD_TRACKER_ACTION_FILE = "tracked_actions"; + private static final String DOWNLOAD_CONTENT_DIRECTORY = "downloads"; + + private static DataSource.@MonotonicNonNull Factory dataSourceFactory; + private static HttpDataSource.@MonotonicNonNull Factory httpDataSourceFactory; + private static @MonotonicNonNull DatabaseProvider databaseProvider; + private static @MonotonicNonNull File downloadDirectory; + private static @MonotonicNonNull Cache downloadCache; + private static @MonotonicNonNull DownloadManager downloadManager; + private static @MonotonicNonNull DownloadTracker downloadTracker; + private static @MonotonicNonNull DownloadNotificationHelper downloadNotificationHelper; + + /** Returns whether extension renderers should be used. */ + public static boolean useExtensionRenderers() { +// return BuildConfig.USE_DECODER_EXTENSIONS; + return false; + } + + public static RenderersFactory buildRenderersFactory( + Context context, boolean preferExtensionRenderer) { + @DefaultRenderersFactory.ExtensionRendererMode + int extensionRendererMode = + useExtensionRenderers() + ? (preferExtensionRenderer + ? DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER + : DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) + : DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF; + return new DefaultRenderersFactory(context.getApplicationContext()) + .setExtensionRendererMode(extensionRendererMode); + } + + public static synchronized HttpDataSource.Factory getHttpDataSourceFactory(Context context) { + if (httpDataSourceFactory == null) { + context = context.getApplicationContext(); + CronetEngineWrapper cronetEngineWrapper = new CronetEngineWrapper(context); + httpDataSourceFactory = + new CronetDataSourceFactory(cronetEngineWrapper, Executors.newSingleThreadExecutor()); + } + return httpDataSourceFactory; + } + + /** Returns a {@link DataSource.Factory}. */ + public static synchronized DataSource.Factory getDataSourceFactory(Context context) { + if (dataSourceFactory == null) { + context = context.getApplicationContext(); + DefaultDataSourceFactory upstreamFactory = + new DefaultDataSourceFactory(context, getHttpDataSourceFactory(context)); + dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context)); + } + return dataSourceFactory; + } + + public static synchronized DownloadNotificationHelper getDownloadNotificationHelper( + Context context) { + if (downloadNotificationHelper == null) { + downloadNotificationHelper = + new DownloadNotificationHelper(context, DOWNLOAD_NOTIFICATION_CHANNEL_ID); + } + return downloadNotificationHelper; + } + + public static synchronized DownloadManager getDownloadManager(Context context) { + ensureDownloadManagerInitialized(context); + return downloadManager; + } + + public static synchronized DownloadTracker getDownloadTracker(Context context) { + ensureDownloadManagerInitialized(context); + return downloadTracker; + } + + private static synchronized Cache getDownloadCache(Context context) { + if (downloadCache == null) { + File downloadContentDirectory = + new File(getDownloadDirectory(context), DOWNLOAD_CONTENT_DIRECTORY); + downloadCache = + new SimpleCache( + downloadContentDirectory, new NoOpCacheEvictor(), getDatabaseProvider(context)); + } + return downloadCache; + } + + private static synchronized void ensureDownloadManagerInitialized(Context context) { + if (downloadManager == null) { + DefaultDownloadIndex downloadIndex = new DefaultDownloadIndex(getDatabaseProvider(context)); + upgradeActionFile( + context, DOWNLOAD_ACTION_FILE, downloadIndex, /* addNewDownloadsAsCompleted= */ false); + upgradeActionFile( + context, + DOWNLOAD_TRACKER_ACTION_FILE, + downloadIndex, + /* addNewDownloadsAsCompleted= */ true); + downloadManager = + new DownloadManager( + context, + getDatabaseProvider(context), + getDownloadCache(context), + getHttpDataSourceFactory(context), + Executors.newFixedThreadPool(/* nThreads= */ 6)); + downloadTracker = + new DownloadTracker(context, getHttpDataSourceFactory(context), downloadManager); + } + } + + private static synchronized void upgradeActionFile( + Context context, + String fileName, + DefaultDownloadIndex downloadIndex, + boolean addNewDownloadsAsCompleted) { + try { + ActionFileUpgradeUtil.upgradeAndDelete( + new File(getDownloadDirectory(context), fileName), + /* downloadIdProvider= */ null, + downloadIndex, + /* deleteOnFailure= */ true, + addNewDownloadsAsCompleted); + } catch (IOException e) { + Log.e(TAG, "Failed to upgrade action file: " + fileName, e); + } + } + + private static synchronized DatabaseProvider getDatabaseProvider(Context context) { + if (databaseProvider == null) { + databaseProvider = new ExoDatabaseProvider(context); + } + return databaseProvider; + } + + private static synchronized File getDownloadDirectory(Context context) { + if (downloadDirectory == null) { + downloadDirectory = context.getExternalFilesDir(/* type= */ null); + if (downloadDirectory == null) { + downloadDirectory = context.getFilesDir(); + } + } + return downloadDirectory; + } + + private static CacheDataSource.Factory buildReadOnlyCacheDataSource( + DataSource.Factory upstreamFactory, Cache cache) { + return new CacheDataSource.Factory() + .setCache(cache) + .setUpstreamDataSourceFactory(upstreamFactory) + .setCacheWriteDataSinkFactory(null) + .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR); + } + + private DemoUtil() {} +} diff --git a/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/DownloadTracker.java b/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/DownloadTracker.java new file mode 100644 index 00000000..07f4dd2f --- /dev/null +++ b/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/DownloadTracker.java @@ -0,0 +1,423 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.demo; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkStateNotNull; + +import android.content.Context; +import android.content.DialogInterface; +import android.net.Uri; +import android.os.AsyncTask; +import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.fragment.app.FragmentManager; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.RenderersFactory; +import com.google.android.exoplayer2.drm.DrmInitData; +import com.google.android.exoplayer2.drm.DrmSession; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; +import com.google.android.exoplayer2.drm.OfflineLicenseHelper; +import com.google.android.exoplayer2.offline.Download; +import com.google.android.exoplayer2.offline.DownloadCursor; +import com.google.android.exoplayer2.offline.DownloadHelper; +import com.google.android.exoplayer2.offline.DownloadHelper.LiveContentUnsupportedException; +import com.google.android.exoplayer2.offline.DownloadIndex; +import com.google.android.exoplayer2.offline.DownloadManager; +import com.google.android.exoplayer2.offline.DownloadRequest; +import com.google.android.exoplayer2.offline.DownloadService; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; +import com.google.android.exoplayer2.upstream.HttpDataSource; +import com.google.android.exoplayer2.util.Log; +import com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.HashMap; +import java.util.concurrent.CopyOnWriteArraySet; + +/** Tracks media that has been downloaded. */ +public class DownloadTracker { + + /** Listens for changes in the tracked downloads. */ + public interface Listener { + + /** Called when the tracked downloads changed. */ + void onDownloadsChanged(); + } + + private static final String TAG = "DownloadTracker"; + + private final Context context; + private final HttpDataSource.Factory httpDataSourceFactory; + private final CopyOnWriteArraySet listeners; + private final HashMap downloads; + private final DownloadIndex downloadIndex; + private final DefaultTrackSelector.Parameters trackSelectorParameters; + + @Nullable private StartDownloadDialogHelper startDownloadDialogHelper; + + public DownloadTracker( + Context context, + HttpDataSource.Factory httpDataSourceFactory, + DownloadManager downloadManager) { + this.context = context.getApplicationContext(); + this.httpDataSourceFactory = httpDataSourceFactory; + listeners = new CopyOnWriteArraySet<>(); + downloads = new HashMap<>(); + downloadIndex = downloadManager.getDownloadIndex(); + trackSelectorParameters = DownloadHelper.getDefaultTrackSelectorParameters(context); + downloadManager.addListener(new DownloadManagerListener()); + loadDownloads(); + } + + public void addListener(Listener listener) { + checkNotNull(listener); + listeners.add(listener); + } + + public void removeListener(Listener listener) { + listeners.remove(listener); + } + + public boolean isDownloaded(MediaItem mediaItem) { + Download download = downloads.get(checkNotNull(mediaItem.playbackProperties).uri); + return download != null && download.state != Download.STATE_FAILED; + } + + @Nullable + public DownloadRequest getDownloadRequest(Uri uri) { + Download download = downloads.get(uri); + return download != null && download.state != Download.STATE_FAILED ? download.request : null; + } + + public void toggleDownload( + FragmentManager fragmentManager, MediaItem mediaItem, RenderersFactory renderersFactory) { + Download download = downloads.get(checkNotNull(mediaItem.playbackProperties).uri); + if (download != null) { + DownloadService.sendRemoveDownload( + context, DemoDownloadService.class, download.request.id, /* foreground= */ false); + } else { + if (startDownloadDialogHelper != null) { + startDownloadDialogHelper.release(); + } + startDownloadDialogHelper = + new StartDownloadDialogHelper( + fragmentManager, + DownloadHelper.forMediaItem( + context, mediaItem, renderersFactory, httpDataSourceFactory), + mediaItem); + } + } + + private void loadDownloads() { + try (DownloadCursor loadedDownloads = downloadIndex.getDownloads()) { + while (loadedDownloads.moveToNext()) { + Download download = loadedDownloads.getDownload(); + downloads.put(download.request.uri, download); + } + } catch (IOException e) { + Log.w(TAG, "Failed to query downloads", e); + } + } + + private class DownloadManagerListener implements DownloadManager.Listener { + + @Override + public void onDownloadChanged( + @NonNull DownloadManager downloadManager, + @NonNull Download download, + @Nullable Exception finalException) { + downloads.put(download.request.uri, download); + for (Listener listener : listeners) { + listener.onDownloadsChanged(); + } + } + + @Override + public void onDownloadRemoved( + @NonNull DownloadManager downloadManager, @NonNull Download download) { + downloads.remove(download.request.uri); + for (Listener listener : listeners) { + listener.onDownloadsChanged(); + } + } + } + + private final class StartDownloadDialogHelper + implements DownloadHelper.Callback, + DialogInterface.OnClickListener, + DialogInterface.OnDismissListener { + + private final FragmentManager fragmentManager; + private final DownloadHelper downloadHelper; + private final MediaItem mediaItem; + + private TrackSelectionDialog trackSelectionDialog; + private MappedTrackInfo mappedTrackInfo; + private WidevineOfflineLicenseFetchTask widevineOfflineLicenseFetchTask; + @Nullable private byte[] keySetId; + + public StartDownloadDialogHelper( + FragmentManager fragmentManager, DownloadHelper downloadHelper, MediaItem mediaItem) { + this.fragmentManager = fragmentManager; + this.downloadHelper = downloadHelper; + this.mediaItem = mediaItem; + downloadHelper.prepare(this); + } + + public void release() { + downloadHelper.release(); + if (trackSelectionDialog != null) { + trackSelectionDialog.dismiss(); + } + if (widevineOfflineLicenseFetchTask != null) { + widevineOfflineLicenseFetchTask.cancel(false); + } + } + + // DownloadHelper.Callback implementation. + + @Override + public void onPrepared(@NonNull DownloadHelper helper) { + @Nullable Format format = getFirstFormatWithDrmInitData(helper); + if (format == null) { + onDownloadPrepared(helper); + return; + } + + // The content is DRM protected. We need to acquire an offline license. + if (Util.SDK_INT < 18) { + Toast.makeText(context, R.string.error_drm_unsupported_before_api_18, Toast.LENGTH_LONG) + .show(); + Log.e(TAG, "Downloading DRM protected content is not supported on API versions below 18"); + return; + } + // TODO(internal b/163107948): Support cases where DrmInitData are not in the manifest. + if (!hasSchemaData(format.drmInitData)) { + Toast.makeText(context, R.string.download_start_error_offline_license, Toast.LENGTH_LONG) + .show(); + Log.e( + TAG, + "Downloading content where DRM scheme data is not located in the manifest is not" + + " supported"); + return; + } + widevineOfflineLicenseFetchTask = + new WidevineOfflineLicenseFetchTask( + format, + mediaItem.playbackProperties.drmConfiguration.licenseUri, + httpDataSourceFactory, + /* dialogHelper= */ this, + helper); + widevineOfflineLicenseFetchTask.execute(); + } + + @Override + public void onPrepareError(@NonNull DownloadHelper helper, @NonNull IOException e) { + boolean isLiveContent = e instanceof LiveContentUnsupportedException; + int toastStringId = + isLiveContent ? R.string.download_live_unsupported : R.string.download_start_error; + String logMessage = + isLiveContent ? "Downloading live content unsupported" : "Failed to start download"; + Toast.makeText(context, toastStringId, Toast.LENGTH_LONG).show(); + Log.e(TAG, logMessage, e); + } + + // DialogInterface.OnClickListener implementation. + + @Override + public void onClick(DialogInterface dialog, int which) { + for (int periodIndex = 0; periodIndex < downloadHelper.getPeriodCount(); periodIndex++) { + downloadHelper.clearTrackSelections(periodIndex); + for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) { + if (!trackSelectionDialog.getIsDisabled(/* rendererIndex= */ i)) { + downloadHelper.addTrackSelectionForSingleRenderer( + periodIndex, + /* rendererIndex= */ i, + trackSelectorParameters, + trackSelectionDialog.getOverrides(/* rendererIndex= */ i)); + } + } + } + DownloadRequest downloadRequest = buildDownloadRequest(); + if (downloadRequest.streamKeys.isEmpty()) { + // All tracks were deselected in the dialog. Don't start the download. + return; + } + startDownload(downloadRequest); + } + + // DialogInterface.OnDismissListener implementation. + + @Override + public void onDismiss(DialogInterface dialogInterface) { + trackSelectionDialog = null; + downloadHelper.release(); + } + + // Internal methods. + + /** + * Returns the first {@link Format} with a non-null {@link Format#drmInitData} found in the + * content's tracks, or null if none is found. + */ + @Nullable + private Format getFirstFormatWithDrmInitData(DownloadHelper helper) { + for (int periodIndex = 0; periodIndex < helper.getPeriodCount(); periodIndex++) { + MappedTrackInfo mappedTrackInfo = helper.getMappedTrackInfo(periodIndex); + for (int rendererIndex = 0; + rendererIndex < mappedTrackInfo.getRendererCount(); + rendererIndex++) { + TrackGroupArray trackGroups = mappedTrackInfo.getTrackGroups(rendererIndex); + for (int trackGroupIndex = 0; trackGroupIndex < trackGroups.length; trackGroupIndex++) { + TrackGroup trackGroup = trackGroups.get(trackGroupIndex); + for (int formatIndex = 0; formatIndex < trackGroup.length; formatIndex++) { + Format format = trackGroup.getFormat(formatIndex); + if (format.drmInitData != null) { + return format; + } + } + } + } + } + return null; + } + + private void onOfflineLicenseFetched(DownloadHelper helper, byte[] keySetId) { + this.keySetId = keySetId; + onDownloadPrepared(helper); + } + + private void onOfflineLicenseFetchedError(DrmSession.DrmSessionException e) { + Toast.makeText(context, R.string.download_start_error_offline_license, Toast.LENGTH_LONG) + .show(); + Log.e(TAG, "Failed to fetch offline DRM license", e); + } + + private void onDownloadPrepared(DownloadHelper helper) { + if (helper.getPeriodCount() == 0) { + Log.d(TAG, "No periods found. Downloading entire stream."); + startDownload(); + downloadHelper.release(); + return; + } + + mappedTrackInfo = downloadHelper.getMappedTrackInfo(/* periodIndex= */ 0); + if (!TrackSelectionDialog.willHaveContent(mappedTrackInfo)) { + Log.d(TAG, "No dialog content. Downloading entire stream."); + startDownload(); + downloadHelper.release(); + return; + } + trackSelectionDialog = + TrackSelectionDialog.createForMappedTrackInfoAndParameters( + /* titleId= */ R.string.exo_download_description, + mappedTrackInfo, + trackSelectorParameters, + /* allowAdaptiveSelections =*/ false, + /* allowMultipleOverrides= */ true, + /* onClickListener= */ this, + /* onDismissListener= */ this); + trackSelectionDialog.show(fragmentManager, /* tag= */ null); + } + + /** + * Returns whether any the {@link DrmInitData.SchemeData} contained in {@code drmInitData} has + * non-null {@link DrmInitData.SchemeData#data}. + */ + private boolean hasSchemaData(DrmInitData drmInitData) { + for (int i = 0; i < drmInitData.schemeDataCount; i++) { + if (drmInitData.get(i).hasData()) { + return true; + } + } + return false; + } + + private void startDownload() { + startDownload(buildDownloadRequest()); + } + + private void startDownload(DownloadRequest downloadRequest) { + DownloadService.sendAddDownload( + context, DemoDownloadService.class, downloadRequest, /* foreground= */ false); + } + + private DownloadRequest buildDownloadRequest() { + return downloadHelper + .getDownloadRequest(Util.getUtf8Bytes(checkNotNull(mediaItem.mediaMetadata.title))) + .copyWithKeySetId(keySetId); + } + } + + /** Downloads a Widevine offline license in a background thread. */ + @RequiresApi(18) + private static final class WidevineOfflineLicenseFetchTask extends AsyncTask { + + private final Format format; + private final Uri licenseUri; + private final HttpDataSource.Factory httpDataSourceFactory; + private final StartDownloadDialogHelper dialogHelper; + private final DownloadHelper downloadHelper; + + @Nullable private byte[] keySetId; + @Nullable private DrmSession.DrmSessionException drmSessionException; + + public WidevineOfflineLicenseFetchTask( + Format format, + Uri licenseUri, + HttpDataSource.Factory httpDataSourceFactory, + StartDownloadDialogHelper dialogHelper, + DownloadHelper downloadHelper) { + this.format = format; + this.licenseUri = licenseUri; + this.httpDataSourceFactory = httpDataSourceFactory; + this.dialogHelper = dialogHelper; + this.downloadHelper = downloadHelper; + } + + @Override + protected Void doInBackground(Void... voids) { + OfflineLicenseHelper offlineLicenseHelper = + OfflineLicenseHelper.newWidevineInstance( + licenseUri.toString(), + httpDataSourceFactory, + new DrmSessionEventListener.EventDispatcher()); + try { + keySetId = offlineLicenseHelper.downloadLicense(format); + } catch (DrmSession.DrmSessionException e) { + drmSessionException = e; + } finally { + offlineLicenseHelper.release(); + } + return null; + } + + @Override + protected void onPostExecute(Void aVoid) { + if (drmSessionException != null) { + dialogHelper.onOfflineLicenseFetchedError(drmSessionException); + } else { + dialogHelper.onOfflineLicenseFetched(downloadHelper, checkStateNotNull(keySetId)); + } + } + } +} diff --git a/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/IntentUtil.java b/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/IntentUtil.java new file mode 100644 index 00000000..d2d962c5 --- /dev/null +++ b/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/IntentUtil.java @@ -0,0 +1,230 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.demo; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkState; + +import android.content.Intent; +import android.net.Uri; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Util to read from and populate an intent. */ +public class IntentUtil { + + // Actions. + + public static final String ACTION_VIEW = "com.google.android.exoplayer.demo.action.VIEW"; + public static final String ACTION_VIEW_LIST = + "com.google.android.exoplayer.demo.action.VIEW_LIST"; + + // Activity extras. + + public static final String PREFER_EXTENSION_DECODERS_EXTRA = "prefer_extension_decoders"; + + // Media item configuration extras. + + public static final String URI_EXTRA = "uri"; + public static final String MIME_TYPE_EXTRA = "mime_type"; + public static final String CLIP_START_POSITION_MS_EXTRA = "clip_start_position_ms"; + public static final String CLIP_END_POSITION_MS_EXTRA = "clip_end_position_ms"; + + public static final String AD_TAG_URI_EXTRA = "ad_tag_uri"; + + public static final String DRM_SCHEME_EXTRA = "drm_scheme"; + public static final String DRM_LICENSE_URI_EXTRA = "drm_license_uri"; + public static final String DRM_KEY_REQUEST_PROPERTIES_EXTRA = "drm_key_request_properties"; + public static final String DRM_SESSION_FOR_CLEAR_CONTENT = "drm_session_for_clear_content"; + public static final String DRM_MULTI_SESSION_EXTRA = "drm_multi_session"; + public static final String DRM_FORCE_DEFAULT_LICENSE_URI_EXTRA = "drm_force_default_license_uri"; + + public static final String SUBTITLE_URI_EXTRA = "subtitle_uri"; + public static final String SUBTITLE_MIME_TYPE_EXTRA = "subtitle_mime_type"; + public static final String SUBTITLE_LANGUAGE_EXTRA = "subtitle_language"; + + /** Creates a list of {@link MediaItem media items} from an {@link Intent}. */ + public static List createMediaItemsFromIntent(Intent intent) { + List mediaItems = new ArrayList<>(); + if (ACTION_VIEW_LIST.equals(intent.getAction())) { + int index = 0; + while (intent.hasExtra(URI_EXTRA + "_" + index)) { + Uri uri = Uri.parse(intent.getStringExtra(URI_EXTRA + "_" + index)); + mediaItems.add(createMediaItemFromIntent(uri, intent, /* extrasKeySuffix= */ "_" + index)); + index++; + } + } else { + Uri uri = intent.getData(); + mediaItems.add(createMediaItemFromIntent(uri, intent, /* extrasKeySuffix= */ "")); + } + return mediaItems; + } + + /** Populates the intent with the given list of {@link MediaItem media items}. */ + public static void addToIntent(List mediaItems, Intent intent) { + Assertions.checkArgument(!mediaItems.isEmpty()); + if (mediaItems.size() == 1) { + MediaItem mediaItem = mediaItems.get(0); + MediaItem.PlaybackProperties playbackProperties = checkNotNull(mediaItem.playbackProperties); + intent.setAction(ACTION_VIEW).setData(mediaItem.playbackProperties.uri); + addPlaybackPropertiesToIntent(playbackProperties, intent, /* extrasKeySuffix= */ ""); + addClippingPropertiesToIntent( + mediaItem.clippingProperties, intent, /* extrasKeySuffix= */ ""); + } else { + intent.setAction(ACTION_VIEW_LIST); + for (int i = 0; i < mediaItems.size(); i++) { + MediaItem mediaItem = mediaItems.get(i); + MediaItem.PlaybackProperties playbackProperties = + checkNotNull(mediaItem.playbackProperties); + intent.putExtra(URI_EXTRA + ("_" + i), playbackProperties.uri.toString()); + addPlaybackPropertiesToIntent(playbackProperties, intent, /* extrasKeySuffix= */ "_" + i); + addClippingPropertiesToIntent( + mediaItem.clippingProperties, intent, /* extrasKeySuffix= */ "_" + i); + } + } + } + + private static MediaItem createMediaItemFromIntent( + Uri uri, Intent intent, String extrasKeySuffix) { + @Nullable String mimeType = intent.getStringExtra(MIME_TYPE_EXTRA + extrasKeySuffix); + MediaItem.Builder builder = + new MediaItem.Builder() + .setUri(uri) + .setMimeType(mimeType) + .setAdTagUri(intent.getStringExtra(AD_TAG_URI_EXTRA + extrasKeySuffix)) + .setSubtitles(createSubtitlesFromIntent(intent, extrasKeySuffix)) + .setClipStartPositionMs( + intent.getLongExtra(CLIP_START_POSITION_MS_EXTRA + extrasKeySuffix, 0)) + .setClipEndPositionMs( + intent.getLongExtra( + CLIP_END_POSITION_MS_EXTRA + extrasKeySuffix, C.TIME_END_OF_SOURCE)); + + return populateDrmPropertiesFromIntent(builder, intent, extrasKeySuffix).build(); + } + + private static List createSubtitlesFromIntent( + Intent intent, String extrasKeySuffix) { + if (!intent.hasExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix)) { + return Collections.emptyList(); + } + return Collections.singletonList( + new MediaItem.Subtitle( + Uri.parse(intent.getStringExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix)), + checkNotNull(intent.getStringExtra(SUBTITLE_MIME_TYPE_EXTRA + extrasKeySuffix)), + intent.getStringExtra(SUBTITLE_LANGUAGE_EXTRA + extrasKeySuffix), + C.SELECTION_FLAG_DEFAULT)); + } + + private static MediaItem.Builder populateDrmPropertiesFromIntent( + MediaItem.Builder builder, Intent intent, String extrasKeySuffix) { + String schemeKey = DRM_SCHEME_EXTRA + extrasKeySuffix; + @Nullable String drmSchemeExtra = intent.getStringExtra(schemeKey); + if (drmSchemeExtra == null) { + return builder; + } + Map headers = new HashMap<>(); + @Nullable + String[] keyRequestPropertiesArray = + intent.getStringArrayExtra(DRM_KEY_REQUEST_PROPERTIES_EXTRA + extrasKeySuffix); + if (keyRequestPropertiesArray != null) { + for (int i = 0; i < keyRequestPropertiesArray.length; i += 2) { + headers.put(keyRequestPropertiesArray[i], keyRequestPropertiesArray[i + 1]); + } + } + builder + .setDrmUuid(Util.getDrmUuid(Util.castNonNull(drmSchemeExtra))) + .setDrmLicenseUri(intent.getStringExtra(DRM_LICENSE_URI_EXTRA + extrasKeySuffix)) + .setDrmMultiSession( + intent.getBooleanExtra(DRM_MULTI_SESSION_EXTRA + extrasKeySuffix, false)) + .setDrmForceDefaultLicenseUri( + intent.getBooleanExtra(DRM_FORCE_DEFAULT_LICENSE_URI_EXTRA + extrasKeySuffix, false)) + .setDrmLicenseRequestHeaders(headers); + if (intent.getBooleanExtra(DRM_SESSION_FOR_CLEAR_CONTENT + extrasKeySuffix, false)) { + builder.setDrmSessionForClearTypes(ImmutableList.of(C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_AUDIO)); + } + return builder; + } + + private static void addPlaybackPropertiesToIntent( + MediaItem.PlaybackProperties playbackProperties, Intent intent, String extrasKeySuffix) { + intent + .putExtra(MIME_TYPE_EXTRA + extrasKeySuffix, playbackProperties.mimeType) + .putExtra( + AD_TAG_URI_EXTRA + extrasKeySuffix, + playbackProperties.adTagUri != null ? playbackProperties.adTagUri.toString() : null); + if (playbackProperties.drmConfiguration != null) { + addDrmConfigurationToIntent(playbackProperties.drmConfiguration, intent, extrasKeySuffix); + } + if (!playbackProperties.subtitles.isEmpty()) { + checkState(playbackProperties.subtitles.size() == 1); + MediaItem.Subtitle subtitle = playbackProperties.subtitles.get(0); + intent.putExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix, subtitle.uri.toString()); + intent.putExtra(SUBTITLE_MIME_TYPE_EXTRA + extrasKeySuffix, subtitle.mimeType); + intent.putExtra(SUBTITLE_LANGUAGE_EXTRA + extrasKeySuffix, subtitle.language); + } + } + + private static void addDrmConfigurationToIntent( + MediaItem.DrmConfiguration drmConfiguration, Intent intent, String extrasKeySuffix) { + intent.putExtra(DRM_SCHEME_EXTRA + extrasKeySuffix, drmConfiguration.uuid.toString()); + intent.putExtra( + DRM_LICENSE_URI_EXTRA + extrasKeySuffix, + drmConfiguration.licenseUri != null ? drmConfiguration.licenseUri.toString() : null); + intent.putExtra(DRM_MULTI_SESSION_EXTRA + extrasKeySuffix, drmConfiguration.multiSession); + intent.putExtra( + DRM_FORCE_DEFAULT_LICENSE_URI_EXTRA + extrasKeySuffix, + drmConfiguration.forceDefaultLicenseUri); + + String[] drmKeyRequestProperties = new String[drmConfiguration.requestHeaders.size() * 2]; + int index = 0; + for (Map.Entry entry : drmConfiguration.requestHeaders.entrySet()) { + drmKeyRequestProperties[index++] = entry.getKey(); + drmKeyRequestProperties[index++] = entry.getValue(); + } + intent.putExtra(DRM_KEY_REQUEST_PROPERTIES_EXTRA + extrasKeySuffix, drmKeyRequestProperties); + + List drmSessionForClearTypes = drmConfiguration.sessionForClearTypes; + if (!drmSessionForClearTypes.isEmpty()) { + // Only video and audio together are supported. + Assertions.checkState( + drmSessionForClearTypes.size() == 2 + && drmSessionForClearTypes.contains(C.TRACK_TYPE_VIDEO) + && drmSessionForClearTypes.contains(C.TRACK_TYPE_AUDIO)); + intent.putExtra(DRM_SESSION_FOR_CLEAR_CONTENT + extrasKeySuffix, true); + } + } + + private static void addClippingPropertiesToIntent( + MediaItem.ClippingProperties clippingProperties, Intent intent, String extrasKeySuffix) { + if (clippingProperties.startPositionMs != 0) { + intent.putExtra( + CLIP_START_POSITION_MS_EXTRA + extrasKeySuffix, clippingProperties.startPositionMs); + } + if (clippingProperties.endPositionMs != C.TIME_END_OF_SOURCE) { + intent.putExtra( + CLIP_END_POSITION_MS_EXTRA + extrasKeySuffix, clippingProperties.endPositionMs); + } + } +} diff --git a/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/PlayerActivity.java new file mode 100644 index 00000000..9e6dd00b --- /dev/null +++ b/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -0,0 +1,564 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.demo; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.graphics.Point; +import android.net.Uri; +import android.os.Bundle; +import android.util.Pair; +import android.view.KeyEvent; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.PlaybackPreparer; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.RenderersFactory; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.audio.AudioAttributes; +import com.google.android.exoplayer2.drm.FrameworkMediaDrm; +import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer.DecoderInitializationException; +import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException; +import com.google.android.exoplayer2.offline.DownloadRequest; +import com.google.android.exoplayer2.source.BehindLiveWindowException; +import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; +import com.google.android.exoplayer2.source.MediaSourceFactory; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.source.ads.AdsLoader; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.ui.DebugTextViewHelper; +import com.google.android.exoplayer2.ui.StyledPlayerControlView; +import com.google.android.exoplayer2.ui.StyledPlayerView; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.util.ErrorMessageProvider; +import com.google.android.exoplayer2.util.EventLogger; +import com.google.android.exoplayer2.util.Util; +import com.mux.stats.sdk.core.MuxSDKViewOrientation; +import com.mux.stats.sdk.core.model.CustomerPlayerData; +import com.mux.stats.sdk.core.model.CustomerVideoData; +import com.mux.stats.sdk.muxstats.MuxStatsExoPlayer; +import java.net.CookieHandler; +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** An activity that plays media using {@link SimpleExoPlayer}. */ +public class PlayerActivity extends AppCompatActivity + implements OnClickListener, PlaybackPreparer, StyledPlayerControlView.VisibilityListener { + + // Saved instance state keys. + + private static final String KEY_TRACK_SELECTOR_PARAMETERS = "track_selector_parameters"; + private static final String KEY_WINDOW = "window"; + private static final String KEY_POSITION = "position"; + private static final String KEY_AUTO_PLAY = "auto_play"; + + private static final String VIDEO_TITLE_EXTRA = "video_title"; + + private static final CookieManager DEFAULT_COOKIE_MANAGER; + + static { + DEFAULT_COOKIE_MANAGER = new CookieManager(); + DEFAULT_COOKIE_MANAGER.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER); + } + + protected StyledPlayerView playerView; + protected LinearLayout debugRootView; + protected TextView debugTextView; + protected SimpleExoPlayer player; + + private boolean isShowingTrackSelectionDialog; + private Button selectTracksButton; + private DataSource.Factory dataSourceFactory; + private List mediaItems; + private DefaultTrackSelector trackSelector; + private DefaultTrackSelector.Parameters trackSelectorParameters; + private DebugTextViewHelper debugViewHelper; + private TrackGroupArray lastSeenTrackGroupArray; + private boolean startAutoPlay; + private int startWindow; + private long startPosition; + + // Fields used only for ad playback. + + private MuxStatsExoPlayer muxStats; + private AdsLoader adsLoader; + private Uri loadedAdTagUri; + + // Activity lifecycle + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + dataSourceFactory = DemoUtil.getDataSourceFactory(/* context= */ this); + if (CookieHandler.getDefault() != DEFAULT_COOKIE_MANAGER) { + CookieHandler.setDefault(DEFAULT_COOKIE_MANAGER); + } + + setContentView(); + debugRootView = findViewById(R.id.controls_root); + debugTextView = findViewById(R.id.debug_text_view); + selectTracksButton = findViewById(R.id.select_tracks_button); + selectTracksButton.setOnClickListener(this); + + playerView = findViewById(R.id.player_view); + playerView.setControllerVisibilityListener(this); + playerView.setErrorMessageProvider(new PlayerErrorMessageProvider()); + playerView.requestFocus(); + + if (savedInstanceState != null) { + trackSelectorParameters = savedInstanceState.getParcelable(KEY_TRACK_SELECTOR_PARAMETERS); + startAutoPlay = savedInstanceState.getBoolean(KEY_AUTO_PLAY); + startWindow = savedInstanceState.getInt(KEY_WINDOW); + startPosition = savedInstanceState.getLong(KEY_POSITION); + } else { + DefaultTrackSelector.ParametersBuilder builder = + new DefaultTrackSelector.ParametersBuilder(/* context= */ this); + trackSelectorParameters = builder.build(); + clearStartPosition(); + } + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + if (muxStats == null) { + return; + } + if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) { + muxStats.orientationChange(MuxSDKViewOrientation.LANDSCAPE); + } + if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) { + muxStats.orientationChange(MuxSDKViewOrientation.PORTRAIT); + } + } + + @Override + public void onNewIntent(Intent intent) { + super.onNewIntent(intent); + releasePlayer(); + clearStartPosition(); + setIntent(intent); + } + + @Override + public void onStart() { + super.onStart(); + if (Util.SDK_INT > 23) { + initializePlayer(); + if (playerView != null) { + playerView.onResume(); + } + } + } + + @Override + public void onResume() { + super.onResume(); + if (Util.SDK_INT <= 23 || player == null) { + initializePlayer(); + if (playerView != null) { + playerView.onResume(); + } + } + } + + @Override + public void onPause() { + super.onPause(); + if (Util.SDK_INT <= 23) { + if (playerView != null) { + playerView.onPause(); + } + releasePlayer(); + } + } + + @Override + public void onStop() { + super.onStop(); + if (Util.SDK_INT > 23) { + if (playerView != null) { + playerView.onPause(); + } + releasePlayer(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + } + + @Override + public void onRequestPermissionsResult( + int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (grantResults.length == 0) { + // Empty results are triggered if a permission is requested while another request was already + // pending and can be safely ignored in this case. + return; + } + if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { + initializePlayer(); + } else { + showToast(R.string.storage_permission_denied); + finish(); + } + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + updateTrackSelectorParameters(); + updateStartPosition(); + outState.putParcelable(KEY_TRACK_SELECTOR_PARAMETERS, trackSelectorParameters); + outState.putBoolean(KEY_AUTO_PLAY, startAutoPlay); + outState.putInt(KEY_WINDOW, startWindow); + outState.putLong(KEY_POSITION, startPosition); + } + + // Activity input + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + // See whether the player view wants to handle media or DPAD keys events. + return playerView.dispatchKeyEvent(event) || super.dispatchKeyEvent(event); + } + + // OnClickListener methods + + @Override + public void onClick(View view) { + if (view == selectTracksButton + && !isShowingTrackSelectionDialog + && TrackSelectionDialog.willHaveContent(trackSelector)) { + isShowingTrackSelectionDialog = true; + TrackSelectionDialog trackSelectionDialog = + TrackSelectionDialog.createForTrackSelector( + trackSelector, + /* onDismissListener= */ dismissedDialog -> isShowingTrackSelectionDialog = false); + trackSelectionDialog.show(getSupportFragmentManager(), /* tag= */ null); + } + } + + // PlaybackPreparer implementation + + @Override + public void preparePlayback() { + player.prepare(); + } + + // PlayerControlView.VisibilityListener implementation + + @Override + public void onVisibilityChange(int visibility) { + debugRootView.setVisibility(visibility); + } + + // Internal methods + + protected void setContentView() { + setContentView(R.layout.player_activity); + } + + /** @return Whether initialization was successful. */ + protected boolean initializePlayer() { + if (player == null) { + Intent intent = getIntent(); + + mediaItems = createMediaItems(intent); + if (mediaItems.isEmpty()) { + return false; + } + + boolean preferExtensionDecoders = + intent.getBooleanExtra(IntentUtil.PREFER_EXTENSION_DECODERS_EXTRA, false); + RenderersFactory renderersFactory = + DemoUtil.buildRenderersFactory(/* context= */ this, preferExtensionDecoders); + MediaSourceFactory mediaSourceFactory = + new DefaultMediaSourceFactory(dataSourceFactory) + .setAdViewProvider(playerView); + + trackSelector = new DefaultTrackSelector(/* context= */ this); + trackSelector.setParameters(trackSelectorParameters); + lastSeenTrackGroupArray = null; + player = + new SimpleExoPlayer.Builder(/* context= */ this, renderersFactory) + .setMediaSourceFactory(mediaSourceFactory) + .setTrackSelector(trackSelector) + .build(); + player.addListener(new PlayerEventListener()); + player.addAnalyticsListener(new EventLogger(trackSelector)); + player.setAudioAttributes(AudioAttributes.DEFAULT, /* handleAudioFocus= */ true); + player.setPlayWhenReady(startAutoPlay); + playerView.setPlayer(player); + playerView.setPlaybackPreparer(this); + debugViewHelper = new DebugTextViewHelper(player, debugTextView); + debugViewHelper.start(); + + CustomerPlayerData customerPlayerData = new CustomerPlayerData(); + customerPlayerData.setEnvironmentKey("YOUR_ENVIRONMENT_KEY_HERE"); + CustomerVideoData customerVideoData = new CustomerVideoData(); + customerVideoData.setVideoTitle(intent.getStringExtra(VIDEO_TITLE_EXTRA)); + muxStats = new MuxStatsExoPlayer( + this, player, "demo-player", customerPlayerData, customerVideoData); + Point size = new Point(); + getWindowManager().getDefaultDisplay().getSize(size); + muxStats.setScreenSize(size.x, size.y); + muxStats.setPlayerView(playerView); + muxStats.enableMuxCoreDebug(true, false); + } + boolean haveStartPosition = startWindow != C.INDEX_UNSET; + if (haveStartPosition) { + player.seekTo(startWindow, startPosition); + } + player.setMediaItems(mediaItems, /* resetPosition= */ !haveStartPosition); + player.prepare(); + updateButtonVisibility(); + return true; + } + + private List createMediaItems(Intent intent) { + String action = intent.getAction(); + boolean actionIsListView = IntentUtil.ACTION_VIEW_LIST.equals(action); + if (!actionIsListView && !IntentUtil.ACTION_VIEW.equals(action)) { + showToast(getString(R.string.unexpected_intent_action, action)); + finish(); + return Collections.emptyList(); + } + + List mediaItems = + createMediaItems(intent, DemoUtil.getDownloadTracker(/* context= */ this)); + boolean hasAds = false; + for (int i = 0; i < mediaItems.size(); i++) { + MediaItem mediaItem = mediaItems.get(i); + + if (!Util.checkCleartextTrafficPermitted(mediaItem)) { + showToast(R.string.error_cleartext_not_permitted); + return Collections.emptyList(); + } + if (Util.maybeRequestReadExternalStoragePermission(/* activity= */ this, mediaItem)) { + // The player will be reinitialized if the permission is granted. + return Collections.emptyList(); + } + + MediaItem.DrmConfiguration drmConfiguration = + checkNotNull(mediaItem.playbackProperties).drmConfiguration; + if (drmConfiguration != null) { + if (Util.SDK_INT < 18) { + showToast(R.string.error_drm_unsupported_before_api_18); + finish(); + return Collections.emptyList(); + } else if (!FrameworkMediaDrm.isCryptoSchemeSupported(drmConfiguration.uuid)) { + showToast(R.string.error_drm_unsupported_scheme); + finish(); + return Collections.emptyList(); + } + } + hasAds |= mediaItem.playbackProperties.adTagUri != null; + } + return mediaItems; + } + + protected void releasePlayer() { + if (player != null) { + updateTrackSelectorParameters(); + updateStartPosition(); + debugViewHelper.stop(); + debugViewHelper = null; + player.release(); + player = null; + mediaItems = Collections.emptyList(); + trackSelector = null; + } + if (adsLoader != null) { + adsLoader.setPlayer(null); + } + } + + private void updateTrackSelectorParameters() { + if (trackSelector != null) { + trackSelectorParameters = trackSelector.getParameters(); + } + } + + private void updateStartPosition() { + if (player != null) { + startAutoPlay = player.getPlayWhenReady(); + startWindow = player.getCurrentWindowIndex(); + startPosition = Math.max(0, player.getContentPosition()); + } + } + + protected void clearStartPosition() { + startAutoPlay = true; + startWindow = C.INDEX_UNSET; + startPosition = C.TIME_UNSET; + } + + // User controls + + private void updateButtonVisibility() { + selectTracksButton.setEnabled( + player != null && TrackSelectionDialog.willHaveContent(trackSelector)); + } + + private void showControls() { + debugRootView.setVisibility(View.VISIBLE); + } + + private void showToast(int messageId) { + showToast(getString(messageId)); + } + + private void showToast(String message) { + Toast.makeText(getApplicationContext(), message, Toast.LENGTH_LONG).show(); + } + + private static boolean isBehindLiveWindow(ExoPlaybackException e) { + if (e.type != ExoPlaybackException.TYPE_SOURCE) { + return false; + } + Throwable cause = e.getSourceException(); + while (cause != null) { + if (cause instanceof BehindLiveWindowException) { + return true; + } + cause = cause.getCause(); + } + return false; + } + + private class PlayerEventListener implements Player.EventListener { + + @Override + public void onPlaybackStateChanged(@Player.State int playbackState) { + if (playbackState == Player.STATE_ENDED) { + showControls(); + } + updateButtonVisibility(); + } + + @Override + public void onPlayerError(@NonNull ExoPlaybackException e) { + if (isBehindLiveWindow(e)) { + clearStartPosition(); + initializePlayer(); + } else { + updateButtonVisibility(); + showControls(); + } + } + + @Override + @SuppressWarnings("ReferenceEquality") + public void onTracksChanged( + @NonNull TrackGroupArray trackGroups, @NonNull TrackSelectionArray trackSelections) { + updateButtonVisibility(); + if (trackGroups != lastSeenTrackGroupArray) { + MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo(); + if (mappedTrackInfo != null) { + if (mappedTrackInfo.getTypeSupport(C.TRACK_TYPE_VIDEO) + == MappedTrackInfo.RENDERER_SUPPORT_UNSUPPORTED_TRACKS) { + showToast(R.string.error_unsupported_video); + } + if (mappedTrackInfo.getTypeSupport(C.TRACK_TYPE_AUDIO) + == MappedTrackInfo.RENDERER_SUPPORT_UNSUPPORTED_TRACKS) { + showToast(R.string.error_unsupported_audio); + } + } + lastSeenTrackGroupArray = trackGroups; + } + } + } + + private class PlayerErrorMessageProvider implements ErrorMessageProvider { + + @Override + @NonNull + public Pair getErrorMessage(@NonNull ExoPlaybackException e) { + String errorString = getString(R.string.error_generic); + if (e.type == ExoPlaybackException.TYPE_RENDERER) { + Exception cause = e.getRendererException(); + if (cause instanceof DecoderInitializationException) { + // Special case for decoder initialization failures. + DecoderInitializationException decoderInitializationException = + (DecoderInitializationException) cause; + if (decoderInitializationException.codecInfo == null) { + if (decoderInitializationException.getCause() instanceof DecoderQueryException) { + errorString = getString(R.string.error_querying_decoders); + } else if (decoderInitializationException.secureDecoderRequired) { + errorString = + getString( + R.string.error_no_secure_decoder, decoderInitializationException.mimeType); + } else { + errorString = + getString(R.string.error_no_decoder, decoderInitializationException.mimeType); + } + } else { + errorString = + getString( + R.string.error_instantiating_decoder, + decoderInitializationException.codecInfo.name); + } + } + } + return Pair.create(0, errorString); + } + } + + private static List createMediaItems(Intent intent, DownloadTracker downloadTracker) { + List mediaItems = new ArrayList<>(); + for (MediaItem item : IntentUtil.createMediaItemsFromIntent(intent)) { + @Nullable + DownloadRequest downloadRequest = + downloadTracker.getDownloadRequest(checkNotNull(item.playbackProperties).uri); + if (downloadRequest != null) { + MediaItem.Builder builder = item.buildUpon(); + builder + .setMediaId(downloadRequest.id) + .setUri(downloadRequest.uri) + .setCustomCacheKey(downloadRequest.customCacheKey) + .setMimeType(downloadRequest.mimeType) + .setStreamKeys(downloadRequest.streamKeys) + .setDrmKeySetId(downloadRequest.keySetId); + mediaItems.add(builder.build()); + } else { + mediaItems.add(item); + } + } + return mediaItems; + } +} diff --git a/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java b/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java new file mode 100644 index 00000000..ea5b38ce --- /dev/null +++ b/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java @@ -0,0 +1,591 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.demo; + +import static com.google.android.exoplayer2.util.Assertions.checkArgument; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkState; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.res.AssetManager; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.util.JsonReader; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.BaseExpandableListAdapter; +import android.widget.ExpandableListView; +import android.widget.ExpandableListView.OnChildClickListener; +import android.widget.ImageButton; +import android.widget.TextView; +import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.MediaMetadata; +import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.RenderersFactory; +import com.google.android.exoplayer2.offline.DownloadService; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSourceInputStream; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.util.Log; +import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** An activity for selecting from a list of media samples. */ +public class SampleChooserActivity extends AppCompatActivity + implements DownloadTracker.Listener, OnChildClickListener { + + private static final String TAG = "SampleChooserActivity"; + private static final String GROUP_POSITION_PREFERENCE_KEY = "sample_chooser_group_position"; + private static final String CHILD_POSITION_PREFERENCE_KEY = "sample_chooser_child_position"; + + private String[] uris; + private boolean useExtensionRenderers; + private DownloadTracker downloadTracker; + private SampleAdapter sampleAdapter; + private MenuItem preferExtensionDecodersMenuItem; + private ExpandableListView sampleListView; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.sample_chooser_activity); + sampleAdapter = new SampleAdapter(); + sampleListView = findViewById(R.id.sample_list); + + sampleListView.setAdapter(sampleAdapter); + sampleListView.setOnChildClickListener(this); + + Intent intent = getIntent(); + String dataUri = intent.getDataString(); + if (dataUri != null) { + uris = new String[] {dataUri}; + } else { + ArrayList uriList = new ArrayList<>(); + AssetManager assetManager = getAssets(); + try { + for (String asset : assetManager.list("")) { + if (asset.endsWith(".exolist.json")) { + uriList.add("asset:///" + asset); + } + } + } catch (IOException e) { + Toast.makeText(getApplicationContext(), R.string.sample_list_load_error, Toast.LENGTH_LONG) + .show(); + } + uris = new String[uriList.size()]; + uriList.toArray(uris); + Arrays.sort(uris); + } + + useExtensionRenderers = DemoUtil.useExtensionRenderers(); + downloadTracker = DemoUtil.getDownloadTracker(/* context= */ this); + loadSample(); + + // Start the download service if it should be running but it's not currently. + // Starting the service in the foreground causes notification flicker if there is no scheduled + // action. Starting it in the background throws an exception if the app is in the background too + // (e.g. if device screen is locked). + try { + DownloadService.start(this, DemoDownloadService.class); + } catch (IllegalStateException e) { + DownloadService.startForeground(this, DemoDownloadService.class); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.sample_chooser_menu, menu); + preferExtensionDecodersMenuItem = menu.findItem(R.id.prefer_extension_decoders); + preferExtensionDecodersMenuItem.setVisible(useExtensionRenderers); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + item.setChecked(!item.isChecked()); + return true; + } + + @Override + public void onStart() { + super.onStart(); + downloadTracker.addListener(this); + sampleAdapter.notifyDataSetChanged(); + } + + @Override + public void onStop() { + downloadTracker.removeListener(this); + super.onStop(); + } + + @Override + public void onDownloadsChanged() { + sampleAdapter.notifyDataSetChanged(); + } + + @Override + public void onRequestPermissionsResult( + int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (grantResults.length == 0) { + // Empty results are triggered if a permission is requested while another request was already + // pending and can be safely ignored in this case. + return; + } + if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { + loadSample(); + } else { + Toast.makeText(getApplicationContext(), R.string.sample_list_load_error, Toast.LENGTH_LONG) + .show(); + finish(); + } + } + + private void loadSample() { + checkNotNull(uris); + + for (int i = 0; i < uris.length; i++) { + Uri uri = Uri.parse(uris[i]); + if (Util.maybeRequestReadExternalStoragePermission(this, uri)) { + return; + } + } + + SampleListLoader loaderTask = new SampleListLoader(); + loaderTask.execute(uris); + } + + private void onPlaylistGroups(final List groups, boolean sawError) { + if (sawError) { + Toast.makeText(getApplicationContext(), R.string.sample_list_load_error, Toast.LENGTH_LONG) + .show(); + } + sampleAdapter.setPlaylistGroups(groups); + + SharedPreferences preferences = getPreferences(MODE_PRIVATE); + int groupPosition = preferences.getInt(GROUP_POSITION_PREFERENCE_KEY, /* defValue= */ -1); + int childPosition = preferences.getInt(CHILD_POSITION_PREFERENCE_KEY, /* defValue= */ -1); + // Clear the group and child position if either are unset or if either are out of bounds. + if (groupPosition != -1 + && childPosition != -1 + && groupPosition < groups.size() + && childPosition < groups.get(groupPosition).playlists.size()) { + sampleListView.expandGroup(groupPosition); // shouldExpandGroup does not work without this. + sampleListView.setSelectedChild(groupPosition, childPosition, /* shouldExpandGroup= */ true); + } + } + + @Override + public boolean onChildClick( + ExpandableListView parent, View view, int groupPosition, int childPosition, long id) { + // Save the selected item first to be able to restore it if the tested code crashes. + SharedPreferences.Editor prefEditor = getPreferences(MODE_PRIVATE).edit(); + prefEditor.putInt(GROUP_POSITION_PREFERENCE_KEY, groupPosition); + prefEditor.putInt(CHILD_POSITION_PREFERENCE_KEY, childPosition); + prefEditor.apply(); + + PlaylistHolder playlistHolder = (PlaylistHolder) view.getTag(); + Intent intent = new Intent(this, PlayerActivity.class); + intent.putExtra( + IntentUtil.PREFER_EXTENSION_DECODERS_EXTRA, + isNonNullAndChecked(preferExtensionDecodersMenuItem)); + IntentUtil.addToIntent(playlistHolder.mediaItems, intent); + startActivity(intent); + return true; + } + + private void onSampleDownloadButtonClicked(PlaylistHolder playlistHolder) { + int downloadUnsupportedStringId = getDownloadUnsupportedStringId(playlistHolder); + if (downloadUnsupportedStringId != 0) { + Toast.makeText(getApplicationContext(), downloadUnsupportedStringId, Toast.LENGTH_LONG) + .show(); + } else { + RenderersFactory renderersFactory = + DemoUtil.buildRenderersFactory( + /* context= */ this, isNonNullAndChecked(preferExtensionDecodersMenuItem)); + downloadTracker.toggleDownload( + getSupportFragmentManager(), playlistHolder.mediaItems.get(0), renderersFactory); + } + } + + private int getDownloadUnsupportedStringId(PlaylistHolder playlistHolder) { + if (playlistHolder.mediaItems.size() > 1) { + return R.string.download_playlist_unsupported; + } + MediaItem.PlaybackProperties playbackProperties = + checkNotNull(playlistHolder.mediaItems.get(0).playbackProperties); + if (playbackProperties.adTagUri != null) { + return R.string.download_ads_unsupported; + } + String scheme = playbackProperties.uri.getScheme(); + if (!("http".equals(scheme) || "https".equals(scheme))) { + return R.string.download_scheme_unsupported; + } + return 0; + } + + private static boolean isNonNullAndChecked(@Nullable MenuItem menuItem) { + // Temporary workaround for layouts that do not inflate the options menu. + return menuItem != null && menuItem.isChecked(); + } + + private final class SampleListLoader extends AsyncTask> { + + private boolean sawError; + + @Override + protected List doInBackground(String... uris) { + List result = new ArrayList<>(); + Context context = getApplicationContext(); + DataSource dataSource = DemoUtil.getDataSourceFactory(context).createDataSource(); + for (String uri : uris) { + DataSpec dataSpec = new DataSpec(Uri.parse(uri)); + InputStream inputStream = new DataSourceInputStream(dataSource, dataSpec); + try { + readPlaylistGroups(new JsonReader(new InputStreamReader(inputStream, "UTF-8")), result); + } catch (Exception e) { + Log.e(TAG, "Error loading sample list: " + uri, e); + sawError = true; + } finally { + Util.closeQuietly(dataSource); + } + } + return result; + } + + @Override + protected void onPostExecute(List result) { + onPlaylistGroups(result, sawError); + } + + private void readPlaylistGroups(JsonReader reader, List groups) + throws IOException { + reader.beginArray(); + while (reader.hasNext()) { + readPlaylistGroup(reader, groups); + } + reader.endArray(); + } + + private void readPlaylistGroup(JsonReader reader, List groups) + throws IOException { + String groupName = ""; + ArrayList playlistHolders = new ArrayList<>(); + + reader.beginObject(); + while (reader.hasNext()) { + String name = reader.nextName(); + switch (name) { + case "name": + groupName = reader.nextString(); + break; + case "samples": + reader.beginArray(); + while (reader.hasNext()) { + playlistHolders.add(readEntry(reader, false)); + } + reader.endArray(); + break; + case "_comment": + reader.nextString(); // Ignore. + break; + default: + throw new ParserException("Unsupported name: " + name); + } + } + reader.endObject(); + + PlaylistGroup group = getGroup(groupName, groups); + group.playlists.addAll(playlistHolders); + } + + private PlaylistHolder readEntry(JsonReader reader, boolean insidePlaylist) throws IOException { + Uri uri = null; + String extension = null; + String title = null; + ArrayList children = null; + Uri subtitleUri = null; + String subtitleMimeType = null; + String subtitleLanguage = null; + + MediaItem.Builder mediaItem = new MediaItem.Builder(); + reader.beginObject(); + while (reader.hasNext()) { + String name = reader.nextName(); + switch (name) { + case "name": + title = reader.nextString(); + break; + case "uri": + uri = Uri.parse(reader.nextString()); + break; + case "extension": + extension = reader.nextString(); + break; + case "clip_start_position_ms": + mediaItem.setClipStartPositionMs(reader.nextLong()); + break; + case "clip_end_position_ms": + mediaItem.setClipEndPositionMs(reader.nextLong()); + break; + case "ad_tag_uri": + mediaItem.setAdTagUri(reader.nextString()); + break; + case "drm_scheme": + mediaItem.setDrmUuid(Util.getDrmUuid(reader.nextString())); + break; + case "drm_license_uri": + case "drm_license_url": // For backward compatibility only. + mediaItem.setDrmLicenseUri(reader.nextString()); + break; + case "drm_key_request_properties": + Map requestHeaders = new HashMap<>(); + reader.beginObject(); + while (reader.hasNext()) { + requestHeaders.put(reader.nextName(), reader.nextString()); + } + reader.endObject(); + mediaItem.setDrmLicenseRequestHeaders(requestHeaders); + break; + case "drm_session_for_clear_content": + if (reader.nextBoolean()) { + mediaItem.setDrmSessionForClearTypes( + ImmutableList.of(C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_AUDIO)); + } + break; + case "drm_multi_session": + mediaItem.setDrmMultiSession(reader.nextBoolean()); + break; + case "drm_force_default_license_uri": + mediaItem.setDrmForceDefaultLicenseUri(reader.nextBoolean()); + break; + case "subtitle_uri": + subtitleUri = Uri.parse(reader.nextString()); + break; + case "subtitle_mime_type": + subtitleMimeType = reader.nextString(); + break; + case "subtitle_language": + subtitleLanguage = reader.nextString(); + break; + case "playlist": + checkState(!insidePlaylist, "Invalid nesting of playlists"); + children = new ArrayList<>(); + reader.beginArray(); + while (reader.hasNext()) { + children.add(readEntry(reader, /* insidePlaylist= */ true)); + } + reader.endArray(); + break; + default: + throw new ParserException("Unsupported attribute name: " + name); + } + } + reader.endObject(); + + if (children != null) { + List mediaItems = new ArrayList<>(); + for (int i = 0; i < children.size(); i++) { + mediaItems.addAll(children.get(i).mediaItems); + } + return new PlaylistHolder(title, mediaItems); + } else { + @Nullable + String adaptiveMimeType = + Util.getAdaptiveMimeTypeForContentType(Util.inferContentType(uri, extension)); + mediaItem + .setUri(uri) + .setMediaMetadata(new MediaMetadata.Builder().setTitle(title).build()) + .setMimeType(adaptiveMimeType); + if (subtitleUri != null) { + MediaItem.Subtitle subtitle = + new MediaItem.Subtitle( + subtitleUri, + checkNotNull( + subtitleMimeType, "subtitle_mime_type is required if subtitle_uri is set."), + subtitleLanguage); + mediaItem.setSubtitles(Collections.singletonList(subtitle)); + } + return new PlaylistHolder(title, Collections.singletonList(mediaItem.build())); + } + } + + private PlaylistGroup getGroup(String groupName, List groups) { + for (int i = 0; i < groups.size(); i++) { + if (Util.areEqual(groupName, groups.get(i).title)) { + return groups.get(i); + } + } + PlaylistGroup group = new PlaylistGroup(groupName); + groups.add(group); + return group; + } + } + + private final class SampleAdapter extends BaseExpandableListAdapter implements OnClickListener { + + private List playlistGroups; + + public SampleAdapter() { + playlistGroups = Collections.emptyList(); + } + + public void setPlaylistGroups(List playlistGroups) { + this.playlistGroups = playlistGroups; + notifyDataSetChanged(); + } + + @Override + public PlaylistHolder getChild(int groupPosition, int childPosition) { + return getGroup(groupPosition).playlists.get(childPosition); + } + + @Override + public long getChildId(int groupPosition, int childPosition) { + return childPosition; + } + + @Override + public View getChildView( + int groupPosition, + int childPosition, + boolean isLastChild, + View convertView, + ViewGroup parent) { + View view = convertView; + if (view == null) { + view = getLayoutInflater().inflate(R.layout.sample_list_item, parent, false); + View downloadButton = view.findViewById(R.id.download_button); + downloadButton.setOnClickListener(this); + downloadButton.setFocusable(false); + } + initializeChildView(view, getChild(groupPosition, childPosition)); + return view; + } + + @Override + public int getChildrenCount(int groupPosition) { + return getGroup(groupPosition).playlists.size(); + } + + @Override + public PlaylistGroup getGroup(int groupPosition) { + return playlistGroups.get(groupPosition); + } + + @Override + public long getGroupId(int groupPosition) { + return groupPosition; + } + + @Override + public View getGroupView( + int groupPosition, boolean isExpanded, View convertView, ViewGroup parent) { + View view = convertView; + if (view == null) { + view = + getLayoutInflater() + .inflate(android.R.layout.simple_expandable_list_item_1, parent, false); + } + ((TextView) view).setText(getGroup(groupPosition).title); + return view; + } + + @Override + public int getGroupCount() { + return playlistGroups.size(); + } + + @Override + public boolean hasStableIds() { + return false; + } + + @Override + public boolean isChildSelectable(int groupPosition, int childPosition) { + return true; + } + + @Override + public void onClick(View view) { + onSampleDownloadButtonClicked((PlaylistHolder) view.getTag()); + } + + private void initializeChildView(View view, PlaylistHolder playlistHolder) { + view.setTag(playlistHolder); + TextView sampleTitle = view.findViewById(R.id.sample_title); + sampleTitle.setText(playlistHolder.title); + + boolean canDownload = getDownloadUnsupportedStringId(playlistHolder) == 0; + boolean isDownloaded = + canDownload && downloadTracker.isDownloaded(playlistHolder.mediaItems.get(0)); + ImageButton downloadButton = view.findViewById(R.id.download_button); + downloadButton.setTag(playlistHolder); + downloadButton.setColorFilter( + canDownload ? (isDownloaded ? 0xFF42A5F5 : 0xFFBDBDBD) : 0xFF666666); + downloadButton.setImageResource( + isDownloaded ? R.drawable.ic_download_done : R.drawable.ic_download); + } + } + + private static final class PlaylistHolder { + + public final String title; + public final List mediaItems; + + private PlaylistHolder(String title, List mediaItems) { + checkArgument(!mediaItems.isEmpty()); + this.title = title; + this.mediaItems = Collections.unmodifiableList(new ArrayList<>(mediaItems)); + } + } + + private static final class PlaylistGroup { + + public final String title; + public final List playlists; + + public PlaylistGroup(String title) { + this.title = title; + this.playlists = new ArrayList<>(); + } + } +} diff --git a/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/TrackSelectionDialog.java b/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/TrackSelectionDialog.java new file mode 100644 index 00000000..d3f9b388 --- /dev/null +++ b/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/TrackSelectionDialog.java @@ -0,0 +1,373 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.demo; + +import android.app.Dialog; +import android.content.DialogInterface; +import android.content.res.Resources; +import android.os.Bundle; +import android.util.SparseArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatDialog; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentPagerAdapter; +import androidx.viewpager.widget.ViewPager; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.SelectionOverride; +import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; +import com.google.android.exoplayer2.ui.TrackSelectionView; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.material.tabs.TabLayout; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Dialog to select tracks. */ +public final class TrackSelectionDialog extends DialogFragment { + + private final SparseArray tabFragments; + private final ArrayList tabTrackTypes; + + private int titleId; + private DialogInterface.OnClickListener onClickListener; + private DialogInterface.OnDismissListener onDismissListener; + + /** + * Returns whether a track selection dialog will have content to display if initialized with the + * specified {@link DefaultTrackSelector} in its current state. + */ + public static boolean willHaveContent(DefaultTrackSelector trackSelector) { + MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo(); + return mappedTrackInfo != null && willHaveContent(mappedTrackInfo); + } + + /** + * Returns whether a track selection dialog will have content to display if initialized with the + * specified {@link MappedTrackInfo}. + */ + public static boolean willHaveContent(MappedTrackInfo mappedTrackInfo) { + for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) { + if (showTabForRenderer(mappedTrackInfo, i)) { + return true; + } + } + return false; + } + + /** + * Creates a dialog for a given {@link DefaultTrackSelector}, whose parameters will be + * automatically updated when tracks are selected. + * + * @param trackSelector The {@link DefaultTrackSelector}. + * @param onDismissListener A {@link DialogInterface.OnDismissListener} to call when the dialog is + * dismissed. + */ + public static TrackSelectionDialog createForTrackSelector( + DefaultTrackSelector trackSelector, DialogInterface.OnDismissListener onDismissListener) { + MappedTrackInfo mappedTrackInfo = + Assertions.checkNotNull(trackSelector.getCurrentMappedTrackInfo()); + TrackSelectionDialog trackSelectionDialog = new TrackSelectionDialog(); + DefaultTrackSelector.Parameters parameters = trackSelector.getParameters(); + trackSelectionDialog.init( + /* titleId= */ R.string.track_selection_title, + mappedTrackInfo, + /* initialParameters = */ parameters, + /* allowAdaptiveSelections =*/ true, + /* allowMultipleOverrides= */ false, + /* onClickListener= */ (dialog, which) -> { + DefaultTrackSelector.ParametersBuilder builder = parameters.buildUpon(); + for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) { + builder + .clearSelectionOverrides(/* rendererIndex= */ i) + .setRendererDisabled( + /* rendererIndex= */ i, + trackSelectionDialog.getIsDisabled(/* rendererIndex= */ i)); + List overrides = + trackSelectionDialog.getOverrides(/* rendererIndex= */ i); + if (!overrides.isEmpty()) { + builder.setSelectionOverride( + /* rendererIndex= */ i, + mappedTrackInfo.getTrackGroups(/* rendererIndex= */ i), + overrides.get(0)); + } + } + trackSelector.setParameters(builder); + }, + onDismissListener); + return trackSelectionDialog; + } + + /** + * Creates a dialog for given {@link MappedTrackInfo} and {@link DefaultTrackSelector.Parameters}. + * + * @param titleId The resource id of the dialog title. + * @param mappedTrackInfo The {@link MappedTrackInfo} to display. + * @param initialParameters The {@link DefaultTrackSelector.Parameters} describing the initial + * track selection. + * @param allowAdaptiveSelections Whether adaptive selections (consisting of more than one track) + * can be made. + * @param allowMultipleOverrides Whether tracks from multiple track groups can be selected. + * @param onClickListener {@link DialogInterface.OnClickListener} called when tracks are selected. + * @param onDismissListener {@link DialogInterface.OnDismissListener} called when the dialog is + * dismissed. + */ + public static TrackSelectionDialog createForMappedTrackInfoAndParameters( + int titleId, + MappedTrackInfo mappedTrackInfo, + DefaultTrackSelector.Parameters initialParameters, + boolean allowAdaptiveSelections, + boolean allowMultipleOverrides, + DialogInterface.OnClickListener onClickListener, + DialogInterface.OnDismissListener onDismissListener) { + TrackSelectionDialog trackSelectionDialog = new TrackSelectionDialog(); + trackSelectionDialog.init( + titleId, + mappedTrackInfo, + initialParameters, + allowAdaptiveSelections, + allowMultipleOverrides, + onClickListener, + onDismissListener); + return trackSelectionDialog; + } + + public TrackSelectionDialog() { + tabFragments = new SparseArray<>(); + tabTrackTypes = new ArrayList<>(); + // Retain instance across activity re-creation to prevent losing access to init data. + setRetainInstance(true); + } + + private void init( + int titleId, + MappedTrackInfo mappedTrackInfo, + DefaultTrackSelector.Parameters initialParameters, + boolean allowAdaptiveSelections, + boolean allowMultipleOverrides, + DialogInterface.OnClickListener onClickListener, + DialogInterface.OnDismissListener onDismissListener) { + this.titleId = titleId; + this.onClickListener = onClickListener; + this.onDismissListener = onDismissListener; + for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) { + if (showTabForRenderer(mappedTrackInfo, i)) { + int trackType = mappedTrackInfo.getRendererType(/* rendererIndex= */ i); + TrackGroupArray trackGroupArray = mappedTrackInfo.getTrackGroups(i); + TrackSelectionViewFragment tabFragment = new TrackSelectionViewFragment(); + tabFragment.init( + mappedTrackInfo, + /* rendererIndex= */ i, + initialParameters.getRendererDisabled(/* rendererIndex= */ i), + initialParameters.getSelectionOverride(/* rendererIndex= */ i, trackGroupArray), + allowAdaptiveSelections, + allowMultipleOverrides); + tabFragments.put(i, tabFragment); + tabTrackTypes.add(trackType); + } + } + } + + /** + * Returns whether a renderer is disabled. + * + * @param rendererIndex Renderer index. + * @return Whether the renderer is disabled. + */ + public boolean getIsDisabled(int rendererIndex) { + TrackSelectionViewFragment rendererView = tabFragments.get(rendererIndex); + return rendererView != null && rendererView.isDisabled; + } + + /** + * Returns the list of selected track selection overrides for the specified renderer. There will + * be at most one override for each track group. + * + * @param rendererIndex Renderer index. + * @return The list of track selection overrides for this renderer. + */ + public List getOverrides(int rendererIndex) { + TrackSelectionViewFragment rendererView = tabFragments.get(rendererIndex); + return rendererView == null ? Collections.emptyList() : rendererView.overrides; + } + + @Override + @NonNull + public Dialog onCreateDialog(Bundle savedInstanceState) { + // We need to own the view to let tab layout work correctly on all API levels. We can't use + // AlertDialog because it owns the view itself, so we use AppCompatDialog instead, themed using + // the AlertDialog theme overlay with force-enabled title. + AppCompatDialog dialog = + new AppCompatDialog(getActivity(), R.style.TrackSelectionDialogThemeOverlay); + dialog.setTitle(titleId); + return dialog; + } + + @Override + public void onDismiss(@NonNull DialogInterface dialog) { + super.onDismiss(dialog); + onDismissListener.onDismiss(dialog); + } + + @Override + public View onCreateView( + LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View dialogView = inflater.inflate(R.layout.track_selection_dialog, container, false); + TabLayout tabLayout = dialogView.findViewById(R.id.track_selection_dialog_tab_layout); + ViewPager viewPager = dialogView.findViewById(R.id.track_selection_dialog_view_pager); + Button cancelButton = dialogView.findViewById(R.id.track_selection_dialog_cancel_button); + Button okButton = dialogView.findViewById(R.id.track_selection_dialog_ok_button); + viewPager.setAdapter(new FragmentAdapter(getChildFragmentManager())); + tabLayout.setupWithViewPager(viewPager); + tabLayout.setVisibility(tabFragments.size() > 1 ? View.VISIBLE : View.GONE); + cancelButton.setOnClickListener(view -> dismiss()); + okButton.setOnClickListener( + view -> { + onClickListener.onClick(getDialog(), DialogInterface.BUTTON_POSITIVE); + dismiss(); + }); + return dialogView; + } + + private static boolean showTabForRenderer(MappedTrackInfo mappedTrackInfo, int rendererIndex) { + TrackGroupArray trackGroupArray = mappedTrackInfo.getTrackGroups(rendererIndex); + if (trackGroupArray.length == 0) { + return false; + } + int trackType = mappedTrackInfo.getRendererType(rendererIndex); + return isSupportedTrackType(trackType); + } + + private static boolean isSupportedTrackType(int trackType) { + switch (trackType) { + case C.TRACK_TYPE_VIDEO: + case C.TRACK_TYPE_AUDIO: + case C.TRACK_TYPE_TEXT: + return true; + default: + return false; + } + } + + private static String getTrackTypeString(Resources resources, int trackType) { + switch (trackType) { + case C.TRACK_TYPE_VIDEO: + return resources.getString(R.string.exo_track_selection_title_video); + case C.TRACK_TYPE_AUDIO: + return resources.getString(R.string.exo_track_selection_title_audio); + case C.TRACK_TYPE_TEXT: + return resources.getString(R.string.exo_track_selection_title_text); + default: + throw new IllegalArgumentException(); + } + } + + private final class FragmentAdapter extends FragmentPagerAdapter { + + public FragmentAdapter(FragmentManager fragmentManager) { + super(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT); + } + + @Override + @NonNull + public Fragment getItem(int position) { + return tabFragments.valueAt(position); + } + + @Override + public int getCount() { + return tabFragments.size(); + } + + @Override + public CharSequence getPageTitle(int position) { + return getTrackTypeString(getResources(), tabTrackTypes.get(position)); + } + } + + /** Fragment to show a track selection in tab of the track selection dialog. */ + public static final class TrackSelectionViewFragment extends Fragment + implements TrackSelectionView.TrackSelectionListener { + + private MappedTrackInfo mappedTrackInfo; + private int rendererIndex; + private boolean allowAdaptiveSelections; + private boolean allowMultipleOverrides; + + /* package */ boolean isDisabled; + /* package */ List overrides; + + public TrackSelectionViewFragment() { + // Retain instance across activity re-creation to prevent losing access to init data. + setRetainInstance(true); + } + + public void init( + MappedTrackInfo mappedTrackInfo, + int rendererIndex, + boolean initialIsDisabled, + @Nullable SelectionOverride initialOverride, + boolean allowAdaptiveSelections, + boolean allowMultipleOverrides) { + this.mappedTrackInfo = mappedTrackInfo; + this.rendererIndex = rendererIndex; + this.isDisabled = initialIsDisabled; + this.overrides = + initialOverride == null + ? Collections.emptyList() + : Collections.singletonList(initialOverride); + this.allowAdaptiveSelections = allowAdaptiveSelections; + this.allowMultipleOverrides = allowMultipleOverrides; + } + + @Override + public View onCreateView( + LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View rootView = + inflater.inflate( + R.layout.exo_track_selection_dialog, container, /* attachToRoot= */ false); + TrackSelectionView trackSelectionView = rootView.findViewById(R.id.exo_track_selection_view); + trackSelectionView.setShowDisableOption(true); + trackSelectionView.setAllowMultipleOverrides(allowMultipleOverrides); + trackSelectionView.setAllowAdaptiveSelections(allowAdaptiveSelections); + trackSelectionView.init( + mappedTrackInfo, + rendererIndex, + isDisabled, + overrides, + /* trackFormatComparator= */ null, + /* listener= */ this); + return rootView; + } + + @Override + public void onTrackSelectionChanged( + boolean isDisabled, @NonNull List overrides) { + this.isDisabled = isDisabled; + this.overrides = overrides; + } + } +} diff --git a/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/package-info.java b/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/package-info.java new file mode 100644 index 00000000..cc22be27 --- /dev/null +++ b/demo/src/r2_12_1/java/com/google/android/exoplayer2/demo/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package com.google.android.exoplayer2.demo; + +import com.google.android.exoplayer2.util.NonNullApi; diff --git a/demo/src/r2_12_1/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProvider.java b/demo/src/r2_12_1/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProvider.java new file mode 100644 index 00000000..e70538d7 --- /dev/null +++ b/demo/src/r2_12_1/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProvider.java @@ -0,0 +1,57 @@ +/* + * 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 java.lang.Math.min; + +import java.io.IOException; +import java.nio.ByteBuffer; +import org.chromium.net.UploadDataProvider; +import org.chromium.net.UploadDataSink; + +/** + * A {@link UploadDataProvider} implementation that provides data from a {@code byte[]}. + */ +/* package */ final class ByteArrayUploadDataProvider extends UploadDataProvider { + + private final byte[] data; + + private int position; + + public ByteArrayUploadDataProvider(byte[] data) { + this.data = data; + } + + @Override + public long getLength() { + return data.length; + } + + @Override + public void read(UploadDataSink uploadDataSink, ByteBuffer byteBuffer) throws IOException { + int readLength = min(byteBuffer.remaining(), data.length - position); + byteBuffer.put(data, position, readLength); + position += readLength; + uploadDataSink.onReadSucceeded(false); + } + + @Override + public void rewind(UploadDataSink uploadDataSink) throws IOException { + position = 0; + uploadDataSink.onRewindSucceeded(); + } + +} diff --git a/demo/src/r2_12_1/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java b/demo/src/r2_12_1/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java new file mode 100644 index 00000000..26a60d33 --- /dev/null +++ b/demo/src/r2_12_1/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java @@ -0,0 +1,1025 @@ +/* + * 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.android.exoplayer2.util.Util.castNonNull; +import static java.lang.Math.max; +import static java.lang.Math.min; + +import android.net.Uri; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlayerLibraryInfo; +import com.google.android.exoplayer2.upstream.BaseDataSource; +import com.google.android.exoplayer2.upstream.DataSourceException; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.HttpDataSource; +import com.google.android.exoplayer2.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.Util; +import com.google.common.base.Predicate; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.Executor; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; +import org.chromium.net.CronetEngine; +import org.chromium.net.CronetException; +import org.chromium.net.NetworkException; +import org.chromium.net.UrlRequest; +import org.chromium.net.UrlRequest.Status; +import org.chromium.net.UrlResponseInfo; + +/** + * DataSource without intermediate buffer based on Cronet API set using UrlRequest. + * + *

Note: HTTP request headers will be set using all parameters passed via (in order of decreasing + * priority) the {@code dataSpec}, {@link #setRequestProperty} and the default parameters used to + * construct the instance. + */ +public class CronetDataSource extends BaseDataSource implements HttpDataSource { + + /** + * Thrown when an error is encountered when trying to open a {@link CronetDataSource}. + */ + public static final class OpenException extends HttpDataSourceException { + + /** + * Returns the status of the connection establishment at the moment when the error occurred, as + * defined by {@link UrlRequest.Status}. + */ + public final int cronetConnectionStatus; + + public OpenException(IOException cause, DataSpec dataSpec, int cronetConnectionStatus) { + super(cause, dataSpec, TYPE_OPEN); + this.cronetConnectionStatus = cronetConnectionStatus; + } + + public OpenException(String errorMessage, DataSpec dataSpec, int cronetConnectionStatus) { + super(errorMessage, dataSpec, TYPE_OPEN); + this.cronetConnectionStatus = cronetConnectionStatus; + } + + } + + static { + ExoPlayerLibraryInfo.registerModule("goog.exo.cronet"); + } + + /** + * The default connection timeout, in milliseconds. + */ + public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 8 * 1000; + /** + * The default read timeout, in milliseconds. + */ + 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(). + private static final int READ_BUFFER_SIZE_BYTES = 32 * 1024; + + private final CronetEngine cronetEngine; + private final Executor executor; + private final int connectTimeoutMs; + private final int readTimeoutMs; + private final boolean resetTimeoutOnRedirects; + private final boolean handleSetCookieRequests; + @Nullable private final RequestProperties defaultRequestProperties; + private final RequestProperties requestProperties; + private final ConditionVariable operation; + private final Clock clock; + + @Nullable private Predicate contentTypePredicate; + + // Accessed by the calling thread only. + private boolean opened; + private long bytesToSkip; + private long bytesRemaining; + + // Written from the calling thread only. currentUrlRequest.start() calls ensure writes are visible + // to reads made by the Cronet thread. + @Nullable private UrlRequest currentUrlRequest; + @Nullable private DataSpec currentDataSpec; + + // Reference written and read by calling thread only. Passed to Cronet thread as a local variable. + // operation.open() calls ensure writes into the buffer are visible to reads made by the calling + // thread. + @Nullable private ByteBuffer readBuffer; + + // Written from the Cronet thread only. operation.open() calls ensure writes are visible to reads + // made by the calling thread. + @Nullable private UrlResponseInfo responseInfo; + @Nullable private IOException exception; + private boolean finished; + + private volatile long currentConnectTimeoutMs; + + /** + * Creates an instance. + * + * @param cronetEngine A CronetEngine. + * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may + * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread + * hop from Cronet's internal network thread to the response handling thread. However, to + * avoid slowing down overall network performance, care must be taken to make sure response + * handling is a fast operation when using a direct executor. + */ + public CronetDataSource(CronetEngine cronetEngine, Executor executor) { + this( + cronetEngine, + executor, + DEFAULT_CONNECT_TIMEOUT_MILLIS, + DEFAULT_READ_TIMEOUT_MILLIS, + /* resetTimeoutOnRedirects= */ false, + /* defaultRequestProperties= */ null); + } + + /** + * Creates an instance. + * + * @param cronetEngine A CronetEngine. + * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may + * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread + * hop from Cronet's internal network thread to the response handling thread. However, to + * avoid slowing down overall network performance, care must be taken to make sure response + * handling is a fast operation when using a direct executor. + * @param connectTimeoutMs The connection timeout, in milliseconds. + * @param readTimeoutMs The read timeout, in milliseconds. + * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs. + * @param defaultRequestProperties Optional default {@link RequestProperties} to be sent to the + * server as HTTP headers on every request. + */ + public CronetDataSource( + CronetEngine cronetEngine, + Executor executor, + int connectTimeoutMs, + int readTimeoutMs, + boolean resetTimeoutOnRedirects, + @Nullable RequestProperties defaultRequestProperties) { + this( + cronetEngine, + executor, + connectTimeoutMs, + readTimeoutMs, + resetTimeoutOnRedirects, + Clock.DEFAULT, + defaultRequestProperties, + /* handleSetCookieRequests= */ false); + } + + /** + * Creates an instance. + * + * @param cronetEngine A CronetEngine. + * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may + * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread + * hop from Cronet's internal network thread to the response handling thread. However, to + * avoid slowing down overall network performance, care must be taken to make sure response + * handling is a fast operation when using a direct executor. + * @param connectTimeoutMs The connection timeout, in milliseconds. + * @param readTimeoutMs The read timeout, in milliseconds. + * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs. + * @param defaultRequestProperties Optional default {@link RequestProperties} to be sent to the + * server as HTTP headers on every request. + * @param handleSetCookieRequests Whether "Set-Cookie" requests on redirect should be forwarded to + * the redirect url in the "Cookie" header. + */ + public CronetDataSource( + CronetEngine cronetEngine, + Executor executor, + int connectTimeoutMs, + int readTimeoutMs, + boolean resetTimeoutOnRedirects, + @Nullable RequestProperties defaultRequestProperties, + boolean handleSetCookieRequests) { + this( + cronetEngine, + executor, + connectTimeoutMs, + readTimeoutMs, + resetTimeoutOnRedirects, + Clock.DEFAULT, + defaultRequestProperties, + handleSetCookieRequests); + } + + /** + * Creates an instance. + * + * @param cronetEngine A CronetEngine. + * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may + * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread + * hop from Cronet's internal network thread to the response handling thread. However, to + * avoid slowing down overall network performance, care must be taken to make sure response + * handling is a fast operation when using a direct executor. + * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the + * predicate then an {@link InvalidContentTypeException} is thrown from {@link + * #open(DataSpec)}. + * @deprecated Use {@link #CronetDataSource(CronetEngine, Executor)} and {@link + * #setContentTypePredicate(Predicate)}. + */ + @SuppressWarnings("deprecation") + @Deprecated + public CronetDataSource( + CronetEngine cronetEngine, + Executor executor, + @Nullable Predicate contentTypePredicate) { + this( + cronetEngine, + executor, + contentTypePredicate, + DEFAULT_CONNECT_TIMEOUT_MILLIS, + DEFAULT_READ_TIMEOUT_MILLIS, + /* resetTimeoutOnRedirects= */ false, + /* defaultRequestProperties= */ null); + } + + /** + * Creates an instance. + * + * @param cronetEngine A CronetEngine. + * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may + * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread + * hop from Cronet's internal network thread to the response handling thread. However, to + * avoid slowing down overall network performance, care must be taken to make sure response + * handling is a fast operation when using a direct executor. + * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the + * predicate then an {@link InvalidContentTypeException} is thrown from {@link + * #open(DataSpec)}. + * @param connectTimeoutMs The connection timeout, in milliseconds. + * @param readTimeoutMs The read timeout, in milliseconds. + * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs. + * @param defaultRequestProperties Optional default {@link RequestProperties} to be sent to the + * server as HTTP headers on every request. + * @deprecated Use {@link #CronetDataSource(CronetEngine, Executor, int, int, boolean, + * RequestProperties)} and {@link #setContentTypePredicate(Predicate)}. + */ + @SuppressWarnings("deprecation") + @Deprecated + public CronetDataSource( + CronetEngine cronetEngine, + Executor executor, + @Nullable Predicate contentTypePredicate, + int connectTimeoutMs, + int readTimeoutMs, + boolean resetTimeoutOnRedirects, + @Nullable RequestProperties defaultRequestProperties) { + this( + cronetEngine, + executor, + contentTypePredicate, + connectTimeoutMs, + readTimeoutMs, + resetTimeoutOnRedirects, + defaultRequestProperties, + /* handleSetCookieRequests= */ false); + } + + /** + * Creates an instance. + * + * @param cronetEngine A CronetEngine. + * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may + * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread + * hop from Cronet's internal network thread to the response handling thread. However, to + * avoid slowing down overall network performance, care must be taken to make sure response + * handling is a fast operation when using a direct executor. + * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the + * predicate then an {@link InvalidContentTypeException} is thrown from {@link + * #open(DataSpec)}. + * @param connectTimeoutMs The connection timeout, in milliseconds. + * @param readTimeoutMs The read timeout, in milliseconds. + * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs. + * @param defaultRequestProperties Optional default {@link RequestProperties} to be sent to the + * server as HTTP headers on every request. + * @param handleSetCookieRequests Whether "Set-Cookie" requests on redirect should be forwarded to + * the redirect url in the "Cookie" header. + * @deprecated Use {@link #CronetDataSource(CronetEngine, Executor, int, int, boolean, + * RequestProperties, boolean)} and {@link #setContentTypePredicate(Predicate)}. + */ + @Deprecated + public CronetDataSource( + CronetEngine cronetEngine, + Executor executor, + @Nullable Predicate contentTypePredicate, + int connectTimeoutMs, + int readTimeoutMs, + boolean resetTimeoutOnRedirects, + @Nullable RequestProperties defaultRequestProperties, + boolean handleSetCookieRequests) { + this( + cronetEngine, + executor, + connectTimeoutMs, + readTimeoutMs, + resetTimeoutOnRedirects, + Clock.DEFAULT, + defaultRequestProperties, + handleSetCookieRequests); + this.contentTypePredicate = contentTypePredicate; + } + + /* package */ CronetDataSource( + CronetEngine cronetEngine, + Executor executor, + int connectTimeoutMs, + int readTimeoutMs, + boolean resetTimeoutOnRedirects, + Clock clock, + @Nullable RequestProperties defaultRequestProperties, + boolean handleSetCookieRequests) { + super(/* isNetwork= */ true); + this.urlRequestCallback = new UrlRequestCallback(); + this.cronetEngine = Assertions.checkNotNull(cronetEngine); + this.executor = Assertions.checkNotNull(executor); + this.connectTimeoutMs = connectTimeoutMs; + this.readTimeoutMs = readTimeoutMs; + this.resetTimeoutOnRedirects = resetTimeoutOnRedirects; + this.clock = Assertions.checkNotNull(clock); + this.defaultRequestProperties = defaultRequestProperties; + this.handleSetCookieRequests = handleSetCookieRequests; + requestProperties = new RequestProperties(); + operation = new ConditionVariable(); + } + + /** + * Sets a content type {@link Predicate}. If a content type is rejected by the predicate then a + * {@link HttpDataSource.InvalidContentTypeException} is thrown from {@link #open(DataSpec)}. + * + * @param contentTypePredicate The content type {@link Predicate}, or {@code null} to clear a + * predicate that was previously set. + */ + public void setContentTypePredicate(@Nullable Predicate contentTypePredicate) { + this.contentTypePredicate = contentTypePredicate; + } + + // HttpDataSource implementation. + + @Override + public void setRequestProperty(String name, String value) { + requestProperties.set(name, value); + } + + @Override + public void clearRequestProperty(String name) { + requestProperties.remove(name); + } + + @Override + public void clearAllRequestProperties() { + requestProperties.clear(); + } + + @Override + public int getResponseCode() { + return responseInfo == null || responseInfo.getHttpStatusCode() <= 0 + ? -1 + : responseInfo.getHttpStatusCode(); + } + + @Override + public Map> getResponseHeaders() { + return responseInfo == null ? Collections.emptyMap() : responseInfo.getAllHeaders(); + } + + @Override + @Nullable + public Uri getUri() { + return responseInfo == null ? null : Uri.parse(responseInfo.getUrl()); + } + + @Override + public long open(DataSpec dataSpec) throws HttpDataSourceException { + Assertions.checkNotNull(dataSpec); + Assertions.checkState(!opened); + + operation.close(); + resetConnectTimeout(); + currentDataSpec = dataSpec; + UrlRequest urlRequest; + try { + urlRequest = buildRequestBuilder(dataSpec).build(); + currentUrlRequest = urlRequest; + } catch (IOException e) { + throw new OpenException(e, dataSpec, Status.IDLE); + } + urlRequest.start(); + + transferInitializing(dataSpec); + try { + boolean connectionOpened = blockUntilConnectTimeout(); + if (exception != null) { + throw new OpenException(exception, dataSpec, getStatus(urlRequest)); + } else if (!connectionOpened) { + // The timeout was reached before the connection was opened. + throw new OpenException(new SocketTimeoutException(), dataSpec, getStatus(urlRequest)); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new OpenException(new InterruptedIOException(), dataSpec, Status.INVALID); + } + + // Check for a valid response code. + UrlResponseInfo responseInfo = Assertions.checkNotNull(this.responseInfo); + int responseCode = responseInfo.getHttpStatusCode(); + if (responseCode < 200 || responseCode > 299) { + byte[] responseBody = Util.EMPTY_BYTE_ARRAY; + ByteBuffer readBuffer = getOrCreateReadBuffer(); + while (!readBuffer.hasRemaining()) { + operation.close(); + readBuffer.clear(); + readInternal(readBuffer); + if (finished) { + break; + } + readBuffer.flip(); + int existingResponseBodyEnd = responseBody.length; + responseBody = Arrays.copyOf(responseBody, responseBody.length + readBuffer.remaining()); + readBuffer.get(responseBody, existingResponseBodyEnd, readBuffer.remaining()); + } + + InvalidResponseCodeException exception = + new InvalidResponseCodeException( + responseCode, + responseInfo.getHttpStatusText(), + responseInfo.getAllHeaders(), + dataSpec, + responseBody); + if (responseCode == 416) { + exception.initCause(new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE)); + } + throw exception; + } + + // Check for a valid content type. + Predicate contentTypePredicate = this.contentTypePredicate; + if (contentTypePredicate != null) { + List contentTypeHeaders = responseInfo.getAllHeaders().get(CONTENT_TYPE); + String contentType = isEmpty(contentTypeHeaders) ? null : contentTypeHeaders.get(0); + if (contentType != null && !contentTypePredicate.apply(contentType)) { + throw new InvalidContentTypeException(contentType, dataSpec); + } + } + + // If we requested a range starting from a non-zero position and received a 200 rather than a + // 206, then the server does not support partial requests. We'll need to manually skip to the + // requested position. + bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0; + + // Calculate the content length. + if (!isCompressed(responseInfo)) { + if (dataSpec.length != C.LENGTH_UNSET) { + bytesRemaining = dataSpec.length; + } else { + bytesRemaining = getContentLength(responseInfo); + } + } else { + // If the response is compressed then the content length will be that of the compressed data + // which isn't what we want. Always use the dataSpec length in this case. + bytesRemaining = dataSpec.length; + } + + opened = true; + transferStarted(dataSpec); + + return bytesRemaining; + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws HttpDataSourceException { + Assertions.checkState(opened); + + if (readLength == 0) { + return 0; + } else if (bytesRemaining == 0) { + return C.RESULT_END_OF_INPUT; + } + + ByteBuffer readBuffer = getOrCreateReadBuffer(); + while (!readBuffer.hasRemaining()) { + // Fill readBuffer with more data from Cronet. + operation.close(); + readBuffer.clear(); + readInternal(readBuffer); + + if (finished) { + bytesRemaining = 0; + return C.RESULT_END_OF_INPUT; + } else { + // The operation didn't time out, fail or finish, and therefore data must have been read. + readBuffer.flip(); + Assertions.checkState(readBuffer.hasRemaining()); + if (bytesToSkip > 0) { + int bytesSkipped = (int) min(readBuffer.remaining(), bytesToSkip); + readBuffer.position(readBuffer.position() + bytesSkipped); + bytesToSkip -= bytesSkipped; + } + } + } + + int bytesRead = min(readBuffer.remaining(), readLength); + readBuffer.get(buffer, offset, bytesRead); + + if (bytesRemaining != C.LENGTH_UNSET) { + bytesRemaining -= bytesRead; + } + bytesTransferred(bytesRead); + return bytesRead; + } + + /** + * Reads up to {@code buffer.remaining()} bytes of data and stores them into {@code buffer}, + * starting at {@code buffer.position()}. Advances the position of the buffer by the number of + * bytes read and returns this length. + * + *

If there is an error, a {@link HttpDataSourceException} is thrown and the contents of {@code + * buffer} should be ignored. If the exception has error code {@code + * HttpDataSourceException.TYPE_READ}, note that Cronet may continue writing into {@code buffer} + * after the method has returned. Thus the caller should not attempt to reuse the buffer. + * + *

If {@code buffer.remaining()} is zero then 0 is returned. Otherwise, if no data is available + * because the end of the opened range has been reached, then {@link C#RESULT_END_OF_INPUT} is + * returned. Otherwise, the call will block until at least one byte of data has been read and the + * number of bytes read is returned. + * + *

Passed buffer must be direct ByteBuffer. If you have a non-direct ByteBuffer, consider the + * alternative read method with its backed array. + * + * @param buffer The ByteBuffer into which the read data should be stored. Must be a direct + * ByteBuffer. + * @return The number of bytes read, or {@link C#RESULT_END_OF_INPUT} if no data is available + * because the end of the opened range has been reached. + * @throws HttpDataSourceException If an error occurs reading from the source. + * @throws IllegalArgumentException If {@code buffer} is not a direct ByteBuffer. + */ + public int read(ByteBuffer buffer) throws HttpDataSourceException { + Assertions.checkState(opened); + + if (!buffer.isDirect()) { + throw new IllegalArgumentException("Passed buffer is not a direct ByteBuffer"); + } + if (!buffer.hasRemaining()) { + return 0; + } else if (bytesRemaining == 0) { + return C.RESULT_END_OF_INPUT; + } + int readLength = buffer.remaining(); + + if (readBuffer != null) { + // Skip all the bytes we can from readBuffer if there are still bytes to skip. + if (bytesToSkip != 0) { + if (bytesToSkip >= readBuffer.remaining()) { + bytesToSkip -= readBuffer.remaining(); + readBuffer.position(readBuffer.limit()); + } else { + readBuffer.position(readBuffer.position() + (int) bytesToSkip); + bytesToSkip = 0; + } + } + + // If there is existing data in the readBuffer, read as much as possible. Return if any read. + int copyBytes = copyByteBuffer(/* src= */ readBuffer, /* dst= */ buffer); + if (copyBytes != 0) { + if (bytesRemaining != C.LENGTH_UNSET) { + bytesRemaining -= copyBytes; + } + bytesTransferred(copyBytes); + return copyBytes; + } + } + + boolean readMore = true; + while (readMore) { + // If bytesToSkip > 0, read into intermediate buffer that we can discard instead of caller's + // buffer. If we do not need to skip bytes, we may write to buffer directly. + final boolean useCallerBuffer = bytesToSkip == 0; + + operation.close(); + + if (!useCallerBuffer) { + ByteBuffer readBuffer = getOrCreateReadBuffer(); + readBuffer.clear(); + if (bytesToSkip < READ_BUFFER_SIZE_BYTES) { + readBuffer.limit((int) bytesToSkip); + } + } + + // Fill buffer with more data from Cronet. + readInternal(useCallerBuffer ? buffer : castNonNull(readBuffer)); + + if (finished) { + bytesRemaining = 0; + return C.RESULT_END_OF_INPUT; + } else { + // The operation didn't time out, fail or finish, and therefore data must have been read. + Assertions.checkState( + useCallerBuffer + ? readLength > buffer.remaining() + : castNonNull(readBuffer).position() > 0); + // If we meant to skip bytes, subtract what was left and repeat, otherwise, continue. + if (useCallerBuffer) { + readMore = false; + } else { + bytesToSkip -= castNonNull(readBuffer).position(); + } + } + } + + final int bytesRead = readLength - buffer.remaining(); + if (bytesRemaining != C.LENGTH_UNSET) { + bytesRemaining -= bytesRead; + } + bytesTransferred(bytesRead); + return bytesRead; + } + + @Override + public synchronized void close() { + if (currentUrlRequest != null) { + currentUrlRequest.cancel(); + currentUrlRequest = null; + } + if (readBuffer != null) { + readBuffer.limit(0); + } + currentDataSpec = null; + responseInfo = null; + exception = null; + finished = false; + if (opened) { + opened = false; + transferEnded(); + } + } + + /** Returns current {@link UrlRequest}. May be null if the data source is not opened. */ + @Nullable + protected UrlRequest getCurrentUrlRequest() { + return currentUrlRequest; + } + + /** Returns current {@link UrlResponseInfo}. May be null if the data source is not opened. */ + @Nullable + protected UrlResponseInfo getCurrentUrlResponseInfo() { + return responseInfo; + } + + // Internal methods. + + private UrlRequest.Builder buildRequestBuilder(DataSpec dataSpec) throws IOException { + UrlRequest.Builder requestBuilder = + cronetEngine + .newUrlRequestBuilder(dataSpec.uri.toString(), urlRequestCallback, executor) + .allowDirectExecutor(); + + // Set the headers. + Map requestHeaders = new HashMap<>(); + if (defaultRequestProperties != null) { + requestHeaders.putAll(defaultRequestProperties.getSnapshot()); + } + requestHeaders.putAll(requestProperties.getSnapshot()); + requestHeaders.putAll(dataSpec.httpRequestHeaders); + + for (Entry headerEntry : requestHeaders.entrySet()) { + String key = headerEntry.getKey(); + String value = headerEntry.getValue(); + requestBuilder.addHeader(key, value); + } + + if (dataSpec.httpBody != null && !requestHeaders.containsKey(CONTENT_TYPE)) { + throw new IOException("HTTP request with non-empty body must set Content-Type"); + } + + // Set the Range header. + if (dataSpec.position != 0 || dataSpec.length != C.LENGTH_UNSET) { + StringBuilder rangeValue = new StringBuilder(); + rangeValue.append("bytes="); + rangeValue.append(dataSpec.position); + rangeValue.append("-"); + if (dataSpec.length != C.LENGTH_UNSET) { + rangeValue.append(dataSpec.position + dataSpec.length - 1); + } + requestBuilder.addHeader("Range", rangeValue.toString()); + } + // TODO: Uncomment when https://bugs.chromium.org/p/chromium/issues/detail?id=711810 is fixed + // (adjusting the code as necessary). + // Force identity encoding unless gzip is allowed. + // if (!dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_GZIP)) { + // requestBuilder.addHeader("Accept-Encoding", "identity"); + // } + // Set the method and (if non-empty) the body. + requestBuilder.setHttpMethod(dataSpec.getHttpMethodString()); + if (dataSpec.httpBody != null) { + requestBuilder.setUploadDataProvider( + new ByteArrayUploadDataProvider(dataSpec.httpBody), executor); + } + return requestBuilder; + } + + private boolean blockUntilConnectTimeout() throws InterruptedException { + long now = clock.elapsedRealtime(); + boolean opened = false; + while (!opened && now < currentConnectTimeoutMs) { + opened = operation.block(currentConnectTimeoutMs - now + 5 /* fudge factor */); + now = clock.elapsedRealtime(); + } + return opened; + } + + private void resetConnectTimeout() { + currentConnectTimeoutMs = clock.elapsedRealtime() + connectTimeoutMs; + } + + /** + * Reads up to {@code buffer.remaining()} bytes of data from {@code currentUrlRequest} and stores + * them into {@code buffer}. If there is an error and {@code buffer == readBuffer}, then it resets + * the current {@code readBuffer} object so that it is not reused in the future. + * + * @param buffer The ByteBuffer into which the read data is stored. Must be a direct ByteBuffer. + * @throws HttpDataSourceException If an error occurs reading from the source. + */ + @SuppressWarnings("ReferenceEquality") + private void readInternal(ByteBuffer buffer) throws HttpDataSourceException { + castNonNull(currentUrlRequest).read(buffer); + try { + if (!operation.block(readTimeoutMs)) { + throw new SocketTimeoutException(); + } + } catch (InterruptedException e) { + // The operation is ongoing so replace buffer to avoid it being written to by this + // operation during a subsequent request. + if (buffer == readBuffer) { + readBuffer = null; + } + Thread.currentThread().interrupt(); + throw new HttpDataSourceException( + new InterruptedIOException(), + castNonNull(currentDataSpec), + HttpDataSourceException.TYPE_READ); + } catch (SocketTimeoutException e) { + // The operation is ongoing so replace buffer to avoid it being written to by this + // operation during a subsequent request. + if (buffer == readBuffer) { + readBuffer = null; + } + throw new HttpDataSourceException( + e, castNonNull(currentDataSpec), HttpDataSourceException.TYPE_READ); + } + + if (exception != null) { + throw new HttpDataSourceException( + exception, castNonNull(currentDataSpec), HttpDataSourceException.TYPE_READ); + } + } + + private ByteBuffer getOrCreateReadBuffer() { + if (readBuffer == null) { + readBuffer = ByteBuffer.allocateDirect(READ_BUFFER_SIZE_BYTES); + readBuffer.limit(0); + } + return readBuffer; + } + + private static boolean isCompressed(UrlResponseInfo info) { + for (Map.Entry entry : info.getAllHeadersAsList()) { + if (entry.getKey().equalsIgnoreCase("Content-Encoding")) { + return !entry.getValue().equalsIgnoreCase("identity"); + } + } + return false; + } + + private static long getContentLength(UrlResponseInfo info) { + long contentLength = C.LENGTH_UNSET; + Map> headers = info.getAllHeaders(); + List contentLengthHeaders = headers.get("Content-Length"); + String contentLengthHeader = null; + if (!isEmpty(contentLengthHeaders)) { + contentLengthHeader = contentLengthHeaders.get(0); + if (!TextUtils.isEmpty(contentLengthHeader)) { + try { + contentLength = Long.parseLong(contentLengthHeader); + } catch (NumberFormatException e) { + Log.e(TAG, "Unexpected Content-Length [" + contentLengthHeader + "]"); + } + } + } + List contentRangeHeaders = headers.get("Content-Range"); + if (!isEmpty(contentRangeHeaders)) { + String contentRangeHeader = contentRangeHeaders.get(0); + Matcher matcher = CONTENT_RANGE_HEADER_PATTERN.matcher(contentRangeHeader); + if (matcher.find()) { + try { + long contentLengthFromRange = + Long.parseLong(Assertions.checkNotNull(matcher.group(2))) + - Long.parseLong(Assertions.checkNotNull(matcher.group(1))) + + 1; + if (contentLength < 0) { + // Some proxy servers strip the Content-Length header. Fall back to the length + // calculated here in this case. + contentLength = contentLengthFromRange; + } else if (contentLength != contentLengthFromRange) { + // If there is a discrepancy between the Content-Length and Content-Range headers, + // assume the one with the larger value is correct. We have seen cases where carrier + // change one of them to reduce the size of a request, but it is unlikely anybody + // would increase it. + Log.w(TAG, "Inconsistent headers [" + contentLengthHeader + "] [" + contentRangeHeader + + "]"); + contentLength = max(contentLength, contentLengthFromRange); + } + } catch (NumberFormatException e) { + Log.e(TAG, "Unexpected Content-Range [" + contentRangeHeader + "]"); + } + } + } + return contentLength; + } + + 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() { + @Override + public void onStatus(int status) { + statusHolder[0] = status; + conditionVariable.open(); + } + }); + conditionVariable.block(); + return statusHolder[0]; + } + + @EnsuresNonNullIf(result = false, expression = "#1") + private static boolean isEmpty(@Nullable List list) { + return list == null || list.isEmpty(); + } + + // Copy as much as possible from the src buffer into dst buffer. + // Returns the number of bytes copied. + private static int copyByteBuffer(ByteBuffer src, ByteBuffer dst) { + int remaining = min(src.remaining(), dst.remaining()); + int limit = src.limit(); + src.limit(src.position() + remaining); + dst.put(src); + src.limit(limit); + return remaining; + } + + private final class UrlRequestCallback extends UrlRequest.Callback { + + @Override + public synchronized void onRedirectReceived( + UrlRequest request, UrlResponseInfo info, String newLocationUrl) { + if (request != currentUrlRequest) { + return; + } + UrlRequest urlRequest = Assertions.checkNotNull(currentUrlRequest); + DataSpec dataSpec = Assertions.checkNotNull(currentDataSpec); + if (dataSpec.httpMethod == DataSpec.HTTP_METHOD_POST) { + int responseCode = info.getHttpStatusCode(); + // The industry standard is to disregard POST redirects when the status code is 307 or 308. + if (responseCode == 307 || responseCode == 308) { + exception = + new InvalidResponseCodeException( + responseCode, + info.getHttpStatusText(), + info.getAllHeaders(), + dataSpec, + /* responseBody= */ Util.EMPTY_BYTE_ARRAY); + operation.open(); + return; + } + } + if (resetTimeoutOnRedirects) { + resetConnectTimeout(); + } + + if (!handleSetCookieRequests) { + request.followRedirect(); + return; + } + + List setCookieHeaders = info.getAllHeaders().get(SET_COOKIE); + if (isEmpty(setCookieHeaders)) { + request.followRedirect(); + return; + } + + urlRequest.cancel(); + DataSpec redirectUrlDataSpec; + if (dataSpec.httpMethod == DataSpec.HTTP_METHOD_POST) { + // For POST redirects that aren't 307 or 308, the redirect is followed but request is + // transformed into a GET. + redirectUrlDataSpec = + dataSpec + .buildUpon() + .setUri(newLocationUrl) + .setHttpMethod(DataSpec.HTTP_METHOD_GET) + .setHttpBody(null) + .build(); + } else { + redirectUrlDataSpec = dataSpec.withUri(Uri.parse(newLocationUrl)); + } + UrlRequest.Builder requestBuilder; + try { + requestBuilder = buildRequestBuilder(redirectUrlDataSpec); + } catch (IOException e) { + exception = e; + return; + } + String cookieHeadersValue = parseCookies(setCookieHeaders); + attachCookies(requestBuilder, cookieHeadersValue); + currentUrlRequest = requestBuilder.build(); + currentUrlRequest.start(); + } + + @Override + public synchronized void onResponseStarted(UrlRequest request, UrlResponseInfo info) { + if (request != currentUrlRequest) { + return; + } + responseInfo = info; + operation.open(); + } + + @Override + public synchronized void onReadCompleted( + UrlRequest request, UrlResponseInfo info, ByteBuffer buffer) { + if (request != currentUrlRequest) { + return; + } + operation.open(); + } + + @Override + public synchronized void onSucceeded(UrlRequest request, UrlResponseInfo info) { + if (request != currentUrlRequest) { + return; + } + finished = true; + operation.open(); + } + + @Override + public synchronized void onFailed( + UrlRequest request, UrlResponseInfo info, CronetException error) { + if (request != currentUrlRequest) { + return; + } + if (error instanceof NetworkException + && ((NetworkException) error).getErrorCode() + == NetworkException.ERROR_HOSTNAME_NOT_RESOLVED) { + exception = new UnknownHostException(); + } else { + exception = error; + } + operation.open(); + } + } +} diff --git a/demo/src/r2_12_1/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java b/demo/src/r2_12_1/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java new file mode 100644 index 00000000..85c9d09a --- /dev/null +++ b/demo/src/r2_12_1/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java @@ -0,0 +1,364 @@ +/* + * 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.android.exoplayer2.ExoPlayerLibraryInfo.DEFAULT_USER_AGENT; + +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; +import com.google.android.exoplayer2.upstream.HttpDataSource; +import com.google.android.exoplayer2.upstream.HttpDataSource.BaseFactory; +import com.google.android.exoplayer2.upstream.HttpDataSource.Factory; +import com.google.android.exoplayer2.upstream.TransferListener; +import java.util.concurrent.Executor; +import org.chromium.net.CronetEngine; + +/** + * A {@link Factory} that produces {@link CronetDataSource}. + */ +public final class CronetDataSourceFactory extends BaseFactory { + + /** + * The default connection timeout, in milliseconds. + */ + public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = + CronetDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS; + + /** + * The default read timeout, in milliseconds. + */ + public static final int DEFAULT_READ_TIMEOUT_MILLIS = + CronetDataSource.DEFAULT_READ_TIMEOUT_MILLIS; + + private final CronetEngineWrapper cronetEngineWrapper; + private final Executor executor; + @Nullable private final TransferListener transferListener; + private final int connectTimeoutMs; + private final int readTimeoutMs; + private final boolean resetTimeoutOnRedirects; + private final HttpDataSource.Factory fallbackFactory; + + /** + * Creates an instance. + * + *

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

Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout. + * + * @param cronetEngineWrapper A {@link CronetEngineWrapper}. + * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. + * @param fallbackFactory A {@link HttpDataSource.Factory} which is used as a fallback in case no + * suitable CronetEngine can be build. + */ + public CronetDataSourceFactory( + CronetEngineWrapper cronetEngineWrapper, + Executor executor, + HttpDataSource.Factory fallbackFactory) { + this( + cronetEngineWrapper, + executor, + /* transferListener= */ null, + DEFAULT_CONNECT_TIMEOUT_MILLIS, + DEFAULT_READ_TIMEOUT_MILLIS, + false, + fallbackFactory); + } + + /** + * Creates an instance. + * + *

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

Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout. + * + * @param cronetEngineWrapper A {@link CronetEngineWrapper}. + * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. + */ + public CronetDataSourceFactory(CronetEngineWrapper cronetEngineWrapper, Executor executor) { + this(cronetEngineWrapper, executor, DEFAULT_USER_AGENT); + } + + /** + * Creates an instance. + * + *

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

Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout. + * + * @param cronetEngineWrapper A {@link CronetEngineWrapper}. + * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. + * @param userAgent A user agent used to create a fallback HttpDataSource if needed. + */ + public CronetDataSourceFactory( + CronetEngineWrapper cronetEngineWrapper, Executor executor, String userAgent) { + this( + cronetEngineWrapper, + executor, + /* transferListener= */ null, + DEFAULT_CONNECT_TIMEOUT_MILLIS, + DEFAULT_READ_TIMEOUT_MILLIS, + false, + new DefaultHttpDataSourceFactory( + userAgent, + /* listener= */ null, + DEFAULT_CONNECT_TIMEOUT_MILLIS, + DEFAULT_READ_TIMEOUT_MILLIS, + false)); + } + + /** + * Creates an instance. + * + *

If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link + * DefaultHttpDataSourceFactory} will be used instead. + * + * @param cronetEngineWrapper A {@link CronetEngineWrapper}. + * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. + * @param connectTimeoutMs The connection timeout, in milliseconds. + * @param readTimeoutMs The read timeout, in milliseconds. + * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs. + * @param userAgent A user agent used to create a fallback HttpDataSource if needed. + */ + public CronetDataSourceFactory( + CronetEngineWrapper cronetEngineWrapper, + Executor executor, + int connectTimeoutMs, + int readTimeoutMs, + boolean resetTimeoutOnRedirects, + String userAgent) { + this( + cronetEngineWrapper, + executor, + /* transferListener= */ null, + DEFAULT_CONNECT_TIMEOUT_MILLIS, + DEFAULT_READ_TIMEOUT_MILLIS, + resetTimeoutOnRedirects, + new DefaultHttpDataSourceFactory( + userAgent, + /* listener= */ null, + connectTimeoutMs, + readTimeoutMs, + resetTimeoutOnRedirects)); + } + + /** + * Creates an instance. + * + *

If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided + * fallback {@link HttpDataSource.Factory} will be used instead. + * + * @param cronetEngineWrapper A {@link CronetEngineWrapper}. + * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. + * @param connectTimeoutMs The connection timeout, in milliseconds. + * @param readTimeoutMs The read timeout, in milliseconds. + * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs. + * @param fallbackFactory A {@link HttpDataSource.Factory} which is used as a fallback in case no + * suitable CronetEngine can be build. + */ + public CronetDataSourceFactory( + CronetEngineWrapper cronetEngineWrapper, + Executor executor, + int connectTimeoutMs, + int readTimeoutMs, + boolean resetTimeoutOnRedirects, + HttpDataSource.Factory fallbackFactory) { + this( + cronetEngineWrapper, + executor, + /* transferListener= */ null, + connectTimeoutMs, + readTimeoutMs, + resetTimeoutOnRedirects, + fallbackFactory); + } + + /** + * Creates an instance. + * + *

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

Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout. + * + * @param cronetEngineWrapper A {@link CronetEngineWrapper}. + * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. + * @param transferListener An optional listener. + * @param fallbackFactory A {@link HttpDataSource.Factory} which is used as a fallback in case no + * suitable CronetEngine can be build. + */ + public CronetDataSourceFactory( + CronetEngineWrapper cronetEngineWrapper, + Executor executor, + @Nullable TransferListener transferListener, + HttpDataSource.Factory fallbackFactory) { + this( + cronetEngineWrapper, + executor, + transferListener, + DEFAULT_CONNECT_TIMEOUT_MILLIS, + DEFAULT_READ_TIMEOUT_MILLIS, + false, + fallbackFactory); + } + + /** + * Creates an instance. + * + *

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

Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout. + * + * @param cronetEngineWrapper A {@link CronetEngineWrapper}. + * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. + * @param transferListener An optional listener. + */ + public CronetDataSourceFactory( + CronetEngineWrapper cronetEngineWrapper, + Executor executor, + @Nullable TransferListener transferListener) { + this(cronetEngineWrapper, executor, transferListener, DEFAULT_USER_AGENT); + } + + /** + * Creates an instance. + * + *

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

Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout. + * + * @param cronetEngineWrapper A {@link CronetEngineWrapper}. + * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. + * @param transferListener An optional listener. + * @param userAgent A user agent used to create a fallback HttpDataSource if needed. + */ + public CronetDataSourceFactory( + CronetEngineWrapper cronetEngineWrapper, + Executor executor, + @Nullable TransferListener transferListener, + String userAgent) { + this( + cronetEngineWrapper, + executor, + transferListener, + DEFAULT_CONNECT_TIMEOUT_MILLIS, + DEFAULT_READ_TIMEOUT_MILLIS, + false, + new DefaultHttpDataSourceFactory( + userAgent, + transferListener, + DEFAULT_CONNECT_TIMEOUT_MILLIS, + DEFAULT_READ_TIMEOUT_MILLIS, + false)); + } + + /** + * Creates an instance. + * + *

If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link + * DefaultHttpDataSourceFactory} will be used instead. + * + * @param cronetEngineWrapper A {@link CronetEngineWrapper}. + * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. + * @param transferListener An optional listener. + * @param connectTimeoutMs The connection timeout, in milliseconds. + * @param readTimeoutMs The read timeout, in milliseconds. + * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs. + * @param userAgent A user agent used to create a fallback HttpDataSource if needed. + */ + public CronetDataSourceFactory( + CronetEngineWrapper cronetEngineWrapper, + Executor executor, + @Nullable TransferListener transferListener, + int connectTimeoutMs, + int readTimeoutMs, + boolean resetTimeoutOnRedirects, + String userAgent) { + this( + cronetEngineWrapper, + executor, + transferListener, + DEFAULT_CONNECT_TIMEOUT_MILLIS, + DEFAULT_READ_TIMEOUT_MILLIS, + resetTimeoutOnRedirects, + new DefaultHttpDataSourceFactory( + userAgent, transferListener, connectTimeoutMs, readTimeoutMs, resetTimeoutOnRedirects)); + } + + /** + * Creates an instance. + * + *

If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided + * fallback {@link HttpDataSource.Factory} will be used instead. + * + * @param cronetEngineWrapper A {@link CronetEngineWrapper}. + * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. + * @param transferListener An optional listener. + * @param connectTimeoutMs The connection timeout, in milliseconds. + * @param readTimeoutMs The read timeout, in milliseconds. + * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs. + * @param fallbackFactory A {@link HttpDataSource.Factory} which is used as a fallback in case no + * suitable CronetEngine can be build. + */ + public CronetDataSourceFactory( + CronetEngineWrapper cronetEngineWrapper, + Executor executor, + @Nullable TransferListener transferListener, + int connectTimeoutMs, + int readTimeoutMs, + boolean resetTimeoutOnRedirects, + HttpDataSource.Factory fallbackFactory) { + this.cronetEngineWrapper = cronetEngineWrapper; + this.executor = executor; + this.transferListener = transferListener; + this.connectTimeoutMs = connectTimeoutMs; + this.readTimeoutMs = readTimeoutMs; + this.resetTimeoutOnRedirects = resetTimeoutOnRedirects; + this.fallbackFactory = fallbackFactory; + } + + @Override + protected HttpDataSource createDataSourceInternal(HttpDataSource.RequestProperties + defaultRequestProperties) { + CronetEngine cronetEngine = cronetEngineWrapper.getCronetEngine(); + if (cronetEngine == null) { + return fallbackFactory.createDataSource(); + } + CronetDataSource dataSource = + new CronetDataSource( + cronetEngine, + executor, + connectTimeoutMs, + readTimeoutMs, + resetTimeoutOnRedirects, + defaultRequestProperties); + if (transferListener != null) { + dataSource.addTransferListener(transferListener); + } + return dataSource; + } + +} diff --git a/demo/src/r2_12_1/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java b/demo/src/r2_12_1/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java new file mode 100644 index 00000000..9f709b14 --- /dev/null +++ b/demo/src/r2_12_1/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java @@ -0,0 +1,251 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.cronet; + +import static java.lang.Math.min; + +import android.content.Context; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.util.Log; +import com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import org.chromium.net.CronetEngine; +import org.chromium.net.CronetProvider; + +/** + * A wrapper class for a {@link CronetEngine}. + */ +public final class CronetEngineWrapper { + + private static final String TAG = "CronetEngineWrapper"; + + @Nullable private final CronetEngine cronetEngine; + @CronetEngineSource private final int cronetEngineSource; + + /** + * Source of {@link CronetEngine}. One of {@link #SOURCE_NATIVE}, {@link #SOURCE_GMS}, {@link + * #SOURCE_UNKNOWN}, {@link #SOURCE_USER_PROVIDED} or {@link #SOURCE_UNAVAILABLE}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({SOURCE_NATIVE, SOURCE_GMS, SOURCE_UNKNOWN, SOURCE_USER_PROVIDED, SOURCE_UNAVAILABLE}) + public @interface CronetEngineSource {} + /** + * Natively bundled Cronet implementation. + */ + public static final int SOURCE_NATIVE = 0; + /** + * Cronet implementation from GMSCore. + */ + public static final int SOURCE_GMS = 1; + /** + * Other (unknown) Cronet implementation. + */ + public static final int SOURCE_UNKNOWN = 2; + /** + * User-provided Cronet engine. + */ + public static final int SOURCE_USER_PROVIDED = 3; + /** + * No Cronet implementation available. Fallback Http provider is used if possible. + */ + public static final int SOURCE_UNAVAILABLE = 4; + + /** + * Creates a wrapper for a {@link CronetEngine} which automatically selects the most suitable + * {@link CronetProvider}. Sets wrapper to prefer natively bundled Cronet over GMSCore Cronet + * if both are available. + * + * @param context A context. + */ + public CronetEngineWrapper(Context context) { + this(context, false); + } + + /** + * Creates a wrapper for a {@link CronetEngine} which automatically selects the most suitable + * {@link CronetProvider} based on user preference. + * + * @param context A context. + * @param preferGMSCoreCronet Whether Cronet from GMSCore should be preferred over natively + * bundled Cronet if both are available. + */ + public CronetEngineWrapper(Context context, boolean preferGMSCoreCronet) { + CronetEngine cronetEngine = null; + @CronetEngineSource int cronetEngineSource = SOURCE_UNAVAILABLE; + List cronetProviders = new ArrayList<>(CronetProvider.getAllProviders(context)); + // Remove disabled and fallback Cronet providers from list + for (int i = cronetProviders.size() - 1; i >= 0; i--) { + if (!cronetProviders.get(i).isEnabled() + || CronetProvider.PROVIDER_NAME_FALLBACK.equals(cronetProviders.get(i).getName())) { + cronetProviders.remove(i); + } + } + // Sort remaining providers by type and version. + CronetProviderComparator providerComparator = new CronetProviderComparator(preferGMSCoreCronet); + Collections.sort(cronetProviders, providerComparator); + for (int i = 0; i < cronetProviders.size() && cronetEngine == null; i++) { + String providerName = cronetProviders.get(i).getName(); + try { + cronetEngine = cronetProviders.get(i).createBuilder().build(); + if (providerComparator.isNativeProvider(providerName)) { + cronetEngineSource = SOURCE_NATIVE; + } else if (providerComparator.isGMSCoreProvider(providerName)) { + cronetEngineSource = SOURCE_GMS; + } else { + cronetEngineSource = SOURCE_UNKNOWN; + } + Log.d(TAG, "CronetEngine built using " + providerName); + } catch (SecurityException e) { + Log.w(TAG, "Failed to build CronetEngine. Please check if current process has " + + "android.permission.ACCESS_NETWORK_STATE."); + } catch (UnsatisfiedLinkError e) { + Log.w(TAG, "Failed to link Cronet binaries. Please check if native Cronet binaries are " + + "bundled into your app."); + } + } + if (cronetEngine == null) { + Log.w(TAG, "Cronet not available. Using fallback provider."); + } + this.cronetEngine = cronetEngine; + this.cronetEngineSource = cronetEngineSource; + } + + /** + * Creates a wrapper for an existing CronetEngine. + * + * @param cronetEngine An existing CronetEngine. + */ + public CronetEngineWrapper(CronetEngine cronetEngine) { + this.cronetEngine = cronetEngine; + this.cronetEngineSource = SOURCE_USER_PROVIDED; + } + + /** + * Returns the source of the wrapped {@link CronetEngine}. + * + * @return A {@link CronetEngineSource} value. + */ + @CronetEngineSource + public int getCronetEngineSource() { + return cronetEngineSource; + } + + /** + * Returns the wrapped {@link CronetEngine}. + * + * @return The CronetEngine, or null if no CronetEngine is available. + */ + @Nullable + /* package */ CronetEngine getCronetEngine() { + return cronetEngine; + } + + private static class CronetProviderComparator implements Comparator { + + @Nullable private final String gmsCoreCronetName; + private final boolean preferGMSCoreCronet; + + // Multi-catch can only be used for API 19+ in this case. + // Field#get(null) is blocked by the null-checker, but is safe because the field is static. + @SuppressWarnings({"UseMultiCatch", "nullness:argument.type.incompatible"}) + public CronetProviderComparator(boolean preferGMSCoreCronet) { + // GMSCore CronetProvider classes are only available in some configurations. + // Thus, we use reflection to copy static name. + String gmsCoreVersionString = null; + try { + Class cronetProviderInstallerClass = + Class.forName("com.google.android.gms.net.CronetProviderInstaller"); + Field providerNameField = cronetProviderInstallerClass.getDeclaredField("PROVIDER_NAME"); + gmsCoreVersionString = (String) providerNameField.get(null); + } catch (ClassNotFoundException e) { + // GMSCore CronetProvider not available. + } catch (NoSuchFieldException e) { + // GMSCore CronetProvider not available. + } catch (IllegalAccessException e) { + // GMSCore CronetProvider not available. + } + gmsCoreCronetName = gmsCoreVersionString; + this.preferGMSCoreCronet = preferGMSCoreCronet; + } + + @Override + public int compare(CronetProvider providerLeft, CronetProvider providerRight) { + int typePreferenceLeft = evaluateCronetProviderType(providerLeft.getName()); + int typePreferenceRight = evaluateCronetProviderType(providerRight.getName()); + if (typePreferenceLeft != typePreferenceRight) { + return typePreferenceLeft - typePreferenceRight; + } + return -compareVersionStrings(providerLeft.getVersion(), providerRight.getVersion()); + } + + public boolean isNativeProvider(String providerName) { + return CronetProvider.PROVIDER_NAME_APP_PACKAGED.equals(providerName); + } + + public boolean isGMSCoreProvider(String providerName) { + return gmsCoreCronetName != null && gmsCoreCronetName.equals(providerName); + } + + /** + * Convert Cronet provider name into a sortable preference value. + * Smaller values are preferred. + */ + private int evaluateCronetProviderType(String providerName) { + if (isNativeProvider(providerName)) { + return 1; + } + if (isGMSCoreProvider(providerName)) { + return preferGMSCoreCronet ? 0 : 2; + } + // Unknown provider type. + return -1; + } + + /** + * Compares version strings of format "12.123.35.23". + */ + private static int compareVersionStrings(String versionLeft, String versionRight) { + if (versionLeft == null || versionRight == null) { + return 0; + } + String[] versionStringsLeft = Util.split(versionLeft, "\\."); + String[] versionStringsRight = Util.split(versionRight, "\\."); + int minLength = min(versionStringsLeft.length, versionStringsRight.length); + for (int i = 0; i < minLength; i++) { + if (!versionStringsLeft[i].equals(versionStringsRight[i])) { + try { + int versionIntLeft = Integer.parseInt(versionStringsLeft[i]); + int versionIntRight = Integer.parseInt(versionStringsRight[i]); + return versionIntLeft - versionIntRight; + } catch (NumberFormatException e) { + return 0; + } + } + } + return 0; + } + } + +} diff --git a/demo/src/r2_12_1/java/com/google/android/exoplayer2/ext/cronet/package-info.java b/demo/src/r2_12_1/java/com/google/android/exoplayer2/ext/cronet/package-info.java new file mode 100644 index 00000000..ec0cf8df --- /dev/null +++ b/demo/src/r2_12_1/java/com/google/android/exoplayer2/ext/cronet/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package com.google.android.exoplayer2.ext.cronet; + +import com.google.android.exoplayer2.util.NonNullApi; diff --git a/demo/src/r2_12_1/res/drawable-hdpi/ic_download.png b/demo/src/r2_12_1/res/drawable-hdpi/ic_download.png new file mode 100644 index 0000000000000000000000000000000000000000..fa3ebbb31013c4b0710249bda570dc6907f89c98 GIT binary patch literal 199 zcmeAS@N?(olHy`uVBq!ia0vp^W+2SL0wmRZ7KH(+K2I0Nkch)?FYe}TFyLuPbW376 z)pJlo?yPcZ^WudL4T}87nO^?OR<7M-I>5VN zSGv-x+Pqecz(p>M;SU(JI7^xBN~X5I(&E2*)#%EBpV^XG4Bw*~qz?ElcvjfUb;wkU z|k0wldT1B8K8mZytjh{y4_Q*ZMgP~c#YJsK$U z)~(q1m-m)lF^^vy&zFBx;@>#sNTW$[{bH;=%sZG7QZ>uQBcw?@Ce|ClF7G156^ z#Rt_}Q9HRs{l8sYa>)1&$3+cMLFMTJXY2PZ*sH$V@BeR?lj;@>g(qYZ*L3k@0BvRP MboFyt=akR{0D4_J0ssI2 literal 0 HcmV?d00001 diff --git a/demo/src/r2_12_1/res/drawable-mdpi/ic_download_done.png b/demo/src/r2_12_1/res/drawable-mdpi/ic_download_done.png new file mode 100644 index 0000000000000000000000000000000000000000..08073a2a6dce34c3c57ba728240d4c9b31f519c9 GIT binary patch literal 182 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 literal 0 HcmV?d00001 diff --git a/demo/src/r2_12_1/res/drawable-xhdpi/ic_download_done.png b/demo/src/r2_12_1/res/drawable-xhdpi/ic_download_done.png new file mode 100644 index 0000000000000000000000000000000000000000..2339c0bf16e704b0a777a7e3c9d5531b0a510f40 GIT binary patch literal 304 zcmV-00nh%4P)IsdXBUJ^aMRZgMdI00mE<+!URGH7!WWZp?4YzLLj#78*sp#%eeQR+5h(h zpU*@@M5K%f6N`gK08*$NPl}P_QkWc)No zp0wzm<4Iq@f-8%H6FwQ5h{)lg0tG2ZK_Vg|du0PUE_qut;+EY20000wE~K=l?3?( zGdMK-S2&PeVl4v{nd9l=7*fHQBylW3q=!jSRb_$47PHi3l>`%oTW(u>v!t(@t;!9V zJy}SEMLFBJwR6>j3&&D;GRpgW8CV!3IGof3H#NT7KbyJZy7o@HnimIlhFeGN^F5VT@o&a8c7027j!s^099ZJR31yKy14VEd-{KK aWAi^1&nHZeEnWw7CxfS}pUXO@geCxTuwFg@ literal 0 HcmV?d00001 diff --git a/demo/src/r2_12_1/res/drawable-xxhdpi/ic_download_done.png b/demo/src/r2_12_1/res/drawable-xxhdpi/ic_download_done.png new file mode 100644 index 0000000000000000000000000000000000000000..b631a00088b192f8793e8e9c721cd133cd7f325d GIT binary patch literal 450 zcmV;z0X_bSP)m=(edlr3EaGus#)?oguD&H&p zIqNikR=;N@(%Q)yu)G+c#_vjPXVtQ0IaWy9wl6I3NXm)z2G)5erPRi)ezQWQwUkx$ zte(IIZ=^IxIkw)z8n1MwLZ4Z@w2HYvX-AfjKdl)m%dtwcgRDSlhgJZd_7W<~vD{MH zffb3TMa%NE20z=k;$n$Lz${M-la)x@wHR2Uj@z*~n5P~v%hTAhcvxi9A`lS~5fNdJ soc0vv6s9nRDNJDs6A=*+5fKrQ4-@T7x~OO3+yDRo07*qoM6N<$g37tT1ONa4 literal 0 HcmV?d00001 diff --git a/demo/src/r2_12_1/res/drawable-xxxhdpi/ic_download.png b/demo/src/r2_12_1/res/drawable-xxxhdpi/ic_download.png new file mode 100644 index 0000000000000000000000000000000000000000..f9bfb5edba7fdff1e63a71c0dfaab6ae5dc2a9a9 GIT binary patch literal 263 zcmeAS@N?(olHy`uVBq!ia0vp^6F``W8A#63;L-+CoB=)|u0Z-f7!+45O$Kt9OM?7@ z8U8o4yxP?RDjWv+R;i913rU`GJA70J0Yf7HdZ(Wfyo{Xm^Fh6({ z_nPtA>pxZT^|vw>_nO_(IRD_a*ip%vc=Os1g?FzWTps**Qn22_*r1(DN<1H4T7bql vcxeelge1npOP|+Xm)v7s>gTe~DWM4f1o3xI literal 0 HcmV?d00001 diff --git a/demo/src/r2_12_1/res/drawable-xxxhdpi/ic_download_done.png b/demo/src/r2_12_1/res/drawable-xxxhdpi/ic_download_done.png new file mode 100644 index 0000000000000000000000000000000000000000..52fe8f69907ddd3cb364be0d13278919d7c32afa GIT binary patch literal 575 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q1xWh(YZ)^zFtK^MIEGX(zP-elel=0T;bVHa ztAV$mt6-;+qmPRKhYANvi%^%qp%>LH5(Y;#lsgm-97$3V>)x2N%tvc(%f=6OcmLn5 ze!O??eRm5#k4Y+?5R}Dv*~h(e$NBv4PM&hYPb+<n!&;B2AjaGHus}W8=sWkcGlSLi_P>!sxl9B ztJ++Bm`_%HNy3yt@tDafP z>ptX>esalW@x1gd-(QBOBU)|dN1rZk)cs|8_{O@=bGo;EXwj29F2BQ7Jbp&kwGHj! zC5FZyY-j(pT|D{4#fHjrZh2FLO3!V+=QfY!{L4qGu{kDl)%UMxD)n=p*m-S}jat;> zlr7qxDf3h=8o!Fjwe)ZLsMQ%}Dd(ydb@J1UjMn79ltm`}ozpf0RX+0&nW=F$dC_;} zdFq~Dwyd{le^l@KetqXOkTH)EL}s3_$z0_6Ii*wVro5|~k^iiWgEo + + + + + + + + + + + +