chromium/chrome/android/javatests/src/org/chromium/chrome/browser/webapps/WebappActivityTestRule.java

// 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.chrome.browser.webapps;

import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;

import android.content.Intent;
import android.net.Uri;
import android.view.View;
import android.view.ViewGroup;

import androidx.browser.customtabs.TrustedWebUtils;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.platform.app.InstrumentationRegistry;

import org.hamcrest.Matchers;

import org.chromium.base.ThreadUtils;
import org.chromium.base.test.util.Criteria;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.UrlUtils;
import org.chromium.cc.input.BrowserControlsState;
import org.chromium.chrome.browser.app.ChromeActivity;
import org.chromium.chrome.browser.browserservices.intents.WebappConstants;
import org.chromium.chrome.browser.browserservices.ui.splashscreen.SplashController;
import org.chromium.chrome.browser.customtabs.CustomTabsIntentTestUtils;
import org.chromium.chrome.browser.tab.TabBrowserControlsConstraintsHelper;
import org.chromium.chrome.test.ChromeActivityTestRule;
import org.chromium.chrome.test.util.ChromeTabUtils;
import org.chromium.content_public.browser.test.util.DOMUtils;
import org.chromium.content_public.browser.test.util.JavaScriptUtils;

/** Custom {@link ChromeActivityTestRule} for tests using {@link WebappActivity}. */
public class WebappActivityTestRule extends ChromeActivityTestRule<WebappActivity> {
    public static final String WEBAPP_ID = "webapp_id";
    public static final String WEBAPP_NAME = "webapp name";
    public static final String WEBAPP_SHORT_NAME = "webapp short name";

    private static final long STARTUP_TIMEOUT = 15000L;

    // Empty 192x192 image generated with:
    // ShortcutHelper.encodeBitmapAsString(Bitmap.createBitmap(192, 192, Bitmap.Config.ARGB_4444));
    public static final String TEST_ICON =
            "iVBORw0KGgoAAAANSUhEUgAAAMAAAADACAYAAABS3GwHAAAABHNCSVQICAgIfAhkiAAAAKZJREFU"
                    + "eJztwTEBAAAAwqD1T20JT6AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
                    + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
                    + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD4GQN4AAe3mX6IA"
                    + "AAAASUVORK5CYII=";

    // Empty 512x512 image generated with:
    // ShortcutHelper.encodeBitmapAsString(Bitmap.createBitmap(512, 512, Bitmap.Config.ARGB_4444));
    public static final String TEST_SPLASH_ICON =
            "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6AAAABHNCSVQICAgIfAhkiAAABA9JREFU"
                    + "eJztwTEBAAAAwqD1T20Hb6AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
                    + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
                    + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
                    + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
                    + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
                    + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
                    + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
                    + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
                    + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
                    + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
                    + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
                    + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
                    + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
                    + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
                    + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
                    + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
                    + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
                    + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
                    + "AAAAAAAAAOA3AvAAAdln8YgAAAAASUVORK5CYII=";

    public WebappActivityTestRule() {
        super(WebappActivity.class);
    }

    /**
     * Creates the Intent that starts the WebAppActivity. This is meant to be overriden by other
     * tests in order for them to pass some specific values, but it defaults to a web app that just
     * loads about:blank to avoid a network load. This results in the URL bar showing because {@link
     * UrlUtils} cannot parse this type of URL.
     */
    public Intent createIntent() {
        Intent intent =
                new Intent(ApplicationProvider.getApplicationContext(), WebappActivity.class);
        intent.setAction(Intent.ACTION_VIEW);
        intent.setData(Uri.parse(WebappActivity.WEBAPP_SCHEME + "://" + WEBAPP_ID));
        intent.putExtra(WebappConstants.EXTRA_ID, WEBAPP_ID);
        intent.putExtra(WebappConstants.EXTRA_URL, "about:blank");
        intent.putExtra(WebappConstants.EXTRA_NAME, WEBAPP_NAME);
        intent.putExtra(WebappConstants.EXTRA_SHORT_NAME, WEBAPP_SHORT_NAME);
        return intent;
    }

    /** Adds a mock Custom Tab session token to the intent. */
    public void addTwaExtrasToIntent(Intent intent) {
        Intent cctIntent =
                CustomTabsIntentTestUtils.createMinimalCustomTabIntent(
                        ApplicationProvider.getApplicationContext(), "about:blank");
        intent.putExtras(cctIntent.getExtras());
        intent.putExtra(TrustedWebUtils.EXTRA_LAUNCH_AS_TRUSTED_WEB_ACTIVITY, true);
    }

