chromium/chrome/browser/ui/android/desktop_windowing/java/src/org/chromium/chrome/browser/ui/desktop_windowing/AppHeaderCoordinatorUnitTest.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.ui.desktop_windowing;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;

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

import android.app.Activity;
import android.graphics.Color;
import android.graphics.Rect;
import android.os.Bundle;
import android.view.View;

import androidx.core.graphics.Insets;
import androidx.core.view.WindowInsetsCompat;
import androidx.test.ext.junit.rules.ActivityScenarioRule;

import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;
import org.robolectric.annotation.Config;
import org.robolectric.annotation.LooperMode;
import org.robolectric.annotation.LooperMode.Mode;

import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.base.test.util.HistogramWatcher;
import org.chromium.chrome.browser.browser_controls.BrowserStateBrowserControlsVisibilityDelegate;
import org.chromium.chrome.browser.lifecycle.ActivityLifecycleDispatcher;
import org.chromium.chrome.browser.ui.desktop_windowing.AppHeaderUtils.DesktopWindowHeuristicResult;
import org.chromium.ui.InsetObserver;
import org.chromium.ui.InsetsRectProvider;
import org.chromium.ui.base.TestActivity;

import java.util.List;

/** Unit test for {@link AppHeaderCoordinator}. */
@RunWith(BaseRobolectricTestRunner.class)
@Config(sdk = 30)
@LooperMode(Mode.PAUSED)
public class AppHeaderCoordinatorUnitTest {
    private static final int WINDOW_WIDTH = 600;
    private static final int WINDOW_HEIGHT = 800;
    private static final Rect WINDOW_RECT = new Rect(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT);
    private static final int LEFT_BLOCK = 10;
    private static final int RIGHT_BLOCK = 20;
    private static final int HEADER_HEIGHT = 30;
    private static final Rect WIDEST_UNOCCLUDED_RECT =
            new Rect(LEFT_BLOCK, 0, WINDOW_WIDTH - RIGHT_BLOCK, HEADER_HEIGHT);

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

    @Rule
    public ActivityScenarioRule<TestActivity> mActivityScenarioRule =
            new ActivityScenarioRule<>(TestActivity.class);

    @Mock private BrowserStateBrowserControlsVisibilityDelegate mBrowserControlsVisDelegate;
    @Mock private InsetObserver mInsetObserver;
    @Mock private InsetsRectProvider mInsetsRectProvider;
    @Mock private ActivityLifecycleDispatcher mActivityLifecycleDispatcher;
    @Mock private DesktopWindowStateProvider.AppHeaderObserver mObserver;
    @Captor private ArgumentCaptor<InsetsRectProvider.Observer> mInsetRectObserverCaptor;

    private AppHeaderCoordinator mAppHeaderCoordinator;
    private Activity mSpyActivity;
    private View mSpyRootView;
    private WindowInsetsCompat mLastSeenRawWindowInsets = new WindowInsetsCompat(null);
    private Bundle mSavedInstanceStateBundle;

    @Before
    public void setup() {
        mActivityScenarioRule.getScenario().onActivity(activity -> mSpyActivity = spy(activity));
        doReturn(true).when(mSpyActivity).isInMultiWindowMode();
        mSpyRootView = spy(mSpyActivity.getWindow().getDecorView());
        AppHeaderCoordinator.setInsetsRectProviderForTesting(mInsetsRectProvider);
        doAnswer(inv -> mLastSeenRawWindowInsets).when(mInsetObserver).getLastRawWindowInsets();
        setupWithNoInsets();
        mSavedInstanceStateBundle = new Bundle();
        initAppHeaderCoordinator();
    }

