chromium/chrome/android/javatests/src/org/chromium/chrome/browser/webapps/WebappNavigationTest.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.assertEquals;

import static org.chromium.base.ApplicationState.HAS_DESTROYED_ACTIVITIES;
import static org.chromium.base.ApplicationState.HAS_PAUSED_ACTIVITIES;
import static org.chromium.base.ApplicationState.HAS_STOPPED_ACTIVITIES;

import android.app.Instrumentation.ActivityMonitor;
import android.content.Intent;
import android.content.IntentFilter;
import android.graphics.Color;
import android.net.Uri;
import android.util.Base64;

import androidx.test.filters.LargeTest;
import androidx.test.filters.SmallTest;
import androidx.test.platform.app.InstrumentationRegistry;

import org.hamcrest.Matchers;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.RuleChain;
import org.junit.rules.TestRule;
import org.junit.runner.RunWith;

import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.CommandLine;
import org.chromium.base.ThreadUtils;
import org.chromium.base.test.util.CommandLineFlags;
import org.chromium.base.test.util.Criteria;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.DisabledTest;
import org.chromium.base.test.util.DoNotBatch;
import org.chromium.base.test.util.Feature;
import org.chromium.base.test.util.Restriction;
import org.chromium.blink.mojom.DisplayMode;
import org.chromium.cc.input.BrowserControlsState;
import org.chromium.chrome.browser.ChromeTabbedActivity;
import org.chromium.chrome.browser.app.ChromeActivity;
import org.chromium.chrome.browser.browserservices.intents.BrowserServicesIntentDataProvider;
import org.chromium.chrome.browser.browserservices.intents.WebappConstants;
import org.chromium.chrome.browser.customtabs.CustomTabNightModeStateController;
import org.chromium.chrome.browser.customtabs.DefaultBrowserProviderImpl;
import org.chromium.chrome.browser.customtabs.FakeDefaultBrowserProviderImpl;
import org.chromium.chrome.browser.customtabs.content.CustomTabIntentHandler;
import org.chromium.chrome.browser.customtabs.dependency_injection.BaseCustomTabActivityModule;
import org.chromium.chrome.browser.dependency_injection.ModuleOverridesRule;
import org.chromium.chrome.browser.firstrun.FirstRunStatus;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.test.MockCertVerifierRuleAndroid;
import org.chromium.chrome.browser.theme.TopUiThemeColorProvider;
import org.chromium.chrome.test.ChromeActivityTestRule;
import org.chromium.chrome.test.ChromeJUnit4ClassRunner;
import org.chromium.chrome.test.R;
import org.chromium.chrome.test.util.ChromeTabUtils;
import org.chromium.chrome.test.util.browser.contextmenu.ContextMenuUtils;
import org.chromium.chrome.test.util.browser.webapps.WebappTestPage;
import org.chromium.components.browser_ui.styles.ChromeColors;
import org.chromium.components.permissions.PermissionDialogController;
import org.chromium.content_public.browser.test.NativeLibraryTestUtils;
import org.chromium.content_public.browser.test.util.DOMUtils;
import org.chromium.content_public.common.ContentSwitches;
import org.chromium.net.test.EmbeddedTestServer;
import org.chromium.ui.base.PageTransition;
import org.chromium.ui.modaldialog.ModalDialogManager;
import org.chromium.ui.test.util.UiRestriction;

import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;

/** Tests web navigations originating from a WebappActivity. */
@RunWith(ChromeJUnit4ClassRunner.class)
@DoNotBatch(reason = "tests run on startup.")
@CommandLineFlags.Add({ChromeSwitches.DISABLE_FIRST_RUN_EXPERIENCE})
public class WebappNavigationTest {
    public final WebappActivityTestRule mActivityTestRule = new WebappActivityTestRule();

    public MockCertVerifierRuleAndroid mCertVerifierRule =
            new MockCertVerifierRuleAndroid(0 /* net::OK */);