    @Override
    protected void before() throws Throwable {
        super.before();
        // We run the WebappRegistry calls on the UI thread to prevent
        // ConcurrentModificationExceptions caused by multiple threads iterating and
        // modifying its hashmap at the same time.
        final TestFetchStorageCallback callback = new TestFetchStorageCallback();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    // Register the webapp so when the data storage is opened, the
                    // test doesn't crash.
                    WebappRegistry.refreshSharedPrefsForTesting();
                    WebappRegistry.getInstance().register(WEBAPP_ID, callback);
                });

        // Running this on the UI thread causes issues, so can't group everything
        // into one runnable.
        callback.waitForCallback(0);

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    callback.getStorage()
                            .updateFromWebappIntentDataProvider(
                                    WebappIntentDataProviderFactory.create(createIntent()));
                });
    }

    @Override
    protected void after() {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    WebappRegistry.getInstance().clearForTesting();
                });
        super.after();
    }

    /** Starts up the WebappActivity and sets up the test observer. */
    public final void startWebappActivity() {
        startWebappActivity(createIntent());
    }

    /** Starts up the WebappActivity with a specific Intent and sets up the test observer. */
    public final void startWebappActivity(Intent intent) {
        String startUrl = intent.getStringExtra(WebappConstants.EXTRA_URL);

        launchActivity(intent);

        WebappActivity webappActivity = getActivity();
        InstrumentationRegistry.getInstrumentation().waitForIdleSync();

        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    Criteria.checkThat(webappActivity.getActivityTab(), Matchers.notNullValue());
                },
                STARTUP_TIMEOUT,
                CriteriaHelper.DEFAULT_POLLING_INTERVAL);

        ChromeTabUtils.waitForTabPageLoaded(webappActivity.getActivityTab(), startUrl);
        waitUntilSplashscreenHides();
    }

    public static void assertToolbarShownMaybeHideable(ChromeActivity activity) {
        @BrowserControlsState int state = getToolbarShowState(activity);
        assertTrue(state == BrowserControlsState.SHOWN || state == BrowserControlsState.BOTH);
    }

    public static @BrowserControlsState int getToolbarShowState(ChromeActivity activity) {
        return ThreadUtils.runOnUiThreadBlocking(
                () ->
                        TabBrowserControlsConstraintsHelper.getConstraints(
                                activity.getActivityTab()));
    }

    /**
     * Executing window.open() through a click on a link, as it needs user gesture to avoid Chrome
     * blocking it as a popup.
     */
    public static void jsWindowOpen(ChromeActivity activity, String url) throws Exception {
        String injectedHtml =
                String.format(
                        "var aTag = document.createElement('testId');"
                                + "aTag.id = 'testId';"
                                + "aTag.innerHTML = 'Click Me!';"
                                + "aTag.onclick = function() {"
                                + "  window.open('%s');"
                                + "  return false;"
                                + "};"
                                + "document.body.insertAdjacentElement('afterbegin', aTag);",
                        url);
        JavaScriptUtils.executeJavaScriptAndWaitForResult(
                activity.getActivityTab().getWebContents(), injectedHtml);
        DOMUtils.clickNode(activity.getActivityTab().getWebContents(), "testId");
    }

    /**
     * Starts up the WebappActivity and sets up the test observer. Wait till Splashscreen full
     * loaded.
     */
    public final ViewGroup startWebappActivityAndWaitForSplashScreen() {
        return startWebappActivityAndWaitForSplashScreen(createIntent());
    }

    /**
     * Starts up the WebappActivity and sets up the test observer. Wait till Splashscreen full
     * loaded. Intent url is modified to one that takes more time to load.
     */
    public final ViewGroup startWebappActivityAndWaitForSplashScreen(Intent intent) {
        // Reset the url to one that takes more time to load.
        // This is to make sure splash screen won't disappear during test.
        intent.putExtra(WebappConstants.EXTRA_URL, getTestServer().getURL("/slow?2"));
        launchActivity(intent);
        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    // we are waiting for WebappActivity#getActivityTab() to be non-null because we
                    // want to ensure that native has been loaded.
                    // We also wait till the splash screen has finished initializing.
                    Criteria.checkThat(getActivity().getActivityTab(), Matchers.notNullValue());

                    View splashScreen =
                            getSplashController(getActivity()).getSplashScreenForTests();
                    Criteria.checkThat(splashScreen, Matchers.notNullValue());

                    if (!(splashScreen instanceof ViewGroup)) return;
                    Criteria.checkThat(
                            ((ViewGroup) splashScreen).getChildCount(), Matchers.greaterThan(0));
                },
                STARTUP_TIMEOUT,
                CriteriaHelper.DEFAULT_POLLING_INTERVAL);

        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
        View splashScreen = getSplashController(getActivity()).getSplashScreenForTests();
        assertNotNull("No splash screen available.", splashScreen);

        // TODO(pkotwicz): Change return type in order to accommodate new-style WebAPKs.
        // (crbug.com/958288)
        return (splashScreen instanceof ViewGroup) ? (ViewGroup) splashScreen : null;
    }

    /** Waits for the splash screen to be hidden. */
    public void waitUntilSplashscreenHides() {
        waitUntilSplashHides(getActivity());
    }

    public static void waitUntilSplashHides(WebappActivity activity) {
        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    return getSplashController(activity).wasSplashScreenHiddenForTests();
                },
                STARTUP_TIMEOUT,
                CriteriaHelper.DEFAULT_POLLING_INTERVAL);
    }

    public boolean isSplashScreenVisible() {
        return getSplashController(getActivity()).getSplashScreenForTests() != null;
    }

    public static SplashController getSplashController(WebappActivity activity) {
        return activity.getComponent().resolveSplashController();
    }
}