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

import static org.chromium.base.test.util.Restriction.RESTRICTION_TYPE_NON_LOW_END_DEVICE;
import static org.chromium.ui.test.util.ViewUtils.createMotionEvent;

import android.content.pm.ActivityInfo;
import android.view.View;

import androidx.test.filters.MediumTest;
import androidx.test.platform.app.InstrumentationRegistry;

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

import org.chromium.base.ThreadUtils;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.base.test.util.Batch;
import org.chromium.base.test.util.CallbackHelper;
import org.chromium.base.test.util.CommandLineFlags;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.Feature;
import org.chromium.base.test.util.Restriction;
import org.chromium.base.test.util.UrlUtils;
import org.chromium.chrome.browser.compositor.layouts.Layout;
import org.chromium.chrome.browser.compositor.layouts.LayoutManagerChrome;
import org.chromium.chrome.browser.compositor.layouts.LayoutManagerImpl;
import org.chromium.chrome.browser.compositor.layouts.SceneChangeObserver;
import org.chromium.chrome.browser.compositor.layouts.StaticLayout;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.browser.layouts.animation.CompositorAnimationHandler;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabSelectionType;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.tabmodel.TabModelSelectorTabModelObserver;
import org.chromium.chrome.test.ChromeJUnit4ClassRunner;
import org.chromium.chrome.test.ChromeTabbedActivityTestRule;
import org.chromium.chrome.test.R;
import org.chromium.chrome.test.batch.BlankCTATabInitialStateRule;
import org.chromium.chrome.test.util.ChromeTabUtils;
import org.chromium.components.browser_ui.widget.gesture.SwipeGestureListener.ScrollDirection;
import org.chromium.components.browser_ui.widget.gesture.SwipeGestureListener.SwipeHandler;
import org.chromium.content_public.browser.test.util.TouchCommon;
import org.chromium.content_public.browser.test.util.UiUtils;
import org.chromium.ui.test.util.UiRestriction;

import java.util.concurrent.TimeoutException;

/** Tests swiping the toolbar to switch tabs. */
@RunWith(ChromeJUnit4ClassRunner.class)
@CommandLineFlags.Add({ChromeSwitches.DISABLE_FIRST_RUN_EXPERIENCE})
@Batch(Batch.PER_CLASS)
public class ToolbarSwipeTest {

    @ClassRule
    public static ChromeTabbedActivityTestRule sActivityTestRule =
            new ChromeTabbedActivityTestRule();

    @Rule
    public BlankCTATabInitialStateRule mBlankCTATabInitialStateRule =
            new BlankCTATabInitialStateRule(sActivityTestRule, false);

    private float mPxToDp = 1.0f;
    private float mTabsViewWidthDp;

    @Before
    public void setUp() throws InterruptedException {
        float dpToPx =
                InstrumentationRegistry.getInstrumentation()
                        .getContext()
                        .getResources()
                        .getDisplayMetrics()
                        .density;
        mPxToDp = 1.0f / dpToPx;

        CompositorAnimationHandler.setTestingMode(true);
    }

    @After
    public void tearDown() {
        sActivityTestRule
                .getActivity()
                .setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
    }

    @Test
    @MediumTest
    @Feature({"Android-TabSwitcher"})
    @Restriction(RESTRICTION_TYPE_NON_LOW_END_DEVICE)
    public void testToolbarSwipeOnlyTab() throws TimeoutException {
        initToolbarSwipeTest(false, 0, false);
        runToolbarSideSwipeTestOnCurrentModel(ScrollDirection.RIGHT, 0, false);
        runToolbarSideSwipeTestOnCurrentModel(ScrollDirection.LEFT, 0, false);
    }

