// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.android_webview.test;

import android.content.Context;
import android.content.Intent;
import android.util.AndroidRuntimeException;
import android.util.Base64;
import android.view.ViewGroup;

import androidx.test.InstrumentationRegistry;
import androidx.test.runner.lifecycle.Stage;

import org.junit.Assert;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;

import org.chromium.android_webview.AwBrowserContext;
import org.chromium.android_webview.AwBrowserProcess;
import org.chromium.android_webview.AwContents;
import org.chromium.android_webview.AwContents.DependencyFactory;
import org.chromium.android_webview.AwContents.InternalAccessDelegate;
import org.chromium.android_webview.AwContents.NativeDrawFunctorFactory;
import org.chromium.android_webview.AwContentsClient;
import org.chromium.android_webview.AwSettings;
import org.chromium.android_webview.test.util.GraphicsTestUtils;
import org.chromium.android_webview.test.util.JSUtils;
import org.chromium.base.Log;
import org.chromium.base.test.BaseActivityTestRule;
import org.chromium.base.test.util.ApplicationTestUtils;
import org.chromium.base.test.util.CallbackHelper;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.InMemorySharedPreferences;
import org.chromium.base.test.util.ScalableTimeout;
import org.chromium.content_public.browser.LoadUrlParams;
import org.chromium.content_public.browser.test.util.TestCallbackHelperContainer.OnPageFinishedHelper;
import org.chromium.content_public.browser.test.util.TestThreadUtils;
import org.chromium.net.test.util.TestWebServer;

import java.lang.annotation.Annotation;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/** Custom ActivityTestRunner for WebView instrumentation tests */
public class AwActivityTestRule extends BaseActivityTestRule<AwTestRunnerActivity> {
    public static final long WAIT_TIMEOUT_MS = 15000L;

    // Only use scaled timeout if you are certain it's not being called further up the call stack.
    public static final long SCALED_WAIT_TIMEOUT_MS = ScalableTimeout.scaleTimeout(15000L);

    public static final int CHECK_INTERVAL = 100;

    private static final String TAG = "AwActivityTestRule";

    private static final Pattern MAYBE_QUOTED_STRING = Pattern.compile("^(\"?)(.*)\\1$");

    private static boolean sBrowserProcessStarted;

    /**
     * An interface to call onCreateWindow(AwContents).
     */
    public interface OnCreateWindowHandler {
        /** This will be called when a new window pops up from the current webview. */
        public boolean onCreateWindow(AwContents awContents);
    }

    private Description mCurrentTestDescription;

    // The browser context needs to be a process-wide singleton.
    private AwBrowserContext mBrowserContext;

    private List<WeakReference<AwContents>> mAwContentsDestroyedInTearDown = new ArrayList<>();

    public AwActivityTestRule() {
        super(AwTestRunnerActivity.class);
    }

    @Override
    public Statement apply(final Statement base, Description description) {
        mCurrentTestDescription = description;
        return super.apply(new Statement() {
            @Override
            public void evaluate() throws Throwable {
                setUp();
                try {
                    base.evaluate();
                } finally {
                    tearDown();
                }
            }
        }, description);
    }

    public void setUp() {
        if (needsAwBrowserContextCreated()) {
            createAwBrowserContext();
        }
        if (needsBrowserProcessStarted()) {
            startBrowserProcess();
        } else {
            assert !sBrowserProcessStarted
                : "needsBrowserProcessStarted false and @Batch are incompatible";
        }
    }

    public void tearDown() {
        if (!needsAwContentsCleanup()) return;

        TestThreadUtils.runOnUiThreadBlocking(() -> {
            for (WeakReference<AwContents> awContentsRef : mAwContentsDestroyedInTearDown) {
                AwContents awContents = awContentsRef.get();
                if (awContents == null) continue;
                awContents.destroy();
            }
        });
        // Flush the UI queue since destroy posts again to UI thread.
        TestThreadUtils.runOnUiThreadBlocking(() -> { mAwContentsDestroyedInTearDown.clear(); });
    }

    public boolean needsHideActionBar() {
        return false;
    }