    @Test
    public void notEnabledWithNoTopInsets() {
        var watcher =
                HistogramWatcher.newSingleRecordWatcher(
                        "Android.DesktopWindowHeuristicResult",
                        DesktopWindowHeuristicResult.CAPTION_BAR_TOP_INSETS_ABSENT);
        // Bottom insets with height = 30
        Insets bottomInsets = Insets.of(0, 0, 0, 30);
        // Left block: 10, right block: 20
        List<Rect> blockedRects =
                List.of(
                        new Rect(0, WINDOW_HEIGHT - 30, LEFT_BLOCK, WINDOW_HEIGHT),
                        new Rect(
                                WINDOW_WIDTH - RIGHT_BLOCK,
                                WINDOW_HEIGHT - 30,
                                LEFT_BLOCK,
                                WINDOW_HEIGHT));
        Rect widestUnOccludedRect =
                new Rect(LEFT_BLOCK, WINDOW_HEIGHT - 30, WINDOW_WIDTH - RIGHT_BLOCK, WINDOW_HEIGHT);
        setupInsetsRectProvider(bottomInsets, blockedRects, widestUnOccludedRect, WINDOW_RECT);
        notifyInsetsRectObserver();

        assertFalse(
                "Desktop Windowing not enabled for bottom insets.",
                mAppHeaderCoordinator.isInDesktopWindow());
        watcher.assertExpected();
    }

    @Test
    public void notEnabledWithBoundingRectsWithPartialHeight() {
        var watcher =
                HistogramWatcher.newSingleRecordWatcher(
                        "Android.DesktopWindowHeuristicResult",
                        DesktopWindowHeuristicResult.CAPTION_BAR_BOUNDING_RECT_INVALID_HEIGHT);
        // Bottom insets with height = 30
        Insets insets = Insets.of(0, 30, 0, 0);
        // Left block: 10, right block: 20. Bounding rect is not at the full height.
        List<Rect> blockedRects =
                List.of(
                        new Rect(0, 0, LEFT_BLOCK, HEADER_HEIGHT - 10),
                        new Rect(WINDOW_WIDTH - RIGHT_BLOCK, 0, WINDOW_WIDTH, HEADER_HEIGHT - 10));
        Rect widestUnoccludedRect = new Rect(0, 20, WINDOW_WIDTH, HEADER_HEIGHT - 10);
        setupInsetsRectProvider(insets, blockedRects, widestUnoccludedRect, WINDOW_RECT);
        notifyInsetsRectObserver();

        assertFalse(
                "Desktop Windowing enabled for widestUnOccludedRect with less height "
                        + " than the insets.",
                mAppHeaderCoordinator.isInDesktopWindow());
        watcher.assertExpected();
    }

    @Test
    public void notEnabledWithLessThanTwoBoundingRects() {
        var watcher =
                HistogramWatcher.newSingleRecordWatcher(
                        "Android.DesktopWindowHeuristicResult",
                        DesktopWindowHeuristicResult.CAPTION_BAR_BOUNDING_RECTS_UNEXPECTED_NUMBER);
        // Top insets with height of 30.
        Insets insets = Insets.of(0, 30, 0, 0);
        // Left block: 10
        List<Rect> blockedRects = List.of(new Rect(0, 0, LEFT_BLOCK, 30));
        Rect widestUnoccludedRect = new Rect(LEFT_BLOCK, 0, WINDOW_WIDTH, 30);
        setupInsetsRectProvider(insets, blockedRects, widestUnoccludedRect, WINDOW_RECT);
        notifyInsetsRectObserver();

        assertFalse(
                "Desktop Windowing enabled with only one bounding rect.",
                mAppHeaderCoordinator.isInDesktopWindow());
        watcher.assertExpected();
    }

