/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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 androidx.test.espresso.web.action;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.util.concurrent.Futures.transform;
import static com.google.common.util.concurrent.Futures.transformAsync;
import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.webkit.ValueCallback;
import android.webkit.WebHistoryItem;
import android.webkit.WebView;
import androidx.test.espresso.web.model.Evaluation;
import androidx.test.espresso.web.model.ModelCodec;
import androidx.test.espresso.web.model.WindowReference;
import com.google.android.apps.common.testing.testrunner.web.Conduit;
import com.google.android.apps.common.testing.testrunner.web.JavaScriptBridge;
import com.google.common.base.Function;
import com.google.common.util.concurrent.AbstractFuture;
import com.google.common.util.concurrent.AsyncFunction;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.regex.Pattern;
import javax.annotation.Nullable;
/**
* Wraps scripts into WebDriver atoms, which are used to ensure consistent behaviour cross-browser.
*/
final class JavascriptEvaluation {
private JavascriptEvaluation() {}
private static final ScriptPreparer SCRIPT_PREPARER;
private static final AsyncFunction<PreparedScript, String> RAW_EVALUATOR;
private static final Function<String, Evaluation> DECODE_EVALUATION =
new Function<String, Evaluation>() {
@Override
public Evaluation apply(String in) {
return ModelCodec.decodeEvaluation(in);
}
};
private static final int SANITIZER_SYNC = 1;
private static final Handler MAIN_HANDLER =
new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message m) {
switch (m.what) {
case SANITIZER_SYNC:
((SanitizerTask) m.obj).sanitizerSync();
break;
}
}
};
static {
if (Build.VERSION.SDK_INT < 19) {
SCRIPT_PREPARER = new ScriptPreparer(true);
RAW_EVALUATOR = new AsyncConduitEvaluation();
} else {
SCRIPT_PREPARER = new ScriptPreparer(false);
RAW_EVALUATOR = new AsyncJavascriptEvaluation();
}
}
/**
* Evaluates a script on a given WebView.
*
* <p>Scripts are only evaluated when a WebView is deemed sane. That is:
*
* <ul>
* <li>The WebView's back/forward list's last item agrees with the WebView
* <li>The WebView's reported content height is non-zero
* <li>The WebView's reported progress is 100
* <li>The document.documentElement object for the DOM of the selected window is non-null
* <ul>
* Scripts are evaluated on the WebKit/Chromium thread (that is - not the Main thread). A
* Future is returned which contains the result of the evaluation.
*/
static ListenableFuture<Evaluation> evaluate(
final WebView view,
final String script,
final List<Object> arguments,
@Nullable final WindowReference window) {
UnpreparedScript unprepared = new UnpreparedScript(view, script, arguments, window);
SanitizerTask sanitizer = new SanitizerTask(unprepared);
view.post(sanitizer);
ListenableFuture<PreparedScript> preparedScript =
transform(sanitizer, SCRIPT_PREPARER, directExecutor());
ListenableFuture<String> rawEvaluation =
transformAsync(preparedScript, RAW_EVALUATOR, directExecutor());
ListenableFuture<Evaluation> parsedEvaluation =
transform(rawEvaluation, DECODE_EVALUATION, directExecutor());
return parsedEvaluation;
}
/** Ensures the WebView meetings minimum sanity guidelines. */
private static class SanitizerTask extends AbstractFuture<UnpreparedScript> implements Runnable {
// Defines as a JavaScript function to avoid the "unsafe_eval" error when strict CSP is defined.
private static final String DOC_ELEMENT_PRESENT =
"function checkDocElement() {return document.documentElement != null &&"
+ " document.readyState === 'complete';}";
private static final int DELAY = 100;
private final UnpreparedScript unprepared;
private String sanityMessage = "";
private int count;
public SanitizerTask(UnpreparedScript unprepared) {
this.unprepared = checkNotNull(unprepared);
count = 0;
}
@Override
public void run() {
if (Looper.myLooper() != Looper.getMainLooper()) {
unprepared.view.post(this);
} else {
try {
innerSanity();
} catch (RuntimeException re) {
setException(re);
}
}
}
void sanitizerSync() {
if (isWebViewSane()) {
set(unprepared);
} else {
// try again!
unprepared.view.post(this);
}
}
private void innerSanity() {
count++;
checkState(
count < 250,
"Waited over: %s millis but webview never went sane: %s",
250 * DELAY,
sanityMessage);
if (isWebViewSane()) {
PreparedScript docCheckScript =
SCRIPT_PREPARER.apply(
new UnpreparedScript(
unprepared.view,
DOC_ELEMENT_PRESENT,
Collections.EMPTY_LIST,
unprepared.window));
ListenableFuture<String> futureRaw = null;
try {
futureRaw = RAW_EVALUATOR.apply(docCheckScript);
} catch (Exception e) {
setException(e);
return;
}
final ListenableFuture<Evaluation> futureParsed =
Futures.transform(futureRaw, DECODE_EVALUATION, directExecutor());
futureParsed.addListener(
new Runnable() {
@Override
public void run() {
try {
Evaluation eval = futureParsed.get();
if (eval.getStatus() == 0) {
if ((Boolean) eval.getValue()) {
if (Build.VERSION.SDK_INT == 10) {
set(unprepared);
} else {
// webview seems ready, but force it to respond to a requestFocusNodeHref
// call
// and check if it is still sane after the response.
// This works around flakes in API 15 where progress updates may not be sent
// without a requestFocusNodeHref call.
unprepared.view.post(
new Runnable() {
@Override
public void run() {
unprepared.view.requestFocusNodeHref(
MAIN_HANDLER.obtainMessage(SANITIZER_SYNC, SanitizerTask.this));
}
});
}
} else {
unprepared.view.postDelayed(SanitizerTask.this, DELAY);
}
} else {
setException(
new RuntimeException("Fatal exception checking document state: " + eval));
}
} catch (ExecutionException ee) {
setException(ee.getCause());
} catch (InterruptedException ie) {
setException(ie.getCause());
}
}
},
MoreExecutors.directExecutor());
} else {
unprepared.view.postDelayed(this, DELAY);
}
}
/** Determines if url and historyUrl match, or if or if they are blank/correlate to url data */
private static boolean urlAndHistoryUrlMatch(String url, String historyUrl) {
if (url.equals(historyUrl)) {
return true;
}
final String blankUrl = "about:blank";
final String dataUrlStartsWith = "data:";
url = url.toLowerCase();
historyUrl = historyUrl.toLowerCase();
// Check if we are dealing with dataURL and if we are, return True and assume the history
// and current url match. This is because in some versions of webkit, though
// documentation for loadDataWithBaseUrl states that baseUrl and historyUrl should default to
// about:blank when they are provided with null vales, this isn't always the case and
// sometimes has the value of "data:text/html;charset=utf-8;base64," so a strict equals match
// is not sufficient.
return (url.equals(blankUrl) || url.startsWith(dataUrlStartsWith))
&& (historyUrl.equals(blankUrl) || historyUrl.startsWith(dataUrlStartsWith));
}
private boolean isWebViewSane() {
String url = unprepared.view.getUrl();
WebHistoryItem current = unprepared.view.copyBackForwardList().getCurrentItem();
boolean getUrlReady = url != null;
boolean webHistoryReady = current != null;
if (getUrlReady && webHistoryReady) {
String historyUrl = current.getUrl();
boolean viewAndHistoryMatch = urlAndHistoryUrlMatch(url, historyUrl);
boolean nonZeroContentHeight = unprepared.view.getContentHeight() != 0;
boolean progressComplete = unprepared.view.getProgress() == 100;
sanityMessage =
String.format(
"viewAndHistoryUrlsMatch: %s, nonZeroContentHeight: %s, progressComplete: %s",
viewAndHistoryMatch, nonZeroContentHeight, progressComplete);
return viewAndHistoryMatch && progressComplete && nonZeroContentHeight;
} else {
sanityMessage =
String.format(
"view.getUrl() != null: %s view.copyBackForwardList().getCurrentItem() != null: %s",
getUrlReady, webHistoryReady);
}
return false;
}
}
/** Contains the raw script, it's arguments, and the webview to run it against. */
private static class UnpreparedScript {
private final WebView view;
private final String script;
private final List<Object> args;
@Nullable private final WindowReference window;
UnpreparedScript(
WebView view, String script, List<Object> args, @Nullable WindowReference window) {
this.view = checkNotNull(view);
this.script = checkNotNull(script);
this.args = checkNotNull(args);
this.window = window;
}
}
/**
* Contains a script which has been wrapped with the EXECUTE_SCRIPT atom, has been properly
* escaped, and potentially conduitized.
*/
private static class PreparedScript {
private final WebView view;
private final String script;
@Nullable private final Conduit conduit;
PreparedScript(WebView view, String script, @Nullable Conduit conduit) {
this.view = checkNotNull(view);
this.script = checkNotNull(script);
this.conduit = conduit;
}
}
private static final class ScriptPreparer implements Function<UnpreparedScript, PreparedScript> {
private final boolean conduitize;
public ScriptPreparer(boolean conduitize) {
this.conduitize = conduitize;
}
@Override
public PreparedScript apply(UnpreparedScript unprepared) {
StringBuilder atomized = atomize(unprepared.script, unprepared.args, unprepared.window);
Conduit conduit = null;
if (conduitize) {
conduit = JavaScriptBridge.makeConduit();
atomized = conduit.wrapScriptInConduit(atomized).insert(0, "javascript:");
}
return new PreparedScript(unprepared.view, atomized.toString(), conduit);
}
private StringBuilder atomize(
String script, List<Object> args, WindowReference windowReference) {
int guessedSize = EvaluationAtom.EXECUTE_SCRIPT_ANDROID.length() + script.length() + 1024;
if (windowReference != null) {
guessedSize += EvaluationAtom.GET_ELEMENT_ANDROID.length();
}
StringBuilder toExecute = new StringBuilder(guessedSize).append("var my_wind = ");
if (windowReference != null) {
toExecute
.append("(")
.append(EvaluationAtom.GET_ELEMENT_ANDROID)
.append(")(")
.append(ModelCodec.encode(windowReference))
.append("[\"WINDOW\"]);");
} else {
toExecute.append("null;");
}
toExecute.append("return (").append(EvaluationAtom.EXECUTE_SCRIPT_ANDROID).append(")(");
if (isFunctionDefinition(script)) {
// Simply passes the script in if it's a JavaScript function.
toExecute.append(script);
} else {
// The script defines a function body.
toExecute = escapeAndQuote(toExecute, script);
}
toExecute
.append(",")
.append(ModelCodec.encode(args))
.append(",")
.append(conduitize) // JSON.stringify at webdriver level. Necessary for conduits.
.append(",")
.append("my_wind)");
return wrapInFunction(toExecute);
}
private StringBuilder wrapInFunction(StringBuilder script) {
script.insert(0, "(function(){").append("})()");
return script;
}
private static final Pattern FUNCTION_PATTERN =
Pattern.compile(
"^\s*function\s*\w*\s*\(.*\}\s*$", Pattern.DOTALL | Pattern.MULTILINE);
static boolean isFunctionDefinition(String script) {
return FUNCTION_PATTERN.matcher(script).matches();
}
private StringBuilder escapeAndQuote(StringBuilder scriptBuffer, String toWrap) {
scriptBuffer.append("\"");
for (int i = 0; i < toWrap.length(); i++) {
char c = toWrap.charAt(i);
switch (c) {
case '\"': // literally: "
case '