    private Intent getLaunchIntent() {
        if (needsHideActionBar()) {
            Intent intent = getActivityIntent();
            intent.putExtra(AwTestRunnerActivity.FLAG_HIDE_ACTION_BAR, true);
            return intent;
        }
        return null;
    }

    @Override
    public void launchActivity(Intent intent) {
        if (getActivity() != null) return;
        super.launchActivity(intent);
        ApplicationTestUtils.waitForActivityState(getActivity(), Stage.RESUMED);
    }

    public AwTestRunnerActivity launchActivity() {
        launchActivity(getLaunchIntent());
        return getActivity();
    }

    public AwBrowserContext createAwBrowserContextOnUiThread(InMemorySharedPreferences prefs) {
        // Native pointer is initialized later in startBrowserProcess if needed.
        return new AwBrowserContext(prefs, 0);
    }

    public TestDependencyFactory createTestDependencyFactory() {
        return new TestDependencyFactory();
    }

    /**
     * Override this to return false if the test doesn't want to create an
     * AwBrowserContext automatically.
     */
    public boolean needsAwBrowserContextCreated() {
        return true;
    }

    /**
     * Override this to return false if the test doesn't want the browser
     * startup sequence to be run automatically.
     *
     * @return Whether the instrumentation test requires the browser process to
     *         already be started.
     */
    public boolean needsBrowserProcessStarted() {
        return true;
    }

    /**
     * Override this to return false if test doesn't need all AwContents to be
     * destroyed explicitly after the test.
     */
    public boolean needsAwContentsCleanup() {
        return true;
    }

    public void createAwBrowserContext() {
        if (mBrowserContext != null) {
            throw new AndroidRuntimeException("There should only be one browser context.");
        }
        launchActivity(); // The Activity must be launched in order to load native code
        final InMemorySharedPreferences prefs = new InMemorySharedPreferences();
        TestThreadUtils.runOnUiThreadBlockingNoException(
                () -> mBrowserContext = createAwBrowserContextOnUiThread(prefs));
    }

    public void startBrowserProcess() {
        doStartBrowserProcess(false);
    }

    public void startBrowserProcessWithVulkan() {
        doStartBrowserProcess(true);
    }

    private void doStartBrowserProcess(boolean useVulkan) {
        // The Activity must be launched in order for proper webview statics to be setup.
        launchActivity();
        if (!sBrowserProcessStarted) {
            sBrowserProcessStarted = true;
            TestThreadUtils.runOnUiThreadBlocking(() -> {
                AwTestContainerView.installDrawFnFunctionTable(useVulkan);
                AwBrowserProcess.start();
            });
        }
        if (mBrowserContext != null) {
            TestThreadUtils.runOnUiThreadBlocking(
                    ()
                            -> mBrowserContext.setNativePointer(
                                    AwBrowserContext.getDefault()
                                            .getNativeBrowserContextPointer()));
        }
    }

    public void runOnUiThread(Runnable r) {
        TestThreadUtils.runOnUiThreadBlocking(r);
    }

    public static void enableJavaScriptOnUiThread(final AwContents awContents) {
        TestThreadUtils.runOnUiThreadBlocking(
                () -> awContents.getSettings().setJavaScriptEnabled(true));
    }

    public static boolean getJavaScriptEnabledOnUiThread(final AwContents awContents)
            throws ExecutionException {
        return TestThreadUtils.runOnUiThreadBlocking(
                () -> awContents.getSettings().getJavaScriptEnabled());
    }

    public static void setNetworkAvailableOnUiThread(
            final AwContents awContents, final boolean networkUp) {
        TestThreadUtils.runOnUiThreadBlocking(() -> awContents.setNetworkAvailable(networkUp));
    }

    /**
     * Loads url on the UI thread and blocks until onPageFinished is called.
     */
    public void loadUrlSync(final AwContents awContents, CallbackHelper onPageFinishedHelper,
            final String url) throws Exception {
        loadUrlSync(awContents, onPageFinishedHelper, url, null);
    }

