chromium/chrome/android/junit/src/org/chromium/chrome/browser/fullscreen/BrowserControlsManagerUnitTest.java

// Copyright 2020 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.fullscreen;

import static androidx.test.espresso.matcher.ViewMatchers.assertThat;

import static org.hamcrest.Matchers.greaterThan;
import static org.hamcrest.Matchers.lessThan;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.atLeast;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import static org.chromium.ui.test.util.MockitoHelper.doCallback;

import android.app.Activity;
import android.content.res.Resources;
import android.view.View;

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.shadows.ShadowLooper;

import org.chromium.base.ActivityState;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.Callback;
import org.chromium.base.UserDataHost;
import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.base.test.util.Features.EnableFeatures;
import org.chromium.cc.input.BrowserControlsState;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.ActivityTabProvider;
import org.chromium.chrome.browser.browser_controls.BrowserControlsStateProvider;
import org.chromium.chrome.browser.browser_controls.BrowserStateBrowserControlsVisibilityDelegate;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabBrowserControlsOffsetHelper;
import org.chromium.chrome.browser.tab.TabCreationState;
import org.chromium.chrome.browser.tab.TabLaunchType;
import org.chromium.chrome.browser.tab.TabObserver;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.browser.tabmodel.TabModelObserver;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.toolbar.ControlContainer;
import org.chromium.components.embedder_support.view.ContentView;
import org.chromium.ui.util.TokenHolder;

import java.util.Collections;
import java.util.concurrent.TimeUnit;

/** Unit tests for {@link BrowserControlsManager}. */
@RunWith(BaseRobolectricTestRunner.class)
public class BrowserControlsManagerUnitTest {
    @Rule public MockitoRule mockitoRule = MockitoJUnit.rule();

    // Since these tests don't depend on the heights being pixels, we can use these as dpi directly.
    private static final int TOOLBAR_HEIGHT = 56;
    private static final int EXTRA_TOP_CONTROL_HEIGHT = 20;

    @Mock private Activity mActivity;
    @Mock private ControlContainer mControlContainer;
    @Mock private View mContainerView;
    @Mock private TabModelSelector mTabModelSelector;
    @Mock private ActivityTabProvider mActivityTabProvider;
    @Mock private Resources mResources;
    @Mock private BrowserControlsStateProvider.Observer mBrowserControlsStateProviderObserver;
    @Mock private Tab mTab;
    @Mock private ContentView mContentView;
    @Mock private TabModel mTabModel;
    @Mock private TabBrowserControlsOffsetHelper mTabBrowserControlsOffsetHelper;

    private @Captor ArgumentCaptor<Callback<Tab>> mCallbackTabCaptor;
    private @Captor ArgumentCaptor<TabModelObserver> mTabModelObserverCaptor;
    private @Captor ArgumentCaptor<TabObserver> mTabObserverCaptor;

    private UserDataHost mUserDataHost = new UserDataHost();
    private BrowserControlsManager mBrowserControlsManager;
    private BrowserStateBrowserControlsVisibilityDelegate mControlsDelegate;

    @Before
    public void setUp() {
        ApplicationStatus.onStateChangeForTesting(mActivity, ActivityState.CREATED);
        when(mActivity.getResources()).thenReturn(mResources);
        when(mResources.getDimensionPixelSize(R.dimen.control_container_height))
                .thenReturn(TOOLBAR_HEIGHT);
        when(mControlContainer.getView()).thenReturn(mContainerView);

        // Only the last/current visibility matters and is verified by tests.
        doCallback(
                        (Integer visibility) ->
                                when(mContainerView.getVisibility()).thenReturn(visibility))
                .when(mContainerView)
                .setVisibility(anyInt());
        mContainerView.setVisibility(View.VISIBLE);

        when(mTab.isUserInteractable()).thenReturn(true);
        when(mTab.isInitialized()).thenReturn(true);
        when(mTab.getUserDataHost()).thenReturn(mUserDataHost);
        when(mTab.getContentView()).thenReturn(mContentView);
        doNothing().when(mContentView).removeOnHierarchyChangeListener(any());
        doNothing().when(mContentView).removeOnSystemUiVisibilityChangeListener(any());
        doNothing().when(mContentView).addOnHierarchyChangeListener(any());
        doNothing().when(mContentView).addOnSystemUiVisibilityChangeListener(any());
        when(mTabModelSelector.getModels()).thenReturn(Collections.singletonList(mTabModel));
        when(mTabModel.getComprehensiveModel()).thenReturn(mTabModel);

        BrowserControlsManager browserControlsManager =
                new BrowserControlsManager(mActivity, BrowserControlsManager.ControlsPosition.TOP);
        mBrowserControlsManager = spy(browserControlsManager);
        mBrowserControlsManager.initialize(
                mControlContainer,
                mActivityTabProvider,
                mTabModelSelector,
                R.dimen.control_container_height);
        mControlsDelegate = mBrowserControlsManager.getBrowserVisibilityDelegate();
        mBrowserControlsManager.addObserver(mBrowserControlsStateProviderObserver);
        when(mBrowserControlsManager.getTab()).thenReturn(mTab);
    }