    private final TestRule mModuleOverridesRule =
            new ModuleOverridesRule()
                    .setOverride(
                            BaseCustomTabActivityModule.Factory.class,
                            (BrowserServicesIntentDataProvider intentDataProvider,
                                    CustomTabNightModeStateController nightModeController,
                                    CustomTabIntentHandler.IntentIgnoringCriterion
                                            intentIgnoringCriterion,
                                    TopUiThemeColorProvider topUiThemeColorProvider,
                                    DefaultBrowserProviderImpl customTabDefaultBrowserProvider) ->
                                    new BaseCustomTabActivityModule(
                                            intentDataProvider,
                                            nightModeController,
                                            intentIgnoringCriterion,
                                            topUiThemeColorProvider,
                                            new FakeDefaultBrowserProviderImpl()));

    @Rule
    public RuleChain mRuleChain =
            RuleChain.emptyRuleChain()
                    .around(mActivityTestRule)
                    .around(mCertVerifierRule)
                    .around(mModuleOverridesRule);

    @Before
    public void setUp() {
        NativeLibraryTestUtils.loadNativeLibraryNoBrowserProcess();

        mActivityTestRule.getEmbeddedTestServerRule().setServerUsesHttps(true);
        Uri mapToUri =
                Uri.parse(mActivityTestRule.getEmbeddedTestServerRule().getServer().getURL("/"));
        CommandLine.getInstance()
                .appendSwitchWithValue(
                        ContentSwitches.HOST_RESOLVER_RULES, "MAP * " + mapToUri.getAuthority());
    }

    /**
     * Test that navigating a webapp whose launch intent does not specify a theme colour outside of
     * the webapp scope by tapping a regular link: - Shows a CCT-like webapp toolbar. - Uses the
     * default theme colour as the toolbar colour.
     */
    @Test
    @SmallTest
    @Feature({"Webapps"})
    @Restriction(UiRestriction.RESTRICTION_TYPE_PHONE)
    public void testRegularLinkOffOriginNoWebappThemeColor() throws Exception {
        WebappActivity activity = runWebappActivityAndWaitForIdle(mActivityTestRule.createIntent());
        assertEquals(
                BrowserControlsState.HIDDEN, WebappActivityTestRule.getToolbarShowState(activity));

        addAnchorAndClick(offOriginUrl(), "_self");

        ChromeTabUtils.waitForTabPageLoaded(activity.getActivityTab(), offOriginUrl());
        WebappActivityTestRule.assertToolbarShownMaybeHideable(activity);
        assertEquals(getDefaultPrimaryColor(), activity.getToolbarManager().getPrimaryColor());
    }

    /**
     * Test that navigating a webapp whose launch intent specifies a theme colour outside of the
     * webapp scope by tapping a regular link: - Shows a CCT-like webapp toolbar. - Uses the webapp
     * theme colour as the toolbar colour.
     */
    @Test
    @SmallTest
    @Feature({"Webapps"})
    @Restriction(UiRestriction.RESTRICTION_TYPE_PHONE)
    public void testRegularLinkOffOriginThemeColor() throws Exception {
        WebappActivity activity =
                runWebappActivityAndWaitForIdle(
                        mActivityTestRule
                                .createIntent()
                                .putExtra(WebappConstants.EXTRA_THEME_COLOR, (long) Color.CYAN));
        assertEquals(
                BrowserControlsState.HIDDEN, WebappActivityTestRule.getToolbarShowState(activity));

        addAnchorAndClick(offOriginUrl(), "_self");

        ChromeTabUtils.waitForTabPageLoaded(activity.getActivityTab(), offOriginUrl());
        WebappActivityTestRule.assertToolbarShownMaybeHideable(activity);
        assertEquals(Color.CYAN, activity.getToolbarManager().getPrimaryColor());
    }

