From 28695d9ab56dbd4a0940949900deccb5a7a57e01 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Wed, 17 Jun 2020 07:57:27 +0100 Subject: [PATCH] Move IMA SDK callbacks into inner class The release() method was added in the recent IMA API changes for preloading and now 'collides' with the ExoPlayer AdsLoader release method. This led to all ads completing being treated as a call to completely release the ads loader, which meant that the ad playback state was not updated on resuming after all ads had completed, which in turn led to playback getting stuck buffering on returning from the background after all ads played. Move the IMA callbacks into an inner class to avoid this. Issue: #7508 PiperOrigin-RevId: 316834561 --- RELEASENOTES.md | 3 + .../exoplayer2/ext/ima/ImaAdsLoader.java | 615 +++++++++--------- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 82 ++- 3 files changed, 373 insertions(+), 327 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 125ca8ccc9e..e268181b669 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -215,6 +215,9 @@ * Add option to skip ads before the start position. * Catch unexpected errors in `stopAd` to avoid a crash ([#7492](https://github.com/google/ExoPlayer/issues/7492)). + * Fix a bug that caused playback to be stuck buffering on resuming from + the background after all ads had played to the end + ([#7508](https://github.com/google/ExoPlayer/issues/7508)). * Demo app: Retain previous position in list of samples. * Add Guava dependency. diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 013791844bc..98718752bfa 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -86,14 +86,7 @@ * For more details see {@link AdDisplayContainer#registerVideoControlsOverlay(View)} and {@link * AdViewProvider#getAdOverlayViews()}. */ -public final class ImaAdsLoader - implements Player.EventListener, - AdsLoader, - VideoAdPlayer, - ContentProgressProvider, - AdErrorListener, - AdsLoadedListener, - AdEventListener { +public final class ImaAdsLoader implements Player.EventListener, AdsLoader { static { ExoPlayerLibraryInfo.registerModule("goog.exo.ima"); @@ -364,12 +357,13 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { */ private static final int IMA_AD_STATE_NONE = 0; /** - * The ad playback state when IMA has called {@link #playAd(AdMediaInfo)} and not {@link - * #pauseAd(AdMediaInfo)}. + * The ad playback state when IMA has called {@link ComponentListener#playAd(AdMediaInfo)} and not + * {@link ComponentListener##pauseAd(AdMediaInfo)}. */ private static final int IMA_AD_STATE_PLAYING = 1; /** - * The ad playback state when IMA has called {@link #pauseAd(AdMediaInfo)} while playing an ad. + * The ad playback state when IMA has called {@link ComponentListener#pauseAd(AdMediaInfo)} while + * playing an ad. */ private static final int IMA_AD_STATE_PAUSED = 2; @@ -386,7 +380,8 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { private final ImaFactory imaFactory; private final Timeline.Period period; private final Handler handler; - private final List adCallbacks; + private final ComponentListener componentListener; + private final List adCallbacks; private final AdDisplayContainer adDisplayContainer; private final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader; private final Runnable updateAdProgressRunnable; @@ -400,7 +395,7 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { @Nullable private Player player; private VideoProgressUpdate lastContentProgress; private VideoProgressUpdate lastAdProgress; - private int lastVolumePercentage; + private int lastVolumePercent; @Nullable private AdsManager adsManager; private boolean isAdsManagerInitialized; @@ -443,10 +438,10 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { */ @Nullable private AdInfo pendingAdPrepareErrorAdInfo; /** - * If a content period has finished but IMA has not yet called {@link #playAd(AdMediaInfo)}, - * stores the value of {@link SystemClock#elapsedRealtime()} when the content stopped playing. - * This can be used to determine a fake, increasing content position. {@link C#TIME_UNSET} - * otherwise. + * If a content period has finished but IMA has not yet called {@link + * ComponentListener#playAd(AdMediaInfo)}, stores the value of {@link + * SystemClock#elapsedRealtime()} when the content stopped playing. This can be used to determine + * a fake, increasing content position. {@link C#TIME_UNSET} otherwise. */ private long fakeContentProgressElapsedRealtimeMs; /** @@ -456,7 +451,10 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { private long fakeContentProgressOffsetMs; /** Stores the pending content position when a seek operation was intercepted to play an ad. */ private long pendingContentPositionMs; - /** Whether {@link #getContentProgress()} has sent {@link #pendingContentPositionMs} to IMA. */ + /** + * Whether {@link ComponentListener#getContentProgress()} has sent {@link + * #pendingContentPositionMs} to IMA. + */ private boolean sentPendingContentPositionMs; /** * Stores the real time in milliseconds at which the player started buffering, possibly due to not @@ -528,14 +526,15 @@ private ImaAdsLoader( imaSdkSettings.setPlayerVersion(IMA_SDK_SETTINGS_PLAYER_VERSION); period = new Timeline.Period(); handler = Util.createHandler(getImaLooper(), /* callback= */ null); + componentListener = new ComponentListener(); adCallbacks = new ArrayList<>(/* initialCapacity= */ 1); adDisplayContainer = imaFactory.createAdDisplayContainer(); - adDisplayContainer.setPlayer(/* videoAdPlayer= */ this); + adDisplayContainer.setPlayer(/* videoAdPlayer= */ componentListener); adsLoader = imaFactory.createAdsLoader( context.getApplicationContext(), imaSdkSettings, adDisplayContainer); - adsLoader.addAdErrorListener(/* adErrorListener= */ this); - adsLoader.addAdsLoadedListener(/* adsLoadedListener= */ this); + adsLoader.addAdErrorListener(componentListener); + adsLoader.addAdsLoadedListener(componentListener); updateAdProgressRunnable = this::updateAdProgress; adInfoByAdMediaInfo = new HashMap<>(); supportedMimeTypes = Collections.emptyList(); @@ -596,7 +595,7 @@ public void requestAds(ViewGroup adViewGroup) { if (vastLoadTimeoutMs != TIMEOUT_UNSET) { request.setVastLoadTimeout(vastLoadTimeoutMs); } - request.setContentProgressProvider(this); + request.setContentProgressProvider(componentListener); pendingAdRequestContext = new Object(); request.setUserRequestContext(pendingAdRequestContext); adsLoader.requestAds(request); @@ -645,7 +644,7 @@ public void start(EventListener eventListener, AdViewProvider adViewProvider) { player.addListener(this); boolean playWhenReady = player.getPlayWhenReady(); this.eventListener = eventListener; - lastVolumePercentage = 0; + lastVolumePercent = 0; lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; ViewGroup adViewGroup = adViewProvider.getAdViewGroup(); @@ -682,9 +681,9 @@ public void stop() { adPlaybackState.withAdResumePositionUs( playingAd ? C.msToUs(player.getCurrentPosition()) : 0); } - lastVolumePercentage = getVolume(); + lastVolumePercent = getPlayerVolumePercent(); lastAdProgress = getAdVideoProgressUpdate(); - lastContentProgress = getContentProgress(); + lastContentProgress = getContentVideoProgressUpdate(); adDisplayContainer.unregisterAllVideoControlsOverlays(); player.removeListener(this); this.player = null; @@ -695,8 +694,8 @@ public void stop() { public void release() { pendingAdRequestContext = null; destroyAdsManager(); - adsLoader.removeAdsLoadedListener(/* adsLoadedListener= */ this); - adsLoader.removeAdErrorListener(/* adErrorListener= */ this); + adsLoader.removeAdsLoadedListener(componentListener); + adsLoader.removeAdErrorListener(componentListener); imaPausedContent = false; imaAdState = IMA_AD_STATE_NONE; imaAdMediaInfo = null; @@ -704,7 +703,7 @@ public void release() { imaAdInfo = null; pendingAdLoadError = null; adPlaybackState = AdPlaybackState.NONE; - hasAdPlaybackState = false; + hasAdPlaybackState = true; updateAdPlaybackState(); } @@ -720,275 +719,6 @@ public void handlePrepareError(int adGroupIndex, int adIndexInAdGroup, IOExcepti } } - // com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener implementation. - - @Override - public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) { - AdsManager adsManager = adsManagerLoadedEvent.getAdsManager(); - if (!Util.areEqual(pendingAdRequestContext, adsManagerLoadedEvent.getUserRequestContext())) { - adsManager.destroy(); - return; - } - pendingAdRequestContext = null; - this.adsManager = adsManager; - adsManager.addAdErrorListener(this); - adsManager.addAdEventListener(this); - if (adEventListener != null) { - adsManager.addAdEventListener(adEventListener); - } - if (player != null) { - // If a player is attached already, start playback immediately. - try { - adPlaybackState = AdPlaybackStateFactory.fromCuePoints(adsManager.getAdCuePoints()); - hasAdPlaybackState = true; - updateAdPlaybackState(); - } catch (RuntimeException e) { - maybeNotifyInternalError("onAdsManagerLoaded", e); - } - } - } - - // AdEvent.AdEventListener implementation. - - @Override - public void onAdEvent(AdEvent adEvent) { - AdEventType adEventType = adEvent.getType(); - if (DEBUG && adEventType != AdEventType.AD_PROGRESS) { - Log.d(TAG, "onAdEvent: " + adEventType); - } - if (adsManager == null) { - // Drop events after release. - return; - } - try { - handleAdEvent(adEvent); - } catch (RuntimeException e) { - maybeNotifyInternalError("onAdEvent", e); - } - } - - // AdErrorEvent.AdErrorListener implementation. - - @Override - public void onAdError(AdErrorEvent adErrorEvent) { - AdError error = adErrorEvent.getError(); - if (DEBUG) { - Log.d(TAG, "onAdError", error); - } - if (adsManager == null) { - // No ads were loaded, so allow playback to start without any ads. - pendingAdRequestContext = null; - adPlaybackState = AdPlaybackState.NONE; - hasAdPlaybackState = true; - updateAdPlaybackState(); - } else if (isAdGroupLoadError(error)) { - try { - handleAdGroupLoadError(error); - } catch (RuntimeException e) { - maybeNotifyInternalError("onAdError", e); - } - } - if (pendingAdLoadError == null) { - pendingAdLoadError = AdLoadException.createForAllAds(error); - } - maybeNotifyPendingAdLoadError(); - } - - // ContentProgressProvider implementation. - - @Override - public VideoProgressUpdate getContentProgress() { - VideoProgressUpdate videoProgressUpdate = getContentVideoProgressUpdate(); - if (DEBUG) { - Log.d(TAG, "Content progress: " + videoProgressUpdate); - } - - if (waitingForPreloadElapsedRealtimeMs != C.TIME_UNSET) { - // IMA is polling the player position but we are buffering for an ad to preload, so playback - // may be stuck. Detect this case and signal an error if applicable. - long stuckElapsedRealtimeMs = - SystemClock.elapsedRealtime() - waitingForPreloadElapsedRealtimeMs; - if (stuckElapsedRealtimeMs >= THRESHOLD_AD_PRELOAD_MS) { - waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; - handleAdGroupLoadError(new IOException("Ad preloading timed out")); - maybeNotifyPendingAdLoadError(); - } - } - - return videoProgressUpdate; - } - - // VideoAdPlayer implementation. - - @Override - public VideoProgressUpdate getAdProgress() { - throw new IllegalStateException("Unexpected call to getAdProgress when using preloading"); - } - - @Override - public int getVolume() { - @Nullable Player player = this.player; - if (player == null) { - return lastVolumePercentage; - } - - @Nullable Player.AudioComponent audioComponent = player.getAudioComponent(); - if (audioComponent != null) { - return (int) (audioComponent.getVolume() * 100); - } - - // Check for a selected track using an audio renderer. - TrackSelectionArray trackSelections = player.getCurrentTrackSelections(); - for (int i = 0; i < player.getRendererCount() && i < trackSelections.length; i++) { - if (player.getRendererType(i) == C.TRACK_TYPE_AUDIO && trackSelections.get(i) != null) { - return 100; - } - } - return 0; - } - - @Override - public void loadAd(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) { - try { - if (DEBUG) { - Log.d(TAG, "loadAd " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo); - } - if (adsManager == null) { - // Drop events after release. - return; - } - int adGroupIndex = getAdGroupIndexForAdPod(adPodInfo); - int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; - AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); - adInfoByAdMediaInfo.put(adMediaInfo, adInfo); - if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) { - // We have already marked this ad as having failed to load, so ignore the request. IMA will - // timeout after its media load timeout. - return; - } - AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; - if (adGroup.count == C.LENGTH_UNSET) { - adPlaybackState = - adPlaybackState.withAdCount( - adInfo.adGroupIndex, Math.max(adPodInfo.getTotalAds(), adGroup.states.length)); - adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; - } - for (int i = 0; i < adIndexInAdGroup; i++) { - // Any preceding ads that haven't loaded are not going to load. - if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) { - adPlaybackState = - adPlaybackState.withAdLoadError( - /* adGroupIndex= */ adGroupIndex, /* adIndexInAdGroup= */ i); - } - } - Uri adUri = Uri.parse(adMediaInfo.getUrl()); - adPlaybackState = - adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri); - updateAdPlaybackState(); - } catch (RuntimeException e) { - maybeNotifyInternalError("loadAd", e); - } - } - - @Override - public void addCallback(VideoAdPlayerCallback videoAdPlayerCallback) { - adCallbacks.add(videoAdPlayerCallback); - } - - @Override - public void removeCallback(VideoAdPlayerCallback videoAdPlayerCallback) { - adCallbacks.remove(videoAdPlayerCallback); - } - - @Override - public void playAd(AdMediaInfo adMediaInfo) { - if (DEBUG) { - Log.d(TAG, "playAd " + getAdMediaInfoString(adMediaInfo)); - } - if (adsManager == null) { - // Drop events after release. - return; - } - - if (imaAdState == IMA_AD_STATE_PLAYING) { - // IMA does not always call stopAd before resuming content. - // See [Internal: b/38354028]. - Log.w(TAG, "Unexpected playAd without stopAd"); - } - - try { - if (imaAdState == IMA_AD_STATE_NONE) { - // IMA is requesting to play the ad, so stop faking the content position. - fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; - fakeContentProgressOffsetMs = C.TIME_UNSET; - imaAdState = IMA_AD_STATE_PLAYING; - imaAdMediaInfo = adMediaInfo; - imaAdInfo = Assertions.checkNotNull(adInfoByAdMediaInfo.get(adMediaInfo)); - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onPlay(adMediaInfo); - } - if (pendingAdPrepareErrorAdInfo != null && pendingAdPrepareErrorAdInfo.equals(imaAdInfo)) { - pendingAdPrepareErrorAdInfo = null; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onError(adMediaInfo); - } - } - updateAdProgress(); - } else { - imaAdState = IMA_AD_STATE_PLAYING; - Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onResume(adMediaInfo); - } - } - if (!Assertions.checkNotNull(player).getPlayWhenReady()) { - Assertions.checkNotNull(adsManager).pause(); - } - } catch (RuntimeException e) { - maybeNotifyInternalError("playAd", e); - } - } - - @Override - public void stopAd(AdMediaInfo adMediaInfo) { - if (DEBUG) { - Log.d(TAG, "stopAd " + getAdMediaInfoString(adMediaInfo)); - } - if (adsManager == null) { - // Drop event after release. - return; - } - - try { - Assertions.checkNotNull(player); - Assertions.checkState(imaAdState != IMA_AD_STATE_NONE); - stopAdInternal(); - } catch (RuntimeException e) { - maybeNotifyInternalError("stopAd", e); - } - } - - @Override - public void pauseAd(AdMediaInfo adMediaInfo) { - if (DEBUG) { - Log.d(TAG, "pauseAd " + getAdMediaInfoString(adMediaInfo)); - } - if (imaAdState == IMA_AD_STATE_NONE) { - // This method is called after content is resumed. - return; - } - - try { - Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); - imaAdState = IMA_AD_STATE_PAUSED; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onPause(adMediaInfo); - } - } catch (RuntimeException e) { - maybeNotifyInternalError("pauseAd", e); - } - } - // Player.EventListener implementation. @Override @@ -1265,6 +995,27 @@ private void stopUpdatingAdProgress() { handler.removeCallbacks(updateAdProgressRunnable); } + private int getPlayerVolumePercent() { + @Nullable Player player = this.player; + if (player == null) { + return lastVolumePercent; + } + + @Nullable Player.AudioComponent audioComponent = player.getAudioComponent(); + if (audioComponent != null) { + return (int) (audioComponent.getVolume() * 100); + } + + // Check for a selected track using an audio renderer. + TrackSelectionArray trackSelections = player.getCurrentTrackSelections(); + for (int i = 0; i < player.getRendererCount() && i < trackSelections.length; i++) { + if (player.getRendererType(i) == C.TRACK_TYPE_AUDIO && trackSelections.get(i) != null) { + return 100; + } + } + return 0; + } + private void handlePlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) { if (playingAd && imaAdState == IMA_AD_STATE_PLAYING) { if (!bufferingAd && playbackState == Player.STATE_BUFFERING) { @@ -1581,8 +1332,8 @@ private static boolean hasMidrollAdGroups(long[] adGroupTimesUs) { private void destroyAdsManager() { if (adsManager != null) { - adsManager.removeAdErrorListener(this); - adsManager.removeAdEventListener(this); + adsManager.removeAdErrorListener(componentListener); + adsManager.removeAdEventListener(componentListener); if (adEventListener != null) { adsManager.removeAdEventListener(adEventListener); } @@ -1607,6 +1358,272 @@ com.google.ads.interactivemedia.v3.api.AdsLoader createAdsLoader( Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer); } + private final class ComponentListener + implements VideoAdPlayer, + ContentProgressProvider, + AdErrorListener, + AdsLoadedListener, + AdEventListener { + + // com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener implementation. + + @Override + public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) { + AdsManager adsManager = adsManagerLoadedEvent.getAdsManager(); + if (!Util.areEqual(pendingAdRequestContext, adsManagerLoadedEvent.getUserRequestContext())) { + adsManager.destroy(); + return; + } + pendingAdRequestContext = null; + ImaAdsLoader.this.adsManager = adsManager; + adsManager.addAdErrorListener(this); + adsManager.addAdEventListener(this); + if (adEventListener != null) { + adsManager.addAdEventListener(adEventListener); + } + if (player != null) { + // If a player is attached already, start playback immediately. + try { + adPlaybackState = AdPlaybackStateFactory.fromCuePoints(adsManager.getAdCuePoints()); + hasAdPlaybackState = true; + updateAdPlaybackState(); + } catch (RuntimeException e) { + maybeNotifyInternalError("onAdsManagerLoaded", e); + } + } + } + + // AdEvent.AdEventListener implementation. + + @Override + public void onAdEvent(AdEvent adEvent) { + AdEventType adEventType = adEvent.getType(); + if (DEBUG && adEventType != AdEventType.AD_PROGRESS) { + Log.d(TAG, "onAdEvent: " + adEventType); + } + if (adsManager == null) { + // Drop events after release. + return; + } + try { + handleAdEvent(adEvent); + } catch (RuntimeException e) { + maybeNotifyInternalError("onAdEvent", e); + } + } + + // AdErrorEvent.AdErrorListener implementation. + + @Override + public void onAdError(AdErrorEvent adErrorEvent) { + AdError error = adErrorEvent.getError(); + if (DEBUG) { + Log.d(TAG, "onAdError", error); + } + if (adsManager == null) { + // No ads were loaded, so allow playback to start without any ads. + pendingAdRequestContext = null; + adPlaybackState = AdPlaybackState.NONE; + hasAdPlaybackState = true; + updateAdPlaybackState(); + } else if (isAdGroupLoadError(error)) { + try { + handleAdGroupLoadError(error); + } catch (RuntimeException e) { + maybeNotifyInternalError("onAdError", e); + } + } + if (pendingAdLoadError == null) { + pendingAdLoadError = AdLoadException.createForAllAds(error); + } + maybeNotifyPendingAdLoadError(); + } + + // ContentProgressProvider implementation. + + @Override + public VideoProgressUpdate getContentProgress() { + VideoProgressUpdate videoProgressUpdate = getContentVideoProgressUpdate(); + if (DEBUG) { + Log.d(TAG, "Content progress: " + videoProgressUpdate); + } + + if (waitingForPreloadElapsedRealtimeMs != C.TIME_UNSET) { + // IMA is polling the player position but we are buffering for an ad to preload, so playback + // may be stuck. Detect this case and signal an error if applicable. + long stuckElapsedRealtimeMs = + SystemClock.elapsedRealtime() - waitingForPreloadElapsedRealtimeMs; + if (stuckElapsedRealtimeMs >= THRESHOLD_AD_PRELOAD_MS) { + waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; + handleAdGroupLoadError(new IOException("Ad preloading timed out")); + maybeNotifyPendingAdLoadError(); + } + } + + return videoProgressUpdate; + } + + // VideoAdPlayer implementation. + + @Override + public VideoProgressUpdate getAdProgress() { + throw new IllegalStateException("Unexpected call to getAdProgress when using preloading"); + } + + @Override + public int getVolume() { + return getPlayerVolumePercent(); + } + + @Override + public void loadAd(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) { + try { + if (DEBUG) { + Log.d(TAG, "loadAd " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo); + } + if (adsManager == null) { + // Drop events after release. + return; + } + int adGroupIndex = getAdGroupIndexForAdPod(adPodInfo); + int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; + AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); + adInfoByAdMediaInfo.put(adMediaInfo, adInfo); + if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) { + // We have already marked this ad as having failed to load, so ignore the request. IMA + // will timeout after its media load timeout. + return; + } + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; + if (adGroup.count == C.LENGTH_UNSET) { + adPlaybackState = + adPlaybackState.withAdCount( + adInfo.adGroupIndex, Math.max(adPodInfo.getTotalAds(), adGroup.states.length)); + adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; + } + for (int i = 0; i < adIndexInAdGroup; i++) { + // Any preceding ads that haven't loaded are not going to load. + if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) { + adPlaybackState = + adPlaybackState.withAdLoadError( + /* adGroupIndex= */ adGroupIndex, /* adIndexInAdGroup= */ i); + } + } + Uri adUri = Uri.parse(adMediaInfo.getUrl()); + adPlaybackState = + adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri); + updateAdPlaybackState(); + } catch (RuntimeException e) { + maybeNotifyInternalError("loadAd", e); + } + } + + @Override + public void addCallback(VideoAdPlayerCallback videoAdPlayerCallback) { + adCallbacks.add(videoAdPlayerCallback); + } + + @Override + public void removeCallback(VideoAdPlayerCallback videoAdPlayerCallback) { + adCallbacks.remove(videoAdPlayerCallback); + } + + @Override + public void playAd(AdMediaInfo adMediaInfo) { + if (DEBUG) { + Log.d(TAG, "playAd " + getAdMediaInfoString(adMediaInfo)); + } + if (adsManager == null) { + // Drop events after release. + return; + } + + if (imaAdState == IMA_AD_STATE_PLAYING) { + // IMA does not always call stopAd before resuming content. + // See [Internal: b/38354028]. + Log.w(TAG, "Unexpected playAd without stopAd"); + } + + try { + if (imaAdState == IMA_AD_STATE_NONE) { + // IMA is requesting to play the ad, so stop faking the content position. + fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; + fakeContentProgressOffsetMs = C.TIME_UNSET; + imaAdState = IMA_AD_STATE_PLAYING; + imaAdMediaInfo = adMediaInfo; + imaAdInfo = Assertions.checkNotNull(adInfoByAdMediaInfo.get(adMediaInfo)); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onPlay(adMediaInfo); + } + if (pendingAdPrepareErrorAdInfo != null + && pendingAdPrepareErrorAdInfo.equals(imaAdInfo)) { + pendingAdPrepareErrorAdInfo = null; + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onError(adMediaInfo); + } + } + updateAdProgress(); + } else { + imaAdState = IMA_AD_STATE_PLAYING; + Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onResume(adMediaInfo); + } + } + if (!Assertions.checkNotNull(player).getPlayWhenReady()) { + Assertions.checkNotNull(adsManager).pause(); + } + } catch (RuntimeException e) { + maybeNotifyInternalError("playAd", e); + } + } + + @Override + public void stopAd(AdMediaInfo adMediaInfo) { + if (DEBUG) { + Log.d(TAG, "stopAd " + getAdMediaInfoString(adMediaInfo)); + } + if (adsManager == null) { + // Drop event after release. + return; + } + + try { + Assertions.checkNotNull(player); + Assertions.checkState(imaAdState != IMA_AD_STATE_NONE); + stopAdInternal(); + } catch (RuntimeException e) { + maybeNotifyInternalError("stopAd", e); + } + } + + @Override + public void pauseAd(AdMediaInfo adMediaInfo) { + if (DEBUG) { + Log.d(TAG, "pauseAd " + getAdMediaInfoString(adMediaInfo)); + } + if (imaAdState == IMA_AD_STATE_NONE) { + // This method is called after content is resumed. + return; + } + + try { + Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); + imaAdState = IMA_AD_STATE_PAUSED; + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onPause(adMediaInfo); + } + } catch (RuntimeException e) { + maybeNotifyInternalError("pauseAd", e); + } + } + + @Override + public void release() { + // Do nothing. + } + } + // TODO: Consider moving this into AdPlaybackState. private static final class AdInfo { public final int adGroupIndex; diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index 1a85f153712..c3202a26be0 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -44,6 +44,8 @@ import com.google.ads.interactivemedia.v3.api.AdsRequest; import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; import com.google.ads.interactivemedia.v3.api.player.AdMediaInfo; +import com.google.ads.interactivemedia.v3.api.player.ContentProgressProvider; +import com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.MediaItem; @@ -114,6 +116,9 @@ public final class ImaAdsLoaderTest { private ViewGroup adViewGroup; private View adOverlayView; private AdsLoader.AdViewProvider adViewProvider; + private AdEvent.AdEventListener adEventListener; + private ContentProgressProvider contentProgressProvider; + private VideoAdPlayer videoAdPlayer; private TestAdsLoaderListener adsLoaderListener; private FakePlayer fakeExoPlayer; private ImaAdsLoader imaAdsLoader; @@ -192,6 +197,8 @@ public void startAfterRelease() { @Test public void startAndCallbacksAfterRelease() { setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); + // Request ads in order to get a reference to the ad event listener. + imaAdsLoader.requestAds(adViewGroup); imaAdsLoader.release(); imaAdsLoader.start(adsLoaderListener, adViewProvider); fakeExoPlayer.setPlayingContentPosition(/* position= */ 0); @@ -202,16 +209,16 @@ public void startAndCallbacksAfterRelease() { // when using Robolectric and accessing VideoProgressUpdate.VIDEO_TIME_NOT_READY, due to the IMA // SDK being proguarded. imaAdsLoader.requestAds(adViewGroup); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); - imaAdsLoader.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); - imaAdsLoader.playAd(TEST_AD_MEDIA_INFO); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); - imaAdsLoader.pauseAd(TEST_AD_MEDIA_INFO); - imaAdsLoader.stopAd(TEST_AD_MEDIA_INFO); + adEventListener.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); + videoAdPlayer.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); + adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); + videoAdPlayer.playAd(TEST_AD_MEDIA_INFO); + adEventListener.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); + videoAdPlayer.pauseAd(TEST_AD_MEDIA_INFO); + videoAdPlayer.stopAd(TEST_AD_MEDIA_INFO); imaAdsLoader.onPlayerError(ExoPlaybackException.createForSource(new IOException())); imaAdsLoader.onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); + adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); imaAdsLoader.handlePrepareError( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, new IOException()); } @@ -222,27 +229,27 @@ public void playback_withPrerollAd_marksAdAsPlayed() { // Load the preroll ad. imaAdsLoader.start(adsLoaderListener, adViewProvider); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); - imaAdsLoader.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); + adEventListener.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); + videoAdPlayer.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); + adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); // Play the preroll ad. - imaAdsLoader.playAd(TEST_AD_MEDIA_INFO); + videoAdPlayer.playAd(TEST_AD_MEDIA_INFO); fakeExoPlayer.setPlayingAdPosition( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, /* position= */ 0, /* contentPosition= */ 0); fakeExoPlayer.setState(Player.STATE_READY, true); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.FIRST_QUARTILE, mockPrerollSingleAd)); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.MIDPOINT, mockPrerollSingleAd)); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.THIRD_QUARTILE, mockPrerollSingleAd)); + adEventListener.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); + adEventListener.onAdEvent(getAdEvent(AdEventType.FIRST_QUARTILE, mockPrerollSingleAd)); + adEventListener.onAdEvent(getAdEvent(AdEventType.MIDPOINT, mockPrerollSingleAd)); + adEventListener.onAdEvent(getAdEvent(AdEventType.THIRD_QUARTILE, mockPrerollSingleAd)); // Play the content. fakeExoPlayer.setPlayingContentPosition(0); - imaAdsLoader.stopAd(TEST_AD_MEDIA_INFO); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); + videoAdPlayer.stopAd(TEST_AD_MEDIA_INFO); + adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); // Verify that the preroll ad has been marked as played. assertThat(adsLoaderListener.adPlaybackState) @@ -262,7 +269,7 @@ public void playback_withPostrollFetchError_marksAdAsInErrorState() { // Simulate loading an empty postroll ad. imaAdsLoader.start(adsLoaderListener, adViewProvider); - imaAdsLoader.onAdEvent(mockPostrollFetchErrorAdEvent); + adEventListener.onAdEvent(mockPostrollFetchErrorAdEvent); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( @@ -289,7 +296,7 @@ public void playback_withAdNotPreloadingBeforeTimeout_hasNoError() { fakeExoPlayer.setState(Player.STATE_BUFFERING, /* playWhenReady= */ true); // Advance before the timeout and simulating polling content progress. ShadowSystemClock.advanceBy(Duration.ofSeconds(1)); - imaAdsLoader.getContentProgress(); + contentProgressProvider.getContentProgress(); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( @@ -313,7 +320,7 @@ public void playback_withAdNotPreloadingAfterTimeout_hasErrorAdGroup() { fakeExoPlayer.setState(Player.STATE_BUFFERING, /* playWhenReady= */ true); // Advance past the timeout and simulate polling content progress. ShadowSystemClock.advanceBy(Duration.ofSeconds(5)); - imaAdsLoader.getContentProgress(); + contentProgressProvider.getContentProgress(); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( @@ -660,6 +667,8 @@ private void setupMocks() { .thenAnswer(invocation -> userRequestContextCaptor.getValue()); List adsLoadedListeners = new ArrayList<>(); + // Deliberately don't handle removeAdsLoadedListener to allow testing behavior if the IMA SDK + // invokes callbacks after release. doAnswer( invocation -> { adsLoadedListeners.add(invocation.getArgument(0)); @@ -667,13 +676,6 @@ private void setupMocks() { }) .when(mockAdsLoader) .addAdsLoadedListener(any()); - doAnswer( - invocation -> { - adsLoadedListeners.remove(invocation.getArgument(0)); - return null; - }) - .when(mockAdsLoader) - .removeAdsLoadedListener(any()); when(mockAdsManagerLoadedEvent.getAdsManager()).thenReturn(mockAdsManager); when(mockAdsManagerLoadedEvent.getUserRequestContext()) .thenAnswer(invocation -> mockAdsRequest.getUserRequestContext()); @@ -689,6 +691,30 @@ private void setupMocks() { .when(mockAdsLoader) .requestAds(mockAdsRequest); + doAnswer( + invocation -> { + adEventListener = invocation.getArgument(0); + return null; + }) + .when(mockAdsManager) + .addAdEventListener(any()); + + doAnswer( + invocation -> { + contentProgressProvider = invocation.getArgument(0); + return null; + }) + .when(mockAdsRequest) + .setContentProgressProvider(any()); + + doAnswer( + invocation -> { + videoAdPlayer = invocation.getArgument(0); + return null; + }) + .when(mockAdDisplayContainer) + .setPlayer(any()); + when(mockImaFactory.createAdDisplayContainer()).thenReturn(mockAdDisplayContainer); when(mockImaFactory.createAdsRenderingSettings()).thenReturn(mockAdsRenderingSettings); when(mockImaFactory.createAdsRequest()).thenReturn(mockAdsRequest);