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

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.doAnswer;

import static org.chromium.chrome.browser.ui.desktop_windowing.AppHeaderCoordinator.INSTANCE_STATE_KEY_IS_APP_IN_UNFOCUSED_DW;

import android.content.ComponentCallbacks;
import android.content.Intent;
import android.content.res.Resources;
import android.graphics.Rect;
import android.os.Build;
import android.widget.FrameLayout.LayoutParams;
import android.widget.ImageButton;

import androidx.annotation.RequiresApi;
import androidx.core.graphics.Insets;
import androidx.core.view.WindowInsetsCompat;
import androidx.test.filters.MediumTest;
import androidx.test.runner.lifecycle.Stage;

import org.hamcrest.Matchers;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;

import org.chromium.base.ContextUtils;
import org.chromium.base.ThreadUtils;
import org.chromium.base.test.util.ApplicationTestUtils;
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.DisabledTest;
import org.chromium.base.test.util.Features.EnableFeatures;
import org.chromium.base.test.util.Restriction;
import org.chromium.chrome.browser.ChromeTabbedActivity;
import org.chromium.chrome.browser.compositor.layouts.LayoutManagerChromeTablet;
import org.chromium.chrome.browser.compositor.layouts.eventfilter.AreaMotionEventFilter;
import org.chromium.chrome.browser.compositor.overlays.strip.StripLayoutHelperManager;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.browser.hub.HubLayout;
import org.chromium.chrome.browser.multiwindow.MultiWindowUtils;
import org.chromium.chrome.browser.tasks.tab_management.TabUiTestHelper;
import org.chromium.chrome.browser.theme.ThemeUtils;
import org.chromium.chrome.browser.toolbar.ToolbarFeatures;
import org.chromium.chrome.browser.toolbar.top.ToolbarTablet;
import org.chromium.chrome.browser.toolbar.top.tab_strip.TabStripTransitionCoordinator;
import org.chromium.chrome.browser.ui.desktop_windowing.AppHeaderCoordinator;
import org.chromium.chrome.browser.ui.desktop_windowing.AppHeaderState;
import org.chromium.chrome.browser.ui.desktop_windowing.AppHeaderUtils;
import org.chromium.chrome.browser.ui.theme.BrandedColorScheme;
import org.chromium.chrome.test.ChromeJUnit4ClassRunner;
import org.chromium.chrome.test.ChromeTabbedActivityTestRule;
import org.chromium.chrome.test.R;
import org.chromium.ui.InsetObserver;
import org.chromium.ui.InsetsRectProvider;
import org.chromium.ui.test.util.UiRestriction;

/** Browser test for {@link AppHeaderCoordinator} */
@RequiresApi(Build.VERSION_CODES.R)
@Restriction(UiRestriction.RESTRICTION_TYPE_TABLET)
@CommandLineFlags.Add(ChromeSwitches.DISABLE_FIRST_RUN_EXPERIENCE)
@Batch(Batch.PER_CLASS)
@RunWith(ChromeJUnit4ClassRunner.class)
public class AppHeaderCoordinatorBrowserTest {
    private static final int APP_HEADER_LEFT_PADDING = 10;
    private static final int APP_HEADER_RIGHT_PADDING = 20;

    private static final WindowInsetsCompat BOTTOM_NAV_BAR_INSETS =
            new WindowInsetsCompat.Builder()
                    .setInsets(
                            WindowInsetsCompat.Type.navigationBars(),
                            Insets.of(0, 0, 0, /* bottom= */ 100))
                    .build();

    @Rule
    public ChromeTabbedActivityTestRule mActivityTestRule = new ChromeTabbedActivityTestRule();

    @Rule public MockitoRule mockitoRule = MockitoJUnit.rule();

    private @Mock InsetsRectProvider mInsetsRectProvider;

    private Rect mWidestUnoccludedRect = new Rect();
    private Rect mWindowRect = new Rect();
    private int mTestAppHeaderHeight;