    /**
     * Test that navigating a TWA outside of the TWA scope by tapping a regular link: - Expects the
     * Minimal UI toolbar to be shown. - Uses the TWA theme colour in the Minimal UI toolbar.
     */
    @Test
    @SmallTest
    @Feature({"Webapps"})
    @Restriction(UiRestriction.RESTRICTION_TYPE_PHONE)
    public void testRegularLinkOffOriginTwa() throws Exception {
        Intent launchIntent =
                mActivityTestRule
                        .createIntent()
                        .putExtra(WebappConstants.EXTRA_THEME_COLOR, (long) Color.CYAN);
        mActivityTestRule.addTwaExtrasToIntent(launchIntent);
        String url = WebappTestPage.getServiceWorkerUrl(mActivityTestRule.getTestServer());
        CommandLine.getInstance()
                .appendSwitchWithValue(ChromeSwitches.DISABLE_DIGITAL_ASSET_LINK_VERIFICATION, url);
        mActivityTestRule.startWebappActivity(
                launchIntent.putExtra(WebappConstants.EXTRA_URL, url));
        WebappActivity activity = mActivityTestRule.getActivity();
        assertEquals(
                BrowserControlsState.HIDDEN, WebappActivityTestRule.getToolbarShowState(activity));
        addAnchorAndClick(offOriginUrl(), "_self");
        ChromeTabUtils.waitForTabPageLoaded(activity.getActivityTab(), offOriginUrl());
        WebappActivityTestRule.assertToolbarShownMaybeHideable(activity);
        assertEquals(Color.CYAN, activity.getToolbarManager().getPrimaryColor());
    }

    /**
     * Test that navigating outside of the webapp scope as a result of submitting a form with method
     * "POST": - Shows a CCT-like webapp toolbar. - Preserves the theme color specified in the
     * launch intent.
     */
    @Test
    @SmallTest
    @Feature({"Webapps"})
    @Restriction(UiRestriction.RESTRICTION_TYPE_PHONE)
    @DisabledTest(message = "Flaky - crbug.com/359629160")
    public void testFormSubmitOffOrigin() throws Exception {
        Intent launchIntent =
                mActivityTestRule
                        .createIntent()
                        .putExtra(WebappConstants.EXTRA_THEME_COLOR, (long) Color.CYAN);
        mActivityTestRule.addTwaExtrasToIntent(launchIntent);
        WebappActivity activity =
                runWebappActivityAndWaitForIdleWithUrl(
                        launchIntent,
                        mActivityTestRule
                                .getTestServer()
                                .getURL("/chrome/test/data/android/form.html"));

        mActivityTestRule.runJavaScriptCodeInCurrentTab(
                String.format(
                        "document.getElementById('form').setAttribute('action', '%s')",
                        offOriginUrl()));
        clickNodeWithId("post_button");

        ChromeTabUtils.waitForTabPageLoaded(activity.getActivityTab(), offOriginUrl());
        assertEquals(Color.CYAN, activity.getToolbarManager().getPrimaryColor());
    }

    /**
     * Test that navigating outside of the webapp scope by tapping a link with target="_blank": -
     * Opens a new tab. - Causes the toolbar to be shown.
     */
    @Test
    @SmallTest
    @Feature({"Webapps"})
    public void testOffScopeNewTabLinkShowsToolbar() throws Exception {
        runWebappActivityAndWaitForIdle(mActivityTestRule.createIntent());
        addAnchorAndClick(offOriginUrl(), "_blank");
        ChromeActivity activity = mActivityTestRule.getActivity();
        CriteriaHelper.pollUiThread(
                () -> {
                    Criteria.checkThat(
                            activity.getTabModelSelector().getModel(false).getCount(),
                            Matchers.is(2));
                });
        ChromeTabUtils.waitForTabPageLoaded(activity.getActivityTab(), offOriginUrl());

        WebappActivityTestRule.assertToolbarShownMaybeHideable(activity);
    }

