chromium/chrome/android/junit/src/org/chromium/chrome/browser/messages/ChromeMessageQueueMediatorTest.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.messages;

import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.os.Handler;

import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.robolectric.annotation.Config;
import org.robolectric.annotation.LooperMode;

import org.chromium.base.Callback;
import org.chromium.base.UserDataHost;
import org.chromium.base.supplier.ObservableSupplierImpl;
import org.chromium.base.supplier.OneshotSupplierImpl;
import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.base.test.util.CallbackHelper;
import org.chromium.base.test.util.Features.EnableFeatures;
import org.chromium.chrome.browser.ActivityTabProvider;
import org.chromium.chrome.browser.browser_controls.BrowserControlsUtils;
import org.chromium.chrome.browser.browser_controls.BrowserStateBrowserControlsVisibilityDelegate;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.fullscreen.BrowserControlsManager;
import org.chromium.chrome.browser.layouts.LayoutStateProvider;
import org.chromium.chrome.browser.layouts.LayoutStateProvider.LayoutStateObserver;
import org.chromium.chrome.browser.layouts.LayoutType;
import org.chromium.chrome.browser.lifecycle.ActivityLifecycleDispatcher;
import org.chromium.chrome.browser.lifecycle.PauseResumeWithNativeObserver;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetController;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetObserver;
import org.chromium.components.messages.ManagedMessageDispatcher;
import org.chromium.ui.modaldialog.ModalDialogManager;
import org.chromium.ui.modaldialog.ModalDialogManager.ModalDialogManagerObserver;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.util.TokenHolder;

import java.util.concurrent.TimeoutException;

/** Unit tests for {@link ChromeMessageQueueMediator}. */
@RunWith(BaseRobolectricTestRunner.class)
@Config(manifest = Config.NONE)
@LooperMode(LooperMode.Mode.LEGACY)
public class ChromeMessageQueueMediatorTest {
    private static final int EXPECTED_TOKEN = 42;

    @Mock private BrowserControlsManager mBrowserControlsManager;

    @Mock private MessageContainerCoordinator mMessageContainerCoordinator;

    @Mock private LayoutStateProvider mLayoutStateProvider;

    @Mock private ManagedMessageDispatcher mMessageDispatcher;

    @Mock private ModalDialogManager mModalDialogManager;

    @Mock private ActivityTabProvider mActivityTabProvider;

    @Mock private Tab mTab;

    @Mock private ActivityLifecycleDispatcher mActivityLifecycleDispatcher;

    @Mock private BottomSheetController mBottomSheetController;

    @Mock private Handler mQueueHandler;

    private ChromeMessageQueueMediator mMediator;

    @Before
    public void setUp() {
        MockitoAnnotations.initMocks(this);
        when(mMessageDispatcher.suspend()).thenReturn(EXPECTED_TOKEN);
    }

    private void initMediator() {
        OneshotSupplierImpl<LayoutStateProvider> layoutStateProviderOneShotSupplier =
                new OneshotSupplierImpl<>();
        ObservableSupplierImpl<ModalDialogManager> modalDialogManagerSupplier =
                new ObservableSupplierImpl<>();
        mMediator =
                new ChromeMessageQueueMediator(
                        mBrowserControlsManager,
                        mMessageContainerCoordinator,
                        mActivityTabProvider,
                        layoutStateProviderOneShotSupplier,
                        modalDialogManagerSupplier,
                        mBottomSheetController,
                        mActivityLifecycleDispatcher,
                        mMessageDispatcher);
        layoutStateProviderOneShotSupplier.set(mLayoutStateProvider);
        modalDialogManagerSupplier.set(mModalDialogManager);
        mMediator.setQueueHandlerForTesting(mQueueHandler);
    }

    /** Test the queue can be suspended and resumed correctly when toggling layout state change. */
    @Test
    public void testLayoutStateChange() {
        final ArgumentCaptor<LayoutStateObserver> observer =
                ArgumentCaptor.forClass(LayoutStateObserver.class);
        doNothing().when(mLayoutStateProvider).addObserver(observer.capture());
        initMediator();
        observer.getValue().onStartedShowing(LayoutType.TAB_SWITCHER);
        verify(mMessageDispatcher).suspend();
        observer.getValue().onFinishedShowing(LayoutType.BROWSING);
        verify(mMessageDispatcher).resume(EXPECTED_TOKEN);
    }