    @Before
    public void setup() {
        ToolbarFeatures.setIsTabStripLayoutOptimizationEnabledForTesting(true);
        InsetObserver.setInitialRawWindowInsetsForTesting(BOTTOM_NAV_BAR_INSETS);
        AppHeaderCoordinator.setInsetsRectProviderForTesting(mInsetsRectProvider);

        doAnswer(args -> mWidestUnoccludedRect).when(mInsetsRectProvider).getWidestUnoccludedRect();
        doAnswer(args -> mWindowRect).when(mInsetsRectProvider).getWindowRect();

        mActivityTestRule.startMainActivityOnBlankPage();

        // Initialize the strip height for testing. This is due to bots might have different
        // densities.
        Resources res = mActivityTestRule.getActivity().getResources();
        int tabStripHeight = res.getDimensionPixelSize(R.dimen.tab_strip_height);
        int reservedStripTopPadding =
                res.getDimensionPixelSize(R.dimen.tab_strip_reserved_top_padding);
        mTestAppHeaderHeight = tabStripHeight + reservedStripTopPadding;
    }

    @Test
    @MediumTest
    public void testTabStripHeightChangeForTabStripLayoutOptimization() {
        ChromeTabbedActivity activity = mActivityTestRule.getActivity();
        triggerDesktopWindowingModeChange(activity, true);

        CriteriaHelper.pollUiThread(
                () -> {
                    Criteria.checkThat(activity.getToolbarManager(), Matchers.notNullValue());
                    Criteria.checkThat(
                            "Tab strip height is different",
                            activity.getToolbarManager().getTabStripHeightSupplier().get(),
                            Matchers.equalTo(mTestAppHeaderHeight));

                    StripLayoutHelperManager stripLayoutHelperManager =
                            activity.getLayoutManager().getStripLayoutHelperManager();
                    Criteria.checkThat(stripLayoutHelperManager, Matchers.notNullValue());
                    float density = activity.getResources().getDisplayMetrics().density;
                    Criteria.checkThat(
                            "Tab strip does not resized.",
                            stripLayoutHelperManager.getHeight() * density,
                            Matchers.equalTo((float) mTestAppHeaderHeight));
                });
    }

    @Test
    @MediumTest
    @EnableFeatures(ChromeFeatureList.TAB_STRIP_TRANSITION_IN_DESKTOP_WINDOW)
    public void testToggleTabStripVisibilityInDesktopWindow() {
        ChromeTabbedActivity activity = mActivityTestRule.getActivity();
        triggerDesktopWindowingModeChange(activity, true);

        ComponentCallbacks tabStripCallback =
                activity.getToolbarManager().getTabStripTransitionCoordinator();
        Assert.assertNotNull("Tab strip transition callback is null.", tabStripCallback);

        // Set the strip width threshold and trigger a configuration change to force tab strip
        // visibility. This is a test only strategy, as we don't want to actually change the
        // configuration which might result in an activity restart.

        // A very large strip width threshold should hide the strip by adding the scrim.
        TabStripTransitionCoordinator.setFadeTransitionThresholdForTesting(10000);
        ThreadUtils.runOnUiThreadBlocking(
                () ->
                        tabStripCallback.onConfigurationChanged(
                                activity.getResources().getConfiguration()));

        var stripLayoutHelperManager = activity.getLayoutManager().getStripLayoutHelperManager();
        var stripAreaMotionEventFilter =
                (AreaMotionEventFilter) stripLayoutHelperManager.getEventFilter();
        CriteriaHelper.pollUiThread(
                () -> {
                    Criteria.checkThat(
                            "Tab strip scrim should be visible.",
                            stripLayoutHelperManager.isStripScrimVisibleForTesting(),
                            Matchers.equalTo(true));
                    Criteria.checkThat(
                            "Motion event filter area should be empty on an invisible strip.",
                            stripAreaMotionEventFilter.getEventAreaForTesting().isEmpty(),
                            Matchers.equalTo(true));
                });

        // A very small strip width threshold value should show the strip by removing the scrim.
        TabStripTransitionCoordinator.setFadeTransitionThresholdForTesting(1);
        ThreadUtils.runOnUiThreadBlocking(
                () ->
                        tabStripCallback.onConfigurationChanged(
                                activity.getResources().getConfiguration()));
        CriteriaHelper.pollUiThread(
                () -> {
                    Criteria.checkThat(
                            "Tab strip scrim should not be visible.",
                            stripLayoutHelperManager.isStripScrimVisibleForTesting(),
                            Matchers.equalTo(false));
                    Criteria.checkThat(
                            "Motion event filter area should be non-empty on a visible strip.",
                            stripAreaMotionEventFilter.getEventAreaForTesting().isEmpty(),
                            Matchers.equalTo(false));
                });
    }