    /**
     * Test that navigating within the webapp scope by tapping a link with target="_blank": -
     * Launches a new tab. - Causes the toolbar to be shown.
     */
    @Test
    @SmallTest
    @Feature({"Webapps"})
    @DisabledTest(message = "Flaky, see crbug.com/352075550")
    public void testInScopeNewTabLinkShowsToolbar() throws Exception {
        String inScopeUrl =
                WebappTestPage.getNonServiceWorkerUrl(mActivityTestRule.getTestServer());
        runWebappActivityAndWaitForIdle(mActivityTestRule.createIntent());
        addAnchorAndClick(inScopeUrl, "_blank");
        ChromeActivity activity = mActivityTestRule.getActivity();
        CriteriaHelper.pollUiThread(
                () -> {
                    Criteria.checkThat(
                            activity.getTabModelSelector().getModel(false).getCount(),
                            Matchers.is(2));
                });
        ChromeTabUtils.waitForTabPageLoaded(activity.getActivityTab(), inScopeUrl);

        WebappActivityTestRule.assertToolbarShownMaybeHideable(activity);
    }

    /**
     * Test that navigating a webapp within the webapp scope by tapping a regular link shows a
     * CCT-like webapp toolbar.
     */
    @Test
    @SmallTest
    @Feature({"Webapps"})
    @Restriction(UiRestriction.RESTRICTION_TYPE_PHONE)
    public void testInScopeNavigationStaysInWebapp() throws Exception {
        WebappActivity activity = runWebappActivityAndWaitForIdle(mActivityTestRule.createIntent());
        String otherPageUrl =
                WebappTestPage.getNonServiceWorkerUrl(mActivityTestRule.getTestServer());
        addAnchorAndClick(otherPageUrl, "_self");
        ChromeTabUtils.waitForTabPageLoaded(activity.getActivityTab(), otherPageUrl);

        assertEquals(
                BrowserControlsState.HIDDEN, WebappActivityTestRule.getToolbarShowState(activity));
    }

