package org.wordpress.android.editor; import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.content.Context; import android.os.Build; import android.support.annotation.NonNull; import android.util.AttributeSet; import android.view.KeyEvent; import android.view.View; import android.webkit.ConsoleMessage; import android.webkit.ConsoleMessage.MessageLevel; import android.webkit.JsResult; import android.webkit.URLUtil; import android.webkit.WebChromeClient; import android.webkit.WebResourceRequest; import android.webkit.WebResourceResponse; import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; import org.wordpress.android.util.AppLog; import org.wordpress.android.util.AppLog.T; import org.wordpress.android.util.StringUtils; import org.wordpress.android.util.ToastUtils; import org.wordpress.android.util.UrlUtils; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.URL; import java.net.URLConnection; import java.net.URLDecoder; import java.util.HashMap; import java.util.Map; /** * A text editor WebView with support for JavaScript execution. */ public abstract class EditorWebViewAbstract extends WebView { public abstract void execJavaScriptFromString(String javaScript); public static final int REQUEST_TIMEOUT_MS = 30000; private OnImeBackListener mOnImeBackListener; private AuthHeaderRequestListener mAuthHeaderRequestListener; private ErrorListener mErrorListener; private JsCallbackReceiver mJsCallbackReceiver; private boolean mDebugModeEnabled; private Map<String, String> mHeaderMap = new HashMap<>(); public EditorWebViewAbstract(Context context, AttributeSet attrs) { super(context, attrs); configureWebView(); } @SuppressLint("SetJavaScriptEnabled") private void configureWebView() { WebSettings webSettings = this.getSettings(); webSettings.setJavaScriptEnabled(true); webSettings.setDefaultTextEncodingName("utf-8"); this.setWebViewClient(new WebViewClient() { @Override @SuppressWarnings("deprecation") public boolean shouldOverrideUrlLoading(WebView view, String url) { // Only used on API16, because of security issues with addJavascriptInterface below API 17. // Instead of a JS interface, we use an iframe in the editor JavaScript to make callbacks as URL // requests, which are intercepted here. if (url != null && url.startsWith("callback") && mJsCallbackReceiver != null) { String data; try { data = URLDecoder.decode(url, "UTF-8"); } catch (UnsupportedEncodingException e) { // Pretty much impossible AppLog.e(T.EDITOR, "UTF-8 is unsupported on this device, falling back to default"); data = URLDecoder.decode(url); } String[] split = data.split(":", 2); String callbackId = split[0]; String params = (split.length > 1 ? split[1] : ""); mJsCallbackReceiver.executeCallback(callbackId, params); } return true; } @Override public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { AppLog.e(T.EDITOR, description); } @TargetApi(Build.VERSION_CODES.LOLLIPOP) @Override public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { String url = request.getUrl().toString(); if (!URLUtil.isNetworkUrl(url)) { return super.shouldInterceptRequest(view, request); } // Request and add an authorization header for HTTPS resource requests. // Use https:// when requesting the auth header, in case the resource is incorrectly using http://. // If an auth header is returned, force https:// for the actual HTTP request. String authHeader = mAuthHeaderRequestListener.onAuthHeaderRequested(UrlUtils.makeHttps(url)); if (StringUtils.notNullStr(authHeader).length() > 0) { try { url = UrlUtils.makeHttps(url); // Keep any existing request headers from the WebResourceRequest Map<String, String> headerMap = request.getRequestHeaders(); for (Map.Entry<String, String> entry : mHeaderMap.entrySet()) { headerMap.put(entry.getKey(), entry.getValue()); } headerMap.put("Authorization", authHeader); URLConnection conn = setupUrlConnection(url, headerMap); return new WebResourceResponse(conn.getContentType(), conn.getContentEncoding(), conn.getInputStream()); } catch (IOException e) { AppLog.e(T.EDITOR, e); } } return super.shouldInterceptRequest(view, request); } /** * Compatibility method for API < 21 */ @SuppressWarnings("deprecation") @Override public WebResourceResponse shouldInterceptRequest(WebView view, String url) { if (!URLUtil.isNetworkUrl(url)) { return super.shouldInterceptRequest(view, url); } // Request and add an authorization header for HTTPS resource requests. // Use https:// when requesting the auth header, in case the resource is incorrectly using http://. // If an auth header is returned, force https:// for the actual HTTP request. String authHeader = mAuthHeaderRequestListener.onAuthHeaderRequested(UrlUtils.makeHttps(url)); if (StringUtils.notNullStr(authHeader).length() > 0) { try { url = UrlUtils.makeHttps(url); Map<String, String> headerMap = new HashMap<>(mHeaderMap); headerMap.put("Authorization", authHeader); URLConnection conn = setupUrlConnection(url, headerMap); return new WebResourceResponse(conn.getContentType(), conn.getContentEncoding(), conn.getInputStream()); } catch (IOException e) { AppLog.e(T.EDITOR, e); } } return super.shouldInterceptRequest(view, url); } }); this.setWebChromeClient(new WebChromeClient() { @Override public boolean onConsoleMessage(@NonNull ConsoleMessage cm) { if (cm.messageLevel() == MessageLevel.ERROR) { if (mErrorListener != null) { mErrorListener.onJavaScriptError(cm.sourceId(), cm.lineNumber(), cm.message()); } AppLog.e(T.EDITOR, cm.message() + " -- From line " + cm.lineNumber() + " of " + cm.sourceId()); } else { AppLog.d(T.EDITOR, cm.message() + " -- From line " + cm.lineNumber() + " of " + cm.sourceId()); } return true; } @Override public boolean onJsAlert(WebView view, String url, String message, JsResult result) { AppLog.d(T.EDITOR, message); if (mErrorListener != null) { mErrorListener.onJavaScriptAlert(url, message); } return true; } }); } @Override public boolean onCheckIsTextEditor() { return true; } @Override public void setVisibility(int visibility) { notifyVisibilityChanged(visibility == View.VISIBLE); super.setVisibility(visibility); } public boolean shouldSwitchToCompatibilityMode() { return false; } public void setDebugModeEnabled(boolean enabled) { mDebugModeEnabled = enabled; } /** * Handles events that should be triggered when the WebView is hidden or is shown to the user * * @param visible the new visibility status of the WebView */ public void notifyVisibilityChanged(boolean visible) { if (!visible) { this.post(new Runnable() { @Override public void run() { execJavaScriptFromString("ZSSEditor.pauseAllVideos();"); } }); } } public void setOnImeBackListener(OnImeBackListener listener) { mOnImeBackListener = listener; } public void setAuthHeaderRequestListener(AuthHeaderRequestListener listener) { mAuthHeaderRequestListener = listener; } /** * Used on API<17 to handle callbacks as a safe alternative to JavascriptInterface (which has security risks * at those API levels). */ public void setJsCallbackReceiver(JsCallbackReceiver jsCallbackReceiver) { mJsCallbackReceiver = jsCallbackReceiver; } @Override public boolean onKeyPreIme(int keyCode, KeyEvent event) { if (event.getKeyCode() == KeyEvent.KEYCODE_BACK && event.getAction() == KeyEvent.ACTION_UP) { if (mOnImeBackListener != null) { mOnImeBackListener.onImeBack(); } } if (mDebugModeEnabled && event.getKeyCode() == KeyEvent.KEYCODE_VOLUME_UP && event.getAction() == KeyEvent.ACTION_DOWN) { // Log the raw html execJavaScriptFromString("console.log(document.body.innerHTML);"); ToastUtils.showToast(getContext(), "Debug: Raw HTML has been logged"); return true; } return super.onKeyPreIme(keyCode, event); } public void setCustomHeader(String name, String value) { mHeaderMap.put(name, value); } public void setErrorListener(ErrorListener errorListener) { mErrorListener = errorListener; } public interface AuthHeaderRequestListener { String onAuthHeaderRequested(String url); } public interface ErrorListener { void onJavaScriptError(String sourceFile, int lineNumber, String message); void onJavaScriptAlert(String url, String message); } private static URLConnection setupUrlConnection(String url, Map<String, String> headers) throws IOException { URLConnection conn = new URL(url).openConnection(); conn.setReadTimeout(REQUEST_TIMEOUT_MS); conn.setConnectTimeout(REQUEST_TIMEOUT_MS); for (Map.Entry<String, String> entry : headers.entrySet()) { conn.setRequestProperty(entry.getKey(), entry.getValue()); } return conn; } }