    @Test
    @MediumTest
    public void testOnTopResumedActivityChanged_UnfocusedInDesktopWindow() {
        // TODO (crbug/330213938): Also test other scenarios for different values of desktop
        // windowing mode / activity focus states; tests for other input combinations are currently
        // failing even locally due to incorrect tab switcher icon tint.
        doTestOnTopResumedActivityChanged(
                /* isInDesktopWindow= */ true, /* isActivityFocused= */ false);
    }

    @Test
    @MediumTest
    @DisabledTest(message = "Flaky, crbug.com/339854841")
    public void testEnterTabSwitcherInDesktopWindow_HubLayout() {
        ChromeTabbedActivity activity = mActivityTestRule.getActivity();

        // Enter desktop windowing mode.
        triggerDesktopWindowingModeChange(activity, true);
        // Enter the tab switcher.
        TabUiTestHelper.enterTabSwitcher(activity);

        var layoutManager = (LayoutManagerChromeTablet) activity.getLayoutManager();
        var hubLayout = ((HubLayout) layoutManager.getHubLayoutForTesting());
        var hubContainerView = hubLayout.getHubControllerForTesting().getContainerView();
        var params = (LayoutParams) hubContainerView.getLayoutParams();

        CriteriaHelper.pollUiThread(
                () -> {
                    Criteria.checkThat(
                            "Tab switcher container view y-offset should match the app header"
                                    + " height.",
                            (int) hubContainerView.getY(),
                            Matchers.is(mTestAppHeaderHeight));
                    Criteria.checkThat(
                            "Tab switcher container view top margin should match the app header"
                                    + " height.",
                            params.topMargin,
                            Matchers.is(mTestAppHeaderHeight));
                });

        // Exit desktop windowing mode.
        triggerDesktopWindowingModeChange(activity, false);
        CriteriaHelper.pollUiThread(
                () -> {
                    Criteria.checkThat(
                            "Tab switcher container view y-offset should be zero.",
                            hubContainerView.getY(),
                            Matchers.is(0f));
                });
        TabUiTestHelper.clickFirstCardFromTabSwitcher(activity);
    }

    @Test
    @MediumTest
    @DisabledTest(message = "Flaky, crbug.com/339854841")
    public void testEnterDesktopWindowWithTabSwitcherActive_HubLayout() {
        ChromeTabbedActivity activity = mActivityTestRule.getActivity();

        // Enter the tab switcher. Desktop windowing mode is not active initially.
        TabUiTestHelper.enterTabSwitcher(activity);

        var layoutManager = (LayoutManagerChromeTablet) activity.getLayoutManager();
        var hubLayout = ((HubLayout) layoutManager.getHubLayoutForTesting());
        var hubContainerView = hubLayout.getHubControllerForTesting().getContainerView();
        var params = (LayoutParams) hubContainerView.getLayoutParams();

        assertEquals(
                "Tab switcher container view y-offset should be zero.",
                0,
                hubContainerView.getY(),
                0.0);
        assertEquals("Tab switcher container view top margin should be zero.", 0, params.topMargin);

        // Enter desktop windowing mode while the tab switcher is visible.
        triggerDesktopWindowingModeChange(activity, true);

        CriteriaHelper.pollUiThread(
                () -> {
                    Criteria.checkThat(
                            "Tab switcher container view y-offset should match the app header"
                                    + " height.",
                            (int) hubContainerView.getY(),
                            Matchers.is(mTestAppHeaderHeight));
                    Criteria.checkThat(
                            "Tab switcher container view top margin should match the app header"
                                    + " height.",
                            params.topMargin,
                            Matchers.is(mTestAppHeaderHeight));
                });

        // Exit desktop windowing mode.
        triggerDesktopWindowingModeChange(activity, false);
        CriteriaHelper.pollUiThread(
                () -> {
                    Criteria.checkThat(
                            "Tab switcher container view y-offset should be zero.",
                            hubContainerView.getY(),
                            Matchers.is(0f));
                });
        TabUiTestHelper.clickFirstCardFromTabSwitcher(activity);
    }