    @Test
    @SmallTest
    @Feature({"Webapps"})
    public void testOpenInChromeFromContextMenuTabbedChrome() throws Exception {
        // Needed to get full context menu.
        FirstRunStatus.setFirstRunFlowComplete(true);
        runWebappActivityAndWaitForIdle(mActivityTestRule.createIntent());

        addAnchor("myTestAnchorId", offOriginUrl(), "_self");

        IntentFilter filter = new IntentFilter(Intent.ACTION_VIEW);
        filter.addDataScheme("https");
        final ActivityMonitor monitor =
                InstrumentationRegistry.getInstrumentation().addMonitor(filter, null, true);

        ContextMenuUtils.selectContextMenuItem(
                InstrumentationRegistry.getInstrumentation(),
                null /* activity to check for focus after click */,
                mActivityTestRule.getActivity().getActivityTab(),
                "myTestAnchorId",
                R.id.contextmenu_open_in_chrome);

        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    return InstrumentationRegistry.getInstrumentation().checkMonitorHit(monitor, 1);
                });
    }

    @Test
    @SmallTest
    @Feature({"Webapps"})
    public void testOpenInChromeFromCustomMenuTabbedChrome() {
        WebappActivity activity =
                runWebappActivityAndWaitForIdle(
                        mActivityTestRule
                                .createIntent()
                                .putExtra(
                                        WebappConstants.EXTRA_DISPLAY_MODE,
                                        DisplayMode.MINIMAL_UI));

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    activity.getComponent().resolveNavigationController().openCurrentUrlInBrowser();
                });

        ChromeTabbedActivity tabbedChrome =
                ChromeActivityTestRule.waitFor(ChromeTabbedActivity.class);
        ChromeTabUtils.waitForTabPageLoaded(
                tabbedChrome.getActivityTab(),
                WebappTestPage.getServiceWorkerUrl(mActivityTestRule.getTestServer()));
    }

    @Test
    @LargeTest
    @Feature({"Webapps"})
    public void testCloseButtonReturnsToMostRecentInScopeUrl() throws Exception {
        WebappActivity activity = runWebappActivityAndWaitForIdle(mActivityTestRule.createIntent());
        Tab tab = activity.getActivityTab();

        String otherInScopeUrl =
                WebappTestPage.getNonServiceWorkerUrl(mActivityTestRule.getTestServer());
        mActivityTestRule.loadUrlInTab(otherInScopeUrl, PageTransition.LINK, tab);
        assertEquals(otherInScopeUrl, ChromeTabUtils.getUrlStringOnUiThread(tab));

        mActivityTestRule.loadUrlInTab(
                offOriginUrl(), PageTransition.LINK, tab, /* secondsToWait= */ 10);
        String mozillaUrl =
                mActivityTestRule
                        .getTestServer()
                        .getURLWithHostName("mozilla.org", "/defaultresponse");
        mActivityTestRule.loadUrlInTab(
                mozillaUrl, PageTransition.LINK, tab, /* secondsToWait= */ 10);

        // Toolbar with the close button should be visible.
        WebappActivityTestRule.assertToolbarShownMaybeHideable(activity);

        // Navigate back to in-scope through a close button.
        ThreadUtils.runOnUiThreadBlocking(
                () ->
                        activity.getToolbarManager()
                                .getToolbarLayoutForTesting()
                                .findViewById(R.id.close_button)
                                .callOnClick());

        // We should end up on most recent in-scope URL.
        ChromeTabUtils.waitForTabPageLoaded(tab, otherInScopeUrl);
    }

    /**
     * When a Minimal UI is shown as a result of a redirect chain, closing the Minimal UI should
     * return the user to the navigation entry prior to the redirect chain.
     */
    @Test
    @LargeTest
    @Feature({"Webapps"})
    public void testCloseButtonReturnsToUrlBeforeRedirects() throws Exception {
        Intent launchIntent = mActivityTestRule.createIntent();
        mActivityTestRule.addTwaExtrasToIntent(launchIntent);
        WebappActivity activity = runWebappActivityAndWaitForIdle(launchIntent);

        EmbeddedTestServer testServer = mActivityTestRule.getTestServer();
        String initialInScopeUrl = WebappTestPage.getServiceWorkerUrl(testServer);
        ChromeTabUtils.waitForTabPageLoaded(activity.getActivityTab(), initialInScopeUrl);

        final String redirectingUrl =
                testServer.getURL(
                        "/chrome/test/data/android/redirect/js_redirect.html"
                                + "?replace_text="
                                + Base64.encodeToString(
                                        ApiCompatibilityUtils.getBytesUtf8("PARAM_URL"),
                                        Base64.URL_SAFE)
                                + ":"
                                + Base64.encodeToString(
                                        ApiCompatibilityUtils.getBytesUtf8(offOriginUrl()),
                                        Base64.URL_SAFE));
        addAnchorAndClick(redirectingUrl, "_self");

        ChromeTabUtils.waitForTabPageLoaded(activity.getActivityTab(), offOriginUrl());

        // Close the Minimal UI.
        WebappActivityTestRule.assertToolbarShownMaybeHideable(activity);
        ThreadUtils.runOnUiThreadBlocking(
                () ->
                        activity.getToolbarManager()
                                .getToolbarLayoutForTesting()
                                .findViewById(R.id.close_button)
                                .callOnClick());

        // The WebappActivity should be navigated to the page prior to the redirect.
        ChromeTabUtils.waitForTabPageLoaded(activity.getActivityTab(), initialInScopeUrl);
    }

    /** Test a permission dialog can be correctly presented and dismissed by navigation. */
    @Test
    @LargeTest
    @Feature({"Webapps"})
    public void testShowPermissionPrompt() throws TimeoutException, ExecutionException {
        Intent launchIntent = mActivityTestRule.createIntent();
        mActivityTestRule.addTwaExtrasToIntent(launchIntent);

        WebappActivity activity =
                runWebappActivityAndWaitForIdleWithUrl(
                        launchIntent,
                        mActivityTestRule
                                .getTestServer()
                                .getURL("/content/test/data/android/permission_navigation.html"));
        mActivityTestRule.runJavaScriptCodeInCurrentTab("requestGeolocationPermission()");
        CriteriaHelper.pollUiThread(
                () -> PermissionDialogController.getInstance().isDialogShownForTest(),
                "Permission prompt did not appear in allotted time");
        Assert.assertEquals(
                "Only App modal dialog is supported on web apk",
                activity.getModalDialogManager()
                        .getPresenterForTest(ModalDialogManager.ModalDialogType.APP),
                activity.getModalDialogManager().getCurrentPresenterForTest());
        // Launch a new page, which should be in CCT
        mActivityTestRule.runJavaScriptCodeInCurrentTab("navigate()");
        CriteriaHelper.pollUiThread(
                () -> !PermissionDialogController.getInstance().isDialogShownForTest(),
                "Permission prompt is not dismissed.");

        // Toolbar with the close button should be visible.
        WebappActivityTestRule.assertToolbarShownMaybeHideable(activity);

        // Navigate back to in-scope through a close button.
        ThreadUtils.runOnUiThreadBlocking(
                () ->
                        activity.getToolbarManager()
                                .getToolbarLayoutForTesting()
                                .findViewById(R.id.close_button)
                                .callOnClick());
        CriteriaHelper.pollUiThread(
                () -> !PermissionDialogController.getInstance().isDialogShownForTest(),
                "Permission prompt is not dismissed.");
    }

    private WebappActivity runWebappActivityAndWaitForIdle(Intent intent) {
        return runWebappActivityAndWaitForIdleWithUrl(
                intent, WebappTestPage.getServiceWorkerUrl(mActivityTestRule.getTestServer()));
    }

    private WebappActivity runWebappActivityAndWaitForIdleWithUrl(Intent intent, String url) {
        mActivityTestRule.startWebappActivity(intent.putExtra(WebappConstants.EXTRA_URL, url));
        return mActivityTestRule.getActivity();
    }

    private long getDefaultPrimaryColor() {
        return ChromeColors.getDefaultThemeColor(mActivityTestRule.getActivity(), false);
    }

    private String offOriginUrl() {
        return mActivityTestRule.getTestServer().getURLWithHostName("foo.com", "/defaultresponse");
    }

    private void addAnchor(String id, String url, String target) throws Exception {
        mActivityTestRule.runJavaScriptCodeInCurrentTab(
                String.format(
                        "var aTag = document.createElement('a');"
                                + "aTag.id = '%s';"
                                + "aTag.setAttribute('href','%s');"
                                + "aTag.setAttribute('target','%s');"
                                + "aTag.innerHTML = 'Click Me!';"
                                + "document.body.appendChild(aTag);",
                        id, url, target));
    }

    private void clickNodeWithId(String id) throws Exception {
        DOMUtils.clickNode(mActivityTestRule.getActivity().getActivityTab().getWebContents(), id);
    }

    private void addAnchorAndClick(String url, String target) throws Exception {
        addAnchor("testId", url, target);
        clickNodeWithId("testId");
    }

    private void waitForExternalAppOrIntentPicker() {
        CriteriaHelper.pollUiThread(
                () -> {
                    Criteria.checkThat(
                            ApplicationStatus.getStateForApplication(),
                            Matchers.isOneOf(
                                    HAS_PAUSED_ACTIVITIES,
                                    HAS_STOPPED_ACTIVITIES,
                                    HAS_DESTROYED_ACTIVITIES));
                });
    }
}