package com.mopub.mobileads; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.drawable.Drawable; import android.graphics.drawable.GradientDrawable; import android.graphics.drawable.LayerDrawable; import android.media.MediaPlayer; import android.os.Bundle; import android.os.Handler; import android.view.Gravity; import android.view.MotionEvent; import android.view.View; import android.widget.ImageView; import android.widget.RelativeLayout; import android.widget.VideoView; import com.mopub.common.DownloadResponse; import com.mopub.common.DownloadTask; import com.mopub.common.HttpResponses; import com.mopub.common.MoPubBrowser; import com.mopub.common.VisibleForTesting; import com.mopub.common.event.MoPubEvents; import com.mopub.common.logging.MoPubLog; import com.mopub.common.util.AsyncTasks; import com.mopub.common.util.Dips; import com.mopub.common.util.Drawables; import com.mopub.common.util.Intents; import com.mopub.common.util.Streams; import com.mopub.common.util.VersionCode; import com.mopub.exceptions.IntentNotResolvableException; import com.mopub.exceptions.UrlParseException; import com.mopub.mobileads.util.vast.VastCompanionAd; import com.mopub.mobileads.util.vast.VastVideoConfiguration; import org.apache.http.HttpStatus; import org.apache.http.client.methods.HttpGet; import java.io.*; import java.util.*; import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE; import static com.mopub.common.HttpClient.initializeHttpGet; import static com.mopub.mobileads.EventForwardingBroadcastReceiver.ACTION_INTERSTITIAL_CLICK; import static com.mopub.mobileads.EventForwardingBroadcastReceiver.ACTION_INTERSTITIAL_DISMISS; import static com.mopub.mobileads.EventForwardingBroadcastReceiver.ACTION_INTERSTITIAL_SHOW; import static com.mopub.network.TrackingRequest.makeTrackingHttpRequest; public class VastVideoViewController extends BaseVideoViewController implements DownloadTask.DownloadTaskListener { static final String VAST_VIDEO_CONFIGURATION = "vast_video_configuration"; private static final float FIRST_QUARTER_MARKER = 0.25f; private static final float MID_POINT_MARKER = 0.50f; private static final float THIRD_QUARTER_MARKER = 0.75f; private static final long VIDEO_PROGRESS_TIMER_CHECKER_DELAY = 50; private static final int MOPUB_BROWSER_REQUEST_CODE = 1; private static final int MAX_VIDEO_RETRIES = 1; private static final int VIDEO_VIEW_FILE_PERMISSION_ERROR = Integer.MIN_VALUE; static final int DEFAULT_VIDEO_DURATION_FOR_CLOSE_BUTTON = 5 * 1000; static final int MAX_VIDEO_DURATION_FOR_CLOSE_BUTTON = 16 * 1000; static final int START_MARK_THRESHOLD = 2000; private final VastVideoConfiguration mVastVideoConfiguration; private final VastCompanionAd mVastCompanionAd; private final VastVideoToolbar mVastVideoToolbar; private final VideoView mVideoView; private final ImageView mCompanionAdImageView; private final View.OnTouchListener mClickThroughListener; private final Handler mHandler; private final Runnable mVideoProgressCheckerRunnable; private boolean mIsVideoProgressShouldBeChecked; private int mShowCloseButtonDelay = DEFAULT_VIDEO_DURATION_FOR_CLOSE_BUTTON; private boolean mShowCloseButtonEventFired; private boolean mIsStartMarkHit; private boolean mIsFirstMarkHit; private boolean mIsSecondMarkHit; private boolean mIsThirdMarkHit; // This flag indicates that the final video checkpoint has been reached, therefore allowing // us to fire the video completion tracker in MediaPlayer#onCompletion. // This is a safeguard against inconsistent MediaPlayer#onCompletion callbacks due to differing // implementations across Android versions and devices. private boolean mIsFinalMarkHit; private int mSeekerPositionOnPause; private boolean mIsVideoFinishedPlaying; private int mVideoRetries; private boolean mVideoError; private boolean mCompletionTrackerFired; VastVideoViewController(final Context context, final Bundle bundle, final long broadcastIdentifier, final BaseVideoViewControllerListener baseVideoViewControllerListener) throws IllegalStateException { super(context, broadcastIdentifier, baseVideoViewControllerListener); mHandler = new Handler(); mIsVideoProgressShouldBeChecked = false; mSeekerPositionOnPause = -1; mVideoRetries = 0; Serializable serializable = bundle.getSerializable(VAST_VIDEO_CONFIGURATION); if (serializable != null && serializable instanceof VastVideoConfiguration) { mVastVideoConfiguration = (VastVideoConfiguration) serializable; } else { throw new IllegalStateException("VastVideoConfiguration is invalid"); } if (mVastVideoConfiguration.getDiskMediaFileUrl() == null) { throw new IllegalStateException("VastVideoConfiguration does not have a video disk path"); } mVastCompanionAd = mVastVideoConfiguration.getVastCompanionAd(); mClickThroughListener = new View.OnTouchListener() { @Override public boolean onTouch(View view, MotionEvent motionEvent) { if (motionEvent.getAction() == MotionEvent.ACTION_UP && shouldAllowClickThrough()) { handleClick( mVastVideoConfiguration.getClickTrackers(), mVastVideoConfiguration.getClickThroughUrl() ); } return true; } }; createVideoBackground(context); mVideoView = createVideoView(context); mVideoView.requestFocus(); mVastVideoToolbar = createVastVideoToolBar(context); getLayout().addView(mVastVideoToolbar); mCompanionAdImageView = createCompanionAdImageView(context); mVideoProgressCheckerRunnable = createVideoProgressCheckerRunnable(); } @Override protected VideoView getVideoView() { return mVideoView; } @Override protected void onCreate() { super.onCreate(); getBaseVideoViewControllerListener().onSetRequestedOrientation(SCREEN_ORIENTATION_LANDSCAPE); downloadCompanionAd(); makeTrackingHttpRequest( mVastVideoConfiguration.getImpressionTrackers(), getContext(), MoPubEvents.Type.IMPRESSION_REQUEST ); broadcastAction(ACTION_INTERSTITIAL_SHOW); } @Override protected void onResume() { // When resuming, VideoView needs to reinitialize its MediaPlayer with the video path // and therefore reset the count to zero, to let it retry on error mVideoRetries = 0; startProgressChecker(); mVideoView.seekTo(mSeekerPositionOnPause); if (!mIsVideoFinishedPlaying) { mVideoView.start(); } } @Override protected void onPause() { stopProgressChecker(); mSeekerPositionOnPause = mVideoView.getCurrentPosition(); mVideoView.pause(); } @Override protected void onDestroy() { stopProgressChecker(); broadcastAction(ACTION_INTERSTITIAL_DISMISS); } // Enable the device's back button when the video close button has been displayed @Override public boolean backButtonEnabled() { return mShowCloseButtonEventFired; } @Override void onActivityResult(final int requestCode, final int resultCode, final Intent data) { if (requestCode == MOPUB_BROWSER_REQUEST_CODE && resultCode == Activity.RESULT_OK) { getBaseVideoViewControllerListener().onFinish(); } } // DownloadTaskListener @Override public void onComplete(String url, DownloadResponse downloadResponse) { if (downloadResponse != null && downloadResponse.getStatusCode() == HttpStatus.SC_OK) { final Bitmap companionAdBitmap = HttpResponses.asBitmap(downloadResponse); if (companionAdBitmap != null) { // If Bitmap fits in ImageView, then don't use MATCH_PARENT final int width = Dips.dipsToIntPixels(companionAdBitmap.getWidth(), getContext()); final int height = Dips.dipsToIntPixels(companionAdBitmap.getHeight(), getContext()); final int imageViewWidth = mCompanionAdImageView.getMeasuredWidth(); final int imageViewHeight = mCompanionAdImageView.getMeasuredHeight(); if (width < imageViewWidth && height < imageViewHeight) { mCompanionAdImageView.getLayoutParams().width = width; mCompanionAdImageView.getLayoutParams().height = height; } mCompanionAdImageView.setImageBitmap(companionAdBitmap); mCompanionAdImageView.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { if (mVastCompanionAd != null) { handleClick( mVastCompanionAd.getClickTrackers(), mVastCompanionAd.getClickThroughUrl() ); } } }); } } } private void downloadCompanionAd() { if (mVastCompanionAd != null) { try { final HttpGet httpGet = initializeHttpGet(mVastCompanionAd.getImageUrl(), getContext()); final DownloadTask downloadTask = new DownloadTask(this); AsyncTasks.safeExecuteOnExecutor(downloadTask, httpGet); } catch (Exception e) { MoPubLog.d("Failed to download companion ad", e); } } } private Runnable createVideoProgressCheckerRunnable() { // This Runnable must only be run from the main thread due to accessing // class instance variables return new Runnable() { @Override public void run() { float videoLength = mVideoView.getDuration(); float currentPosition = mVideoView.getCurrentPosition(); if (videoLength > 0) { float progressPercentage = currentPosition / videoLength; if (!mIsStartMarkHit && currentPosition >= START_MARK_THRESHOLD) { mIsStartMarkHit = true; makeTrackingHttpRequest(mVastVideoConfiguration.getStartTrackers(), getContext()); } if (!mIsFirstMarkHit && progressPercentage > FIRST_QUARTER_MARKER) { mIsFirstMarkHit = true; makeTrackingHttpRequest(mVastVideoConfiguration.getFirstQuartileTrackers(), getContext()); } if (!mIsSecondMarkHit && progressPercentage > MID_POINT_MARKER) { mIsSecondMarkHit = true; makeTrackingHttpRequest(mVastVideoConfiguration.getMidpointTrackers(), getContext()); } if (!mIsThirdMarkHit && progressPercentage > THIRD_QUARTER_MARKER) { mIsThirdMarkHit = true; mIsFinalMarkHit = true; makeTrackingHttpRequest(mVastVideoConfiguration.getThirdQuartileTrackers(), getContext()); } if (isLongVideo(mVideoView.getDuration()) ) { mVastVideoToolbar.updateCountdownWidget(mShowCloseButtonDelay - mVideoView.getCurrentPosition()); } if (shouldBeInteractable()) { makeVideoInteractable(); } } mVastVideoToolbar.updateDurationWidget(mVideoView.getDuration() - mVideoView.getCurrentPosition()); if (mIsVideoProgressShouldBeChecked) { mHandler.postDelayed(mVideoProgressCheckerRunnable, VIDEO_PROGRESS_TIMER_CHECKER_DELAY); } } }; } private void createVideoBackground(final Context context) { GradientDrawable gradientDrawable = new GradientDrawable( GradientDrawable.Orientation.TOP_BOTTOM, new int[] {Color.argb(0,0,0,0), Color.argb(255,0,0,0)} ); Drawable[] layers = new Drawable[2]; layers[0] = Drawables.THATCHED_BACKGROUND.createDrawable(context); layers[1] = gradientDrawable; LayerDrawable layerList = new LayerDrawable(layers); getLayout().setBackgroundDrawable(layerList); } private VastVideoToolbar createVastVideoToolBar(final Context context) { final VastVideoToolbar vastVideoToolbar = new VastVideoToolbar(context); vastVideoToolbar.setCloseButtonOnTouchListener(new View.OnTouchListener() { @Override public boolean onTouch(View view, MotionEvent motionEvent) { if (motionEvent.getAction() == MotionEvent.ACTION_UP) { getBaseVideoViewControllerListener().onFinish(); } return true; } }); vastVideoToolbar.setLearnMoreButtonOnTouchListener(mClickThroughListener); return vastVideoToolbar; } private VideoView createVideoView(final Context context) { final VideoView videoView = new VideoView(context); videoView.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { @Override public void onPrepared(MediaPlayer mp) { // Called when media source is ready for playback if (mVideoView.getDuration() < MAX_VIDEO_DURATION_FOR_CLOSE_BUTTON) { mShowCloseButtonDelay = mVideoView.getDuration(); } } }); videoView.setOnTouchListener(mClickThroughListener); videoView.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { @Override public void onCompletion(MediaPlayer mp) { stopProgressChecker(); makeVideoInteractable(); videoCompleted(false); mIsVideoFinishedPlaying = true; if (!mVideoError && mIsFinalMarkHit && !mCompletionTrackerFired) { makeTrackingHttpRequest(mVastVideoConfiguration.getCompleteTrackers(), context); mCompletionTrackerFired = true; } videoView.setVisibility(View.GONE); // check the drawable to see if the image view was populated with content if (mCompanionAdImageView.getDrawable() != null) { mCompanionAdImageView.setVisibility(View.VISIBLE); } } }); videoView.setOnErrorListener(new MediaPlayer.OnErrorListener() { @Override public boolean onError(final MediaPlayer mediaPlayer, final int what, final int extra) { if (retryMediaPlayer(mediaPlayer, what, extra)) { return true; } else { stopProgressChecker(); makeVideoInteractable(); videoError(false); mVideoError = true; return false; } } }); videoView.setVideoPath(mVastVideoConfiguration.getDiskMediaFileUrl()); return videoView; } boolean retryMediaPlayer(final MediaPlayer mediaPlayer, final int what, final int extra) { // XXX // VideoView has a bug in versions lower than Jelly Bean, Api Level 16, Android 4.1 // For api < 16, VideoView is not able to read files written to disk since it reads them in // a Context different from the Application and therefore does not have correct permission. // To solve this problem we obtain the video file descriptor ourselves with valid permissions // and pass it to the underlying MediaPlayer in VideoView. if (VersionCode.currentApiLevel().isBelow(VersionCode.JELLY_BEAN) && what == MediaPlayer.MEDIA_ERROR_UNKNOWN && extra == VIDEO_VIEW_FILE_PERMISSION_ERROR && mVideoRetries < MAX_VIDEO_RETRIES) { FileInputStream inputStream = null; try { mediaPlayer.reset(); final File file = new File(mVastVideoConfiguration.getDiskMediaFileUrl()); inputStream = new FileInputStream(file); mediaPlayer.setDataSource(inputStream.getFD()); // XXX // VideoView has a callback registered with the MediaPlayer to set a flag when the // media file has been prepared. Start also sets a flag in VideoView indicating the // desired state is to play the video. Therefore, whichever method finishes last // will check both flags and begin playing the video. mediaPlayer.prepareAsync(); mVideoView.start(); return true; } catch (Exception e) { return false; } finally { Streams.closeStream(inputStream); mVideoRetries++; } } return false; } /** * Called upon user click. Attempts open mopubnativebrowser links in the device browser and all * other links in the MoPub in-app browser. */ @VisibleForTesting void handleClick(final List<String> clickThroughTrackers, final String clickThroughUrl) { makeTrackingHttpRequest(clickThroughTrackers, getContext(), MoPubEvents.Type.CLICK_REQUEST); if (clickThroughUrl == null) { return; } broadcastAction(ACTION_INTERSTITIAL_CLICK); if (Intents.isNativeBrowserScheme(clickThroughUrl)) { try { final Intent intent = Intents.intentForNativeBrowserScheme(clickThroughUrl); Intents.startActivity(getContext(), intent); return; } catch (UrlParseException e) { MoPubLog.d(e.getMessage()); } catch (IntentNotResolvableException e) { MoPubLog.d("Could not handle intent for URI: " + clickThroughUrl + ". " + e.getMessage()); } return; } Bundle bundle = new Bundle(); bundle.putString(MoPubBrowser.DESTINATION_URL_KEY, clickThroughUrl); getBaseVideoViewControllerListener().onStartActivityForResult(MoPubBrowser.class, MOPUB_BROWSER_REQUEST_CODE, bundle); } private ImageView createCompanionAdImageView(final Context context) { RelativeLayout relativeLayout = new RelativeLayout(context); relativeLayout.setGravity(Gravity.CENTER); RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams( RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT); layoutParams.addRule(RelativeLayout.BELOW, mVastVideoToolbar.getId()); getLayout().addView(relativeLayout, layoutParams); ImageView imageView = new ImageView(context); // Set to invisible to have it be drawn to calculate size imageView.setVisibility(View.INVISIBLE); final RelativeLayout.LayoutParams companionAdLayout = new RelativeLayout.LayoutParams( RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT ); relativeLayout.addView(imageView, companionAdLayout); return imageView; } private boolean isLongVideo(final int duration) { return (duration >= MAX_VIDEO_DURATION_FOR_CLOSE_BUTTON); } private void makeVideoInteractable() { mShowCloseButtonEventFired = true; mVastVideoToolbar.makeInteractable(); } private boolean shouldBeInteractable() { return !mShowCloseButtonEventFired && mVideoView.getCurrentPosition() > mShowCloseButtonDelay; } private boolean shouldAllowClickThrough() { return mShowCloseButtonEventFired; } private void startProgressChecker() { if (!mIsVideoProgressShouldBeChecked) { mIsVideoProgressShouldBeChecked = true; mHandler.post(mVideoProgressCheckerRunnable); } } private void stopProgressChecker() { if (mIsVideoProgressShouldBeChecked) { mIsVideoProgressShouldBeChecked = false; mHandler.removeCallbacks(mVideoProgressCheckerRunnable); } } // for testing @Deprecated @VisibleForTesting boolean getIsVideoProgressShouldBeChecked() { return mIsVideoProgressShouldBeChecked; } // for testing @Deprecated @VisibleForTesting int getVideoRetries() { return mVideoRetries; } // for testing @Deprecated @VisibleForTesting int getShowCloseButtonDelay() { return mShowCloseButtonDelay; } // for testing @Deprecated @VisibleForTesting boolean isShowCloseButtonEventFired() { return mShowCloseButtonEventFired; } // for testing @Deprecated @VisibleForTesting void setCloseButtonVisible(boolean visible) { mShowCloseButtonEventFired = visible; } // for testing @Deprecated @VisibleForTesting boolean isVideoFinishedPlaying() { return mIsVideoFinishedPlaying; } // for testing @Deprecated @VisibleForTesting ImageView getCompanionAdImageView() { return mCompanionAdImageView; } // for testing @Deprecated @VisibleForTesting void setFinalMarkHit() { mIsFinalMarkHit = true; } // for testing @Deprecated @VisibleForTesting void setVideoError() { mVideoError = true; } // for testing @Deprecated @VisibleForTesting boolean getVideoError() { return mVideoError; } }