    public void loadUrlSync(final AwContents awContents, CallbackHelper onPageFinishedHelper,
            final String url, final Map<String, String> extraHeaders) throws Exception {
        int currentCallCount = onPageFinishedHelper.getCallCount();
        loadUrlAsync(awContents, url, extraHeaders);
        onPageFinishedHelper.waitForCallback(
                currentCallCount, 1, WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS);
    }

    public void loadUrlSyncAndExpectError(final AwContents awContents,
            CallbackHelper onPageFinishedHelper, CallbackHelper onReceivedErrorHelper,
            final String url) throws Exception {
        int onReceivedErrorCount = onReceivedErrorHelper.getCallCount();
        int onFinishedCallCount = onPageFinishedHelper.getCallCount();
        loadUrlAsync(awContents, url);
        onReceivedErrorHelper.waitForCallback(
                onReceivedErrorCount, 1, WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS);
        onPageFinishedHelper.waitForCallback(
                onFinishedCallCount, 1, WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS);
    }

    /**
     * Loads url on the UI thread but does not block.
     */
    public void loadUrlAsync(final AwContents awContents, final String url) {
        loadUrlAsync(awContents, url, null);
    }

    public void loadUrlAsync(
            final AwContents awContents, final String url, final Map<String, String> extraHeaders) {
        TestThreadUtils.runOnUiThreadBlocking(() -> awContents.loadUrl(url, extraHeaders));
    }

    /**
     * Posts url on the UI thread and blocks until onPageFinished is called.
     */
    public void postUrlSync(final AwContents awContents, CallbackHelper onPageFinishedHelper,
            final String url, byte[] postData) throws Exception {
        int currentCallCount = onPageFinishedHelper.getCallCount();
        postUrlAsync(awContents, url, postData);
        onPageFinishedHelper.waitForCallback(
                currentCallCount, 1, WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS);
    }

    /**
     * Loads url on the UI thread but does not block.
     */
    public void postUrlAsync(final AwContents awContents, final String url, byte[] postData) {
        class PostUrl implements Runnable {
            byte[] mPostData;
            public PostUrl(byte[] postData) {
                mPostData = postData;
            }
            @Override
            public void run() {
                awContents.postUrl(url, mPostData);
            }
        }
        TestThreadUtils.runOnUiThreadBlocking(new PostUrl(postData));
    }

    /**
     * Loads data on the UI thread and blocks until onPageFinished is called.
     */
    public void loadDataSync(final AwContents awContents, CallbackHelper onPageFinishedHelper,
            final String data, final String mimeType, final boolean isBase64Encoded)
            throws Exception {
        int currentCallCount = onPageFinishedHelper.getCallCount();
        loadDataAsync(awContents, data, mimeType, isBase64Encoded);
        onPageFinishedHelper.waitForCallback(
                currentCallCount, 1, WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS);
    }

    public void loadHtmlSync(final AwContents awContents, CallbackHelper onPageFinishedHelper,
            final String html) throws Throwable {
        final String encodedData = Base64.encodeToString(html.getBytes(), Base64.NO_PADDING);
        loadDataSync(awContents, onPageFinishedHelper, encodedData, "text/html", true);
    }

    public void loadDataSyncWithCharset(final AwContents awContents,
            CallbackHelper onPageFinishedHelper, final String data, final String mimeType,
            final boolean isBase64Encoded, final String charset) throws Exception {
        int currentCallCount = onPageFinishedHelper.getCallCount();
        TestThreadUtils.runOnUiThreadBlocking(
                () -> awContents.loadUrl(LoadUrlParams.createLoadDataParams(
                                data, mimeType, isBase64Encoded, charset)));
        onPageFinishedHelper.waitForCallback(
                currentCallCount, 1, WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS);
    }

    /**
     * Loads data on the UI thread but does not block.
     */
    public void loadDataAsync(final AwContents awContents, final String data, final String mimeType,
            final boolean isBase64Encoded) {
        TestThreadUtils.runOnUiThreadBlocking(
                () -> awContents.loadData(data, mimeType, isBase64Encoded ? "base64" : null));
    }