    /** Test the queue can be suspended and resumed correctly when showing/hiding modal dialogs. */
    @Test
    public void testModalDialogChange() {
        final ArgumentCaptor<ModalDialogManagerObserver> observer =
                ArgumentCaptor.forClass(ModalDialogManagerObserver.class);
        doNothing().when(mModalDialogManager).addObserver(observer.capture());
        initMediator();
        observer.getValue().onDialogAdded(new PropertyModel());
        verify(mMessageDispatcher).suspend();
        observer.getValue().onLastDialogDismissed();
        verify(mMessageDispatcher).resume(EXPECTED_TOKEN);
    }

    /** Test the queue can be suspended and resumed correctly when app is paused and resumed. */
    @Test
    public void testActivityStateChange() {
        final ArgumentCaptor<PauseResumeWithNativeObserver> observer =
                ArgumentCaptor.forClass(PauseResumeWithNativeObserver.class);
        doNothing().when(mActivityLifecycleDispatcher).register(observer.capture());
        initMediator();
        observer.getValue().onPauseWithNative();
        verify(mMessageDispatcher).suspend();
        observer.getValue().onResumeWithNative();
        verify(mMessageDispatcher).resume(EXPECTED_TOKEN);
    }

    /** Test the runnable by #onStartShow is reset correctly. */
    @Test
    @EnableFeatures({ChromeFeatureList.SUPPRESS_TOOLBAR_CAPTURES})
    public void testResetOnStartShowRunnable() {
        when(mBrowserControlsManager.getBrowserControlHiddenRatio()).thenReturn(0.5f);
        OneshotSupplierImpl<LayoutStateProvider> layoutStateProviderOneShotSupplier =
                new OneshotSupplierImpl<>();
        ObservableSupplierImpl<ModalDialogManager> modalDialogManagerSupplier =
                new ObservableSupplierImpl<>();
        final ArgumentCaptor<ChromeMessageQueueMediator.BrowserControlsObserver>
                observerArgumentCaptor =
                        ArgumentCaptor.forClass(
                                ChromeMessageQueueMediator.BrowserControlsObserver.class);
        doNothing().when(mBrowserControlsManager).addObserver(observerArgumentCaptor.capture());
        when(mBrowserControlsManager.getBrowserVisibilityDelegate())
                .thenReturn(
                        new BrowserStateBrowserControlsVisibilityDelegate(
                                new ObservableSupplierImpl<>(false)));
        mMediator =
                new ChromeMessageQueueMediator(
                        mBrowserControlsManager,
                        mMessageContainerCoordinator,
                        mActivityTabProvider,
                        layoutStateProviderOneShotSupplier,
                        modalDialogManagerSupplier,
                        mBottomSheetController,
                        mActivityLifecycleDispatcher,
                        mMessageDispatcher);
        ChromeMessageQueueMediator.BrowserControlsObserver observer =
                observerArgumentCaptor.getValue();
        Assert.assertFalse(mMediator.isReadyForShowing());
        Runnable runnable = () -> {};
        mMediator.onRequestShowing(runnable);
        Assert.assertNotNull(observer.getRunnableForTesting());
        Assert.assertFalse(mMediator.isReadyForShowing());
        Assert.assertTrue(mMediator.isPendingShow());

        mMediator.onFinishHiding();
        Assert.assertNull(
                "Callback should be reset to null after hiding is finished",
                observer.getRunnableForTesting());
        Assert.assertFalse(mMediator.isReadyForShowing());
    }