    @Test
    public void notEnabledWithMoreThanTwoBoundingRects() {
        var watcher =
                HistogramWatcher.newSingleRecordWatcher(
                        "Android.DesktopWindowHeuristicResult",
                        DesktopWindowHeuristicResult.CAPTION_BAR_BOUNDING_RECTS_UNEXPECTED_NUMBER);
        // Top insets with height of 30.
        Insets insets = Insets.of(0, 30, 0, 0);
        // Left block: 10, two right block: 5, 15
        List<Rect> blockedRects =
                List.of(
                        new Rect(0, 0, LEFT_BLOCK, 30),
                        new Rect(WINDOW_WIDTH - RIGHT_BLOCK, 0, WINDOW_WIDTH - 15, 30),
                        new Rect(WINDOW_WIDTH - 15, 0, WINDOW_WIDTH, 30));
        Rect widestUnoccludedRect = new Rect(LEFT_BLOCK, 0, WINDOW_WIDTH - RIGHT_BLOCK, 30);
        setupInsetsRectProvider(insets, blockedRects, widestUnoccludedRect, WINDOW_RECT);
        notifyInsetsRectObserver();

        assertFalse(
                "Desktop Windowing enabled with more than two bounding rects.",
                mAppHeaderCoordinator.isInDesktopWindow());
        watcher.assertExpected();
    }

    @Test
    public void notEnabledWhenNotInMultiWindowMode() {
        var watcher =
                HistogramWatcher.newSingleRecordWatcher(
                        "Android.DesktopWindowHeuristicResult",
                        DesktopWindowHeuristicResult.NOT_IN_MULTIWINDOW_MODE);
        doReturn(false).when(mSpyActivity).isInMultiWindowMode();
        setupWithLeftAndRightBoundingRect();
        notifyInsetsRectObserver();

        assertFalse(
                "Desktop Windowing does not enable when not in multi window mode.",
                mAppHeaderCoordinator.isInDesktopWindow());
        watcher.assertExpected();
    }

    @Test
    public void notEnabledWhenNavBarBottomInsetsSeen() {
        var watcher =
                HistogramWatcher.newSingleRecordWatcher(
                        "Android.DesktopWindowHeuristicResult",
                        DesktopWindowHeuristicResult.NAV_BAR_BOTTOM_INSETS_PRESENT);
        setupWithLeftAndRightBoundingRect();
        // Override the last seen raw insets so there's a bottom nav bar insets.
        mLastSeenRawWindowInsets =
                new WindowInsetsCompat.Builder()
                        .setInsets(WindowInsetsCompat.Type.navigationBars(), Insets.of(0, 0, 0, 10))
                        .build();
        notifyInsetsRectObserver();

        assertFalse(
                "Desktop Windowing does not enable when there are bottom insets.",
                mAppHeaderCoordinator.isInDesktopWindow());
        watcher.assertExpected();
    }

    @Test
    public void enableDesktopWindowing() {
        var watcher =
                HistogramWatcher.newSingleRecordWatcher(
                        "Android.DesktopWindowHeuristicResult",
                        DesktopWindowHeuristicResult.IN_DESKTOP_WINDOW);
        setupWithLeftAndRightBoundingRect();
        notifyInsetsRectObserver();

        verifyDesktopWindowingEnabled();

        var expectedState = new AppHeaderState(WINDOW_RECT, WIDEST_UNOCCLUDED_RECT, true);
        assertEquals(
                "AppHeaderState is different.",
                expectedState,
                mAppHeaderCoordinator.getAppHeaderState());
        verify(mObserver).onAppHeaderStateChanged(eq(expectedState));
        watcher.assertExpected();
    }

    @Test
    public void desktopWindowHeuristicResultHistogramNotRecordedWithSameValues() {
        var watcher =
                HistogramWatcher.newBuilder()
                        .expectAnyRecordTimes("Android.DesktopWindowHeuristicResult", 1)
                        .build();
        setupWithLeftAndRightBoundingRect();
        // Override the last seen raw insets so there's a bottom nav bar insets.
        mLastSeenRawWindowInsets =
                new WindowInsetsCompat.Builder()
                        .setInsets(WindowInsetsCompat.Type.navigationBars(), Insets.of(0, 0, 0, 10))
                        .build();

        // Simulate multiple rect updates that will trigger the heuristic checks for desktop
        // windowing mode.
        notifyInsetsRectObserver();
        notifyInsetsRectObserver();

        // Histogram should be emitted just once.
        watcher.assertExpected();
    }