    public void loadDataWithBaseUrlSync(final AwContents awContents,
            CallbackHelper onPageFinishedHelper, final String data, final String mimeType,
            final boolean isBase64Encoded, final String baseUrl, final String historyUrl)
            throws Throwable {
        int currentCallCount = onPageFinishedHelper.getCallCount();
        loadDataWithBaseUrlAsync(awContents, data, mimeType, isBase64Encoded, baseUrl, historyUrl);
        onPageFinishedHelper.waitForCallback(
                currentCallCount, 1, WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS);
    }

    public void loadDataWithBaseUrlAsync(final AwContents awContents, final String data,
            final String mimeType, final boolean isBase64Encoded, final String baseUrl,
            final String historyUrl) throws Throwable {
        runOnUiThread(() -> awContents.loadDataWithBaseURL(baseUrl, data, mimeType,
                                      isBase64Encoded ? "base64" : null, historyUrl));
    }

    /**
     * Reloads the current page synchronously.
     */
    public void reloadSync(final AwContents awContents, CallbackHelper onPageFinishedHelper)
            throws Exception {
        int currentCallCount = onPageFinishedHelper.getCallCount();
        TestThreadUtils.runOnUiThreadBlocking(
                () -> awContents.getNavigationController().reload(true));
        onPageFinishedHelper.waitForCallback(
                currentCallCount, 1, WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS);
    }

    /**
     * Stops loading on the UI thread.
     */
    public void stopLoading(final AwContents awContents) {
        TestThreadUtils.runOnUiThreadBlocking(() -> awContents.stopLoading());
    }

    public void waitForVisualStateCallback(final AwContents awContents) throws Exception {
        final CallbackHelper ch = new CallbackHelper();
        final int chCount = ch.getCallCount();
        TestThreadUtils.runOnUiThreadBlocking(() -> {
            final long requestId = 666;
            awContents.insertVisualStateCallback(requestId, new AwContents.VisualStateCallback() {
                @Override
                public void onComplete(long id) {
                    Assert.assertEquals(requestId, id);
                    ch.notifyCalled();
                }
            });
        });
        ch.waitForCallback(chCount);
    }

    public void insertVisualStateCallbackOnUIThread(final AwContents awContents,
            final long requestId, final AwContents.VisualStateCallback callback) {
        TestThreadUtils.runOnUiThreadBlocking(
                () -> awContents.insertVisualStateCallback(requestId, callback));
    }

    // Waits for the pixel at the center of AwContents to color up into expectedColor.
    // Note that this is a stricter condition that waiting for a visual state callback,
    // as visual state callback only indicates that *something* has appeared in WebView.
    public void waitForPixelColorAtCenterOfView(final AwContents awContents,
            final AwTestContainerView testContainerView, final int expectedColor) {
        pollUiThread(() -> GraphicsTestUtils.getPixelColorAtCenterOfView(
                    awContents, testContainerView) == expectedColor);
    }

    public AwTestContainerView createAwTestContainerView(final AwContentsClient awContentsClient) {
        return createAwTestContainerView(awContentsClient, false, null);
    }

    public AwTestContainerView createAwTestContainerView(final AwContentsClient awContentsClient,
            boolean supportsLegacyQuirks, final TestDependencyFactory testDependencyFactory) {
        AwTestContainerView testContainerView = createDetachedAwTestContainerView(
                awContentsClient, supportsLegacyQuirks, testDependencyFactory);
        getActivity().addView(testContainerView);
        testContainerView.requestFocus();
        return testContainerView;
    }

    public AwBrowserContext getAwBrowserContext() {
        return mBrowserContext;
    }

    public AwTestContainerView createDetachedAwTestContainerView(
            final AwContentsClient awContentsClient) {
        return createDetachedAwTestContainerView(awContentsClient, false, null);
    }

