/* * Copyright 2015-present wequick.net * * 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 net.wequick.small.webkit; import android.support.v7.app.AlertDialog; import android.app.ProgressDialog; import android.content.Context; import android.content.DialogInterface; import android.graphics.Bitmap; import android.net.Uri; import android.util.AttributeSet; import android.util.Log; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.webkit.ConsoleMessage; import android.webkit.JavascriptInterface; import android.webkit.WebChromeClient; import android.webkit.WebSettings; import android.widget.Toast; import net.wequick.small.Small; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.util.HashMap; import java.util.Iterator; import java.util.concurrent.ConcurrentHashMap; /** * <p>A View that displays web pages. This class is the basis upon which you * can display native web pages or some online content within WebActivity. * * <p>This class brings the javascript bridge to connect native and web, all * the usages are on <a href="https://github.com/wequick/Small/wiki/Javascript-API">Javascript API</a>. * * <p>What's more, it brings the ability of access native action bar by web. You can simply do it * in you html meta content as following: * * <pre> * <meta data-owner="small" name="[$pos]-bar-item" content="type=[$type],onclick=[$handler]()"> * </pre> * * For more details see <a href="https://github.com/wequick/Small/wiki/Web/Navigation-bar">Navigation bar</a>. */ public class WebView extends android.webkit.WebView { private static final String SMALL_SCHEME = "small"; private static final String SMALL_HOST_POP = "pop"; private static final String SMALL_HOST_EXEC = "exec"; private static final String SMALL_QUERY_KEY_RET = "ret"; private static final String JS_PREFIX = "javascript:"; /** Js scripts to make a bridge across Native and Web */ private static final String SMALL_INJECT_JS = // Parse window close event "window._onclose=function(){" + "if(typeof(onbeforeclose)=='function'){" + "var s=onbeforeclose();" + "if(typeof(s)=='string'&&!confirm(s))return false;" + "}" + "if(typeof(onclose)=='function')return onclose();" + "};" + "window._close=function(ret){" + "if(typeof(ret)=='string')" + "console.log('" + SMALL_SCHEME + "://" + SMALL_HOST_POP + "?" + SMALL_QUERY_KEY_RET + "='+encodeURIComponent(ret));" + "else " + "console.log('" + SMALL_SCHEME + "://" + SMALL_HOST_POP + "');" + "};" + "window.close=function(){" + "var ret=_onclose();" + "if(ret==false)return;" + "_close(ret)" + "};" + // Bridge "Small={" + "_c:{}," + // Native -> Web. t: the js callback function handle, r: callback result "c:function(t,r){var c=this._c[t];if(!!c){c(r);this._c[t]=null;}}," + // Web -> Native. m: native method name, p: parameters, c: callback function "invoke:function(m,p,c){" + "var t=new Date().getTime()+'';" + "this._c[t]=c;" + "if(!!p)_Small.invoke(m,JSON.stringify(p),t); " + "else _Small.invoke(m,null,t);" + "}" + "};"; /** Js scripts to get html meta data for configuring Native navigation bar */ private static final String SMALL_GET_METAS_JS = "var ms=document.head.getElementsByTagName('meta');" + "var _ms={};" + "for (var i=0;i<ms.length;i++) {" + "var m=ms[i];" + "if(m.name)_ms[m.name]=m.content;" + "};" + "return JSON.stringify(_ms);"; /** Js scripts to get window close result */ private static final String SMALL_GET_CLOSERET_JS = "return window._onclose()"; private static ConcurrentHashMap<String, JsHandler> sJsHandlers; private OnResultListener mOnResultListener = null; private String mTitle = null; private String mLoadingUrl = null; private boolean mInjected = false; private boolean mBlank; private ProgressDialog mProgressDialog = null; private HashMap<String, Boolean> mHasStartedUrl = new HashMap<String, Boolean>(); private HashMap<String, HashMap<String, String>> mMetaContents = null; public WebView(Context context) { super(context); initSettings(); } public WebView(Context context, AttributeSet attrs) { super(context, attrs); initSettings(); } @Override public String getTitle() { return mTitle; } @Override public void loadUrl(String url) { mLoadingUrl = url; super.loadUrl(url); } public HashMap<String, HashMap<String, String>> getMetaContents() { return mMetaContents; } public interface OnResultListener { void onResult(String ret); } public void loadJs(String js) { super.loadUrl(JS_PREFIX + js); } public void execJavascript(String js, OnResultListener listener) { mOnResultListener = listener; String js2Exec = "var ret='';try{ret=function(){" + js + "}();}catch(e){} console.log('" + SMALL_SCHEME + "://" + SMALL_HOST_EXEC + "?" + SMALL_QUERY_KEY_RET + "='+encodeURIComponent(ret))"; loadJs(js2Exec); } @Override public void reload() { mInjected = false; super.reload(); } @Override public boolean onTouchEvent(MotionEvent event) { return super.onTouchEvent(event); } public void close(OnResultListener listener) { execJavascript(SMALL_GET_CLOSERET_JS, listener); } private void callbackJS(String functionId, Object result) { if (result == null) { loadJs("Small.c('" + functionId + "');"); } else { // object to json string String s; if (result instanceof Integer) { s = result.toString(); loadJs("Small.c('" + functionId + "'," + s + ");"); } else if (result instanceof String) { s = "\"" + result.toString() + "\""; loadJs("Small.c('" + functionId + "'," + s + ");"); } else if (result instanceof HashMap) { JSONObject jsonObject = new JSONObject(); HashMap<String, Object> map = (HashMap) result; for (HashMap.Entry<String, Object> entry : map.entrySet()) { try { jsonObject.put(entry.getKey(), entry.getValue()); } catch (JSONException e) { // Ignored } } s = jsonObject.toString(); // loadJs("var s='" + s + "';Small.c('" + functionId + "'," + "JSON.parse(s));"); } } } private void removeCallback(String functionId) { loadJs("Small._c['" + functionId + "']=null;"); } private WebActivity getActivity() { View parent = (View) this.getParent(); return (WebActivity) parent.getContext(); } protected void removeFromParent() { ViewGroup parent = (ViewGroup) this.getParent(); if (parent != null) { parent.removeView(this); } } /** Show empty content */ protected void showBlank() { mBlank = true; setVisibility(View.INVISIBLE); super.loadUrl("about:blank"); } private static final class SmallWebChromeClient extends WebChromeClient { private boolean mConfirmed; private WebView mWebView; SmallWebChromeClient(WebView wv) { mWebView = wv; } @Override public void onReceivedTitle(android.webkit.WebView view, String title) { // Call if html title is set super.onReceivedTitle(view, title); mWebView.mTitle = title; WebActivity activity = ((WebView) view).getActivity(); if (activity != null) { activity.setTitle(title); } // May receive head meta at the same time mWebView.initMetas(); } @Override public void onProgressChanged(android.webkit.WebView view, int newProgress) { super.onProgressChanged(view, newProgress); if (sWebViewClient != null) { WebView wv = (WebView) view; sWebViewClient.onProgressChanged(wv.getActivity(), wv, newProgress); } } @Override public boolean onJsAlert(android.webkit.WebView view, String url, String message, final android.webkit.JsResult result) { Context context = ((WebView) view).getActivity(); if (context == null) return false; AlertDialog.Builder dlg = new AlertDialog.Builder(context); dlg.setPositiveButton("OK", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { mConfirmed = true; result.confirm(); } }); dlg.setMessage(message); AlertDialog alert = dlg.create(); alert.setOnDismissListener(new DialogInterface.OnDismissListener() { @Override public void onDismiss(DialogInterface dialog) { if (!mConfirmed) { result.cancel(); } } }); mConfirmed = false; alert.show(); return true; } @Override public boolean onJsConfirm(android.webkit.WebView view, String url, String message, final android.webkit.JsResult result) { Context context = ((WebView) view).getActivity(); AlertDialog.Builder dlg = new AlertDialog.Builder(context); dlg.setPositiveButton("OK", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { mConfirmed = true; result.confirm(); } }); dlg.setNegativeButton("Cancel", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { mConfirmed = true; result.cancel(); } }); dlg.setMessage(message); AlertDialog alert = dlg.create(); alert.setOnDismissListener(new DialogInterface.OnDismissListener() { @Override public void onDismiss(DialogInterface dialog) { if (!mConfirmed) { result.cancel(); } } }); mConfirmed = false; alert.show(); return true; } @Override public boolean onConsoleMessage(ConsoleMessage consoleMessage) { String msg = consoleMessage.message(); if (msg == null) return false; Uri uri = Uri.parse(msg); if (uri != null && null != uri.getScheme() && uri.getScheme().equals(SMALL_SCHEME)) { String host = uri.getHost(); String ret = uri.getQueryParameter(SMALL_QUERY_KEY_RET); if (host.equals(SMALL_HOST_POP)) { WebActivity activity = mWebView.getActivity(); if (activity != null) { activity.finish(ret); } } else if (host.equals(SMALL_HOST_EXEC)) { if (mWebView.mOnResultListener != null) { mWebView.mOnResultListener.onResult(ret); } } return true; } Log.d(consoleMessage.sourceId(), "line" + consoleMessage.lineNumber() + ": " + consoleMessage.message()); return true; } @Override public void onCloseWindow(android.webkit.WebView window) { super.onCloseWindow(window); mWebView.close(new OnResultListener() { @Override public void onResult(String ret) { if (ret.equals("false")) return; WebActivity activity = mWebView.getActivity(); if (activity != null) { activity.finish(ret); } } }); } } private static final class SmallWebViewClient extends android.webkit.WebViewClient { private final String ANCHOR_SCHEME = "anchor"; @Override public boolean shouldOverrideUrlLoading(android.webkit.WebView view, String url) { WebView wv = (WebView) view; if (wv.mLoadingUrl != null && isSameUrl(url, wv.mLoadingUrl)) { // reload by window.location.reload or something return super.shouldOverrideUrlLoading(view, url); } Boolean hasStarted = wv.mHasStartedUrl.get(url); if (hasStarted != null && hasStarted) { // location redirected before page finished return super.shouldOverrideUrlLoading(view, url); } HitTestResult hit = view.getHitTestResult(); if (hit != null) { Uri uri = Uri.parse(url); if (uri.getScheme().equals(ANCHOR_SCHEME)) { // Scroll to anchor int anchorY = Integer.parseInt(uri.getHost()); view.scrollTo(0, anchorY); } else { Small.openUri(uri, wv.getActivity()); } return true; } return super.shouldOverrideUrlLoading(view, url); } @Override public void onPageStarted(android.webkit.WebView view, String url, Bitmap favicon) { super.onPageStarted(view, url, favicon); WebView wv = (WebView) view; wv.mHasStartedUrl.put(url, true); if (wv.mLoadingUrl != null && wv.mLoadingUrl.equals(url)) { // reload by window.location.reload or something wv.mInjected = false; } if (sWebViewClient != null && url.equals(wv.mLoadingUrl)) { sWebViewClient.onPageStarted(wv.getActivity(), wv, url, favicon); } } @Override public void onPageFinished(android.webkit.WebView view, String url) { super.onPageFinished(view, url); WebView wv = (WebView) view; wv.mHasStartedUrl.remove(url); if (wv.mBlank) { wv.setVisibility(View.VISIBLE); wv.mBlank = false; } HitTestResult hit = view.getHitTestResult(); if (hit != null && hit.getType() == HitTestResult.SRC_ANCHOR_TYPE) { // Triggered by user clicked Uri uri = Uri.parse(url); String anchor = uri.getFragment(); if (anchor != null) { // If is an anchor, calculate the content offset by DOM // and call native to adjust WebView's offset view.loadUrl(JS_PREFIX + "var y=document.body.scrollTop;" + "var e=document.getElementsByName('" + anchor + "')[0];" + "while(e){" + "y+=e.offsetTop-e.scrollTop+e.clientTop;e=e.offsetParent;}" + "location='" + ANCHOR_SCHEME + "://'+y;"); } } if (!wv.mInjected) { // Re-inject Small Js wv.loadJs(SMALL_INJECT_JS); wv.initMetas(); wv.mInjected = true; } if (sWebViewClient != null && isSameUrl(url, wv.mLoadingUrl)) { sWebViewClient.onPageFinished(wv.getActivity(), wv, url); } } @Override public void onReceivedError(android.webkit.WebView view, int errorCode, String description, String failingUrl) { super.onReceivedError(view, errorCode, description, failingUrl); Log.e("Web", "error: " + description); WebView wv = (WebView) view; if (sWebViewClient != null && isSameUrl(failingUrl, wv.mLoadingUrl)) { Context context = wv.getActivity(); sWebViewClient.onReceivedError(context, wv, errorCode, description, failingUrl); } } } private static boolean isSameUrl(String url1, String url2) { if (url1 == null) return url2 == null; if (url2 == null) return false; int len1 = url1.length(); int len2 = url2.length(); switch (len1 - len2) { case 0: return url1.equals(url2); case 1: return url1.indexOf(url2) == 0 && url1.charAt(len2) == '/'; case -1: return url2.indexOf(url1) == 0 && url2.charAt(len1) == '/'; default: return false; } } private void initSettings() { WebSettings webSettings = this.getSettings(); webSettings.setJavaScriptEnabled(true); webSettings.setUserAgentString(webSettings.getUserAgentString() + " Native"); this.addJavascriptInterface(new SmallJsBridge(), "_Small"); this.setWebChromeClient(new SmallWebChromeClient(this)); this.setWebViewClient(new SmallWebViewClient()); } private void initMetas() { if (mMetaContents != null) return; final WebActivity activity = WebView.this.getActivity(); // Get metas for action bar button execJavascript(SMALL_GET_METAS_JS, new OnResultListener() { @Override public void onResult(String ret) { try { JSONObject json = new JSONObject(ret); // Collect bar items // "*-bar-item":"content" HashMap<String, HashMap<String, String>> metaContents = new HashMap<String, HashMap<String, String>>(); Iterator<String> keys = json.keys(); while (keys.hasNext()) { String name = keys.next(); int barItemLoc = name.indexOf("-bar-item"); if (barItemLoc > 0) { try { String content = json.getString(name); String[] attrs = content.split(","); HashMap<String, String> dict = new HashMap<String, String>(); for (int i = 0; i < attrs.length; i++) { String attr = attrs[i]; String key, value; int eqLoc = attr.indexOf("="); if (eqLoc < 0) { // Not found key = "title"; // Default to title value = attr; } else { key = attr.substring(0, eqLoc); value = attr.substring(eqLoc + 1); } dict.put(key, value); } String pos = name.substring(0, barItemLoc); metaContents.put(pos, dict); } catch (JSONException e) { // Ignore } } } if (metaContents.size() > 0) { if (activity != null) { activity.initMenu(metaContents); } mMetaContents = metaContents; } else { mMetaContents = null; } } catch (JSONException e) { // Ignore return; } } }); } /** * @hide Only for Small API */ public static void registerJsHandler(String method, JsHandler handler) { if (method == null || handler == null) return; if (sJsHandlers == null) sJsHandlers = new ConcurrentHashMap<String, JsHandler>(); sJsHandlers.put(method, handler); } /** * Js Bridge */ private class SmallJsBridge { @JavascriptInterface public void invoke(String method, String params, final String callbackFunctionId) { // JS object -> JSON -> HashMap HashMap<String, Object> parameters = new HashMap<String, Object>(); if (params != null) { try { JSONObject json = new JSONObject(params); Iterator<String> keys = json.keys(); while (keys.hasNext()) { String key = keys.next(); String value = json.getString(key); Object oValue = value; if (value.startsWith("[")) { JSONArray array = json.getJSONArray(key); String[] strs = new String[array.length()]; for (int i = 0; i < array.length(); i++) { strs[i] = array.getString(i); } oValue = strs; } parameters.put(key, oValue); } } catch (JSONException e) { // Ignored } } Context context = WebView.this.getActivity(); if (internalInvoke(context, method, parameters, callbackFunctionId)) return; // User custom events if (sJsHandlers == null) return; JsHandler handler = sJsHandlers.get(method); if (handler == null) return; JsResult jsResult = new JsResult(new JsResult.OnFinishListener() { @Override public void finish(Object result) { callbackJS(callbackFunctionId, result); } }); handler.handle(context, parameters, jsResult); } /** * Handle internal API (confirm, alert, toast, hud) * @return true=handled */ private boolean internalInvoke(Context context, String method, HashMap<String, Object> parameters, final String callbackFunctionId) { if (method.equals("confirm")) { String[] btns = (String[]) parameters.get("buttons"); final int nBtn = btns.length; if (nBtn < 1 || nBtn > 3) return true; DialogInterface.OnClickListener onConfirm = new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface arg0, int arg1) { int index; // Map the clicked button index switch (nBtn) { default: case 1: index = 0; break; case 2: if (arg1 == DialogInterface.BUTTON_NEGATIVE) { index = 0; } else { index = 1; } break; case 3: if (arg1 == DialogInterface.BUTTON_NEGATIVE) { index = 0; } else if (arg1 == DialogInterface.BUTTON_NEUTRAL) { index = 1; } else { index = 2; } break; } callbackJS(callbackFunctionId, index); } }; AlertDialog.Builder builder = new AlertDialog.Builder(context); builder.setTitle((String) parameters.get("title")); builder.setMessage((String) parameters.get("message")); builder.setCancelable(false); switch (nBtn) { case 1: builder.setPositiveButton(btns[0], onConfirm); break; case 2: builder.setNegativeButton(btns[0], onConfirm); builder.setPositiveButton(btns[1], onConfirm); break; case 3: builder.setNegativeButton(btns[0], onConfirm); builder.setNeutralButton(btns[1], onConfirm); builder.setPositiveButton(btns[2], onConfirm); break; default: return true; } final AlertDialog.Builder fBuilder = builder; post(new Runnable() { @Override public void run() { fBuilder.create().show(); } }); return true; } else if (method.equals("alert")) { final AlertDialog.Builder fBuilder = new AlertDialog.Builder(context) .setTitle((String) parameters.get("title")) .setMessage((String) parameters.get("message")) .setPositiveButton((String) parameters.get("ok"), new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialogInterface, int i) { callbackJS(callbackFunctionId, 0); } }); post(new Runnable() { @Override public void run() { fBuilder.create().show(); } }); return true; } else if (method.equals("hud")) { String action = (String) parameters.get("action"); if (action.equals("show")) { if (mProgressDialog != null) { mProgressDialog.dismiss(); } mProgressDialog = new ProgressDialog(context); mProgressDialog.setMessage((String) parameters.get("message")); mProgressDialog.show(); } else if (action.equals("hide")) { if (mProgressDialog != null) { String sDelay = (String) parameters.get("delay"); long delay = sDelay != null ? Integer.parseInt(sDelay) * 1000 : 0; postDelayed(new Runnable() { @Override public void run() { mProgressDialog.dismiss(); } }, delay); } } return true; } else if (method.equals("toast")) { String sDelay = (String) parameters.get("delay"); String message = (String) parameters.get("message"); int delay = sDelay != null ? Integer.parseInt(sDelay) : 1; if (delay <= 1) { Toast.makeText(context, message, Toast.LENGTH_SHORT).show(); } else { Toast.makeText(context, message, Toast.LENGTH_LONG).show(); } return true; } return false; } } private static WebViewClient sWebViewClient; /** * @hide Only for Small API */ public static void setWebViewClient(WebViewClient listener) { sWebViewClient = listener; } }