    @Test
    public void changeBoundingRects() {
        setupWithLeftAndRightBoundingRect();
        notifyInsetsRectObserver();

        // Assume the window size changed.
        // Top insets with height of 30.
        Insets insets = Insets.of(0, HEADER_HEIGHT, 0, 0);
        int newWindowWidth = 1000;
        Rect windowRect = new Rect(0, 0, newWindowWidth, WINDOW_HEIGHT);
        // Left block: 10, right block: 20
        List<Rect> blockedRects =
                List.of(
                        new Rect(0, 0, LEFT_BLOCK, HEADER_HEIGHT),
                        new Rect(newWindowWidth - RIGHT_BLOCK, 0, newWindowWidth, HEADER_HEIGHT));
        Rect widestUnoccludedRect =
                new Rect(LEFT_BLOCK, 0, newWindowWidth - RIGHT_BLOCK, HEADER_HEIGHT);
        setupInsetsRectProvider(insets, blockedRects, widestUnoccludedRect, windowRect);
        notifyInsetsRectObserver();

        verifyDesktopWindowingEnabled();

        var expectedState = new AppHeaderState(windowRect, widestUnoccludedRect, true);
        assertEquals(
                "AppHeaderState is different.",
                expectedState,
                mAppHeaderCoordinator.getAppHeaderState());
        verify(mObserver).onAppHeaderStateChanged(eq(expectedState));
    }

    @Test
    public void initializeWithDesktopWindowingThenExit() {
        setupWithLeftAndRightBoundingRect();
        initAppHeaderCoordinator();
        verifyDesktopWindowingEnabled();

        var expectedState = new AppHeaderState(WINDOW_RECT, WIDEST_UNOCCLUDED_RECT, true);
        assertEquals(
                "AppHeaderState is different.",
                expectedState,
                mAppHeaderCoordinator.getAppHeaderState());

        setupWithNoInsets();
        notifyInsetsRectObserver();
        assertFalse(
                "DesktopWindowing should exit when no insets is supplied.",
                mAppHeaderCoordinator.isInDesktopWindow());
        verify(mBrowserControlsVisDelegate).releasePersistentShowingToken(anyInt());

        expectedState = new AppHeaderState(WINDOW_RECT, new Rect(), false);
        assertEquals(
                "AppHeaderState is different.",
                expectedState,
                mAppHeaderCoordinator.getAppHeaderState());
        verify(mObserver).onAppHeaderStateChanged(any());
    }

    @Test
    public void testDestroy() {
        mAppHeaderCoordinator.destroy();

        verify(mInsetsRectProvider).destroy();
    }

    @Test
    public void activityLostFocusInDesktopWindow() {
        setupWithLeftAndRightBoundingRect();
        notifyInsetsRectObserver();

        // Assume that the current activity lost focus.
        mAppHeaderCoordinator.onTopResumedActivityChanged(false);

        assertTrue(
                "Window focus state is not correctly set.",
                mAppHeaderCoordinator.isInUnfocusedDesktopWindow());
    }

    @Test
    public void startupInUnfocusedWindow() {
        // Set initial saved instance state value.
        mSavedInstanceStateBundle.putBoolean(INSTANCE_STATE_KEY_IS_APP_IN_UNFOCUSED_DW, true);
        initAppHeaderCoordinator();

        assertTrue(
                "Window focus state is not correctly set.",
                mAppHeaderCoordinator.isInUnfocusedDesktopWindow());
    }