    public AwTestContainerView createDetachedAwTestContainerView(
            final AwContentsClient awContentsClient, boolean supportsLegacyQuirks,
            TestDependencyFactory testDependencyFactory) {
        if (testDependencyFactory == null) {
            testDependencyFactory = createTestDependencyFactory();
        }
        boolean allowHardwareAcceleration = isHardwareAcceleratedTest();
        final AwTestContainerView testContainerView =
                testDependencyFactory.createAwTestContainerView(
                        getActivity(), allowHardwareAcceleration);

        AwSettings awSettings =
                testDependencyFactory.createAwSettings(getActivity(), supportsLegacyQuirks);
        AwContents awContents = testDependencyFactory.createAwContents(mBrowserContext,
                testContainerView, testContainerView.getContext(),
                testContainerView.getInternalAccessDelegate(),
                testContainerView.getNativeDrawFunctorFactory(), awContentsClient, awSettings,
                testDependencyFactory);
        testContainerView.initialize(awContents);
        mAwContentsDestroyedInTearDown.add(new WeakReference<>(awContents));
        return testContainerView;
    }

    public boolean isHardwareAcceleratedTest() {
        return !testMethodHasAnnotation(DisableHardwareAcceleration.class);
    }

    public AwTestContainerView createAwTestContainerViewOnMainSync(final AwContentsClient client) {
        return createAwTestContainerViewOnMainSync(client, false, null);
    }

    public AwTestContainerView createAwTestContainerViewOnMainSync(
            final AwContentsClient client, final boolean supportsLegacyQuirks) {
        return createAwTestContainerViewOnMainSync(client, supportsLegacyQuirks, null);
    }

    public AwTestContainerView createAwTestContainerViewOnMainSync(final AwContentsClient client,
            final boolean supportsLegacyQuirks, final TestDependencyFactory testDependencyFactory) {
        return TestThreadUtils.runOnUiThreadBlockingNoException(
                () -> createAwTestContainerView(
                                client, supportsLegacyQuirks, testDependencyFactory));
    }

    public void destroyAwContentsOnMainSync(final AwContents awContents) {
        if (awContents == null) return;
        TestThreadUtils.runOnUiThreadBlocking(() -> awContents.destroy());
    }

    public String getTitleOnUiThread(final AwContents awContents) throws Exception {
        return TestThreadUtils.runOnUiThreadBlocking(() -> awContents.getTitle());
    }

    public AwSettings getAwSettingsOnUiThread(final AwContents awContents) throws Exception {
        return TestThreadUtils.runOnUiThreadBlocking(() -> awContents.getSettings());
    }

    /**
     * Verify double quotes in both sides of the raw string. Strip the double quotes and
     * returns rest of the string.
     */
    public String maybeStripDoubleQuotes(String raw) {
        Assert.assertNotNull(raw);
        Matcher m = MAYBE_QUOTED_STRING.matcher(raw);
        Assert.assertTrue(m.matches());
        return m.group(2);
    }

    /**
     * Executes the given snippet of JavaScript code within the given ContentView. Returns the
     * result of its execution in JSON format.
     */
    public String executeJavaScriptAndWaitForResult(final AwContents awContents,
            TestAwContentsClient viewClient, final String code) throws Exception {
        return executeJavaScriptAndWaitForResult(
                awContents, viewClient, code, /*shouldCheckSettings=*/true);
    }

    /**
     * Like {@link #executeJavaScriptAndWaitForResult} but with a parameter to skip the call to
     * {@link checkJavaScriptEnabled}. This is useful if your test expects JavaScript to be disabled
     * (in which case the underlying executeJavaScriptAndWaitForResult() is expected to be a NOOP).
     */
    public String executeJavaScriptAndWaitForResult(final AwContents awContents,
            TestAwContentsClient viewClient, final String code, boolean shouldCheckSettings)
            throws Exception {
        if (shouldCheckSettings) {
            checkJavaScriptEnabled(awContents);
        }
        return JSUtils.executeJavaScriptAndWaitForResult(
                InstrumentationRegistry.getInstrumentation(), awContents,
                viewClient.getOnEvaluateJavaScriptResultHelper(), code);
    }

