chromium/chrome/android/javatests/src/org/chromium/chrome/browser/gesturenav/NavigationTransitionsTest.java

// Copyright 2024 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.gesturenav;

import static org.chromium.ui.base.LocalizationUtils.setRtlForTesting;

import android.graphics.Bitmap;
import android.graphics.Color;

import androidx.activity.BackEventCompat;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.filters.MediumTest;

import org.hamcrest.Matchers;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

import org.chromium.base.ThreadUtils;
import org.chromium.base.test.params.ParameterAnnotations.ClassParameter;
import org.chromium.base.test.params.ParameterAnnotations.UseRunnerDelegate;
import org.chromium.base.test.params.ParameterSet;
import org.chromium.base.test.params.ParameterizedRunner;
import org.chromium.base.test.util.Batch;
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.CriteriaNotSatisfiedException;
import org.chromium.base.test.util.DisableIf;
import org.chromium.base.test.util.Features.EnableFeatures;
import org.chromium.chrome.browser.ViewportTestUtils;
import org.chromium.chrome.browser.back_press.BackPressManager;
import org.chromium.chrome.browser.browser_controls.BrowserControlsStateProvider;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.test.ChromeJUnit4RunnerDelegate;
import org.chromium.chrome.test.ChromeTabbedActivityTestRule;
import org.chromium.chrome.test.util.ChromeTabUtils;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.back_forward_transition.AnimationStage;
import org.chromium.content_public.browser.test.util.Coordinates;
import org.chromium.content_public.browser.test.util.JavaScriptUtils;
import org.chromium.content_public.browser.test.util.TouchCommon;
import org.chromium.content_public.browser.test.util.WebContentsUtils;
import org.chromium.net.test.EmbeddedTestServer;
import org.chromium.ui.base.BackGestureEventSwipeEdge;
import org.chromium.ui.base.UiAndroidFeatures;

import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * End-to-end tests for default navigation transitions.
 *
 * <p>i.e. Dragging the current web contents back in history to reveal the previous web contents.
 * This suite is parameterized to run each test under the gestural navigation path as well as the
 * three-button navigation path.
 */
@RunWith(ParameterizedRunner.class)
@UseRunnerDelegate(ChromeJUnit4RunnerDelegate.class)
@CommandLineFlags.Add({
    ChromeSwitches.DISABLE_FIRST_RUN_EXPERIENCE,
    "enable-features=BackForwardTransitions,BackGestureRefactorAndroid",
    // Resampling can make scroll offsets non-deterministic so turn it off.
    "disable-features=ResamplingScrollEvents",
    "hide-scrollbars"
})
@Batch(Batch.PER_CLASS)
public class NavigationTransitionsTest {
    @Rule
    public ChromeTabbedActivityTestRule mActivityTestRule = new ChromeTabbedActivityTestRule();

    private EmbeddedTestServer mTestServer;

    private ViewportTestUtils mViewportTestUtils;

    private static final int TEST_TIMEOUT = 10000;

    private static final int NAVIGATION_MODE_THREE_BUTTON = 1;
    private static final int NAVIGATION_MODE_GESTURAL = 2;

    private ScreenshotCaptureTestHelper mScreenshotCaptureTestHelper;

    @ClassParameter
    private static List<ParameterSet> sClassParams =
            Arrays.asList(
                    new ParameterSet()
                            .value(NavigationTransitionsTest.NAVIGATION_MODE_THREE_BUTTON)
                            .name("ThreeButtonMode"),
                    new ParameterSet()
                            .value(NavigationTransitionsTest.NAVIGATION_MODE_GESTURAL)
                            .name("Gestural"));

    private int mTestNavigationMode;

    public NavigationTransitionsTest(int navigationModeParam) {
        mTestNavigationMode = navigationModeParam;
    }