    @Test
    @MediumTest
    @Feature({"Android-TabSwitcher"})
    @Restriction(RESTRICTION_TYPE_NON_LOW_END_DEVICE)
    public void testToolbarSwipePrevTab() throws TimeoutException {
        initToolbarSwipeTest(true, 1, false);
        runToolbarSideSwipeTestOnCurrentModel(ScrollDirection.RIGHT, 0, true);
    }

    @Test
    @MediumTest
    @Feature({"Android-TabSwitcher"})
    @Restriction(RESTRICTION_TYPE_NON_LOW_END_DEVICE)
    public void testToolbarSwipeNextTab() throws TimeoutException {
        initToolbarSwipeTest(true, 0, false);
        runToolbarSideSwipeTestOnCurrentModel(ScrollDirection.LEFT, 1, true);
    }

    @Test
    @MediumTest
    @Feature({"Android-TabSwitcher"})
    @Restriction(RESTRICTION_TYPE_NON_LOW_END_DEVICE)
    public void testToolbarSwipePrevTabNone() throws TimeoutException {
        initToolbarSwipeTest(true, 0, false);
        runToolbarSideSwipeTestOnCurrentModel(ScrollDirection.RIGHT, 0, false);
    }

    @Test
    @MediumTest
    @Feature({"Android-TabSwitcher"})
    @Restriction(RESTRICTION_TYPE_NON_LOW_END_DEVICE)
    public void testToolbarSwipeNextTabNone() throws TimeoutException {
        initToolbarSwipeTest(true, 1, false);
        runToolbarSideSwipeTestOnCurrentModel(ScrollDirection.LEFT, 1, false);
    }

    @Test
    @MediumTest
    @Feature({"Android-TabSwitcher"})
    @Restriction(RESTRICTION_TYPE_NON_LOW_END_DEVICE)
    public void testToolbarSwipeNextThenPrevTab() throws TimeoutException {
        initToolbarSwipeTest(true, 0, false);

        runToolbarSideSwipeTestOnCurrentModel(ScrollDirection.LEFT, 1, true);

        final TabModel tabModel =
                sActivityTestRule.getActivity().getTabModelSelector().getModel(false);
        Assert.assertEquals("Incorrect tab index after first swipe.", 1, tabModel.index());

        runToolbarSideSwipeTestOnCurrentModel(ScrollDirection.RIGHT, 0, true);
    }

    @Test
    @MediumTest
    @Feature({"Android-TabSwitcher"})
    @Restriction(RESTRICTION_TYPE_NON_LOW_END_DEVICE)
    public void testToolbarSwipeNextThenPrevTabIncognito() throws TimeoutException {
        initToolbarSwipeTest(true, 0, true);

        runToolbarSideSwipeTestOnCurrentModel(ScrollDirection.LEFT, 1, true);

        final TabModel tabModel =
                sActivityTestRule.getActivity().getTabModelSelector().getModel(true);
        Assert.assertEquals("Incorrect tab index after first swipe.", 1, tabModel.index());

        runToolbarSideSwipeTestOnCurrentModel(ScrollDirection.RIGHT, 0, true);
    }

    /**
     * Initialize a test for the toolbar swipe behavior.
     *
     * @param useTwoTabs Whether the test should use two tabs. One tab is used if {@code false}.
     * @param selectedTab The tab index in the current model to have selected after the tabs are
     *     loaded.
     * @param incognito Whether the test should run on incognito tabs.
     */
    private void initToolbarSwipeTest(boolean useTwoTabs, int selectedTab, boolean incognito) {
        if (incognito) {
            // If incognito, there is no default tab, so open a new one and switch to it.
            sActivityTestRule.loadUrlInNewTab(generateSolidColorUrl("#00ff00"), true);
            sActivityTestRule.getActivity().getTabModelSelector().selectModel(true);
        } else {
            // If not incognito, use the tab the test started on.
            sActivityTestRule.loadUrl(generateSolidColorUrl("#00ff00"));
        }

        if (useTwoTabs) {
            sActivityTestRule.loadUrlInNewTab(generateSolidColorUrl("#0000ff"), incognito);
        }

        ChromeTabUtils.switchTabInCurrentTabModel(sActivityTestRule.getActivity(), selectedTab);
        InstrumentationRegistry.getInstrumentation().waitForIdleSync();

        final TabModelSelector tabModelSelector =
                sActivityTestRule.getActivity().getTabModelSelector();
        final TabModel tabModel = tabModelSelector.getModel(incognito);

        Assert.assertEquals(
                "Incorrect model selected.",
                incognito,
                tabModelSelector.getCurrentModel().isIncognito());
        Assert.assertEquals("Incorrect starting index.", selectedTab, tabModel.index());
        Assert.assertEquals("Incorrect tab count.", useTwoTabs ? 2 : 1, tabModel.getCount());
    }