    /** Test whether #IsReadyForShowing returns correct value. */
    @Test
    @EnableFeatures({ChromeFeatureList.SUPPRESS_TOOLBAR_CAPTURES})
    public void testIsReadyForShowing() {
        final ArgumentCaptor<ChromeMessageQueueMediator.BrowserControlsObserver>
                observerArgumentCaptor =
                        ArgumentCaptor.forClass(
                                ChromeMessageQueueMediator.BrowserControlsObserver.class);
        doNothing().when(mBrowserControlsManager).addObserver(observerArgumentCaptor.capture());
        var visibilitySupplier = new ObservableSupplierImpl<Boolean>();
        visibilitySupplier.set(false);
        var visibilityDelegate =
                new BrowserStateBrowserControlsVisibilityDelegate(visibilitySupplier);
        when(mBrowserControlsManager.getBrowserVisibilityDelegate()).thenReturn(visibilityDelegate);
        initMediator();
        Assert.assertFalse(mMediator.isReadyForShowing());
        visibilitySupplier.set(true);
        when(mBrowserControlsManager.getBrowserControlHiddenRatio()).thenReturn(0f);

        when(mActivityTabProvider.get()).thenReturn(mTab);
        when(mTab.isDestroyed()).thenReturn(false);
        // Mock TabBrowserControlsConstraintsHelper to avoid NPE.
        when(mTab.getUserDataHost()).thenReturn(new UserDataHost());

        mMediator.onRequestShowing(() -> {});
        Assert.assertTrue(mMediator.isReadyForShowing());

        mMediator.onFinishHiding();
        Assert.assertFalse(mMediator.isReadyForShowing());
    }

    /**
     * Test multiple show requests can be made when tab browser controls state changes while browser
     * controls is not fully visible.
     * 1. Initially, tab constraints state is hidden but browser controls is not fully visible yet.
     * 2. A message is allowed to be displayed.
     * 3. Tab constraints is assumed to change from the hidden state while the first message is on
     * the screen.
     * 4. If a second message is enqueued but browser controls is still not ready, it will trigger
     * #onRequestShowing again.
     */
    @Test
    public void testRequestMultipleTimesWhenTabConstraintsChanges() throws TimeoutException {
        final ArgumentCaptor<ChromeMessageQueueMediator.BrowserControlsObserver>
                observerArgumentCaptor =
                        ArgumentCaptor.forClass(
                                ChromeMessageQueueMediator.BrowserControlsObserver.class);
        doNothing().when(mBrowserControlsManager).addObserver(observerArgumentCaptor.capture());
        var visibilitySupplier = new ObservableSupplierImpl<Boolean>();
        visibilitySupplier.set(false);

        // Simulate the browser controls to not be fully visible.
        when(mBrowserControlsManager.getBrowserControlHiddenRatio()).thenReturn(0.5f);
        var visibilityDelegate =
                new BrowserStateBrowserControlsVisibilityDelegate(visibilitySupplier);
        when(mBrowserControlsManager.getBrowserVisibilityDelegate()).thenReturn(visibilityDelegate);
        initMediator();
        var mediator = Mockito.spy(mMediator);

        // #areBrowserControlsReady would return true when the tab browser controls constraint state
        // is hidden.
        doReturn(true).when(mediator).areBrowserControlsReady();
        Assert.assertFalse(mediator.isReadyForShowing());
        CallbackHelper callbackHelper = new CallbackHelper();
        mediator.onRequestShowing(callbackHelper::notifyCalled);
        callbackHelper.waitForOnly();
        ChromeMessageQueueMediator.BrowserControlsObserver observer =
                observerArgumentCaptor.getValue();
        Assert.assertFalse(observer.isRequesting());

        // Real method invocation will return false when the tab browser controls constraint state
        // changes from the hidden state.
        doCallRealMethod().when(mediator).areBrowserControlsReady();
        Assert.assertFalse(
                BrowserControlsUtils.areBrowserControlsFullyVisible(mBrowserControlsManager));
        Assert.assertFalse(mediator.areBrowserControlsReady());
        Assert.assertFalse(mediator.isReadyForShowing());
        mediator.onRequestShowing(() -> {});
        Assert.assertTrue(observer.isRequesting());
    }

    /** Test NPE is not thrown when supplier offers a null value. */
    @Test
    public void testThrowNothingWhenModalDialogManagerIsNull() {
        OneshotSupplierImpl<LayoutStateProvider> layoutStateProviderOneShotSupplier =
                new OneshotSupplierImpl<>();
        ObservableSupplierImpl<ModalDialogManager> modalDialogManagerSupplier =
                new ObservableSupplierImpl<>();
        mMediator =
                new ChromeMessageQueueMediator(
                        mBrowserControlsManager,
                        mMessageContainerCoordinator,
                        mActivityTabProvider,
                        layoutStateProviderOneShotSupplier,
                        modalDialogManagerSupplier,
                        mBottomSheetController,
                        mActivityLifecycleDispatcher,
                        mMessageDispatcher);
        layoutStateProviderOneShotSupplier.set(mLayoutStateProvider);
        // To offer a null value, we have to offer a value other than null first.
        modalDialogManagerSupplier.set(mModalDialogManager);
        modalDialogManagerSupplier.set(null);
    }