    @Before
    public void setUp() {
        mTestServer =
                EmbeddedTestServer.createAndStartServer(
                        ApplicationProvider.getApplicationContext());

        mScreenshotCaptureTestHelper = new ScreenshotCaptureTestHelper();

        mActivityTestRule.startMainActivityOnBlankPage();
        mActivityTestRule.waitForActivityNativeInitializationComplete();
        BackPressManager backPressManager =
                mActivityTestRule.getActivity().getBackPressManagerForTesting();

        if (mTestNavigationMode == NAVIGATION_MODE_THREE_BUTTON) {
            ThreadUtils.runOnUiThreadBlocking(
                    () -> {
                        GestureNavigationTestUtils utils =
                                new GestureNavigationTestUtils(mActivityTestRule);
                        utils.enableGestureNavigationForTesting();
                    });
            backPressManager.setIsGestureNavEnabledSupplier(() -> false);
        } else {
            backPressManager.setIsGestureNavEnabledSupplier(() -> true);
        }

        mScreenshotCaptureTestHelper.setNavScreenshotCallbackForTesting(
                new ScreenshotCaptureTestHelper.NavScreenshotCallback() {
                    @Override
                    public Bitmap onAvailable(int navIndex, Bitmap bitmap, boolean requested) {
                        // TODO(crbug.com/337886037) Capturing a screenshot currently fails in
                        // emulators due to GPU issues. This override ensures we always return a
                        // bitmap so that we can reliably run the test. This is ok since the current
                        // tests don't pixel test the output (we do pixel test in other tests). Once
                        // the emulator issues are fixed though it'd be better to remove this
                        // override to perform a more realistic test.
                        Bitmap overrideBitmap =
                                Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
                        overrideBitmap.eraseColor(Color.YELLOW);
                        return overrideBitmap;
                    }
                });
        mViewportTestUtils = new ViewportTestUtils(mActivityTestRule);
        mViewportTestUtils.setUpForBrowserControls();
    }

    @After
    public void tearDown() {
        mScreenshotCaptureTestHelper.setNavScreenshotCallbackForTesting(null);
    }

    private WebContents getWebContents() {
        return mActivityTestRule.getActivity().getActivityTab().getWebContents();
    }

    private String getCurrentUrl() {
        return ChromeTabUtils.getUrlStringOnUiThread(
                mActivityTestRule.getActivity().getActivityTab());
    }

    private void invokeNavigateGesture(@BackGestureEventSwipeEdge int edge) {
        assert edge == BackEventCompat.EDGE_LEFT || edge == BackEventCompat.EDGE_RIGHT;
        if (mTestNavigationMode == NAVIGATION_MODE_THREE_BUTTON) {
            float width_px =
                    getWebContents().getWidth()
                            * Coordinates.createFor(getWebContents()).getDeviceScaleFactor();

            // Drag far enough to cause the back gesture to invoke.
            float fromEdgeStart = 5.0f;
            float dragDistance = width_px - 10.0f;

            // if EDGE_LEFT
            float fromX = fromEdgeStart;
            float toX = fromEdgeStart + dragDistance;
            if (edge == BackEventCompat.EDGE_RIGHT) {
                fromX = width_px - fromEdgeStart;
                toX = width_px - fromEdgeStart - dragDistance;
            }

            assert fromX > 0 && fromX < width_px;
            assert toX > 0 && toX < width_px;

            // These are arbitrary values that drag far enough to cause the back gesture to invoke.
            //
            // Note: Prefer `performWallClockDrag()` over
            // `GestureNavigationUtils#swipeFromLeftEdge()` because in the renderer, we perform
            // coalescing of input events. `swipeFromLeftEdge()` dispatches all the events at once
            // and all the events can be coalesced into one single event, causing some of the visual
            // effect not being triggered.
            TouchCommon.performWallClockDrag(
                    mActivityTestRule.getActivity(),
                    fromX,
                    toX,
                    /* fromY= */ 400.0f,
                    /* toY= */ 400.0f,
                    /* duration= */ 2000,
                    /* dispatchIntervalMs= */ 60,
                    /* preventFling= */ true);
        } else {
            ThreadUtils.runOnUiThreadBlocking(
                    () -> {
                        BackPressManager manager =
                                mActivityTestRule.getActivity().getBackPressManagerForTesting();
                        var backEvent = new BackEventCompat(0, 0, 0, edge);
                        manager.getCallback().handleOnBackStarted(backEvent);
                        backEvent = new BackEventCompat(1, 0, .8f, edge);
                        manager.getCallback().handleOnBackProgressed(backEvent);
                        manager.getCallback().handleOnBackPressed();
                    });
        }
    }