    private void runToolbarSideSwipeTestOnCurrentModel(
            @ScrollDirection int direction, int finalIndex, boolean expectsSelection)
            throws TimeoutException {
        final CallbackHelper selectCallback = new CallbackHelper();
        final ChromeTabbedActivity activity = sActivityTestRule.getActivity();
        final int id = activity.getCurrentTabModel().getTabAt(finalIndex).getId();
        final TabModelSelectorTabModelObserver observer =
                ThreadUtils.runOnUiThreadBlocking(
                        () -> {
                            return new TabModelSelectorTabModelObserver(
                                    activity.getTabModelSelector()) {
                                @Override
                                public void didSelectTab(
                                        Tab tab, @TabSelectionType int type, int lastId) {
                                    if (tab.getId() == id) selectCallback.notifyCalled();
                                }
                            };
                        });

        int tabSelectedCallCount = selectCallback.getCallCount();

        // Listen for changes in the layout to indicate the swipe has completed.
        final CallbackHelper staticLayoutCallbackHelper = new CallbackHelper();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    activity.getCompositorViewHolderForTesting()
                            .getLayoutManager()
                            .addSceneChangeObserver(
                                    new SceneChangeObserver() {
                                        @Override
                                        public void onSceneChange(Layout layout) {
                                            if (layout instanceof StaticLayout) {
                                                staticLayoutCallbackHelper.notifyCalled();
                                            }
                                        }
                                    });
                });

        int callLayoutChangeCount = staticLayoutCallbackHelper.getCallCount();
        performToolbarSideSwipe(direction);
        staticLayoutCallbackHelper.waitForCallback(callLayoutChangeCount, 1);

        if (expectsSelection) selectCallback.waitForCallback(tabSelectedCallCount, 1);
        ThreadUtils.runOnUiThreadBlocking(() -> observer.destroy());

        Assert.assertEquals(
                "Index after toolbar side swipe is incorrect",
                finalIndex,
                activity.getCurrentTabModel().index());
    }

    private void performToolbarSideSwipe(@ScrollDirection int direction) {
        Assert.assertTrue(
                "Unexpected direction for side swipe " + direction,
                direction == ScrollDirection.LEFT || direction == ScrollDirection.RIGHT);
        final View toolbar = sActivityTestRule.getActivity().findViewById(R.id.toolbar);

        int[] toolbarPos = new int[2];
        toolbar.getLocationOnScreen(toolbarPos);
        final int width = toolbar.getWidth();
        final int height = toolbar.getHeight();

        final int fromX = toolbarPos[0] + width / 2;
        final int toX = toolbarPos[0] + (direction == ScrollDirection.LEFT ? 0 : width);
        final int y = toolbarPos[1] + height / 2;
        final int stepCount = 25;
        final long duration = 500;

        View toolbarRoot =
                sActivityTestRule
                        .getActivity()
                        .getRootUiCoordinatorForTesting()
                        .getToolbarManager()
                        .getContainerViewForTesting();
        TouchCommon.performDrag(toolbarRoot, fromX, toX, y, y, stepCount, duration);
    }

    /** Test that swipes and tab transitions are not causing URL bar to be focused. */
    @Test
    @MediumTest
    @Restriction({UiRestriction.RESTRICTION_TYPE_PHONE, RESTRICTION_TYPE_NON_LOW_END_DEVICE})
    @Feature({"Android-TabSwitcher"})
    public void testOSKIsNotShownDuringSwipe() throws InterruptedException {
        final View urlBar = sActivityTestRule.getActivity().findViewById(R.id.url_bar);
        final LayoutManagerChrome layoutManager = updateTabsViewSize();
        final SwipeHandler swipeHandler = layoutManager.getToolbarSwipeHandler();

        UiUtils.settleDownUI(InstrumentationRegistry.getInstrumentation());
        InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> urlBar.requestFocus());
        UiUtils.settleDownUI(InstrumentationRegistry.getInstrumentation());

        InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> urlBar.clearFocus());
        UiUtils.settleDownUI(InstrumentationRegistry.getInstrumentation());
        ChromeTabUtils.newTabFromMenu(
                InstrumentationRegistry.getInstrumentation(), sActivityTestRule.getActivity());
        UiUtils.settleDownUI(InstrumentationRegistry.getInstrumentation());

        Assert.assertFalse(
                "Keyboard somehow got shown",
                sActivityTestRule
                        .getKeyboardDelegate()
                        .isKeyboardShowing(sActivityTestRule.getActivity(), urlBar));

        PostTask.runOrPostTask(
                TaskTraits.UI_DEFAULT,
                () -> {
                    swipeHandler.onSwipeStarted(ScrollDirection.RIGHT, createMotionEvent(0, 0));
                    float swipeXChange = mTabsViewWidthDp / 2.f;
                    swipeHandler.onSwipeUpdated(
                            createMotionEvent(swipeXChange / mPxToDp, 0.f),
                            swipeXChange / mPxToDp,
                            0.f,
                            swipeXChange / mPxToDp,
                            0.f);
                });

        CriteriaHelper.pollUiThread(
                () -> {
                    return !sActivityTestRule
                            .getActivity()
                            .getLayoutManager()
                            .getActiveLayout()
                            .shouldDisplayContentOverlay();
                });

        PostTask.runOrPostTask(
                TaskTraits.UI_DEFAULT,
                () -> {
                    Assert.assertFalse(
                            "Keyboard should be hidden while swiping",
                            sActivityTestRule
                                    .getKeyboardDelegate()
                                    .isKeyboardShowing(sActivityTestRule.getActivity(), urlBar));
                    swipeHandler.onSwipeFinished();
                });

        CriteriaHelper.pollUiThread(
                () -> {
                    LayoutManagerImpl driver = sActivityTestRule.getActivity().getLayoutManager();
                    return driver.getActiveLayout().shouldDisplayContentOverlay();
                },
                "Layout not requesting Tab Android view be attached");

        Assert.assertFalse(
                "Keyboard should not be shown",
                sActivityTestRule
                        .getKeyboardDelegate()
                        .isKeyboardShowing(sActivityTestRule.getActivity(), urlBar));
    }

    private LayoutManagerChrome updateTabsViewSize() {
        View tabsView = sActivityTestRule.getActivity().getTabsView();
        mTabsViewWidthDp = tabsView.getWidth() * mPxToDp;
        return sActivityTestRule.getActivity().getLayoutManager();
    }

    /**
     * Generate a URL that shows a web page with a solid color. This makes visual debugging easier.
     *
     * @param htmlColor The HTML/CSS color the page should display.
     * @return A URL that shows the solid color when loaded.
     */
    private static String generateSolidColorUrl(String htmlColor) {
        return UrlUtils.encodeHtmlDataUri(
                "<html><head><style>"
                        + "  body { background-color: "
                        + htmlColor
                        + ";}"
                        + "</style></head>"
                        + "<body></body></html>");
    }
}