    private void remakeWithoutSpy() {
        mBrowserControlsManager.destroy();
        mBrowserControlsManager =
                new BrowserControlsManager(mActivity, BrowserControlsManager.ControlsPosition.TOP);
        mBrowserControlsManager.initialize(
                mControlContainer,
                mActivityTabProvider,
                mTabModelSelector,
                R.dimen.control_container_height);
        mBrowserControlsManager.addObserver(mBrowserControlsStateProviderObserver);
        mControlsDelegate = mBrowserControlsManager.getBrowserVisibilityDelegate();

        doCallback((Runnable runnable) -> runnable.run())
                .when(mContainerView)
                .postOnAnimation(any());

        // TabBrowserControlsOffsetHelper casts to TabImpl which is package private, mock instead.
        mUserDataHost.setUserData(
                TabBrowserControlsOffsetHelper.USER_DATA_KEY, mTabBrowserControlsOffsetHelper);
    }

    private void notifyAddTab(Tab tab) {
        verify(mTabModel, atLeast(1)).addObserver(mTabModelObserverCaptor.capture());
        for (TabModelObserver observer : mTabModelObserverCaptor.getAllValues()) {
            observer.didAddTab(
                    tab,
                    TabLaunchType.FROM_LINK,
                    TabCreationState.LIVE_IN_FOREGROUND,
                    /* markedForSelection= */ false);
        }
    }

    private void notifyCurrentTab(Tab tab) {
        verify(mActivityTabProvider, atLeast(1)).addObserver(mCallbackTabCaptor.capture());
        for (Callback<Tab> observer : mCallbackTabCaptor.getAllValues()) {
            observer.onResult(tab);
        }
    }

    private void notifyContentViewScrollingStateChanged(boolean scrolling) {
        verify(mTab, atLeast(1)).addObserver(mTabObserverCaptor.capture());
        for (TabObserver observer : mTabObserverCaptor.getAllValues()) {
            observer.onContentViewScrollingStateChanged(scrolling);
        }
    }

    private void notifyBrowserControlsOffsetChanged(int topControlsOffsetY) {
        verify(mTab, atLeast(1)).addObserver(mTabObserverCaptor.capture());
        for (TabObserver observer : mTabObserverCaptor.getAllValues()) {
            observer.onBrowserControlsOffsetChanged(
                    mTab,
                    topControlsOffsetY,
                    /* bottomControlsOffsetY= */ 0,
                    /* contentOffsetY= */ 0,
                    /* topControlsMinHeightOffsetY= */ 0,
                    /* bottomControlsMinHeightOffsetY= */ 0);
        }
    }

    @Test
    public void testInitialTopControlsHeight() {
        assertEquals(
                "Wrong initial top controls height.",
                TOOLBAR_HEIGHT,
                mBrowserControlsManager.getTopControlsHeight());
    }

    @Test
    public void testListenersNotifiedOfTopControlsHeightChange() {
        final int topControlsHeight = TOOLBAR_HEIGHT + EXTRA_TOP_CONTROL_HEIGHT;
        final int topControlsMinHeight = EXTRA_TOP_CONTROL_HEIGHT;
        mBrowserControlsManager.setTopControlsHeight(topControlsHeight, topControlsMinHeight);
        verify(mBrowserControlsStateProviderObserver)
                .onTopControlsHeightChanged(topControlsHeight, topControlsMinHeight);
    }