    @Test
    @MediumTest
    @DisabledTest(message = "Flaky, crbug.com/340589545")
    public void testRecreateActivitiesInDesktopWindow() {
        // Assume that the current activity enters desktop windowing mode.
        ChromeTabbedActivity firstActivity = mActivityTestRule.getActivity();
        triggerDesktopWindowingModeChange(firstActivity, true);

        // Create a new (desktop) window, that should gain focus and cause the first activity to
        // lose focus.
        Intent intent =
                MultiWindowUtils.createNewWindowIntent(
                        firstActivity.getApplicationContext(),
                        MultiWindowUtils.INVALID_INSTANCE_ID,
                        true,
                        false,
                        true);
        ChromeTabbedActivity secondActivity =
                ApplicationTestUtils.waitForActivityWithClass(
                        ChromeTabbedActivity.class,
                        Stage.RESUMED,
                        () -> ContextUtils.getApplicationContext().startActivity(intent));
        triggerDesktopWindowingModeChange(secondActivity, true);

        // Trigger activity recreation in desktop windowing mode (an app theme change for eg. would
        // trigger this).
        mActivityTestRule.recreateActivity();
        firstActivity = mActivityTestRule.getActivity();
        secondActivity = ApplicationTestUtils.recreateActivity(secondActivity);

        // Activity recreation will send an #onTopResumedActivityChanged(false) signal as the
        // activity is stopping, so both activities will be considered unfocused.
        assertTrue(
                "Saved instance state bundle should hold correct desktop window focus state.",
                firstActivity
                        .getSavedInstanceState()
                        .getBoolean(INSTANCE_STATE_KEY_IS_APP_IN_UNFOCUSED_DW));
        assertTrue(
                "Saved instance state bundle should hold correct desktop window focus state.",
                secondActivity
                        .getSavedInstanceState()
                        .getBoolean(INSTANCE_STATE_KEY_IS_APP_IN_UNFOCUSED_DW));

        // As |secondActivity| regains focus after recreation, it will receive an
        // #onTopResumedActivityChanged(true) signal, that should re-apply the correct top Chrome
        // colors. |firstActivity| should start with the unfocused window colors, based on the saved
        // instance state value.
        verifyToolbarIconTints(
                firstActivity, /* isActivityFocused= */ false, /* isInDesktopWindow= */ true);
        verifyToolbarIconTints(
                secondActivity, /* isActivityFocused= */ true, /* isInDesktopWindow= */ true);

        // TODO(aishwaryarj): Verify tab strip background color too. This is currently failing on
        // the CQ bot.

        // Exit desktop windowing mode and finish the second activity.
        AppHeaderUtils.setAppInDesktopWindowForTesting(false);
        secondActivity.finish();
    }