    public static void checkJavaScriptEnabled(AwContents awContents) throws Exception {
        boolean javaScriptEnabled = AwActivityTestRule.getJavaScriptEnabledOnUiThread(awContents);
        if (!javaScriptEnabled) {
            throw new IllegalStateException(
                    "JavaScript is disabled in this AwContents; did you forget to call "
                    + "AwActivityTestRule.enableJavaScriptOnUiThread()?");
        }
    }

    /**
     * Executes JavaScript code within the given ContentView to get the text content in
     * document body. Returns the result string without double quotes.
     */
    public String getJavaScriptResultBodyTextContent(
            final AwContents awContents, final TestAwContentsClient viewClient) throws Exception {
        String raw = executeJavaScriptAndWaitForResult(
                awContents, viewClient, "document.body.textContent");
        return maybeStripDoubleQuotes(raw);
    }

    /**
     * Adds a JavaScript interface to the AwContents. Does its work synchronously on the UI thread,
     * and can be called from any thread. All the rules of {@link
     * android.webkit.WebView#addJavascriptInterface} apply to this method (ex. you must call this
     * <b>prior</b> to loading the frame you intend to load the JavaScript interface into).
     *
     * @param awContents the AwContents in which to insert the JavaScript interface.
     * @param objectToInject the JavaScript interface to inject.
     * @param javascriptIdentifier the name with which to refer to {@code objectToInject} from
     *        JavaScript code.
     */
    public static void addJavascriptInterfaceOnUiThread(final AwContents awContents,
            final Object objectToInject, final String javascriptIdentifier) throws Exception {
        checkJavaScriptEnabled(awContents);
        TestThreadUtils.runOnUiThreadBlocking(
                () -> awContents.addJavascriptInterface(objectToInject, javascriptIdentifier));
    }

    /**
     * Wrapper around CriteriaHelper.pollInstrumentationThread. This uses AwActivityTestRule-specifc
     * timeouts and treats timeouts and exceptions as test failures automatically.
     */
    public static void pollInstrumentationThread(final Callable<Boolean> callable) {
        CriteriaHelper.pollInstrumentationThread(() -> {
            try {
                return callable.call();
            } catch (Throwable e) {
                Log.e(TAG, "Exception while polling.", e);
                return false;
            }
        }, WAIT_TIMEOUT_MS, CHECK_INTERVAL);
    }

    /**
     * Wrapper around {@link AwActivityTestRule#pollInstrumentationThread()} but runs the
     * callable on the UI thread.
     */
    public void pollUiThread(final Callable<Boolean> callable) {
        pollInstrumentationThread(() -> TestThreadUtils.runOnUiThreadBlocking(callable));
    }

    /**
     * Waits for {@code future} and returns its value (or times out). If {@code future} has an
     * associated Exception, this will re-throw that Exception on the instrumentation thread
     * (wrapping with an unchecked Exception if necessary, to avoid requiring callers to declare
     * checked Exceptions).
     *
     * @param future the {@link Future} representing a value of interest.
     * @return the value {@code future} represents.
     */
    public static <T> T waitForFuture(Future<T> future) {
        try {
            return future.get(SCALED_WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS);
        } catch (ExecutionException e) {
            // ExecutionException means this Future has an associated Exception that we should
            // re-throw on the current thread. We throw the cause instead of ExecutionException,
            // since ExecutionException itself isn't interesting, and might mislead those debugging
            // test failures to suspect this method is the culprit (whereas the root cause is from
            // another thread).
            Throwable cause = e.getCause();
            // If the cause is an unchecked Throwable type, re-throw as-is.
            if (cause instanceof Error) throw(Error) cause;
            if (cause instanceof RuntimeException) throw(RuntimeException) cause;
            // Otherwise, wrap this in an unchecked Exception so callers don't need to declare
            // checked Exceptions.
            throw new RuntimeException(cause);
        } catch (InterruptedException | TimeoutException e) {
            // Don't call e.getCause() for either of these. Unlike ExecutionException, these don't
            // wrap the root cause, but rather are themselves interesting. Again, we wrap these
            // checked Exceptions with an unchecked Exception for the caller's convenience.
            //
            // Although we might be tempted to handle InterruptedException by calling
            // Thread.currentThread().interrupt(), this is not correct in this case. The interrupted
            // thread was likely a different thread than the current thread, so there's nothing
            // special we need to do.
            throw new RuntimeException(e);
        }
    }