    @Test
    public void testBrowserDrivenHeightIncreaseAnimation() {
        final int topControlsHeight = TOOLBAR_HEIGHT + EXTRA_TOP_CONTROL_HEIGHT;
        final int topControlsMinHeight = EXTRA_TOP_CONTROL_HEIGHT;

        // Simulate that we can't animate native browser controls.
        when(mBrowserControlsManager.getTab()).thenReturn(null);
        mBrowserControlsManager.setAnimateBrowserControlsHeightChanges(true);
        mBrowserControlsManager.setTopControlsHeight(topControlsHeight, topControlsMinHeight);

        assertNotEquals(
                "Min-height offset shouldn't immediately change.",
                topControlsMinHeight,
                mBrowserControlsManager.getTopControlsMinHeightOffset());
        assertNotNull(
                "Animator should be initialized.",
                mBrowserControlsManager.getControlsAnimatorForTesting());

        for (long time = 50;
                time < mBrowserControlsManager.getControlsAnimationDurationMsForTesting();
                time += 50) {
            int previousMinHeightOffset = mBrowserControlsManager.getTopControlsMinHeightOffset();
            int previousContentOffset = mBrowserControlsManager.getContentOffset();
            mBrowserControlsManager.getControlsAnimatorForTesting().setCurrentPlayTime(time);
            assertThat(
                    mBrowserControlsManager.getTopControlsMinHeightOffset(),
                    greaterThan(previousMinHeightOffset));
            assertThat(
                    mBrowserControlsManager.getContentOffset(), greaterThan(previousContentOffset));
        }

        mBrowserControlsManager.getControlsAnimatorForTesting().end();
        assertEquals(
                "Min-height offset should be equal to min-height after animation.",
                mBrowserControlsManager.getTopControlsMinHeightOffset(),
                topControlsMinHeight);
        assertEquals(
                "Content offset should be equal to controls height after animation.",
                mBrowserControlsManager.getContentOffset(),
                topControlsHeight);
        assertNull(mBrowserControlsManager.getControlsAnimatorForTesting());
    }

    @Test
    public void testBrowserDrivenHeightDecreaseAnimation() {
        // Simulate that we can't animate native browser controls.
        when(mBrowserControlsManager.getTab()).thenReturn(null);

        mBrowserControlsManager.setTopControlsHeight(
                TOOLBAR_HEIGHT + EXTRA_TOP_CONTROL_HEIGHT, EXTRA_TOP_CONTROL_HEIGHT);

        mBrowserControlsManager.setAnimateBrowserControlsHeightChanges(true);
        mBrowserControlsManager.setTopControlsHeight(TOOLBAR_HEIGHT, 0);

        assertNotEquals(
                "Min-height offset shouldn't immediately change.",
                0,
                mBrowserControlsManager.getTopControlsMinHeightOffset());
        assertNotNull(
                "Animator should be initialized.",
                mBrowserControlsManager.getControlsAnimatorForTesting());

        for (long time = 50;
                time < mBrowserControlsManager.getControlsAnimationDurationMsForTesting();
                time += 50) {
            int previousMinHeightOffset = mBrowserControlsManager.getTopControlsMinHeightOffset();
            int previousContentOffset = mBrowserControlsManager.getContentOffset();
            mBrowserControlsManager.getControlsAnimatorForTesting().setCurrentPlayTime(time);
            assertThat(
                    mBrowserControlsManager.getTopControlsMinHeightOffset(),
                    lessThan(previousMinHeightOffset));
            assertThat(mBrowserControlsManager.getContentOffset(), lessThan(previousContentOffset));
        }

        mBrowserControlsManager.getControlsAnimatorForTesting().end();
        assertEquals(
                "Min-height offset should be equal to the min-height after animation.",
                mBrowserControlsManager.getTopControlsMinHeight(),
                mBrowserControlsManager.getTopControlsMinHeightOffset());
        assertEquals(
                "Content offset should be equal to controls height after animation.",
                mBrowserControlsManager.getTopControlsHeight(),
                mBrowserControlsManager.getContentOffset());
        assertNull(mBrowserControlsManager.getControlsAnimatorForTesting());
    }

    @Test
    public void testChangeTopHeightWithoutAnimation_Browser() {
        // Simulate that we can't animate native browser controls.
        when(mBrowserControlsManager.getTab()).thenReturn(null);

        // Increase the height.
        mBrowserControlsManager.setTopControlsHeight(
                TOOLBAR_HEIGHT + EXTRA_TOP_CONTROL_HEIGHT, EXTRA_TOP_CONTROL_HEIGHT);

        verify(mBrowserControlsManager).showAndroidControls(false);
        assertEquals(
                "Controls should be fully shown after changing the height.",
                TOOLBAR_HEIGHT + EXTRA_TOP_CONTROL_HEIGHT,
                mBrowserControlsManager.getContentOffset());
        assertEquals(
                "Controls should be fully shown after changing the height.",
                0,
                mBrowserControlsManager.getTopControlOffset());
        assertEquals(
                "Min-height offset should be equal to the min-height after height changes.",
                EXTRA_TOP_CONTROL_HEIGHT,
                mBrowserControlsManager.getTopControlsMinHeightOffset());

        // Decrease the height.
        mBrowserControlsManager.setTopControlsHeight(TOOLBAR_HEIGHT, 0);

        // Controls should be fully shown after changing the height.
        verify(mBrowserControlsManager, times(2)).showAndroidControls(false);
        assertEquals(
                "Controls should be fully shown after changing the height.",
                TOOLBAR_HEIGHT,
                mBrowserControlsManager.getContentOffset());
        assertEquals(
                "Controls should be fully shown after changing the height.",
                0,
                mBrowserControlsManager.getTopControlOffset());
        assertEquals(
                "Min-height offset should be equal to the min-height after height changes.",
                0,
                mBrowserControlsManager.getTopControlsMinHeightOffset());
    }

