content: /*
   * 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 androidx.test.espresso.web.util.concurrent.Futures.transform;
  import static androidx.test.espresso.web.util.concurrent.Futures.transformAsync;
  import static androidx.test.internal.util.Checks.checkNotNull;
  import static androidx.test.internal.util.Checks.checkState;
  
  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.annotation.Nullable;
  import androidx.test.espresso.web.bridge.Conduit;
  import androidx.test.espresso.web.bridge.JavaScriptBridge;
  import androidx.test.espresso.web.model.Evaluation;
  import androidx.test.espresso.web.model.ModelCodec;
  import androidx.test.espresso.web.model.WindowReference;
  import androidx.test.espresso.web.util.concurrent.AbstractFuture;
  import androidx.test.espresso.web.util.concurrent.AsyncFunction;
  import androidx.test.espresso.web.util.concurrent.Futures;
  import androidx.test.platform.concurrent.DirectExecutor;
  import com.google.common.util.concurrent.ListenableFuture;
  import java.util.Collections;
  import java.util.List;
  import java.util.concurrent.ExecutionException;
  import java.util.regex.Pattern;
  import kotlin.jvm.functions.Function1;
  
  /**
   * 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 Function1<String, Evaluation> DECODE_EVALUATION =
        new Function1<String, Evaluation>() {
          @Override
          public Evaluation invoke(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 {
  
        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.INSTANCE);
      ListenableFuture<String> rawEvaluation =
          transformAsync(preparedScript, RAW_EVALUATOR, DirectExecutor.INSTANCE);
      ListenableFuture<Evaluation> parsedEvaluation =
          transform(rawEvaluation, DECODE_EVALUATION, DirectExecutor.INSTANCE);
      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.invoke(
                  new UnpreparedScript(
                      unprepared.view,
                      DOC_ELEMENT_PRESENT,
                      Collections.emptyList(),
                      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.INSTANCE);
  
          futureParsed.addListener(
              new Runnable() {
                @Override
                public void run() {
                  try {
                    Evaluation eval = futureParsed.get();
                    if (eval.getStatus() == 0) {
                      if ((Boolean) eval.getValue()) {
  
                        // 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());
                  }
                }
              },
              DirectExecutor.INSTANCE);
  
        } 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 Function1<UnpreparedScript, PreparedScript> {
      private final boolean conduitize;
  
      public ScriptPreparer(boolean conduitize) {
        this.conduitize = conduitize;
      }
  
      @Override
      public PreparedScript invoke(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 '\'': // literally: '
            case '\\': // literally: \
              scriptBuffer.append('\\').append(c);
              break;
            case '\n': // literally a unix-newline.
              scriptBuffer.append("\\n");
              break;
            case '\r':
              scriptBuffer.append("\\r");
              break;
            case '\u2028':
              scriptBuffer.append("\\u2028");
              break;
            case '\u2029':
              scriptBuffer.append("\\u2029");
              break;
            default:
              scriptBuffer.append(c);
          }
        }
        scriptBuffer.append("\"");
        return scriptBuffer;
      }
    }
  
    private static final class AsyncJavascriptEvaluation
        implements AsyncFunction<PreparedScript, String> {
      @Override
      public ListenableFuture<String> apply(final PreparedScript in) {
        if (null != in.conduit) {
          return Futures.<String>immediateFailedFuture(
              new RuntimeException("Conduit script cannot be used"));
        } else {
          final ValueCallbackFuture<String> result = new ValueCallbackFuture<String>();
          if (Looper.myLooper() == Looper.getMainLooper()) {
            in.view.evaluateJavascript(in.script, result);
          } else {
            in.view.post(
                new Runnable() {
                  @Override
                  public void run() {
                    in.view.evaluateJavascript(in.script, result);
                  }
                });
          }
          return result;
        }
      }
    }
  
    private static class ValueCallbackFuture<V> extends AbstractFuture<V>
        implements ValueCallback<V> {
      @Override
      public void onReceiveValue(V value) {
        set(value);
      }
    }
  }
  