    @Test
    public void saveInstanceStateForUnfocusedWindow() {
        mSavedInstanceStateBundle.putBoolean(INSTANCE_STATE_KEY_IS_APP_IN_UNFOCUSED_DW, false);
        setupWithLeftAndRightBoundingRect();
        notifyInsetsRectObserver();

        // Verify initial value.
        assertFalse(
                "Window focus state is not correctly set.",
                mAppHeaderCoordinator.isInUnfocusedDesktopWindow());

        // Assume that the current activity lost focus.
        mAppHeaderCoordinator.onTopResumedActivityChanged(false);
        // Assume that an activity pause triggers saving the instance state.
        mAppHeaderCoordinator.onSaveInstanceState(mSavedInstanceStateBundle);

        assertTrue(mSavedInstanceStateBundle.getBoolean(INSTANCE_STATE_KEY_IS_APP_IN_UNFOCUSED_DW));
    }

    @Test
    public void updateForegroundColor() {
        var insetController = mSpyRootView.getWindowInsetsController();

        mAppHeaderCoordinator.updateForegroundColor(Color.BLACK);
        assertEquals(
                "Background is dark. Expecting APPEARANCE_LIGHT_CAPTION_BARS not set.",
                0,
                insetController.getSystemBarsAppearance() & (1 << 8));

        mAppHeaderCoordinator.updateForegroundColor(Color.WHITE);
        assertEquals(
                "Background is light. Expecting APPEARANCE_LIGHT_CAPTION_BARS set.",
                (1 << 8),
                insetController.getSystemBarsAppearance() & (1 << 8));
    }

    private void initAppHeaderCoordinator() {
        mAppHeaderCoordinator =
                new AppHeaderCoordinator(
                        mSpyActivity,
                        mSpyRootView,
                        mBrowserControlsVisDelegate,
                        mInsetObserver,
                        mActivityLifecycleDispatcher,
                        mSavedInstanceStateBundle);
        mAppHeaderCoordinator.addObserver(mObserver);
    }

    private void setupWithNoInsets() {
        setupInsetsRectProvider(Insets.NONE, List.of(), new Rect(), WINDOW_RECT);
    }

    private void setupWithLeftAndRightBoundingRect() {
        // Top insets with height of 30.
        Insets insets = Insets.of(0, HEADER_HEIGHT, 0, 0);
        // Left block: 10, right block: 20
        List<Rect> blockedRects =
                List.of(
                        new Rect(0, 0, LEFT_BLOCK, HEADER_HEIGHT),
                        new Rect(WINDOW_WIDTH - RIGHT_BLOCK, 0, WINDOW_WIDTH, HEADER_HEIGHT));
        Rect widestUnoccludedRect =
                new Rect(LEFT_BLOCK, 0, WINDOW_WIDTH - RIGHT_BLOCK, HEADER_HEIGHT);
        setupInsetsRectProvider(insets, blockedRects, widestUnoccludedRect, WINDOW_RECT);
    }

    private void setupInsetsRectProvider(
            Insets insets, List<Rect> blockedRects, Rect widestUnOccludedRect, Rect windowRect) {
        mLastSeenRawWindowInsets =
                new WindowInsetsCompat.Builder()
                        .setInsets(WindowInsetsCompat.Type.captionBar(), insets)
                        .build();

        doReturn(windowRect).when(mInsetsRectProvider).getWindowRect();
        doReturn(widestUnOccludedRect).when(mInsetsRectProvider).getWidestUnoccludedRect();
        doReturn(insets).when(mInsetsRectProvider).getCachedInset();
        doReturn(blockedRects).when(mInsetsRectProvider).getBoundingRects();
    }

    private void notifyInsetsRectObserver() {
        verify(mInsetsRectProvider, atLeastOnce()).addObserver(mInsetRectObserverCaptor.capture());
        mInsetRectObserverCaptor
                .getValue()
                .onBoundingRectsUpdated(mInsetsRectProvider.getWidestUnoccludedRect());
    }

    private void verifyDesktopWindowingEnabled() {
        assertTrue("Desktop windowing not enabled.", mAppHeaderCoordinator.isInDesktopWindow());
        verify(mBrowserControlsVisDelegate, atLeastOnce())
                .showControlsPersistentAndClearOldToken(anyInt());
    }
}