    private void performNavigationTransition(
            String expectedUrl, @BackGestureEventSwipeEdge int edge) {
        Tab tab = mActivityTestRule.getActivity().getActivityTab();
        ChromeTabUtils.waitForTabPageLoaded(
                tab,
                expectedUrl,
                () -> {
                    invokeNavigateGesture(edge);
                });
        waitForTransitionFinished();
    }

    private void waitForTransitionFinished() {
        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    try {
                        boolean hasTransition =
                                getWebContents().getCurrentBackForwardTransitionStage()
                                        != AnimationStage.NONE;
                        Criteria.checkThat(hasTransition, Matchers.is(false));
                    } catch (Throwable e) {
                        throw new CriteriaNotSatisfiedException(e);
                    }
                },
                TEST_TIMEOUT,
                CriteriaHelper.DEFAULT_POLLING_INTERVAL);
    }

    /**
     * Basic smoke test of transition back navigation.
     *
     * <p>Ensures that the transition gesture can be used to successfully navigate back in session
     * history.
     */
    @Test
    @MediumTest
    public void smokeTest() throws Throwable {
        // Put "blue.html" and then "green.html" in the session history.
        String url1 = mTestServer.getURL("/chrome/test/data/android/blue.html");
        String url2 = mTestServer.getURL("/chrome/test/data/android/green.html");
        String url3 = mTestServer.getURL("/chrome/test/data/android/simple.html");
        mActivityTestRule.loadUrl(url1);
        mActivityTestRule.loadUrl(url2);
        mActivityTestRule.loadUrl(url3);

        WebContentsUtils.waitForCopyableViewInWebContents(getWebContents());

        // Perform a back gesture transition from the left edge.
        performNavigationTransition(url2, BackEventCompat.EDGE_LEFT);

        Assert.assertEquals(url2, getCurrentUrl());

        // Perform an edge gesture transition from the right edge. In three
        // button mode this goes forward, in gestural mode this goes back.
        if (mTestNavigationMode == NAVIGATION_MODE_THREE_BUTTON) {
            performNavigationTransition(url3, BackEventCompat.EDGE_RIGHT);
            Assert.assertEquals(url3, getCurrentUrl());
        } else {
            performNavigationTransition(url1, BackEventCompat.EDGE_RIGHT);
            Assert.assertEquals(url1, getCurrentUrl());
        }
    }

    /**
     * Test that input works after performing a back navigation.
     *
     * <p>Input is suppressed during the transition so this test ensures suppression is reset at the
     * end of the transition.
     */
    @Test
    @MediumTest
    public void testInputAfterBackTransition() throws Throwable {
        // Put "blue.html" and then "green.html" in the session history.
        String url1 = mTestServer.getURL("/chrome/test/data/android/blue.html");
        String url2 = mTestServer.getURL("/chrome/test/data/android/green.html");
        mActivityTestRule.loadUrl(url1);
        mActivityTestRule.loadUrl(url2);

        WebContentsUtils.waitForCopyableViewInWebContents(getWebContents());
        performNavigationTransition(url1, BackEventCompat.EDGE_LEFT);

        JavaScriptUtils.executeJavaScriptAndWaitForResult(
                getWebContents(),
                "window.numTouches = 0;"
                        + "window.addEventListener('touchstart', () => { window.numTouches++; });");

        // Wait a frame to ensure the touchhandler registration is pushed to the CC thread so that
        // it forwards touches to the main thread.
        WebContentsUtils.waitForCopyableViewInWebContents(getWebContents());

        TouchCommon.singleClickView(
                mActivityTestRule.getActivity().getActivityTab().getContentView());

        WebContentsUtils.waitForCopyableViewInWebContents(getWebContents());

        int numTouches =
                Integer.parseInt(
                        JavaScriptUtils.executeJavaScriptAndWaitForResult(
                                getWebContents(), "window.numTouches"));

        Assert.assertEquals(1, numTouches);
    }

    /**
     * Test that history navigation works when directions are mirrored due to an RTL UI direction.
     */
    @Test
    @MediumTest
    @EnableFeatures({UiAndroidFeatures.MIRROR_BACK_FORWARD_GESTURES_IN_RTL})
    public void testBackNavInRTL() throws Throwable {
        setRtlForTesting(true);

        // Put "blue.html" and then "green.html" in the session history.
        String url1 = mTestServer.getURL("/chrome/test/data/android/blue.html");
        String url2 = mTestServer.getURL("/chrome/test/data/android/green.html");
        String url3 = mTestServer.getURL("/chrome/test/data/android/simple.html");
        mActivityTestRule.loadUrl(url1);
        mActivityTestRule.loadUrl(url2);
        mActivityTestRule.loadUrl(url3);

        WebContentsUtils.waitForCopyableViewInWebContents(getWebContents());
        performNavigationTransition(url2, BackEventCompat.EDGE_RIGHT);
        Assert.assertEquals(url2, getCurrentUrl());

        // Perform an edge gesture transition from the left edge (semantically
        // forward - since we're in RTL). In three button mode this goes
        // forward, in gestural mode this goes back (without a transition).
        if (mTestNavigationMode == NAVIGATION_MODE_THREE_BUTTON) {
            performNavigationTransition(url3, BackEventCompat.EDGE_LEFT);
            Assert.assertEquals(url3, getCurrentUrl());
        } else {
            performNavigationTransition(url1, BackEventCompat.EDGE_LEFT);
            Assert.assertEquals(url1, getCurrentUrl());
        }
    }

    /**
     * Test that the top control is fully visible during a transition.
     *
     * <p>Ensures that the animation is started at the start of the transition.
     */
    @Test
    @MediumTest
    @DisableIf.Build(supported_abis_includes = "x86", message = "https://crbug.com/354197164")
    @DisableIf.Build(supported_abis_includes = "x86_64", message = "https://crbug.com/354197164")
    public void startBackNavWithTopControlHidden() throws Throwable {
        // The top control's offset is -top_controls_height when controls are fully hidden, 0 when
        // fully shown.
        final AtomicInteger topControlOffsetDuringGesture = new AtomicInteger(Integer.MAX_VALUE);
        BrowserControlsStateProvider browserControlsStateProvider =
                mActivityTestRule.getActivity().getBrowserControlsManager();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    browserControlsStateProvider.addObserver(
                            new BrowserControlsStateProvider.Observer() {
                                @Override
                                public void onControlsOffsetChanged(
                                        int topOffset,
                                        int topControlsMinHeightOffset,
                                        int bottomOffset,
                                        int bottomControlsMinHeightOffset,
                                        boolean needsAnimate,
                                        boolean isVisibilityForced) {
                                    // Since in 3-button mode the gesture sequence is two seconds,
                                    // the top control must have started to show during the two
                                    // seconds.
                                    if (getWebContents().getCurrentBackForwardTransitionStage()
                                            == AnimationStage.OTHER) {
                                        topControlOffsetDuringGesture.set(topOffset);
                                    }
                                }
                            });
                });

        // Put "blue.html" and then "green.html" in the session history.
        String url1 = mTestServer.getURL("/chrome/test/data/android/blue.html");
        String url2 = mTestServer.getURL("/chrome/test/data/android/green_scroll.html");
        mActivityTestRule.loadUrl(url1);
        mActivityTestRule.loadUrl(url2);

        WebContentsUtils.waitForCopyableViewInWebContents(getWebContents());

        // Perform a back gesture transition.
        mViewportTestUtils.hideBrowserControls();
        performNavigationTransition(url1, BackEventCompat.EDGE_LEFT);

        Assert.assertEquals(url1, getCurrentUrl());

        Assert.assertTrue(
                topControlOffsetDuringGesture.get() > -mViewportTestUtils.getTopControlsHeightPx());
        mViewportTestUtils.waitForBrowserControlsState(/* shown= */ true);
    }
}