    @Test
    public void testChangeTopHeightWithoutAnimation_Native() {
        int contentOffset = mBrowserControlsManager.getContentOffset();
        int controlOffset = mBrowserControlsManager.getTopControlOffset();
        int minHeightOffset = mBrowserControlsManager.getTopControlsMinHeightOffset();

        // Increase the height.
        mBrowserControlsManager.setTopControlsHeight(
                TOOLBAR_HEIGHT + EXTRA_TOP_CONTROL_HEIGHT, EXTRA_TOP_CONTROL_HEIGHT);

        // Controls visibility and offsets should be managed by native.
        verify(mBrowserControlsManager, never()).showAndroidControls(anyBoolean());
        assertEquals(
                "Content offset should have the initial value before round-trip to native.",
                contentOffset,
                mBrowserControlsManager.getContentOffset());
        assertEquals(
                "Controls offset should have the initial value before round-trip to native.",
                controlOffset,
                mBrowserControlsManager.getTopControlOffset());
        assertEquals(
                "Min-height offset should have the initial value before round-trip to native.",
                minHeightOffset,
                mBrowserControlsManager.getTopControlsMinHeightOffset());

        verify(mBrowserControlsStateProviderObserver)
                .onTopControlsHeightChanged(
                        TOOLBAR_HEIGHT + EXTRA_TOP_CONTROL_HEIGHT, EXTRA_TOP_CONTROL_HEIGHT);

        contentOffset = TOOLBAR_HEIGHT + EXTRA_TOP_CONTROL_HEIGHT;
        controlOffset = 0;
        minHeightOffset = EXTRA_TOP_CONTROL_HEIGHT;

        // Simulate the offset coming from cc::BrowserControlsOffsetManager.
        mBrowserControlsManager
                .getTabControlsObserverForTesting()
                .onBrowserControlsOffsetChanged(
                        mTab, controlOffset, 0, contentOffset, minHeightOffset, 0);

        // Decrease the height.
        mBrowserControlsManager.setTopControlsHeight(TOOLBAR_HEIGHT, 0);

        // Controls visibility and offsets should be managed by native.
        verify(mBrowserControlsManager, never()).showAndroidControls(anyBoolean());
        assertEquals(
                "Controls should be fully shown after getting the offsets from native.",
                contentOffset,
                mBrowserControlsManager.getContentOffset());
        assertEquals(
                "Controls should be fully shown after getting the offsets from native.",
                controlOffset,
                mBrowserControlsManager.getTopControlOffset());
        assertEquals(
                "Min-height offset should be equal to the min-height"
                        + " after getting the offsets from native.",
                minHeightOffset,
                mBrowserControlsManager.getTopControlsMinHeightOffset());

        verify(mBrowserControlsStateProviderObserver).onTopControlsHeightChanged(TOOLBAR_HEIGHT, 0);
    }

    @Test
    @EnableFeatures(ChromeFeatureList.SUPPRESS_TOOLBAR_CAPTURES)
    public void testShowAndroidControlsObserver() {
        remakeWithoutSpy();

        int token =
                mBrowserControlsManager.hideAndroidControlsAndClearOldToken(
                        TokenHolder.INVALID_TOKEN);
        verify(mContainerView).setVisibility(View.INVISIBLE);
        verify(mBrowserControlsStateProviderObserver)
                .onAndroidControlsVisibilityChanged(View.INVISIBLE);

        mBrowserControlsManager.releaseAndroidControlsHidingToken(token);
        assertEquals(View.VISIBLE, mContainerView.getVisibility());
        verify(mBrowserControlsStateProviderObserver)
                .onAndroidControlsVisibilityChanged(View.VISIBLE);
    }