    /**
     * Takes an element out of the {@link BlockingQueue} (or times out).
     */
    public static <T> T waitForNextQueueElement(BlockingQueue<T> queue) throws Exception {
        T value = queue.poll(SCALED_WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS);
        if (value == null) {
            // {@code null} is the special value which means {@link BlockingQueue#poll} has timed
            // out (also: there's no risk for collision with real values, because BlockingQueue does
            // not allow null entries). Instead of returning this special value, let's throw a
            // proper TimeoutException.
            throw new TimeoutException(
                    "Timeout while trying to take next entry from BlockingQueue");
        }
        return value;
    }

    /**
     * Clears the resource cache. Note that the cache is per-application, so this will clear the
     * cache for all WebViews used.
     */
    public void clearCacheOnUiThread(final AwContents awContents, final boolean includeDiskFiles) {
        TestThreadUtils.runOnUiThreadBlocking(() -> awContents.clearCache(includeDiskFiles));
    }

    /**
     * Returns pure page scale.
     */
    public float getScaleOnUiThread(final AwContents awContents) throws Exception {
        return TestThreadUtils.runOnUiThreadBlocking(() -> awContents.getPageScaleFactor());
    }

    /**
     * Returns page scale multiplied by the screen density.
     */
    public float getPixelScaleOnUiThread(final AwContents awContents) throws Exception {
        return TestThreadUtils.runOnUiThreadBlocking(() -> awContents.getScale());
    }

    /**
     * Returns whether a user can zoom the page in.
     */
    public boolean canZoomInOnUiThread(final AwContents awContents) throws Exception {
        return TestThreadUtils.runOnUiThreadBlocking(() -> awContents.canZoomIn());
    }

    /**
     * Returns whether a user can zoom the page out.
     */
    public boolean canZoomOutOnUiThread(final AwContents awContents) throws Exception {
        return TestThreadUtils.runOnUiThreadBlocking(() -> awContents.canZoomOut());
    }

    /**
     * Loads the main html then triggers the popup window.
     */
    public void triggerPopup(final AwContents parentAwContents,
            TestAwContentsClient parentAwContentsClient, TestWebServer testWebServer,
            String mainHtml, String popupHtml, String popupPath, String triggerScript)
            throws Exception {
        enableJavaScriptOnUiThread(parentAwContents);
        TestThreadUtils.runOnUiThreadBlocking(() -> {
            parentAwContents.getSettings().setSupportMultipleWindows(true);
            parentAwContents.getSettings().setJavaScriptCanOpenWindowsAutomatically(true);
        });

        final String parentUrl = testWebServer.setResponse("/popupParent.html", mainHtml, null);
        if (popupHtml != null) {
            testWebServer.setResponse(popupPath, popupHtml, null);
        } else {
            testWebServer.setResponseWithNoContentStatus(popupPath);
        }

        parentAwContentsClient.getOnCreateWindowHelper().setReturnValue(true);
        loadUrlSync(parentAwContents, parentAwContentsClient.getOnPageFinishedHelper(), parentUrl);

        TestAwContentsClient.OnCreateWindowHelper onCreateWindowHelper =
                parentAwContentsClient.getOnCreateWindowHelper();
        int currentCallCount = onCreateWindowHelper.getCallCount();
        TestThreadUtils.runOnUiThreadBlocking(
                () -> parentAwContents.evaluateJavaScriptForTests(triggerScript, null));
        onCreateWindowHelper.waitForCallback(
                currentCallCount, 1, WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS);
    }

    /**
     * Supplies the popup window with AwContents then waits for the popup window to finish loading.
     * @param parentAwContents Parent webview's AwContents.
     */
    public PopupInfo connectPendingPopup(AwContents parentAwContents) throws Exception {
        PopupInfo popupInfo = createPopupContents(parentAwContents);
        loadPopupContents(parentAwContents, popupInfo, null);
        return popupInfo;
    }

