package com.mopub.mraid; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.graphics.Color; import android.graphics.Rect; import android.net.Uri; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.text.TextUtils; import android.view.MotionEvent; import android.view.View; import android.view.View.OnTouchListener; import android.webkit.ConsoleMessage; import android.webkit.JsResult; import android.webkit.WebChromeClient; import android.webkit.WebView; import android.webkit.WebViewClient; import com.mopub.common.AdReport; import com.mopub.common.CloseableLayout.ClosePosition; import com.mopub.common.VisibleForTesting; import com.mopub.common.logging.MoPubLog; import com.mopub.mobileads.BaseWebView; import com.mopub.mobileads.ViewGestureDetector; import com.mopub.mobileads.ViewGestureDetector.UserClickListener; import com.mopub.mobileads.resource.MraidJavascript; import com.mopub.mraid.MraidBridge.MraidWebView.OnVisibilityChangedListener; import com.mopub.mraid.MraidNativeCommandHandler.MraidCommandFailureListener; import org.apache.http.NameValuePair; import org.apache.http.client.utils.URLEncodedUtils; import org.json.JSONObject; import java.net.URI; import java.net.URISyntaxException; import java.util.*; public class MraidBridge { private final AdReport mAdReport; public interface MraidBridgeListener { void onPageLoaded(); void onVisibilityChanged(boolean isVisible); boolean onJsAlert(@NonNull String message, @NonNull JsResult result); boolean onConsoleMessage(@NonNull ConsoleMessage consoleMessage); void onResize(int width, int height, int offsetX, int offsetY, @NonNull ClosePosition closePosition, boolean allowOffscreen) throws MraidCommandException; void onExpand(URI uri, boolean shouldUseCustomClose) throws MraidCommandException; void onClose(); void onUseCustomClose(boolean shouldUseCustomClose); void onSetOrientationProperties(boolean allowOrientationChange, MraidOrientation forceOrientation) throws MraidCommandException; void onOpen(URI uri); void onPlayVideo(URI uri); } private final String FILTERED_JAVASCRIPT_SOURCE = MraidJavascript.JAVASCRIPT_SOURCE .replaceAll("(?m)^\\s+", "") .replaceAll("(?m)^//.*(?=\\n)", ""); @NonNull private final PlacementType mPlacementType; @NonNull private final MraidNativeCommandHandler mMraidNativeCommandHandler; @Nullable private MraidBridgeListener mMraidBridgeListener; @Nullable private MraidWebView mMraidWebView; private boolean mIsClicked; private boolean mHasLoaded; MraidBridge(@Nullable AdReport adReport, @NonNull PlacementType placementType) { this(adReport, placementType, new MraidNativeCommandHandler()); } @VisibleForTesting MraidBridge(@Nullable AdReport adReport, @NonNull PlacementType placementType, @NonNull MraidNativeCommandHandler mraidNativeCommandHandler) { mAdReport = adReport; mPlacementType = placementType; mMraidNativeCommandHandler = mraidNativeCommandHandler; } void setMraidBridgeListener(@Nullable MraidBridgeListener listener) { mMraidBridgeListener = listener; } void attachView(@NonNull MraidWebView mraidWebView) { mMraidWebView = mraidWebView; mMraidWebView.getSettings().setJavaScriptEnabled(true); mMraidWebView.loadUrl("javascript:" + FILTERED_JAVASCRIPT_SOURCE); mMraidWebView.setScrollContainer(false); mMraidWebView.setVerticalScrollBarEnabled(false); mMraidWebView.setHorizontalScrollBarEnabled(false); mMraidWebView.setBackgroundColor(Color.BLACK); mMraidWebView.setWebViewClient(mMraidWebViewClient); mMraidWebView.setWebChromeClient(new WebChromeClient() { @Override public boolean onJsAlert(final WebView view, final String url, final String message, final JsResult result) { if (mMraidBridgeListener != null) { return mMraidBridgeListener.onJsAlert(message, result); } return super.onJsAlert(view, url, message, result); } @Override public boolean onConsoleMessage(@NonNull final ConsoleMessage consoleMessage) { if (mMraidBridgeListener != null) { return mMraidBridgeListener.onConsoleMessage(consoleMessage); } return super.onConsoleMessage(consoleMessage); } @Override public void onShowCustomView(final View view, final CustomViewCallback callback) { super.onShowCustomView(view, callback); } }); final ViewGestureDetector gestureDetector = new ViewGestureDetector( mMraidWebView.getContext(), mMraidWebView, mAdReport); gestureDetector.setUserClickListener(new UserClickListener() { @Override public void onUserClick() { mIsClicked = true; } @Override public void onResetUserClick() { mIsClicked = false; } @Override public boolean wasClicked() { return mIsClicked; } }); mMraidWebView.setOnTouchListener(new OnTouchListener() { @Override public boolean onTouch(final View v, final MotionEvent event) { gestureDetector.sendTouchEvent(event); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_UP: if (!v.hasFocus()) { v.requestFocus(); } break; } return false; } }); mMraidWebView.setVisibilityChangedListener(new OnVisibilityChangedListener() { @Override public void onVisibilityChanged(final boolean isVisible) { if (mMraidBridgeListener != null) { mMraidBridgeListener.onVisibilityChanged(isVisible); } } }); } void detach() { mMraidWebView = null; } public void setContentHtml(@NonNull String htmlData) { if (mMraidWebView == null) { MoPubLog.d("MRAID bridge called setContentHtml before WebView was attached"); return; } mHasLoaded = false; mMraidWebView.loadDataWithBaseURL(null, htmlData, "text/html", "UTF-8", null); } public void setContentUrl(String url) { if (mMraidWebView == null) { MoPubLog.d("MRAID bridge called setContentHtml while WebView was not attached"); return; } mHasLoaded = false; mMraidWebView.loadUrl(url); } void injectJavaScript(@NonNull String javascript) { if (mMraidWebView == null) { MoPubLog.d("Attempted to inject Javascript into MRAID WebView while was not " + "attached:\n\t" + javascript); return; } MoPubLog.v("Injecting Javascript into MRAID WebView:\n\t" + javascript); mMraidWebView.loadUrl("javascript:" + javascript); } private void fireErrorEvent(@NonNull MraidJavascriptCommand command, @NonNull String message) { injectJavaScript("window.mraidbridge.notifyErrorEvent(" + JSONObject.quote(command.toJavascriptString()) + ", " + JSONObject.quote(message) + ")"); } private void fireNativeCommandCompleteEvent(@NonNull MraidJavascriptCommand command) { injectJavaScript("window.mraidbridge.nativeCallComplete(" + JSONObject.quote(command.toJavascriptString()) + ")"); } public static class MraidWebView extends BaseWebView { public interface OnVisibilityChangedListener { void onVisibilityChanged(boolean isVisible); } @Nullable private OnVisibilityChangedListener mOnVisibilityChangedListener; private boolean mIsVisible; public MraidWebView(Context context) { super(context); mIsVisible = getVisibility() == View.VISIBLE; } void setVisibilityChangedListener(@Nullable OnVisibilityChangedListener listener) { mOnVisibilityChangedListener = listener; } @Override protected void onVisibilityChanged(@NonNull View changedView, int visibility) { super.onVisibilityChanged(changedView, visibility); boolean newIsVisible = (visibility == View.VISIBLE); if (newIsVisible != mIsVisible) { mIsVisible = newIsVisible; if (mOnVisibilityChangedListener != null) { mOnVisibilityChangedListener.onVisibilityChanged(mIsVisible); } } } public boolean isVisible() { return mIsVisible; } } private final WebViewClient mMraidWebViewClient = new WebViewClient() { @Override public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { MoPubLog.d("Error: " + description); super.onReceivedError(view, errorCode, description, failingUrl); } @Override public boolean shouldOverrideUrlLoading(@NonNull WebView view, @NonNull String url) { return handleShouldOverrideUrl(url); } @Override public void onPageFinished(@NonNull WebView view, @NonNull String url) { handlePageFinished(); } }; @VisibleForTesting boolean handleShouldOverrideUrl(@NonNull final String url) { URI uri; try { uri = new URI(url); } catch (URISyntaxException e) { MoPubLog.w("Invalid MRAID URL: " + url); fireErrorEvent(MraidJavascriptCommand.UNSPECIFIED, "Mraid command sent an invalid URL"); return true; } // Note that scheme will be null when we are passed a relative Uri String scheme = uri.getScheme(); if ("mopub".equals(scheme)) { return true; } if ("mraid".equals(scheme)) { String host = uri.getHost(); Map<String, String> params = new HashMap<String, String>(); for (NameValuePair pair : URLEncodedUtils.parse(uri, "UTF-8")) { params.put(pair.getName(), pair.getValue()); } MraidJavascriptCommand command = MraidJavascriptCommand.fromJavascriptString(host); try { runCommand(command, params); } catch (MraidCommandException exception) { fireErrorEvent(command, exception.getMessage()); } fireNativeCommandCompleteEvent(command); return true; } // This block handles all other URLs, including sms://, tel://, // clicking a hyperlink, or setting window.location directly in Javascript. It checks for // clicked in order to avoid interfering with automatically browser redirects. if (mIsClicked) { Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); intent.setData(Uri.parse(url)); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); try { if (mMraidWebView == null) { MoPubLog.d("WebView was detached. Unable to load a URL"); return true; } mMraidWebView.getContext().startActivity(intent); return true; } catch (ActivityNotFoundException e) { MoPubLog.d("No activity found to handle this URL " + url); return false; } } return false; } @VisibleForTesting private void handlePageFinished() { // This can happen a second time if the ad does something that changes the window location, // such as a redirect, changing window.location in Javascript, or programmatically clicking // a hyperlink. Note that the handleShouldOverrideUrl method skips doing its own // processing if the user hasn't clicked the ad. if (mHasLoaded) { return; } mHasLoaded = true; if (mMraidBridgeListener != null) { mMraidBridgeListener.onPageLoaded(); } } @VisibleForTesting void runCommand(@NonNull final MraidJavascriptCommand command, @NonNull Map<String, String> params) throws MraidCommandException { if (command.requiresClick(mPlacementType) && !mIsClicked) { throw new MraidCommandException("Cannot execute this command unless the user clicks"); } if (mMraidBridgeListener == null) { throw new MraidCommandException("Invalid state to execute this command"); } if (mMraidWebView == null) { throw new MraidCommandException("The current WebView is being destroyed"); } switch (command) { case CLOSE: mMraidBridgeListener.onClose(); break; case RESIZE: // All these params are required int width = checkRange(parseSize(params.get("width")), 0, 100000); int height = checkRange(parseSize(params.get("height")), 0, 100000); int offsetX = checkRange(parseSize(params.get("offsetX")), -100000, 100000); int offsetY = checkRange(parseSize(params.get("offsetY")), -100000, 100000); ClosePosition closePosition = parseClosePosition( params.get("customClosePosition"), ClosePosition.TOP_RIGHT); boolean allowOffscreen = parseBoolean(params.get("allowOffscreen"), true); mMraidBridgeListener.onResize( width, height, offsetX, offsetY, closePosition, allowOffscreen); break; case EXPAND: URI uri = parseURI(params.get("url"), null); boolean shouldUseCustomClose = parseBoolean(params.get("shouldUseCustomClose"), false); mMraidBridgeListener.onExpand(uri, shouldUseCustomClose); break; case USE_CUSTOM_CLOSE: shouldUseCustomClose = parseBoolean(params.get("shouldUseCustomClose"), false); mMraidBridgeListener.onUseCustomClose(shouldUseCustomClose); break; case OPEN: uri = parseURI(params.get("url")); mMraidBridgeListener.onOpen(uri); break; case SET_ORIENTATION_PROPERTIES: boolean allowOrientationChange = parseBoolean(params.get("allowOrientationChange")); MraidOrientation forceOrientation = parseOrientation(params.get("forceOrientation")); mMraidBridgeListener.onSetOrientationProperties(allowOrientationChange, forceOrientation); break; case PLAY_VIDEO: uri = parseURI(params.get("uri")); mMraidBridgeListener.onPlayVideo(uri); break; case STORE_PICTURE: uri = parseURI(params.get("uri")); mMraidNativeCommandHandler.storePicture(mMraidWebView.getContext(), uri.toString(), new MraidCommandFailureListener() { @Override public void onFailure(final MraidCommandException exception) { fireErrorEvent(command, exception.getMessage()); } }); break; case CREATE_CALENDAR_EVENT: mMraidNativeCommandHandler.createCalendarEvent(mMraidWebView.getContext(), params); break; case UNSPECIFIED: throw new MraidCommandException("Unspecified MRAID Javascript command"); } } private ClosePosition parseClosePosition(@NonNull String text, @NonNull ClosePosition defaultValue) throws MraidCommandException { if (TextUtils.isEmpty(text)) { return defaultValue; } if (text.equals("top-left")) { return ClosePosition.TOP_LEFT; } else if (text.equals("top-right")) { return ClosePosition.TOP_RIGHT; } else if (text.equals("center")) { return ClosePosition.CENTER; } else if (text.equals("bottom-left")) { return ClosePosition.BOTTOM_LEFT; } else if (text.equals("bottom-right")) { return ClosePosition.BOTTOM_RIGHT; } else if (text.equals("top-center")) { return ClosePosition.TOP_CENTER; } else if (text.equals("bottom-center")) { return ClosePosition.BOTTOM_CENTER; } else { throw new MraidCommandException("Invalid close position: " + text); } } private int parseSize(@NonNull String text) throws MraidCommandException { int result; try { result = Integer.parseInt(text, 10); } catch (NumberFormatException e) { throw new MraidCommandException("Invalid numeric parameter: " + text); } return result; } private MraidOrientation parseOrientation(String text) throws MraidCommandException { if ("portrait".equals(text)) { return MraidOrientation.PORTRAIT; } else if ("landscape".equals(text)) { return MraidOrientation.LANDSCAPE; } else if ("none".equals(text)) { return MraidOrientation.NONE; } else { throw new MraidCommandException("Invalid orientation: " + text); } } private int checkRange(int value, int min, int max) throws MraidCommandException { if (value < min || value > max) { throw new MraidCommandException("Integer parameter out of range: " + value); } return value; } private boolean parseBoolean( @Nullable String text, boolean defaultValue) throws MraidCommandException { if (text == null) { return defaultValue; } return parseBoolean(text); } private boolean parseBoolean(final String text) throws MraidCommandException { if ("true".equals(text)) { return true; } else if ("false".equals(text)) { return false; } throw new MraidCommandException("Invalid boolean parameter: " + text); } @NonNull private URI parseURI(@Nullable String encodedText, URI defaultValue) throws MraidCommandException { if (encodedText == null) { return defaultValue; } return parseURI(encodedText); } @NonNull private URI parseURI(@Nullable String encodedText) throws MraidCommandException { if (encodedText == null) { throw new MraidCommandException("Parameter cannot be null"); } try { return new URI(encodedText); } catch (URISyntaxException e) { throw new MraidCommandException("Invalid URL parameter: " + encodedText); } } void notifyViewability(boolean isViewable) { injectJavaScript("mraidbridge.setIsViewable(" + isViewable + ")"); } void notifyPlacementType(PlacementType placementType) { injectJavaScript("mraidbridge.setPlacementType(" + JSONObject.quote(placementType.toJavascriptString()) + ")"); } void notifyViewState(ViewState state) { injectJavaScript("mraidbridge.setState(" + JSONObject.quote(state.toJavascriptString()) + ")"); } void notifySupports(boolean sms, boolean telephone, boolean calendar, boolean storePicture, boolean inlineVideo) { injectJavaScript("mraidbridge.setSupports(" + sms + "," + telephone + "," + calendar + "," + storePicture + "," + inlineVideo + ")"); } @NonNull private String stringifyRect(Rect rect) { return rect.left + "," + rect.top + "," + rect.width() + "," + rect.height(); } @NonNull private String stringifySize(Rect rect) { return rect.width() + "," + rect.height(); } public void notifyScreenMetrics(@NonNull final MraidScreenMetrics screenMetrics) { injectJavaScript("mraidbridge.setScreenSize(" + stringifySize(screenMetrics.getScreenRectDips()) + ");mraidbridge.setMaxSize(" + stringifySize(screenMetrics.getRootViewRectDips()) + ");mraidbridge.setCurrentPosition(" + stringifyRect(screenMetrics.getCurrentAdRectDips()) + ");mraidbridge.setDefaultPosition(" + stringifyRect(screenMetrics.getDefaultAdRectDips()) + ")"); injectJavaScript("mraidbridge.notifySizeChangeEvent(" + stringifySize(screenMetrics.getCurrentAdRect()) + ")"); } void notifyReady() { injectJavaScript("mraidbridge.notifyReadyEvent();"); } boolean isClicked() { return mIsClicked; } boolean isVisible() { return mMraidWebView != null && mMraidWebView.isVisible(); } boolean isAttached() { return mMraidWebView != null; } boolean isLoaded() { return mHasLoaded; } @VisibleForTesting MraidWebView getMraidWebView() { return mMraidWebView; } @VisibleForTesting void setClicked(boolean clicked) { mIsClicked = clicked; } }