/* * Copyright 2012-2014 eBay Software Foundation and selendroid committers. * * 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 io.selendroid.server.model; import android.graphics.Bitmap; import android.net.http.SslError; import android.os.Build; import android.os.Message; import android.view.KeyEvent; import android.view.View; import android.webkit.*; import io.selendroid.server.ServerInstrumentation; import io.selendroid.server.android.*; import io.selendroid.server.android.internal.DomWindow; import io.selendroid.server.common.exceptions.SelendroidException; import io.selendroid.server.common.exceptions.StaleElementReferenceException; import io.selendroid.server.common.exceptions.TimeoutException; import io.selendroid.server.model.internal.WebViewHandleMapper; import io.selendroid.server.model.js.AndroidAtoms; import io.selendroid.server.util.SelendroidLogger; import org.apache.cordova.CordovaChromeClient; import org.apache.cordova.CordovaInterface; import org.apache.cordova.CordovaWebView; import org.apache.cordova.CordovaWebViewClient; import org.apache.cordova.engine.SystemWebChromeClient; import org.apache.cordova.engine.SystemWebView; import org.apache.cordova.engine.SystemWebViewClient; import org.apache.cordova.engine.SystemWebViewEngine; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.lang.reflect.Field; import java.util.*; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static java.util.concurrent.TimeUnit.SECONDS; public class SelendroidWebDriver { private static final String ELEMENT_KEY = "ELEMENT"; private static final long FOCUS_TIMEOUT = 1000L; private static final long POLLING_INTERVAL = 50L; private static final long START_LOADING_TIMEOUT = 700L; static final long UI_TIMEOUT = 3000L; private volatile boolean pageDoneLoading; private volatile boolean pageStartedLoading; private volatile String result; private volatile WebView webview = null; private static final String WINDOW_KEY = "WINDOW"; private volatile boolean editAreaHasFocus; private final Object syncObject = new Object(); private boolean done = false; private ServerInstrumentation serverInstrumentation = null; private SessionCookieManager sm = new SessionCookieManager(); private WebChromeClient chromeClient = null; private DomWindow currentWindowOrFrame; private Queue<String> currentAlertMessage = new LinkedList<String>(); private TouchScreen touch; private KeySender keySender; private MotionSender motionSender; private long scriptTimeout = 60000L; private long asyncScriptTimeout = 0L; private long pageLoadTimeout = 30000L; private final String contextHandle; public SelendroidWebDriver(ServerInstrumentation serverInstrumentation, String handle) { this.contextHandle = WebViewHandleMapper.normalizeHandle(handle); this.serverInstrumentation = serverInstrumentation; init(handle); keySender = new WebViewKeySender(serverInstrumentation, webview); } private static String escapeAndQuote(final String toWrap) { StringBuilder toReturn = new StringBuilder("\""); for (int i = 0; i < toWrap.length(); i++) { char c = toWrap.charAt(i); if (c == '\"') { toReturn.append("\\\""); } else if (c == '\\') { toReturn.append("\\\\"); } else { toReturn.append(c); } } toReturn.append("\""); return toReturn.toString(); } @SuppressWarnings("unchecked") private String convertToJsArgs(JSONArray args, KnownElements ke) throws JSONException { StringBuilder toReturn = new StringBuilder(); int length = args.length(); for (int i = 0; i < length; i++) { toReturn.append((i > 0) ? "," : ""); toReturn.append(convertToJsArgs(args.get(i), ke)); } SelendroidLogger.info("convertToJsArgs: " + toReturn.toString()); return toReturn.toString(); } private String convertToJsArgs(Object obj, KnownElements ke) throws JSONException { StringBuilder toReturn = new StringBuilder(); if (obj == null || obj.equals(null)) { return "null"; } if (obj instanceof JSONArray) { return convertToJsArgs((JSONArray) obj, ke); } if (obj instanceof List<?>) { toReturn.append("["); List<Object> aList = (List<Object>) obj; for (int j = 0; j < aList.size(); j++) { String comma = ((j == 0) ? "" : ","); toReturn.append(comma + convertToJsArgs(aList.get(j), ke)); } toReturn.append("]"); } else if (obj instanceof Map<?, ?>) { Map<Object, Object> aMap = (Map<Object, Object>) obj; String toAdd = "{"; for (Object key : aMap.keySet()) { toAdd += key + ":" + convertToJsArgs(aMap.get(key), ke) + ","; } toReturn.append(toAdd.substring(0, toAdd.length() - 1) + "}"); } else if (obj instanceof AndroidWebElement) { // A WebElement is represented in JavaScript by an Object as // follow: {"ELEMENT":"id"} where "id" refers to the id // of the HTML element in the javascript cache that can // be accessed throught bot.inject.cache.getCache_() toReturn.append("{\"" + ELEMENT_KEY + "\":\"" + ((AndroidWebElement) obj).getId() + "\"}"); } else if (obj instanceof DomWindow) { // A DomWindow is represented in JavaScript by an Object as // follow {"WINDOW":"id"} where "id" refers to the id of the // DOM window in the cache. toReturn.append("{\"" + WINDOW_KEY + "\":\"" + ((DomWindow) obj).getKey() + "\"}"); } else if (obj instanceof Number || obj instanceof Boolean) { toReturn.append(String.valueOf(obj)); } else if (obj instanceof String) { toReturn.append(escapeAndQuote((String) obj)); } else if (obj instanceof JSONObject) { if (((JSONObject) obj).has(ELEMENT_KEY)) { try { AndroidElement ae = ke.get(((JSONObject) obj).getString(ELEMENT_KEY)); toReturn.append(ae.toString()); } catch (JSONException e) { SelendroidLogger.info("exception getting the element id: " + e.toString()); } } else { // send across the object since it's not a webelement toReturn.append(obj.toString()); } } else { SelendroidLogger .info("failed to figure out what this is to convert to execute script:" + obj); } SelendroidLogger.info("convertToJsArgs: " + toReturn.toString()); return toReturn.toString(); } public String getContextHandle() { return contextHandle; } public Object executeAtom(AndroidAtoms atom, KnownElements ke, Object... args) { JSONArray array = new JSONArray(); for (int i = 0; i < args.length; i++) { array.put(args[i]); } try { return executeAtom(atom, array, ke); } catch (JSONException je) { SelendroidLogger.error("Failed to execute atom", je); throw new RuntimeException(je); } } public Object executeAtom(AndroidAtoms atom, JSONArray args, KnownElements ke) throws JSONException { final String myScript = atom.getValue(); String scriptInWindow = "(function(){ " + " var win; try{win=" + getWindowString() + "}catch(e){win=window;}" + "with(win){return (" + myScript + ")(" + convertToJsArgs(args, ke) + ")}})()"; String jsResult = executeJavascriptInWebView("alert('selendroid<' + document.charset + '>:'+" + scriptInWindow + ")"); SelendroidLogger.info("jsResult: " + jsResult); if (jsResult == null || "undefined".equals(jsResult)) { return null; } try { JSONObject json = new JSONObject(jsResult); if (0 != json.optInt("status")) { Object value = json.get("value"); if ((value instanceof String && value.equals("Element does not exist in cache")) || ( value instanceof JSONObject && (((JSONObject) value).getString("message").equals("Element does not exist in cache") || ((JSONObject) value).getString("message").equals("Element is no longer attached to the DOM")))) { throw new StaleElementReferenceException(json.optString("value")); } throw new SelendroidException(json.optString("value")); } if (json.isNull("value")) { return null; } else { return json.get("value"); } } catch (JSONException e) { throw new SelendroidException(e); } } private String executeJavascriptInWebView(final String script) { result = null; serverInstrumentation.getCurrentActivity().runOnUiThread(new Runnable() { public void run() { if (webview.getUrl() == null) { return; } // needed in case the AUT re-set the WebChromeClient, which overrides selendroid's // which handles the onAlert, which is used to communicate response messages. // If this happens to cause undesired behavior for an end user, they should // switch back to NATIVE_APP and then to the webview again, this will allow // selendroid to wrap the 'new' chromeClient set by the AUT. webview.setWebChromeClient(chromeClient); webview.loadUrl("javascript:" + script); } }); long timeout = System.currentTimeMillis() + scriptTimeout; synchronized (syncObject) { while (result == null && (System.currentTimeMillis() < timeout)) { try { syncObject.wait(2000); } catch (InterruptedException e) { throw new SelendroidException(e); } } return result; } } public Object executeScript(String script) { return injectJavascript(script, new JSONArray(), null); } public Object executeScript(String script, JSONArray args, KnownElements ke) { return injectJavascript(script, args, ke); } public Object executeScript(String script, Object args, KnownElements ke) { return injectJavascript(script, args, ke); } public String getCurrentUrl() { if (webview == null) { throw new SelendroidException("No open web view."); } long end = System.currentTimeMillis() + UI_TIMEOUT; final String[] url = new String[1]; done = false; Runnable r = new Runnable() { public void run() { url[0] = webview.getUrl(); synchronized (this) { this.notify(); } } }; runSynchronously(r, UI_TIMEOUT); return url[0]; } public void get(final String url) { resetPageIsLoading(); serverInstrumentation.getCurrentActivity().runOnUiThread(new Runnable() { public void run() { webview.loadUrl(url); } }); waitForPageToLoad(); } public String getWindowSource() throws JSONException { JSONObject source = new JSONObject( (String) executeScript("return (new XMLSerializer()).serializeToString(document.documentElement);")); return source.getString("value"); } protected void init(String handle) { SelendroidLogger.info("Selendroid webdriver init"); webview = WebViewHandleMapper.getWebViewByHandle(handle); if (webview == null) { throw new SelendroidException("No webview found on current activity."); } configureWebView(webview); currentWindowOrFrame = new DomWindow(""); motionSender = new WebViewMotionSender(webview, serverInstrumentation); touch = new AndroidTouchScreen(serverInstrumentation, motionSender); } public TouchScreen getTouch() { return touch; } KeySender getKeySender() { return keySender; } MotionSender getMotionSender() { return motionSender; } private void configureWebView(final WebView view) { serverInstrumentation.getCurrentActivity().runOnUiThread(new Runnable() { @Override public void run() { try { view.clearCache(true); view.clearFormData(); view.clearHistory(); view.setFocusable(true); view.setFocusableInTouchMode(true); view.setNetworkAvailable(true); try { chromeClient = encapulateWebChromeClientForView(view); view.setWebChromeClient(chromeClient); view.setWebViewClient(encapulateWebViewClientForView(view)); } catch (ClassCastException cce) { // the view can potentially have the class name be an empty string // if the app declared its WebView inside another class if (cce.getMessage().contains("cannot be cast to org.apache.cordova.CordovaChromeClient")) { chromeClient = encapsulatedCordovaChromeClientForView((CordovaWebView) view); view.setWebChromeClient(chromeClient); try { view.setWebViewClient(encapulateCordovaWebViewClientForView((CordovaWebView) view)); } catch (Exception e) { // ignore... it's not *as* important to override the WebViewClient } } else { throw cce; } } WebSettings settings = view.getSettings(); settings.setJavaScriptCanOpenWindowsAutomatically(true); settings.setSupportMultipleWindows(true); settings.setBuiltInZoomControls(true); settings.setJavaScriptEnabled(true); settings.setAppCacheEnabled(true); settings.setAppCacheMaxSize(10 * 1024 * 1024); settings.setAppCachePath(""); settings.setDatabaseEnabled(true); settings.setDomStorageEnabled(true); settings.setGeolocationEnabled(true); settings.setSaveFormData(false); settings.setSavePassword(false); settings.setRenderPriority(WebSettings.RenderPriority.HIGH); // Flash settings settings.setPluginState(WebSettings.PluginState.ON); // Geo location settings settings.setGeolocationEnabled(true); settings.setGeolocationDatabasePath("/data/data/selendroid"); } catch (Exception e) { SelendroidLogger.error("Error configuring web view", e); } } }); } private WebChromeClient encapulateWebChromeClientForView(WebView view) { if (view.getClass().getSimpleName().equalsIgnoreCase("CordovaWebView")) { return encapsulatedCordovaChromeClientForView((CordovaWebView)view); } else if (view.getClass().getSimpleName().equalsIgnoreCase("SystemWebView")) { return new ExtendedSystemWebChromeClient(new SystemWebViewEngine((SystemWebView)view)); } else { try { Object webChromeClient = getWebClientViaReflection(view, "mWebChromeClient"); if (webChromeClient != null) { return new WrappedChromeClient((WebChromeClient) webChromeClient); } } catch(Exception e) {} return new SelendroidWebChromeClient(); } } private CordovaChromeClient encapsulatedCordovaChromeClientForView(CordovaWebView view) { try { if (reflectionGet(view, "chromeClient") != null) { return new WrappingCordovaChromeClient(null, view); } } catch (NoSuchFieldException nsfe) {} catch (IllegalAccessException iae) {} return new ExtendedCordovaChromeClient(view); } private WebViewClient encapulateWebViewClientForView(WebView view) { if (view.getClass().getSimpleName().equalsIgnoreCase("CordovaWebView")) { try { return encapulateCordovaWebViewClientForView((CordovaWebView) view); } catch (NoSuchFieldException nsfe) {} catch (IllegalAccessException iae) {} return new SelendroidWebClient(); } else if (view.getClass().getSimpleName().equalsIgnoreCase("SystemWebView")) { return new SelendroidSystemWebViewClient((SystemWebView)view); } else { try { Object webViewClient = getWebClientViaReflection(view, "mWebViewClient"); if (webViewClient != null) { return new WrappingSelendroidWebClient((WebViewClient) webViewClient); } } catch (NoSuchFieldException nsfe) {} catch (IllegalAccessException iae) {} return new SelendroidWebClient(); } } private CordovaWebViewClient encapulateCordovaWebViewClientForView(CordovaWebView view) throws NoSuchFieldException, IllegalAccessException { if (reflectionGet(view, "viewClient") != null) { return new WrappingSelendroidCordovaWebClient(view); } return new SelendroidCordovaWebClient((CordovaInterface)reflectionGet(view, "cordova"), view); } private Object reflectionGet(Object object, String field) throws NoSuchFieldException, IllegalAccessException { Field f = object.getClass().getDeclaredField(field); f.setAccessible(true); return f.get(object); } private Object getWebClientViaReflection(WebView view, String clientField) throws NoSuchFieldException, IllegalAccessException { return reflectionGet(reflectionGet(reflectionGet(view, "mProvider"), "mContentsClientAdapter"), clientField); } private String getWindowString() { String window = ""; if (!currentWindowOrFrame.getKey().equals("")) { window = "document['$wdc_']['" + currentWindowOrFrame.getKey() + "'] ||"; } return (window += "window"); } Object injectJavascript(String toExecute, Object args, KnownElements ke) { try { String executeScript = AndroidAtoms.EXECUTE_SCRIPT.getValue(); toExecute = "var win_context; try{win_context= " + getWindowString() + "}catch(e){" + "win_context=window;}with(win_context){" + toExecute + "}"; String wrappedScript = "(function(){ var win; try{win=" + getWindowString() + "}catch(e){win=window}" + "with(win){return (" + executeScript + ")(" + escapeAndQuote(toExecute) + ", [" + convertToJsArgs(args, ke) + "], true)}})()"; return executeJavascriptInWebView("alert('selendroid<' + document.charset + '>:'+" + wrappedScript + ")"); } catch (JSONException e) { SelendroidLogger.error("Failed to convert args to jsArgs", e); throw new RuntimeException(e); } } Object injectAtomJavascript(String toExecute, Object args, KnownElements ke) throws JSONException { return executeJavascriptInWebView("alert('selendroid<' + document.charset +'>:'+ (" + toExecute + ")(" + convertToJsArgs(args, ke) + "))"); } public Object executeAsyncJavascript(String toExecute, JSONArray args, KnownElements ke) { try { String callbackFunction = "function(result){alert('selendroid<' + document.charset + '>:'+result);}"; String script = "try {(" + AndroidAtoms.EXECUTE_ASYNC_SCRIPT.getValue() + ")(" + escapeAndQuote(toExecute) + ", [" + convertToJsArgs(args, ke) + "], " + asyncScriptTimeout + ", " + callbackFunction + "," + "true, " + getWindowString() + ")}catch(e){alert('selendroid<' + document.charset + '>:{\"status\":13,\"value\":\"' + e + '\"}')}"; return executeJavascriptInWebView(script); } catch (JSONException je) { SelendroidLogger.error("Failed convert JSONArray to jsArgs", je); throw new RuntimeException(je); } } Boolean isInFrame() { return !currentWindowOrFrame.getKey().equals(""); } void resetPageIsLoading() { pageStartedLoading = false; pageDoneLoading = false; } void setEditAreaHasFocus(boolean focused) { editAreaHasFocus = focused; } void waitForPageToLoad() { synchronized (syncObject) { long timeout = System.currentTimeMillis() + START_LOADING_TIMEOUT; while (!pageStartedLoading && (System.currentTimeMillis() < timeout)) { try { syncObject.wait(POLLING_INTERVAL); } catch (InterruptedException e) { throw new RuntimeException(); } } long end = System.currentTimeMillis() + pageLoadTimeout; while (!pageDoneLoading && pageStartedLoading && (System.currentTimeMillis() < end)) { try { syncObject.wait(POLLING_INTERVAL); } catch (InterruptedException e) { throw new RuntimeException(e); } } if (!pageDoneLoading && pageStartedLoading) { throw new TimeoutException(String.format("Timed out after %d seconds waiting for page to load", SECONDS.convert(pageLoadTimeout, MILLISECONDS))); } } } void waitUntilEditAreaHasFocus() { long timeout = System.currentTimeMillis() + FOCUS_TIMEOUT; while (!editAreaHasFocus && (System.currentTimeMillis() < timeout)) { try { Thread.sleep(POLLING_INTERVAL); } catch (InterruptedException e) { throw new RuntimeException(e); } } } public class ExtendedCordovaChromeClient extends CordovaChromeClient { public Boolean callSuper = true; public ExtendedCordovaChromeClient(CordovaWebView app) { super(null, app); } /** * Unconventional way of adding a Javascript interface but the main reason why I took this way * is that it is working stable compared to the webview.addJavascriptInterface way. */ @Override public boolean onJsAlert(WebView view, String url, String message, JsResult jsResult) { if (message != null && message.startsWith("selendroid<")) { jsResult.confirm(); synchronized (syncObject) { String res = message.replaceFirst("selendroid<", ""); int i = res.indexOf(">:"); String enc = res.substring(0, i); res = res.substring(i + 2); /* * Workaround for Japanese character encodings: Replace U+00A5 with backslash so that we * can properly parse JSON strings contains backslash escapes, since WebKit maps 0x5C * (used for character escaping in all of the Japanses character encodings) to U+00A5 (YEN * SIGN) and breaks escape characters. */ if (("EUC-JP".equals(enc) || "Shift_JIS".equals(enc) || "ISO-2022-JP".equals(enc)) && res.contains("\u00a5")) { SelendroidLogger.info("Perform workaround for japanese character encodings"); SelendroidLogger.debug("Original String: " + res); res = res.replace("\u00a5", "\\"); SelendroidLogger.debug("Replaced result: " + res); } result = res; syncObject.notify(); } return true; } else if (callSuper) { currentAlertMessage.add(message == null ? "null" : message); SelendroidLogger.info("new alert message: " + message); return super.onJsAlert(view, url, message, jsResult); } else { return false; } } } public class WrappingCordovaChromeClient extends CordovaChromeClient { private CordovaChromeClient WCC; private ExtendedCordovaChromeClient selendroidCCC; public WrappingCordovaChromeClient(CordovaInterface ci, CordovaWebView view) throws NoSuchFieldException, IllegalAccessException { super(ci, view); selendroidCCC = new ExtendedCordovaChromeClient(view); selendroidCCC.callSuper = false; WCC = (CordovaChromeClient) reflectionGet(view, "chromeClient"); } @Override public boolean onJsAlert(WebView view, String url, String message, JsResult result) { // if the alert was a selendroid one, this method will return true, otherwise false // and the alert should be propagated to the original WebChromeClient if (!selendroidCCC.onJsAlert(view, url, message, result)) { return WCC.onJsAlert(view, url, message, result); } return true; } @Override public boolean onJsConfirm(WebView view, String url, String message, JsResult result) { return WCC.onJsConfirm(view, url, message, result); } @Override public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) { return WCC.onJsPrompt(view, url, message, defaultValue, result); } @Override public void onProgressChanged(WebView view, int newProgress) { WCC.onProgressChanged(view, newProgress); } @Override public void onReceivedTitle(WebView view, String title) { WCC.onReceivedTitle(view, title); } @Override public void onReceivedIcon(WebView view, Bitmap icon) { WCC.onReceivedIcon(view, icon); } @Override public void onReceivedTouchIconUrl(WebView view, String url, boolean precomposed) { WCC.onReceivedTouchIconUrl(view, url, precomposed); } @Override public void onShowCustomView(View view, CustomViewCallback callback) { WCC.onShowCustomView(view, callback); } @Override public void onShowCustomView(View view, int requestedOrientation, CustomViewCallback callback) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { WCC.onShowCustomView(view, requestedOrientation, callback); } } @Override public void onHideCustomView() { WCC.onHideCustomView(); } @Override public boolean onCreateWindow(WebView view, boolean isDialog, boolean isUserGesture, Message resultMsg) { return WCC.onCreateWindow(view, isDialog, isUserGesture, resultMsg); } @Override public void onRequestFocus(WebView view) { WCC.onRequestFocus(view); } @Override public void onCloseWindow(WebView window) { WCC.onCloseWindow(window); } @Override public boolean onJsBeforeUnload(WebView view, String url, String message, JsResult result) { return WCC.onJsBeforeUnload(view, url, message, result); } @Override public void onExceededDatabaseQuota(String url, String databaseIdentifier, long quota, long estimatedDatabaseSize, long totalQuota, WebStorage.QuotaUpdater quotaUpdater) { WCC.onExceededDatabaseQuota(url, databaseIdentifier, quota, estimatedDatabaseSize, totalQuota, quotaUpdater); } @Override public void onReachedMaxAppCacheSize(long requiredStorage, long quota, WebStorage.QuotaUpdater quotaUpdater) { WCC.onReachedMaxAppCacheSize(requiredStorage, quota, quotaUpdater); } @Override public void onGeolocationPermissionsShowPrompt(String origin, GeolocationPermissions.Callback callback) { WCC.onGeolocationPermissionsShowPrompt(origin, callback); } @Override public void onGeolocationPermissionsHidePrompt() { WCC.onGeolocationPermissionsHidePrompt(); } // can't build for things above 4.1.1.4 // @Override // public void onPermissionRequest(PermissionRequest request) { // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // WCC.onPermissionRequest(request); // } // } // // @Override // public void onPermissionRequestCanceled(PermissionRequest request) { // WCC.onPermissionRequestCanceled(request); // } @Override public boolean onJsTimeout() { return WCC.onJsTimeout(); } @Override public void onConsoleMessage(String message, int lineNumber, String sourceID) { WCC.onConsoleMessage(message, lineNumber, sourceID); } @Override public boolean onConsoleMessage(ConsoleMessage consoleMessage) { return WCC.onConsoleMessage(consoleMessage); } @Override public Bitmap getDefaultVideoPoster() { return WCC.getDefaultVideoPoster(); } @Override public View getVideoLoadingProgressView() { return WCC.getVideoLoadingProgressView(); } @Override public void getVisitedHistory(ValueCallback<String[]> callback) { WCC.getVisitedHistory(callback); } // can't build over 4.1.1.4 // @Override // public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback, FileChooserParams fileChooserParams) { // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // return WCC.onShowFileChooser(webView, filePathCallback, fileChooserParams); // } // return false; // } } //Like ExtendedCordovaClient, but for Cordova 4.0.0 public class ExtendedSystemWebChromeClient extends SystemWebChromeClient { public ExtendedSystemWebChromeClient(SystemWebViewEngine parentEngine) { super(parentEngine); } /** * Unconventional way of adding a Javascript interface but the main reason why I took this way * is that it is working stable compared to the webview.addJavascriptInterface way. **/ @Override public boolean onJsAlert(WebView view, String url, String message, JsResult jsResult) { if (message != null && message.startsWith("selendroid<")) { jsResult.confirm(); synchronized (syncObject) { String res = message.replaceFirst("selendroid<", ""); int i = res.indexOf(">:"); String enc = res.substring(0, i); res = res.substring(i + 2); /* Workaround for Japanese character encodings: Replace U+00A5 with backslash so that we * can properly parse JSON strings contains backslash escapes, since WebKit maps 0x5C * (used for character escaping in all of the Japanses character encodings) to U+00A5 (YEN * SIGN) and breaks escape characters. */ if (("EUC-JP".equals(enc) || "Shift_JIS".equals(enc) || "ISO-2022-JP".equals(enc)) && res.contains("\u00a5")) { SelendroidLogger.info("Perform workaround for japanese character encodings"); SelendroidLogger.debug("Original String: " + res); res = res.replace("\u00a5", "\\"); SelendroidLogger.debug("Replaced result: " + res); } result = res; syncObject.notify(); } return true; } else { currentAlertMessage.add(message == null ? "null" : message); SelendroidLogger.info("new alert message: " + message); return super.onJsAlert(view, url, message, jsResult); } } } public class SelendroidSystemWebViewClient extends SystemWebViewClient { public SelendroidSystemWebViewClient(SystemWebView view) { super(new SystemWebViewEngine(view)); } @Override public void onPageStarted(WebView view, String url, Bitmap favicon) { synchronized (syncObject) { pageStartedLoading = true; syncObject.notify(); } } @Override public void onPageFinished(WebView view, String url) { synchronized (syncObject) { pageDoneLoading = true; syncObject.notify(); } } } public class SelendroidCordovaWebClient extends CordovaWebViewClient { public SelendroidCordovaWebClient(CordovaInterface cordova, CordovaWebView view) { super(cordova, view); } @Override public void onPageStarted(WebView view, String url, Bitmap favicon) { synchronized (syncObject) { pageStartedLoading = true; syncObject.notify(); } } @Override public void onPageFinished(WebView view, String url) { synchronized (syncObject) { pageDoneLoading = true; syncObject.notify(); } } } public class WrappingSelendroidCordovaWebClient extends CordovaWebViewClient { private CordovaWebViewClient selendroidCWVC; private CordovaWebViewClient CWVC; public WrappingSelendroidCordovaWebClient(CordovaWebView webView) throws NoSuchFieldException, IllegalAccessException { super(null, webView); CWVC = (CordovaWebViewClient)reflectionGet(webView, "viewClient"); selendroidCWVC = new SelendroidCordovaWebClient(null, webView); } @Override public void onPageStarted(WebView view, String url, Bitmap favicon) { selendroidCWVC.onPageStarted(view, url, favicon); CWVC.onPageStarted(view, url, favicon); } @Override public void onPageFinished(WebView view, String url) { selendroidCWVC.onPageFinished(view, url); CWVC.onPageFinished(view, url); } @Override public boolean shouldOverrideUrlLoading(WebView view, String url) { return CWVC.shouldOverrideUrlLoading(view, url); } @Override public void onLoadResource(WebView view, String url) { CWVC.onLoadResource(view, url); } // can't build over 4.1.1.4 // @Override // public void onPageCommitVisible(WebView view, String url) { // if (Build.VERSION.SDK_INT >= 23) { // CWVC.onPageCommitVisible(view, url); // } // } @Override public WebResourceResponse shouldInterceptRequest(WebView view, String url) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { return CWVC.shouldInterceptRequest(view, url); } return null; } // can't build over 4.1.1.4 // @Override // public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // return CWVC.shouldInterceptRequest(view, request); // } // return null; // } @Override public void onTooManyRedirects(WebView view, Message cancelMsg, Message continueMsg) { CWVC.onTooManyRedirects(view, cancelMsg, continueMsg); } @Override public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { CWVC.onReceivedError(view, errorCode, description, failingUrl); } // can't build over 4.1.1.4 // @Override // public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { // if (Build.VERSION.SDK_INT >= 23) { // CWVC.onReceivedError(view, request, error); // } // } // // @Override // public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) { // if (Build.VERSION.SDK_INT >= 23) { // CWVC.onReceivedHttpError(view, request, errorResponse); // } // } @Override public void onFormResubmission(WebView view, Message dontResend, Message resend) { CWVC.onFormResubmission(view, dontResend, resend); } @Override public void doUpdateVisitedHistory(WebView view, String url, boolean isReload) { CWVC.doUpdateVisitedHistory(view, url, isReload); } @Override public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) { CWVC.onReceivedSslError(view, handler, error); } // can't build above 4.1.1.4 with maven! actually a good reason to switch to gradle. // @Override // public void onReceivedClientCertRequest(WebView view, ClientCertRequest request) { // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // CWVC.onReceivedClientCertRequest(view, request); // } // } @Override public void onReceivedHttpAuthRequest(WebView view, HttpAuthHandler handler, String host, String realm) { CWVC.onReceivedHttpAuthRequest(view, handler, host, realm); } @Override public boolean shouldOverrideKeyEvent(WebView view, KeyEvent event) { return CWVC.shouldOverrideKeyEvent(view, event); } @Override public void onUnhandledKeyEvent(WebView view, KeyEvent event) { CWVC.onUnhandledKeyEvent(view, event); } // can't build over 4.1.1.4 // @Override // public void onUnhandledInputEvent(WebView view, InputEvent event) { // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // CWVC.onUnhandledInputEvent(view, event); // } // } @Override public void onScaleChanged(WebView view, float oldScale, float newScale) { CWVC.onScaleChanged(view, oldScale, newScale); } @Override public void onReceivedLoginRequest(WebView view, String realm, String account, String args) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR1) { CWVC.onReceivedLoginRequest(view, realm, account, args); } } } public class SelendroidWebChromeClient extends WebChromeClient { private boolean callSuper = true; public SelendroidWebChromeClient() { } public SelendroidWebChromeClient(boolean callSuper) { this.callSuper = callSuper; } /** * Unconventional way of adding a Javascript interface but the main reason why I took this way * is that it is working stable compared to the webview.addJavascriptInterface way. */ @Override public boolean onJsAlert(WebView view, String url, String message, JsResult jsResult) { if (message != null && message.startsWith("selendroid<")) { jsResult.confirm(); synchronized (syncObject) { String res = message.replaceFirst("selendroid<", ""); int i = res.indexOf(">:"); String enc = res.substring(0, i); res = res.substring(i + 2); /* * Workaround for Japanese character encodings: Replace U+00A5 with backslash so that we * can properly parse JSON strings contains backslash escapes, since WebKit maps 0x5C * (used for character escaping in all of the Japanses character encodings) to U+00A5 (YEN * SIGN) and breaks escape characters. */ if (("EUC-JP".equals(enc) || "Shift_JIS".equals(enc) || "ISO-2022-JP".equals(enc)) && res.contains("\u00a5")) { SelendroidLogger.info("Perform workaround for japanese character encodings"); SelendroidLogger.debug("Original String: " + res); res = res.replace("\u00a5", "\\"); SelendroidLogger.debug("Replaced result: " + res); } result = res; syncObject.notify(); } return true; } else if (callSuper){ currentAlertMessage.add(message == null ? "null" : message); SelendroidLogger.info("new alert message: " + message); return super.onJsAlert(view, url, message, jsResult); } return false; } @Override public boolean onJsConfirm(WebView view, String url, String message, JsResult result) { currentAlertMessage.add(message == null ? "null" : message); SelendroidLogger.info("new confirm message: " + message); if (callSuper) { return super.onJsConfirm(view, url, message, result); } return false; } @Override public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) { currentAlertMessage.add(message == null ? "null" : message); SelendroidLogger.info("new prompt message: " + message); if (callSuper) { return super.onJsPrompt(view, url, message, defaultValue, result); } return false; } } // Would be so awesome to use a Proxy here and just intercept the two methods we care about. // alas, WebChromeClient is a concrete class and there's no interface provided. // And trying to do alternatives in Android proved to be too difficult public class WrappedChromeClient extends WebChromeClient { private WebChromeClient WCC; private WebChromeClient selendroidWCC = new SelendroidWebChromeClient(false); public WrappedChromeClient(WebChromeClient webChromeClient) { WCC = webChromeClient; } @Override public boolean onJsAlert(WebView view, String url, String message, JsResult result) { // if the alert was a selendroid one, this method will return true, otherwise false // and the alert should be propagated to the original WebChromeClient if (!selendroidWCC.onJsAlert(view, url, message, result)) { return WCC.onJsAlert(view, url, message, result); } return true; } @Override public boolean onJsConfirm(WebView view, String url, String message, JsResult result) { selendroidWCC.onJsConfirm(view, url, message, result); return WCC.onJsConfirm(view, url, message, result); } @Override public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) { selendroidWCC.onJsPrompt(view, url, message, defaultValue, result); return WCC.onJsPrompt(view, url, message, defaultValue, result); } @Override public void onProgressChanged(WebView view, int newProgress) { WCC.onProgressChanged(view, newProgress); } @Override public void onReceivedTitle(WebView view, String title) { WCC.onReceivedTitle(view, title); } @Override public void onReceivedIcon(WebView view, Bitmap icon) { WCC.onReceivedIcon(view, icon); } @Override public void onReceivedTouchIconUrl(WebView view, String url, boolean precomposed) { WCC.onReceivedTouchIconUrl(view, url, precomposed); } @Override public void onShowCustomView(View view, CustomViewCallback callback) { WCC.onShowCustomView(view, callback); } @Override public void onShowCustomView(View view, int requestedOrientation, CustomViewCallback callback) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { WCC.onShowCustomView(view, requestedOrientation, callback); } } @Override public void onHideCustomView() { WCC.onHideCustomView(); } @Override public boolean onCreateWindow(WebView view, boolean isDialog, boolean isUserGesture, Message resultMsg) { return WCC.onCreateWindow(view, isDialog, isUserGesture, resultMsg); } @Override public void onRequestFocus(WebView view) { WCC.onRequestFocus(view); } @Override public void onCloseWindow(WebView window) { WCC.onCloseWindow(window); } @Override public boolean onJsBeforeUnload(WebView view, String url, String message, JsResult result) { return WCC.onJsBeforeUnload(view, url, message, result); } @Override public void onExceededDatabaseQuota(String url, String databaseIdentifier, long quota, long estimatedDatabaseSize, long totalQuota, WebStorage.QuotaUpdater quotaUpdater) { WCC.onExceededDatabaseQuota(url, databaseIdentifier, quota, estimatedDatabaseSize, totalQuota, quotaUpdater); } @Override public void onReachedMaxAppCacheSize(long requiredStorage, long quota, WebStorage.QuotaUpdater quotaUpdater) { WCC.onReachedMaxAppCacheSize(requiredStorage, quota, quotaUpdater); } @Override public void onGeolocationPermissionsShowPrompt(String origin, GeolocationPermissions.Callback callback) { WCC.onGeolocationPermissionsShowPrompt(origin, callback); } @Override public void onGeolocationPermissionsHidePrompt() { WCC.onGeolocationPermissionsHidePrompt(); } // can't build for things above 4.1.1.4 // @Override // public void onPermissionRequest(PermissionRequest request) { // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // WCC.onPermissionRequest(request); // } // } // // @Override // public void onPermissionRequestCanceled(PermissionRequest request) { // WCC.onPermissionRequestCanceled(request); // } @Override public boolean onJsTimeout() { return WCC.onJsTimeout(); } @Override public void onConsoleMessage(String message, int lineNumber, String sourceID) { WCC.onConsoleMessage(message, lineNumber, sourceID); } @Override public boolean onConsoleMessage(ConsoleMessage consoleMessage) { return WCC.onConsoleMessage(consoleMessage); } @Override public Bitmap getDefaultVideoPoster() { return WCC.getDefaultVideoPoster(); } @Override public View getVideoLoadingProgressView() { return WCC.getVideoLoadingProgressView(); } @Override public void getVisitedHistory(ValueCallback<String[]> callback) { WCC.getVisitedHistory(callback); } // can't build over 4.1.1.4 // @Override // public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback, FileChooserParams fileChooserParams) { // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // return WCC.onShowFileChooser(webView, filePathCallback, fileChooserParams); // } // return false; // } } public class SelendroidWebClient extends WebViewClient { @Override public void onPageStarted(WebView view, String url, Bitmap favicon) { synchronized (syncObject) { pageStartedLoading = true; syncObject.notify(); } } @Override public void onPageFinished(WebView view, String url) { synchronized (syncObject) { pageDoneLoading = true; syncObject.notify(); } } } // Would be so awesome to use a Proxy here and just intercept the two methods we care about. // alas, WebViewClient is a concrete class and there's no interface provided. // And trying to do alternatives in Android proved to be too difficult public class WrappingSelendroidWebClient extends WebViewClient { private WebViewClient selendroidWVC = new SelendroidWebClient(); private WebViewClient WVC; public WrappingSelendroidWebClient(WebViewClient webViewClient) { WVC = webViewClient; } @Override public void onPageStarted(WebView view, String url, Bitmap favicon) { selendroidWVC.onPageStarted(view, url, favicon); WVC.onPageStarted(view, url, favicon); } @Override public void onPageFinished(WebView view, String url) { selendroidWVC.onPageFinished(view, url); WVC.onPageFinished(view, url); } @Override public boolean shouldOverrideUrlLoading(WebView view, String url) { return WVC.shouldOverrideUrlLoading(view, url); } @Override public void onLoadResource(WebView view, String url) { WVC.onLoadResource(view, url); } // can't build over 4.1.1.4 // @Override // public void onPageCommitVisible(WebView view, String url) { // if (Build.VERSION.SDK_INT >= 23) { // WVC.onPageCommitVisible(view, url); // } // } @Override public WebResourceResponse shouldInterceptRequest(WebView view, String url) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { return WVC.shouldInterceptRequest(view, url); } return null; } // can't build over 4.1.1.4 // @Override // public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // return WVC.shouldInterceptRequest(view, request); // } // return null; // } @Override public void onTooManyRedirects(WebView view, Message cancelMsg, Message continueMsg) { WVC.onTooManyRedirects(view, cancelMsg, continueMsg); } @Override public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { WVC.onReceivedError(view, errorCode, description, failingUrl); } // can't build over 4.1.1.4 // @Override // public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { // if (Build.VERSION.SDK_INT >= 23) { // WVC.onReceivedError(view, request, error); // } // } // // @Override // public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) { // if (Build.VERSION.SDK_INT >= 23) { // WVC.onReceivedHttpError(view, request, errorResponse); // } // } @Override public void onFormResubmission(WebView view, Message dontResend, Message resend) { WVC.onFormResubmission(view, dontResend, resend); } @Override public void doUpdateVisitedHistory(WebView view, String url, boolean isReload) { WVC.doUpdateVisitedHistory(view, url, isReload); } @Override public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) { WVC.onReceivedSslError(view, handler, error); } // can't build above 4.1.1.4 with maven! actually a good reason to switch to gradle. // @Override // public void onReceivedClientCertRequest(WebView view, ClientCertRequest request) { // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // WVC.onReceivedClientCertRequest(view, request); // } // } @Override public void onReceivedHttpAuthRequest(WebView view, HttpAuthHandler handler, String host, String realm) { WVC.onReceivedHttpAuthRequest(view, handler, host, realm); } @Override public boolean shouldOverrideKeyEvent(WebView view, KeyEvent event) { return WVC.shouldOverrideKeyEvent(view, event); } @Override public void onUnhandledKeyEvent(WebView view, KeyEvent event) { WVC.onUnhandledKeyEvent(view, event); } // can't build over 4.1.1.4 // @Override // public void onUnhandledInputEvent(WebView view, InputEvent event) { // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // WVC.onUnhandledInputEvent(view, event); // } // } @Override public void onScaleChanged(WebView view, float oldScale, float newScale) { WVC.onScaleChanged(view, oldScale, newScale); } @Override public void onReceivedLoginRequest(WebView view, String realm, String account, String args) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR1) { WVC.onReceivedLoginRequest(view, realm, account, args); } } } public String getTitle() { if (webview == null) { throw new SelendroidException("No open web view."); } long end = System.currentTimeMillis() + UI_TIMEOUT; final String[] title = new String[1]; done = false; serverInstrumentation.getCurrentActivity().runOnUiThread(new Runnable() { public void run() { synchronized (syncObject) { title[0] = webview.getTitle(); done = true; syncObject.notify(); } } }); waitForDone(end, UI_TIMEOUT, "Failed to get title"); return title[0]; } private void waitForDone(long end, long timeout, String error) { synchronized (syncObject) { while (!done && System.currentTimeMillis() < end) { try { syncObject.wait(timeout); } catch (InterruptedException e) { throw new SelendroidException(error, e); } } } } private void runSynchronously(Runnable r, long timeout) { synchronized (r) { serverInstrumentation.getCurrentActivity().runOnUiThread(r); try { r.wait(timeout); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } public WebView getWebview() { return webview; } public Set<Cookie> getCookies(String url) { return sm.getAllCookies(url); } public void removeAllCookie(String url) { sm.removeAllCookies(url); } public void remove(String url, String name) { sm.remove(url, name); } public void setCookies(String url, Cookie cookie) { sm.addCookie(url, cookie); } public void frame(int index) throws JSONException { currentWindowOrFrame = processFrameExecutionResult(injectAtomJavascript(AndroidAtoms.FRAME_BY_INDEX.getValue(), index, null)); } public void frame(String frameNameOrId) throws JSONException { currentWindowOrFrame = processFrameExecutionResult(injectAtomJavascript( AndroidAtoms.FRAME_BY_ID_OR_NAME.getValue(), frameNameOrId, null)); } public void frame(AndroidWebElement frameElement) { currentWindowOrFrame = processFrameExecutionResult(executeScript("return arguments[0].contentWindow;", frameElement, null)); } public void switchToDefaultContent() { currentWindowOrFrame = new DomWindow(""); } private DomWindow processFrameExecutionResult(Object result) { if (result == null || "undefined".equals(result)) { return null; } try { JSONObject json = new JSONObject((String) result); JSONObject value = json.getJSONObject("value"); return new DomWindow(value.getString("WINDOW")); } catch (JSONException e) { throw new RuntimeException("Failed to parse JavaScript result: " + result.toString(), e); } } public void back() { resetPageIsLoading(); runSynchronously(new Runnable() { public void run() { webview.goBack(); } }, 500); waitForPageToLoad(); } public void forward() { resetPageIsLoading(); runSynchronously(new Runnable() { public void run() { webview.goForward(); } }, 500); waitForPageToLoad(); } public void refresh() { resetPageIsLoading(); runSynchronously(new Runnable() { public void run() { webview.reload(); } }, 500); waitForPageToLoad(); } public boolean isAlertPresent() { SelendroidLogger.info("checking currentAlertMessage: " + currentAlertMessage.size()); return !currentAlertMessage.isEmpty(); } public String getCurrentAlertMessage() { SelendroidLogger.info("getting currentAlertMessage: " + currentAlertMessage.peek()); return currentAlertMessage.peek(); } public void clearCurrentAlertMessage() { SelendroidLogger.info("clearing the current alert message: " + currentAlertMessage.remove()); } public void setAsyncScriptTimeout(long timeout) { asyncScriptTimeout = timeout; } public void setPageLoadTimeout(long timeout) { pageLoadTimeout = timeout; } }