    /**
     * Creates a popup window with AwContents.
     */
    public PopupInfo createPopupContents(final AwContents parentAwContents) {
        TestAwContentsClient popupContentsClient;
        AwTestContainerView popupContainerView;
        final AwContents popupContents;
        popupContentsClient = new TestAwContentsClient();
        popupContainerView = createAwTestContainerViewOnMainSync(popupContentsClient);
        popupContents = popupContainerView.getAwContents();
        enableJavaScriptOnUiThread(popupContents);
        return new PopupInfo(popupContentsClient, popupContainerView, popupContents);
    }

    /**
     * Waits for the popup window to finish loading.
     * @param parentAwContents Parent webview's AwContents.
     * @param info The PopupInfo.
     * @param onCreateWindowHandler An instance of OnCreateWindowHandler. null if there isn't.
     */
    public void loadPopupContents(final AwContents parentAwContents, PopupInfo info,
            OnCreateWindowHandler onCreateWindowHandler) throws Exception {
        TestAwContentsClient popupContentsClient = info.popupContentsClient;
        final AwContents popupContents = info.popupContents;
        OnPageFinishedHelper onPageFinishedHelper = popupContentsClient.getOnPageFinishedHelper();
        int finishCallCount = onPageFinishedHelper.getCallCount();

        if (onCreateWindowHandler != null) onCreateWindowHandler.onCreateWindow(popupContents);

        TestAwContentsClient.OnReceivedTitleHelper onReceivedTitleHelper =
                popupContentsClient.getOnReceivedTitleHelper();
        int titleCallCount = onReceivedTitleHelper.getCallCount();

        TestThreadUtils.runOnUiThreadBlocking(
                () -> parentAwContents.supplyContentsForPopup(popupContents));

        onPageFinishedHelper.waitForCallback(
                finishCallCount, 1, WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS);
        onReceivedTitleHelper.waitForCallback(
                titleCallCount, 1, WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS);
    }

    private boolean testMethodHasAnnotation(Class<? extends Annotation> clazz) {
        return mCurrentTestDescription.getAnnotation(clazz) != null;
    }

    /**
     * Factory class used in creation of test AwContents instances. Test cases
     * can provide subclass instances to the createAwTest* methods in order to
     * create an AwContents instance with injected test dependencies.
     */
    public static class TestDependencyFactory extends AwContents.DependencyFactory {
        public AwTestContainerView createAwTestContainerView(
                AwTestRunnerActivity activity, boolean allowHardwareAcceleration) {
            return new AwTestContainerView(activity, allowHardwareAcceleration);
        }

        public AwSettings createAwSettings(Context context, boolean supportsLegacyQuirks) {
            return new AwSettings(context, false /* isAccessFromFileURLsGrantedByDefault */,
                    supportsLegacyQuirks, false /* allowEmptyDocumentPersistence */,
                    true /* allowGeolocationOnInsecureOrigins */,
                    false /* doNotUpdateSelectionOnMutatingSelectionRange */);
        }

        public AwContents createAwContents(AwBrowserContext browserContext, ViewGroup containerView,
                Context context, InternalAccessDelegate internalAccessAdapter,
                NativeDrawFunctorFactory nativeDrawFunctorFactory, AwContentsClient contentsClient,
                AwSettings settings, DependencyFactory dependencyFactory) {
            return new AwContents(browserContext, containerView, context, internalAccessAdapter,
                    nativeDrawFunctorFactory, contentsClient, settings, dependencyFactory);
        }
    }

    /**
     * POD object for holding references to helper objects of a popup window.
     */
    public static class PopupInfo {
        public final TestAwContentsClient popupContentsClient;
        public final AwTestContainerView popupContainerView;
        public final AwContents popupContents;

        public PopupInfo(TestAwContentsClient popupContentsClient,
                AwTestContainerView popupContainerView, AwContents popupContents) {
            this.popupContentsClient = popupContentsClient;
            this.popupContainerView = popupContainerView;
            this.popupContents = popupContents;
        }
    }
}