    /** Test NPE is not thrown after destroy. */
    @Test
    public void testThrowNothingAfterDestroy() {
        OneshotSupplierImpl<LayoutStateProvider> layoutStateProviderOneShotSupplier =
                new OneshotSupplierImpl<>();
        ObservableSupplierImpl<ModalDialogManager> modalDialogManagerSupplier =
                new ObservableSupplierImpl<>();
        mMediator =
                new ChromeMessageQueueMediator(
                        mBrowserControlsManager,
                        mMessageContainerCoordinator,
                        mActivityTabProvider,
                        layoutStateProviderOneShotSupplier,
                        modalDialogManagerSupplier,
                        mBottomSheetController,
                        mActivityLifecycleDispatcher,
                        mMessageDispatcher);
        layoutStateProviderOneShotSupplier.set(mLayoutStateProvider);
        modalDialogManagerSupplier.set(mModalDialogManager);
        mMediator.onAnimationStart();
        mMediator.onAnimationEnd();
        verify(mMessageContainerCoordinator, times(1)).onAnimationEnd();
        mMediator.destroy();
        mMediator.onAnimationEnd();
        verify(mMessageContainerCoordinator, times(1)).onAnimationEnd();
    }

    /** Test the queue can be suspended and resumed correctly on omnibox focus events. */
    @Test
    public void testUrlFocusChange() {
        initMediator();
        // Omnibox is focused.
        mMediator.onUrlFocusChange(true);
        verify(mMessageDispatcher).suspend();
        verify(mQueueHandler).removeCallbacksAndMessages(null);
        // Omnibox is out of focus.
        mMediator.onUrlFocusChange(false);
        ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class);
        // Verify that the queue is resumed 1s after the omnibox loses focus.
        verify(mQueueHandler).postDelayed(captor.capture(), eq(1000L));
        captor.getValue().run();
        verify(mMessageDispatcher).resume(EXPECTED_TOKEN);
        Assert.assertEquals(
                "mUrlFocusToken should be invalidated.",
                TokenHolder.INVALID_TOKEN,
                mMediator.getUrlFocusTokenForTesting());
    }

    /** Test the queue can be suspended and resumed correctly when bottom sheet is open/closed. */
    @Test
    public void testBottomSheetChange() {
        final ArgumentCaptor<BottomSheetObserver> observerArgumentCaptor =
                ArgumentCaptor.forClass(BottomSheetObserver.class);
        doNothing().when(mBottomSheetController).addObserver(observerArgumentCaptor.capture());
        initMediator();
        var bottomSheetObserver = observerArgumentCaptor.getValue();
        bottomSheetObserver.onSheetOpened(BottomSheetController.StateChangeReason.NONE);
        verify(mMessageDispatcher).suspend();
        bottomSheetObserver.onSheetClosed(BottomSheetController.StateChangeReason.BACK_PRESS);
        verify(mMessageDispatcher).resume(EXPECTED_TOKEN);
    }

    /** Test the queue can be suspended and resumed correctly when tab is un/available. */
    @Test
    public void testNoValidTab() {
        ArgumentCaptor<Callback<Tab>> captor = ArgumentCaptor.forClass(Callback.class);
        initMediator();
        verify(mActivityTabProvider).addObserver(captor.capture());
        captor.getValue().onResult(null);
        verify(mMessageDispatcher).suspend();

        captor.getValue().onResult(mTab);
        verify(mMessageDispatcher).resume(EXPECTED_TOKEN);
    }

    /** Test when tab is destroyed before {@link ChromeMessageQueueMediator#destroy()}. */
    @Test
    public void testTabDestroyed() {
        initMediator();
        when(mActivityTabProvider.get()).thenReturn(mTab);
        when(mTab.isDestroyed()).thenReturn(true);

        // Expect no error.
        mMediator.areBrowserControlsReady();
    }
}