    @Test
    public void testGetAndroidControlsVisibility() {
        BrowserControlsManager browserControlsManager =
                new BrowserControlsManager(mActivity, BrowserControlsManager.ControlsPosition.TOP);
        assertEquals(View.INVISIBLE, browserControlsManager.getAndroidControlsVisibility());

        browserControlsManager.initialize(
                mControlContainer,
                mActivityTabProvider,
                mTabModelSelector,
                R.dimen.control_container_height);
        assertEquals(View.VISIBLE, browserControlsManager.getAndroidControlsVisibility());

        mContainerView.setVisibility(View.INVISIBLE);
        assertEquals(View.INVISIBLE, browserControlsManager.getAndroidControlsVisibility());
    }

    @Test
    @EnableFeatures(ChromeFeatureList.SUPPRESS_TOOLBAR_CAPTURES)
    public void testScrollingVisibility() {
        remakeWithoutSpy();
        assertEquals(View.VISIBLE, mBrowserControlsManager.getAndroidControlsVisibility());

        // Emit tab event such that we get an active tab observer.
        notifyAddTab(mTab);
        notifyCurrentTab(mTab);

        // Wait for SHOWN otherwise the optimization doesn't take effect.
        ShadowLooper.idleMainLooper(
                BrowserStateBrowserControlsVisibilityDelegate.MINIMUM_SHOW_DURATION_MS,
                TimeUnit.MILLISECONDS);
        assertEquals(BrowserControlsState.BOTH, mControlsDelegate.get().intValue());

        // Hide should be eagerly be reacted to, regardless of scrolling or not.
        notifyContentViewScrollingStateChanged(true);
        int token =
                mBrowserControlsManager.hideAndroidControlsAndClearOldToken(
                        TokenHolder.INVALID_TOKEN);
        assertEquals(View.INVISIBLE, mBrowserControlsManager.getAndroidControlsVisibility());

        // This should not cause visibility updates because we're scrolling.
        mBrowserControlsManager.releaseAndroidControlsHidingToken(token);

        // Now stop scrolling, and the visibility should update.
        notifyContentViewScrollingStateChanged(false);
        assertEquals(View.VISIBLE, mBrowserControlsManager.getAndroidControlsVisibility());

        // Set up the same situation where we're scrolling and have a hidden view that wants to be
        // shown once the scroll is over.
        notifyContentViewScrollingStateChanged(true);
        token =
                mBrowserControlsManager.hideAndroidControlsAndClearOldToken(
                        TokenHolder.INVALID_TOKEN);
        mBrowserControlsManager.releaseAndroidControlsHidingToken(token);
        assertEquals(View.INVISIBLE, mBrowserControlsManager.getAndroidControlsVisibility());

        // But now switch tabs instead. The manager should clear the scrolling signal. Although this
        // is actually the same tab object, nothing is doing an equality check.
        for (Callback<Tab> observer : mCallbackTabCaptor.getAllValues()) {
            observer.onResult(mTab);
        }
        assertEquals(View.VISIBLE, mBrowserControlsManager.getAndroidControlsVisibility());
    }

    @Test
    @EnableFeatures(ChromeFeatureList.SUPPRESS_TOOLBAR_CAPTURES)
    public void testVisibilityOnShownConstraints() {
        remakeWithoutSpy();

        // Emit tab event such that we get an active tab observer.
        notifyAddTab(mTab);
        notifyCurrentTab(mTab);

        // Switching tabs locks the controls, advance time past this.
        assertEquals(BrowserControlsState.SHOWN, mControlsDelegate.get().intValue());
        ShadowLooper.idleMainLooper(
                BrowserStateBrowserControlsVisibilityDelegate.MINIMUM_SHOW_DURATION_MS,
                TimeUnit.MILLISECONDS);
        assertEquals(BrowserControlsState.BOTH, mControlsDelegate.get().intValue());

        // Start scrolling to enable optimizations that delay actions.
        notifyContentViewScrollingStateChanged(true);
        assertEquals(View.VISIBLE, mBrowserControlsManager.getAndroidControlsVisibility());

        // Reduce the size of the controls such that we should hide the java view.
        notifyBrowserControlsOffsetChanged(TOOLBAR_HEIGHT);
        assertEquals(View.INVISIBLE, mBrowserControlsManager.getAndroidControlsVisibility());

        // Now scroll the controls back fully onscreen. Suppression layout optimizations should not
        // restore visibility of the java views eagerly.
        notifyBrowserControlsOffsetChanged(0);
        assertEquals(View.INVISIBLE, mBrowserControlsManager.getAndroidControlsVisibility());

        // However when entering SHOWN state, the optimization should ignore scrolling and
        // immediately restore view visibility.
        mControlsDelegate.set(BrowserControlsState.SHOWN);
        assertEquals(View.VISIBLE, mBrowserControlsManager.getAndroidControlsVisibility());
    }
}