    private void doTestOnTopResumedActivityChanged(
            boolean isInDesktopWindow, boolean isActivityFocused) {
        ToolbarFeatures.setIsTabStripLayoutOptimizationEnabledForTesting(true);
        ChromeTabbedActivity activity = mActivityTestRule.getActivity();

        CriteriaHelper.pollUiThread(
                () -> {
                    var appHeaderCoordinator =
                            activity.getRootUiCoordinatorForTesting()
                                    .getDesktopWindowStateProvider();
                    Criteria.checkThat(appHeaderCoordinator, Matchers.notNullValue());
                });

        // Assume that the current activity lost focus in desktop windowing mode.
        triggerDesktopWindowingModeChange(activity, true);
        ThreadUtils.runOnUiThreadBlocking(
                () -> activity.onTopResumedActivityChanged(isActivityFocused));

        // Verify the toolbar icon tints.
        verifyToolbarIconTints(activity, isActivityFocused, isInDesktopWindow);
    }

    private void verifyToolbarIconTints(
            ChromeTabbedActivity activity, boolean isActivityFocused, boolean isInDesktopWindow) {
        var omniboxIconTint =
                ThemeUtils.getThemedToolbarIconTint(activity, BrandedColorScheme.APP_DEFAULT)
                        .getDefaultColor();
        var nonOmniboxIconTint =
                isInDesktopWindow
                        ? ThemeUtils.getThemedToolbarIconTintForActivityState(
                                        activity, BrandedColorScheme.APP_DEFAULT, isActivityFocused)
                                .getDefaultColor()
                        : omniboxIconTint;

        CriteriaHelper.pollUiThread(
                () -> {
                    var toolbarTablet =
                            (ToolbarTablet)
                                    activity.getToolbarManager().getToolbarLayoutForTesting();
                    Criteria.checkThat(
                            "Home button tint is incorrect",
                            toolbarTablet.getHomeButton().getImageTintList().getDefaultColor(),
                            Matchers.is(nonOmniboxIconTint));
                    Criteria.checkThat(
                            "Tab switcher icon tint is incorrect.",
                            ((ImageButton)
                                            activity.getToolbarManager()
                                                    .getTabSwitcherButtonCoordinatorForTesting()
                                                    .getContainerView())
                                    .getImageTintList()
                                    .getDefaultColor(),
                            Matchers.is(nonOmniboxIconTint));
                    Criteria.checkThat(
                            "App menu button tint is incorrect.",
                            ((ImageButton) activity.getToolbarManager().getMenuButtonView())
                                    .getImageTintList()
                                    .getDefaultColor(),
                            Matchers.is(nonOmniboxIconTint));
                    Criteria.checkThat(
                            "Bookmark button tint is incorrect.",
                            toolbarTablet
                                    .getBookmarkButtonForTesting()
                                    .getImageTintList()
                                    .getDefaultColor(),
                            Matchers.is(omniboxIconTint));
                });
    }

    private void triggerDesktopWindowingModeChange(
            ChromeTabbedActivity activity, boolean isInDesktopWindow) {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    var appHeaderStateProvider =
                            activity.getRootUiCoordinatorForTesting()
                                    .getDesktopWindowStateProvider();
                    setupAppHeaderRects(isInDesktopWindow);
                    var appHeaderState =
                            new AppHeaderState(
                                    mWindowRect, mWidestUnoccludedRect, isInDesktopWindow);
                    ((AppHeaderCoordinator) appHeaderStateProvider)
                            .setStateForTesting(isInDesktopWindow, appHeaderState);
                    AppHeaderUtils.setAppInDesktopWindowForTesting(isInDesktopWindow);
                });
    }

    private void setupAppHeaderRects(boolean isInDesktopWindow) {
        // Configure mock InsetsRectProvider.
        var activity = mActivityTestRule.getActivity();
        activity.getWindow().getDecorView().getGlobalVisibleRect(mWindowRect);
        if (isInDesktopWindow) {
            mWidestUnoccludedRect.set(
                    APP_HEADER_LEFT_PADDING,
                    0,
                    mWindowRect.right - APP_HEADER_RIGHT_PADDING,
                    mTestAppHeaderHeight);
        } else {
            mWidestUnoccludedRect.setEmpty();
        }
    }
}