// 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.compositor;
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.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeast;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
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 android.app.Activity;
import android.content.Context;
import android.os.IBinder;
import android.view.ContextThemeWrapper;
import android.view.MotionEvent;
import android.view.View;
import android.view.Window;
import androidx.core.graphics.Insets;
import androidx.core.view.WindowInsetsCompat;
import androidx.test.core.app.ApplicationProvider;
import org.junit.Assert;
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.MockitoAnnotations;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowLooper;
import org.chromium.base.ActivityState;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.UserDataHost;
import org.chromium.base.supplier.ObservableSupplierImpl;
import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.base.test.util.Features.DisableFeatures;
import org.chromium.base.test.util.Features.EnableFeatures;
import org.chromium.base.test.util.HistogramWatcher;
import org.chromium.base.test.util.JniMocker;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.ActivityTabProvider;
import org.chromium.chrome.browser.browser_controls.BrowserControlsUtils;
import org.chromium.chrome.browser.compositor.layouts.LayoutManagerImpl;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.fullscreen.BrowserControlsManager;
import org.chromium.chrome.browser.layouts.EventFilter.EventType;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.tab.MockTab;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabObserver;
import org.chromium.chrome.browser.tab.TabSelectionType;
import org.chromium.chrome.browser.tabmodel.TabModelSelectorTabObserver;
import org.chromium.chrome.browser.toolbar.top.ToolbarControlContainer;
import org.chromium.chrome.test.util.browser.tabmodel.MockTabModelSelector;
import org.chromium.components.browser_ui.widget.TouchEventObserver;
import org.chromium.components.content_capture.ContentCaptureFeatures;
import org.chromium.components.content_capture.ContentCaptureFeaturesJni;
import org.chromium.components.content_capture.OnscreenContentProvider;
import org.chromium.components.content_capture.OnscreenContentProviderJni;
import org.chromium.components.embedder_support.view.ContentView;
import org.chromium.components.prefs.PrefService;
import org.chromium.content_public.browser.WebContents;
import org.chromium.ui.KeyboardVisibilityDelegate;
import org.chromium.ui.base.ApplicationViewportInsetSupplier;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.mojom.VirtualKeyboardMode;
import org.chromium.ui.resources.ResourceManager;
import org.chromium.ui.resources.dynamics.DynamicResourceLoader;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/** Unit tests for {@link CompositorViewHolder}. */
@RunWith(BaseRobolectricTestRunner.class)
@EnableFeatures({ChromeFeatureList.SUPPRESS_TOOLBAR_CAPTURES_AT_GESTURE_END})
@DisableFeatures({
ChromeFeatureList.FULLSCREEN_INSETS_API_MIGRATION,
ChromeFeatureList.FULLSCREEN_INSETS_API_MIGRATION_ON_AUTOMOTIVE
})
public class CompositorViewHolderUnitTest {
// 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 KEYBOARD_HEIGHT = 741;
private static final long TOUCH_TIME = 0;
private static final MotionEvent MOTION_EVENT_DOWN =
MotionEvent.obtain(TOUCH_TIME, TOUCH_TIME, MotionEvent.ACTION_DOWN, 1, 1, 0);
private static final MotionEvent MOTION_EVENT_UP =
MotionEvent.obtain(TOUCH_TIME, TOUCH_TIME, MotionEvent.ACTION_UP, 1, 1, 0);
private static final MotionEvent MOTION_ACTION_HOVER_ENTER =
MotionEvent.obtain(TOUCH_TIME, TOUCH_TIME, MotionEvent.ACTION_HOVER_ENTER, 1, 1, 0);
private static final WindowInsetsCompat VISIBLE_SYSTEM_BARS_WINDOW_INSETS =
new WindowInsetsCompat.Builder()
.setInsets(WindowInsetsCompat.Type.systemBars(), Insets.of(0, 100, 0, 100))
.build();
enum EventSource {
IN_MOTION,
TOUCH_EVENT_OBSERVER;
}
@Rule public JniMocker mJniMocker = new JniMocker();
@Mock private Activity mActivity;
@Mock private Profile mProfile;
@Mock private Profile mIncognitoProfile;
@Mock private ToolbarControlContainer mControlContainer;
@Mock private View mContainerView;
@Mock private ActivityTabProvider mActivityTabProvider;
@Mock private android.content.res.Resources mResources;
@Mock private WebContents mWebContents;
@Mock private ContentView mContentView;
@Mock private CompositorView mCompositorView;
@Mock private ResourceManager mResourceManager;
@Mock private LayoutManagerImpl mLayoutManager;
@Mock private KeyboardVisibilityDelegate mMockKeyboard;
@Mock private WindowAndroid mWindowAndroid;
@Mock private Window mWindow;
@Mock private View mDecorView;
@Mock private DynamicResourceLoader mDynamicResourceLoader;
@Mock private PrefService mPrefService;
@Mock private OnscreenContentProvider.Natives mOnscreenContentProviderJni;
@Mock private ContentCaptureFeatures.Natives mContentCaptureFeaturesJni;
@Captor private ArgumentCaptor<TabObserver> mTabObserverCaptor;
private Context mContext;
private MockTabModelSelector mTabModelSelector;
private Tab mTab;
private CompositorViewHolder mCompositorViewHolder;
private BrowserControlsManager mBrowserControlsManager;
private ApplicationViewportInsetSupplier mViewportInsets;
private ObservableSupplierImpl<Integer> mKeyboardInsetSupplier;
private ObservableSupplierImpl<Integer> mKeyboardAccessoryInsetSupplier;
private final UserDataHost mUserDataHost = new UserDataHost();
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
mJniMocker.mock(OnscreenContentProviderJni.TEST_HOOKS, mOnscreenContentProviderJni);
mJniMocker.mock(ContentCaptureFeaturesJni.TEST_HOOKS, mContentCaptureFeaturesJni);
ApplicationStatus.onStateChangeForTesting(mActivity, ActivityState.CREATED);
// Setup the mock keyboard.
KeyboardVisibilityDelegate.setInstance(mMockKeyboard);
mViewportInsets = ApplicationViewportInsetSupplier.createForTests();
mKeyboardInsetSupplier = new ObservableSupplierImpl<>();
mViewportInsets.setKeyboardInsetSupplier(mKeyboardInsetSupplier);
mKeyboardAccessoryInsetSupplier = new ObservableSupplierImpl<>();
mViewportInsets.setKeyboardAccessoryInsetSupplier(mKeyboardAccessoryInsetSupplier);
when(mIncognitoProfile.isOffTheRecord()).thenReturn(true);
// Setup the TabModelSelector.
mTabModelSelector =
new MockTabModelSelector(
mProfile,
mIncognitoProfile,
0,
0,
(id, incognito) ->
spy(new MockTab(id, incognito ? mIncognitoProfile : mProfile)));
mTab = mTabModelSelector.addMockTab();
mTabModelSelector.getModel(false).setIndex(0, TabSelectionType.FROM_NEW);
// Setup for BrowserControlsManager which initiates content/control offset changes
// for CompositorViewHolder.
when(mActivity.getResources()).thenReturn(mResources);
when(mResources.getDimensionPixelSize(R.dimen.control_container_height))
.thenReturn(TOOLBAR_HEIGHT);
when(mControlContainer.getView()).thenReturn(mContainerView);
when(mTab.isUserInteractable()).thenReturn(true);
BrowserControlsManager browserControlsManager =
new BrowserControlsManager(mActivity, BrowserControlsManager.ControlsPosition.TOP);
mBrowserControlsManager = spy(browserControlsManager);
mBrowserControlsManager.initialize(
mControlContainer,
mActivityTabProvider,
mTabModelSelector,
R.dimen.control_container_height);
when(mBrowserControlsManager.getTab()).thenReturn(mTab);
mContext =
new ContextThemeWrapper(
ApplicationProvider.getApplicationContext(),
R.style.Theme_BrowserUI_DayNight);
when(mCompositorView.getResourceManager()).thenReturn(mResourceManager);
when(mResourceManager.getDynamicResourceLoader()).thenReturn(mDynamicResourceLoader);
mCompositorViewHolder = spy(new CompositorViewHolder(mContext, null));
mCompositorViewHolder.setLayoutManager(mLayoutManager);
mCompositorViewHolder.setControlContainer(mControlContainer);
mCompositorViewHolder.setCompositorViewForTesting(mCompositorView);
mCompositorViewHolder.setBrowserControlsManager(mBrowserControlsManager);
mCompositorViewHolder.setApplicationViewportInsetSupplier(mViewportInsets);
mCompositorViewHolder.onFinishNativeInitialization(mTabModelSelector, null);
when(mCompositorViewHolder.getCurrentTab()).thenReturn(mTab);
when(mCompositorViewHolder.getRootWindowInsets())
.thenReturn(VISIBLE_SYSTEM_BARS_WINDOW_INSETS.toWindowInsets());
when(mTab.getWebContents()).thenReturn(mWebContents);
when(mTab.getContentView()).thenReturn(mContentView);
when(mTab.getView()).thenReturn(mContentView);
when(mTab.getUserDataHost()).thenReturn(mUserDataHost);
when(mActivity.getWindow()).thenReturn(mWindow);
when(mWindow.getDecorView()).thenReturn(mDecorView);
when(mDecorView.getFitsSystemWindows()).thenReturn(true);
IBinder windowToken = mock(IBinder.class);
when(mContainerView.getWindowToken()).thenReturn(windowToken);
when(mContentView.getWindowToken()).thenReturn(windowToken);
}
private List<EventSource> observeTouchAndMotionEvents() {
List<EventSource> eventSequence = new ArrayList<>();
mCompositorViewHolder
.getInMotionSupplier()
.addObserver((inMotion) -> eventSequence.add(EventSource.IN_MOTION));
// This touch observer is used as a proxy for when ViewGroup#dispatchTouchEvent is called,
// which is when the touch is propagated to children.
mCompositorViewHolder.addTouchEventObserver(
new TouchEventObserver() {
@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
return false;
}
@Override
public boolean dispatchTouchEvent(MotionEvent e) {
eventSequence.add(EventSource.TOUCH_EVENT_OBSERVER);
return false;
}
});
return eventSequence;
}
// controlsResizeView tests ---
// For these tests, we will simulate the scrolls assuming we either completely show or hide (or
// scroll until the min-height) the controls and don't leave at in-between positions. The reason
// is that CompositorViewHolder only flips the mControlsResizeView bit if the controls are
// idle, meaning they're at the min-height or fully shown. Making sure the controls snap to
// these two positions is not CVH's responsibility as it's handled in native code by compositor
// or blink.
@Test
public void testControlsResizeViewChanges() {
// Let's use simpler numbers for this test.
final int topHeight = 100;
final int topMinHeight = 0;
TabModelSelectorTabObserver tabControlsObserver =
mBrowserControlsManager.getTabControlsObserverForTesting();
mBrowserControlsManager.setTopControlsHeight(topHeight, topMinHeight);
// Send initial offsets.
tabControlsObserver.onBrowserControlsOffsetChanged(
mTab,
/* topControlsOffsetY= */ 0,
/* bottomControlsOffsetY= */ 0,
/* contentOffsetY= */ 100,
/* topControlsMinHeightOffsetY= */ 0,
/* bottomControlsMinHeightOffsetY= */ 0);
// Initially, the controls should be fully visible.
assertTrue(
"Browser controls aren't fully visible.",
BrowserControlsUtils.areBrowserControlsFullyVisible(mBrowserControlsManager));
// ControlsResizeView is false, but it should be true when the controls are fully visible.
verify(mCompositorView).onControlsResizeViewChanged(any(), eq(true));
reset(mCompositorView);
// Scroll to fully hidden.
tabControlsObserver.onBrowserControlsOffsetChanged(
mTab,
/* topControlsOffsetY= */ -100,
/* bottomControlsOffsetY= */ 0,
/* contentOffsetY= */ 0,
/* topControlsMinHeightOffsetY= */ 0,
/* bottomControlsMinHeightOffsetY= */ 0);
assertTrue(
"Browser controls aren't at min-height.",
mBrowserControlsManager.areBrowserControlsAtMinHeight());
// ControlsResizeView is true, but it should be false when the controls are hidden.
verify(mCompositorView).onControlsResizeViewChanged(any(), eq(false));
reset(mCompositorView);
// Now, scroll back to fully visible.
tabControlsObserver.onBrowserControlsOffsetChanged(
mTab,
/* topControlsOffsetY= */ 0,
/* bottomControlsOffsetY= */ 0,
/* contentOffsetY= */ 100,
/* topControlsMinHeightOffsetY= */ 0,
/* bottomControlsMinHeightOffsetY= */ 0);
assertFalse(
"Browser controls are hidden when they should be fully visible.",
mBrowserControlsManager.areBrowserControlsAtMinHeight());
assertTrue(
"Browser controls aren't fully visible.",
BrowserControlsUtils.areBrowserControlsFullyVisible(mBrowserControlsManager));
// #controlsResizeView should be flipped back to true.
// ControlsResizeView is false, but it should be true when the controls are fully visible.
verify(mCompositorView).onControlsResizeViewChanged(any(), eq(true));
reset(mCompositorView);
}
// Test that a page opted in to view transitions gets an early resize event
// on the controls starting to show.
@Test
@DisableFeatures(ChromeFeatureList.BROWSER_CONTROLS_EARLY_RESIZE)
public void testResizeViewOnWillShowControlsWithViewTransition() {
final int topHeight = 100;
final int topMinHeight = 0;
TabModelSelectorTabObserver tabControlsObserver =
mBrowserControlsManager.getTabControlsObserverForTesting();
mBrowserControlsManager.setTopControlsHeight(topHeight, topMinHeight);
// Send initial offsets.
tabControlsObserver.onBrowserControlsOffsetChanged(
mTab,
/* topControlsOffsetY= */ -topHeight,
/* bottomControlsOffsetY= */ 0,
/* contentOffsetY= */ 0,
/* topControlsMinHeightOffsetY= */ 0,
/* bottomControlsMinHeightOffsetY= */ 0);
// Initially, the controls should be hidden.
assertTrue(
"Browser controls aren't fully hidden.",
BrowserControlsUtils.areBrowserControlsOffScreen(mBrowserControlsManager));
// Simulate the browser issuing a "show browser controls" signal to the renderer.
mCompositorViewHolder.onWillShowBrowserControls(/* viewTransitionOptIn= */ false);
// This should must not cause the controls to start resizing the view yet.
verify(mCompositorView, never()).onControlsResizeViewChanged(any(), anyBoolean());
reset(mCompositorView);
// Do the same but this time with the page having the view transition opt in.
mCompositorViewHolder.onWillShowBrowserControls(/* viewTransitionOptIn= */ true);
// This should cause the controls to start resizing the view.
verify(mCompositorView).onControlsResizeViewChanged(any(), eq(true));
reset(mCompositorView);
}
// Test for the browser controls early resize flagged behavior.
// https://crbug.com/332331777
@Test
@EnableFeatures(ChromeFeatureList.BROWSER_CONTROLS_EARLY_RESIZE)
public void testResizeViewOnWillShowControls() {
final int topHeight = 100;
final int topMinHeight = 0;
TabModelSelectorTabObserver tabControlsObserver =
mBrowserControlsManager.getTabControlsObserverForTesting();
mBrowserControlsManager.setTopControlsHeight(topHeight, topMinHeight);
// Send initial offsets.
tabControlsObserver.onBrowserControlsOffsetChanged(
mTab,
/* topControlsOffsetY= */ -topHeight,
/* bottomControlsOffsetY= */ 0,
/* contentOffsetY= */ 0,
/* topControlsMinHeightOffsetY= */ 0,
/* bottomControlsMinHeightOffsetY= */ 0);
// Initially, the controls should be hidden.
assertTrue(
"Browser controls aren't fully hidden.",
BrowserControlsUtils.areBrowserControlsOffScreen(mBrowserControlsManager));
// Simulate the browser issuing a "show browser controls" signal to the renderer.
mCompositorViewHolder.onWillShowBrowserControls(/* viewTransitionOptIn= */ false);
// This should cause the controls to start resizing the view.
verify(mCompositorView).onControlsResizeViewChanged(any(), eq(true));
reset(mCompositorView);
// Simulating a show-animation partially updating the controls, this shouldn't cause another
// resize.
tabControlsObserver.onBrowserControlsOffsetChanged(
mTab,
/* topControlsOffsetY= */ -topHeight / 2,
/* bottomControlsOffsetY= */ 0,
/* contentOffsetY= */ topHeight / 2,
/* topControlsMinHeightOffsetY= */ 0,
/* bottomControlsMinHeightOffsetY= */ 0);
verify(mCompositorView, never()).onControlsResizeViewChanged(any(), anyBoolean());
reset(mCompositorView);
// The controls finished animating in. Since they already resized the view, this should also
// be a no-op.
tabControlsObserver.onBrowserControlsOffsetChanged(
mTab,
/* topControlsOffsetY= */ 0,
/* bottomControlsOffsetY= */ 0,
/* contentOffsetY= */ topHeight,
/* topControlsMinHeightOffsetY= */ 0,
/* bottomControlsMinHeightOffsetY= */ 0);
verify(mCompositorView, never()).onControlsResizeViewChanged(any(), anyBoolean());
reset(mCompositorView);
// The controls going back to hidden should resize the view as usual.
tabControlsObserver.onBrowserControlsOffsetChanged(
mTab,
/* topControlsOffsetY= */ -topHeight,
/* bottomControlsOffsetY= */ 0,
/* contentOffsetY= */ 0,
/* topControlsMinHeightOffsetY= */ 0,
/* bottomControlsMinHeightOffsetY= */ 0);
verify(mCompositorView).onControlsResizeViewChanged(any(), eq(false));
reset(mCompositorView);
}
// TODO(bokan): Ensure disabling the flag-guard reverts to old behavior.
// https://crbug.com/332331777
@Test
@DisableFeatures(ChromeFeatureList.BROWSER_CONTROLS_EARLY_RESIZE)
public void testResizeViewOnWillShowControlsFlagGuarded() {
final int topHeight = 100;
final int topMinHeight = 0;
TabModelSelectorTabObserver tabControlsObserver =
mBrowserControlsManager.getTabControlsObserverForTesting();
mBrowserControlsManager.setTopControlsHeight(topHeight, topMinHeight);
// Send initial offsets.
tabControlsObserver.onBrowserControlsOffsetChanged(
mTab,
/* topControlsOffsetY= */ -topHeight,
/* bottomControlsOffsetY= */ 0,
/* contentOffsetY= */ 0,
/* topControlsMinHeightOffsetY= */ 0,
/* bottomControlsMinHeightOffsetY= */ 0);
// Initially, the controls should be hidden.
assertTrue(
"Browser controls aren't fully hidden.",
BrowserControlsUtils.areBrowserControlsOffScreen(mBrowserControlsManager));
// Simulate the browser issuing a "show browser controls" signal to the renderer.
mCompositorViewHolder.onWillShowBrowserControls(/* viewTransitionOptIn= */ false);
// This should must not cause the controls to start resizing the view yet.
verify(mCompositorView, never()).onControlsResizeViewChanged(any(), anyBoolean());
reset(mCompositorView);
// The controls finished animating in. Since they already resized the view, this should
// cause the resize the occur.
tabControlsObserver.onBrowserControlsOffsetChanged(
mTab,
/* topControlsOffsetY= */ 0,
/* bottomControlsOffsetY= */ 0,
/* contentOffsetY= */ topHeight,
/* topControlsMinHeightOffsetY= */ 0,
/* bottomControlsMinHeightOffsetY= */ 0);
verify(mCompositorView).onControlsResizeViewChanged(any(), eq(true));
reset(mCompositorView);
}
@Test
@EnableFeatures({
ChromeFeatureList.FULLSCREEN_INSETS_API_MIGRATION,
ChromeFeatureList.FULLSCREEN_INSETS_API_MIGRATION_ON_AUTOMOTIVE
})
public void testHandleSystemUiVisibilityChangesWithUpdatedFullscreenApis() {
when(mWindowAndroid.getActivity()).thenReturn(new WeakReference<>(mActivity));
mCompositorViewHolder.onNativeLibraryReady(mWindowAndroid, null, null);
mCompositorViewHolder.handleSystemUiVisibilityChange();
}
@Test
public void testControlsResizeViewChangesWithMinHeight() {
// Let's use simpler numbers for this test. We'll simulate the scrolling logic in the
// compositor. Which means the top and bottom controls will have the same normalized ratio.
// E.g. if the top content offset is 25 (at min-height so the normalized ratio is 0), the
// bottom content offset will be 0 (min-height-0 + normalized-ratio-0 * rest-of-height-60).
final int topHeight = 100;
final int topMinHeight = 25;
final int bottomHeight = 60;
final int bottomMinHeight = 0;
TabModelSelectorTabObserver tabControlsObserver =
mBrowserControlsManager.getTabControlsObserverForTesting();
mBrowserControlsManager.setTopControlsHeight(topHeight, topMinHeight);
mBrowserControlsManager.setBottomControlsHeight(bottomHeight, bottomMinHeight);
// Send initial offsets.
tabControlsObserver.onBrowserControlsOffsetChanged(
mTab,
/* topControlsOffsetY= */ 0,
/* bottomControlsOffsetY= */ 0,
/* contentOffsetY= */ 100,
/* topControlsMinHeightOffsetY= */ 25,
/* bottomControlsMinHeightOffsetY= */ 0);
// Initially, the controls should be fully visible.
assertTrue(
"Browser controls aren't fully visible.",
BrowserControlsUtils.areBrowserControlsFullyVisible(mBrowserControlsManager));
// ControlsResizeView is false, but it should be true when the controls are fully visible.
verify(mCompositorView).onControlsResizeViewChanged(any(), eq(true));
reset(mCompositorView);
// Scroll all the way to the min-height.
tabControlsObserver.onBrowserControlsOffsetChanged(
mTab,
/* topControlsOffsetY= */ -75,
/* bottomControlsOffsetY= */ 60,
/* contentOffsetY= */ 25,
/* topControlsMinHeightOffsetY= */ 25,
/* bottomControlsMinHeightOffsetY= */ 0);
assertTrue(
"Browser controls aren't at min-height.",
mBrowserControlsManager.areBrowserControlsAtMinHeight());
// ControlsResizeView is true but it should be false when the controls are at min-height.
verify(mCompositorView).onControlsResizeViewChanged(any(), eq(false));
reset(mCompositorView);
// Now, scroll back to fully visible.
tabControlsObserver.onBrowserControlsOffsetChanged(
mTab,
/* topControlsOffsetY= */ 0,
/* bottomControlsOffsetY= */ 0,
/* contentOffsetY= */ 100,
/* topControlsMinHeightOffsetY= */ 25,
/* bottomControlsMinHeightOffsetY= */ 0);
assertFalse(
"Browser controls are at min-height when they should be fully visible.",
mBrowserControlsManager.areBrowserControlsAtMinHeight());
assertTrue(
"Browser controls aren't fully visible.",
BrowserControlsUtils.areBrowserControlsFullyVisible(mBrowserControlsManager));
// #controlsResizeView should be flipped back to true.
verify(mCompositorView).onControlsResizeViewChanged(any(), eq(true));
reset(mCompositorView);
}
@Test
public void testControlsResizeViewWhenControlsAreNotIdle() {
// Let's use simpler numbers for this test. We'll simulate the scrolling logic in the
// compositor. Which means the top and bottom controls will have the same normalized ratio.
// E.g. if the top content offset is 25 (at min-height so the normalized ratio is 0), the
// bottom content offset will be 0 (min-height-0 + normalized-ratio-0 * rest-of-height-60).
final int topHeight = 100;
final int topMinHeight = 25;
final int bottomHeight = 60;
final int bottomMinHeight = 0;
TabModelSelectorTabObserver tabControlsObserver =
mBrowserControlsManager.getTabControlsObserverForTesting();
mBrowserControlsManager.setTopControlsHeight(topHeight, topMinHeight);
mBrowserControlsManager.setBottomControlsHeight(bottomHeight, bottomMinHeight);
// Send initial offsets.
tabControlsObserver.onBrowserControlsOffsetChanged(
mTab,
/* topControlsOffsetY= */ 0,
/* bottomControlsOffsetY= */ 0,
/* contentOffsetY= */ 100,
/* topControlsMinHeightOffsetY= */ 25,
/* bottomControlsMinHeightOffsetY= */ 0);
// ControlsResizeView is false but it should be true when the controls are fully visible.
verify(mCompositorView).onControlsResizeViewChanged(any(), eq(true));
reset(mCompositorView);
// Scroll a little hide the controls partially.
tabControlsObserver.onBrowserControlsOffsetChanged(
mTab,
/* topControlsOffsetY= */ -25,
/* bottomControlsOffsetY= */ 20,
/* contentOffsetY= */ 75,
/* topControlsMinHeightOffsetY= */ 25,
/* bottomControlsMinHeightOffsetY= */ 0);
// ControlsResizeView is false, but it should still be true. No-op updates won't trigger a
// changed event.
verify(mCompositorView, times(0)).onControlsResizeViewChanged(any(), eq(true));
reset(mCompositorView);
// Scroll controls all the way to the min-height.
tabControlsObserver.onBrowserControlsOffsetChanged(
mTab,
/* topControlsOffsetY= */ -75,
/* bottomControlsOffsetY= */ 60,
/* contentOffsetY= */ 25,
/* topControlsMinHeightOffsetY= */ 25,
/* bottomControlsMinHeightOffsetY= */ 0);
// ControlsResizeView is true but it should've flipped to false since the controls are idle
// now.
verify(mCompositorView).onControlsResizeViewChanged(any(), eq(false));
reset(mCompositorView);
// Scroll controls to show a little more.
tabControlsObserver.onBrowserControlsOffsetChanged(
mTab,
/* topControlsOffsetY= */ -50,
/* bottomControlsOffsetY= */ 40,
/* contentOffsetY= */ 50,
/* topControlsMinHeightOffsetY= */ 25,
/* bottomControlsMinHeightOffsetY= */ 0);
// ControlsResizeView is true, but it should still be false. No-op updates won't trigger a
// changed event.
verify(mCompositorView, times(0)).onControlsResizeViewChanged(any(), eq(false));
}
// --- controlsResizeView tests
// Keyboard resize tests for geometrychange event fired to JS.
@Test
public void testWebContentResizeTriggeredDueToKeyboardShow() {
mCompositorViewHolder.updateVirtualKeyboardMode(VirtualKeyboardMode.OVERLAYS_CONTENT);
reset(mWebContents);
// Viewport dimensions when keyboard is hidden.
int fullViewportHeight = 941;
int fullViewportWidth = 1080;
// adjustedHeight is the height of the CompositorViewHolder from Android View layout
// after showing the keyboard. This simulates a reduced layout height from the keyboard
// taking up the bottom space.
int adjustedHeight = fullViewportHeight - KEYBOARD_HEIGHT;
when(mMockKeyboard.isKeyboardShowing(any(), any())).thenReturn(true);
when(mMockKeyboard.calculateTotalKeyboardHeight(any())).thenReturn(KEYBOARD_HEIGHT);
when(mCompositorViewHolder.getWidth()).thenReturn(fullViewportWidth);
when(mCompositorViewHolder.getHeight()).thenReturn(adjustedHeight);
mKeyboardInsetSupplier.set(KEYBOARD_HEIGHT);
mCompositorViewHolder.updateWebContentsSize(mTab);
// Expect fullViewportHeight since in OVERLAYS_CONTENT the keyboard doesn't cause a resize
// to the WebContents.
verify(mWebContents, times(1)).setSize(fullViewportWidth, fullViewportHeight);
verify(mCompositorViewHolder, times(1))
.notifyVirtualKeyboardOverlayRect(
mWebContents, 0, 0, fullViewportWidth, KEYBOARD_HEIGHT);
reset(mWebContents);
// Hide the keyboard.
when(mMockKeyboard.isKeyboardShowing(any(), any())).thenReturn(false);
when(mMockKeyboard.calculateTotalKeyboardHeight(any())).thenReturn(0);
when(mCompositorViewHolder.getWidth()).thenReturn(fullViewportWidth);
when(mCompositorViewHolder.getHeight()).thenReturn(fullViewportHeight);
mKeyboardInsetSupplier.set(0);
mCompositorViewHolder.updateWebContentsSize(mTab);
verify(mWebContents, times(1)).setSize(fullViewportWidth, fullViewportHeight);
verify(mCompositorViewHolder, times(1))
.notifyVirtualKeyboardOverlayRect(mWebContents, 0, 0, 0, 0);
}
@Test
public void testOverlayGeometryNotTriggeredDueToNoKeyboard() {
mCompositorViewHolder.updateVirtualKeyboardMode(VirtualKeyboardMode.OVERLAYS_CONTENT);
reset(mWebContents);
int viewportHeight = 941;
int viewportWidth = 1080;
// Simulate the keyboard being hidden
when(mMockKeyboard.isKeyboardShowing(any(), any())).thenReturn(false);
when(mMockKeyboard.calculateTotalKeyboardHeight(any())).thenReturn(0);
when(mCompositorViewHolder.getWidth()).thenReturn(viewportWidth);
when(mCompositorViewHolder.getHeight()).thenReturn(viewportHeight);
mKeyboardInsetSupplier.set(0);
// Ensure updating the WebContents size doesn't dispatch a keyboard geometry event to
// web content. The updateWebContentsSize call simulates the Views layout that happens as a
// result of the keyboard showing, which happens after the inset is set.
mCompositorViewHolder.updateWebContentsSize(mTab);
verify(mWebContents, times(1)).setSize(viewportWidth, viewportHeight);
verify(mCompositorViewHolder, times(0))
.notifyVirtualKeyboardOverlayRect(mWebContents, 0, 0, 0, 0);
}
@Test
public void testWebContentResizeWhenInOSKResizesVisualMode() {
mCompositorViewHolder.updateVirtualKeyboardMode(VirtualKeyboardMode.RESIZES_VISUAL);
reset(mWebContents);
// Viewport dimensions while keyboard is hidden.
int fullViewportHeight = 941;
int fullViewportWidth = 1080;
// adjustedHeight is height of the CompositorViewHolder from Android View layout. This
// simulates a reduced layout height from the keyboard taking up the bottom space.
int adjustedHeight = fullViewportHeight - KEYBOARD_HEIGHT;
when(mMockKeyboard.isKeyboardShowing(any(), any())).thenReturn(true);
when(mMockKeyboard.calculateTotalKeyboardHeight(any())).thenReturn(KEYBOARD_HEIGHT);
mKeyboardInsetSupplier.set(KEYBOARD_HEIGHT);
when(mCompositorViewHolder.getWidth()).thenReturn(fullViewportWidth);
when(mCompositorViewHolder.getHeight()).thenReturn(adjustedHeight);
mCompositorViewHolder.updateWebContentsSize(mTab);
// In RESIZES_VISUAL mode, CompositorViewHolder ensures that size changes from the virtual
// keyboard don't affect the WebContents' size.
verify(mWebContents, times(1)).setSize(fullViewportWidth, fullViewportHeight);
verify(mCompositorViewHolder, times(0))
.notifyVirtualKeyboardOverlayRect(mWebContents, 0, 0, 0, 0);
}
@Test
public void testWebContentResizeWhenInOSKResizesContentMode() {
mCompositorViewHolder.updateVirtualKeyboardMode(VirtualKeyboardMode.RESIZES_CONTENT);
reset(mWebContents);
// Viewport dimensions while keyboard is hidden.
int fullViewportHeight = 941;
int fullViewportWidth = 1080;
// adjustedHeight is height of the CompositorViewHolder from Android View layout. This
// simulates a reduced layout height from the keyboard taking up the bottom space.
int adjustedHeight = fullViewportHeight - KEYBOARD_HEIGHT;
when(mMockKeyboard.isKeyboardShowing(any(), any())).thenReturn(true);
when(mMockKeyboard.calculateTotalKeyboardHeight(any())).thenReturn(KEYBOARD_HEIGHT);
when(mCompositorViewHolder.getWidth()).thenReturn(fullViewportWidth);
when(mCompositorViewHolder.getHeight()).thenReturn(adjustedHeight);
mKeyboardInsetSupplier.set(KEYBOARD_HEIGHT);
// In RESIZES_CONTENT mode, CompositorViewHolder resizes the WebContents by the keyboard
// height.
verify(mWebContents, times(1)).setSize(fullViewportWidth, adjustedHeight - TOOLBAR_HEIGHT);
verify(mCompositorViewHolder, times(0))
.notifyVirtualKeyboardOverlayRect(mWebContents, 0, 0, 0, 0);
}
@Test
public void testWebContentResizeByBottomSheetInset() {
var bottomSheetInsetSupplier = new ObservableSupplierImpl<Integer>();
mViewportInsets.setBottomSheetInsetSupplier(bottomSheetInsetSupplier);
reset(mWebContents);
int fullViewportHeight = 941;
int fullViewportWidth = 1080;
int bottomSheetOffset = 420;
when(mCompositorViewHolder.getWidth()).thenReturn(fullViewportWidth);
when(mCompositorViewHolder.getHeight()).thenReturn(fullViewportHeight);
bottomSheetInsetSupplier.set(bottomSheetOffset);
// adjustedHeight is height of the CompositorViewHolder from Android View layout. This
// simulates a reduced layout height from bottom sheet taking up the space at the bottom.
int adjustedHeight = fullViewportHeight - bottomSheetOffset;
verify(mWebContents, times(1)).setSize(fullViewportWidth, adjustedHeight - TOOLBAR_HEIGHT);
}
@Test
public void testOverlayGeometryWhenViewNotAttachedToWindow() {
mCompositorViewHolder.updateVirtualKeyboardMode(VirtualKeyboardMode.OVERLAYS_CONTENT);
reset(mWebContents);
when(mContentView.getWindowToken()).thenReturn(null);
// Viewport dimensions while keyboard is hidden.
int fullViewportHeight = 941;
int fullViewportWidth = 1080;
// adjustedHeight is height of the CompositorViewHolder from Android View layout. This
// simulates a reduced layout height from the keyboard taking up the bottom space.
int adjustedHeight = fullViewportHeight - KEYBOARD_HEIGHT;
when(mMockKeyboard.isKeyboardShowing(any(), any())).thenReturn(true);
when(mMockKeyboard.calculateTotalKeyboardHeight(any())).thenReturn(KEYBOARD_HEIGHT);
mKeyboardInsetSupplier.set(KEYBOARD_HEIGHT);
when(mCompositorViewHolder.getWidth()).thenReturn(fullViewportWidth);
when(mCompositorViewHolder.getHeight()).thenReturn(adjustedHeight);
// Ensure updateWebContentsSize in OVERLAYS_CONTENT mode doesn't send keyboard geometry
// events to content if the view is detached.
mCompositorViewHolder.updateWebContentsSize(mTab);
verify(mCompositorViewHolder, times(0))
.notifyVirtualKeyboardOverlayRect(mWebContents, 0, 0, 0, 0);
}
@Test
public void testAccessoryInsetsResizeWebContents() {
int viewportHeight = 800;
int viewportWidth = 300;
int accessoryHeight = 500;
when(mCompositorViewHolder.getWidth()).thenReturn(viewportWidth);
when(mCompositorViewHolder.getHeight()).thenReturn(viewportHeight);
// This is only relevant for RESIZES_CONTENT mode since in RESIZES_VISUAL or
// OVERLAYS_CONTENT the WebContents does not need to be resized by keyboard-related insets.
mCompositorViewHolder.updateVirtualKeyboardMode(VirtualKeyboardMode.RESIZES_CONTENT);
// Updating the VirtualKeyboardMode will update the viewport size. The test is setup so the
// browser controls are showing so they'll be subtracted from the viewport height.
verify(mWebContents, times(1)).setSize(viewportWidth, viewportHeight - TOOLBAR_HEIGHT);
reset(mWebContents);
// Simulate showing a keyboard accessory of some kind. This should cause the WebContents to
// be resized without any other action.
mKeyboardAccessoryInsetSupplier.set(accessoryHeight);
verify(mWebContents, times(1))
.setSize(viewportWidth, viewportHeight - accessoryHeight - TOOLBAR_HEIGHT);
}
@Test
@DisableFeatures(ChromeFeatureList.SUPPRESS_TOOLBAR_CAPTURES_AT_GESTURE_END)
public void testInMotionSupplier() {
mCompositorViewHolder.dispatchTouchEvent(MOTION_EVENT_DOWN);
mCompositorViewHolder.onInterceptTouchEvent(MOTION_EVENT_DOWN);
Assert.assertTrue(mCompositorViewHolder.getInMotionSupplier().get());
mCompositorViewHolder.dispatchTouchEvent(MOTION_EVENT_UP);
mCompositorViewHolder.onInterceptTouchEvent(MOTION_EVENT_UP);
Assert.assertFalse(mCompositorViewHolder.getInMotionSupplier().get());
mCompositorViewHolder.dispatchTouchEvent(MOTION_EVENT_DOWN);
mCompositorViewHolder.onInterceptTouchEvent(MOTION_EVENT_DOWN);
Assert.assertTrue(mCompositorViewHolder.getInMotionSupplier().get());
// Simulate a child handling a scroll, where they call requestDisallowInterceptTouchEvent
// and then we no longer get onInterceptTouchEvent. The dispatchTouchEvent alone should
// still cause our motion status to correctly update.
mCompositorViewHolder.requestDisallowInterceptTouchEvent(true);
mCompositorViewHolder.dispatchTouchEvent(MOTION_EVENT_UP);
Assert.assertFalse(mCompositorViewHolder.getInMotionSupplier().get());
}
@Test
public void testGestureBeginEndInMotionSupplier() {
when(mWindowAndroid.getActivity()).thenReturn(new WeakReference<>(mActivity));
mCompositorViewHolder.onNativeLibraryReady(
mWindowAndroid, /* tabContentManager= */ null, mPrefService);
mCompositorViewHolder.onContentChanged();
verify(mTab, atLeast(1)).addObserver(mTabObserverCaptor.capture());
mTabObserverCaptor.getAllValues().forEach((obs) -> obs.onGestureBegin());
Assert.assertTrue(mCompositorViewHolder.getInMotionSupplier().get());
mTabObserverCaptor.getAllValues().forEach((obs) -> obs.onGestureEnd());
Assert.assertFalse(mCompositorViewHolder.getInMotionSupplier().get());
}
@Test
public void testOnInterceptHoverEvent() {
when(mMockKeyboard.isKeyboardShowing(any(), any())).thenReturn(false);
when(mLayoutManager.onInterceptMotionEvent(
MOTION_ACTION_HOVER_ENTER, false, EventType.HOVER))
.thenReturn(true);
boolean intercepted =
mCompositorViewHolder.onInterceptHoverEvent(MOTION_ACTION_HOVER_ENTER);
verify(mLayoutManager)
.onInterceptMotionEvent(MOTION_ACTION_HOVER_ENTER, false, EventType.HOVER);
Assert.assertTrue(
"#onInterceptHoverEvent should return true if the LayoutManager intercepts the"
+ " event.",
intercepted);
}
@Test
public void testOnHoverEvent() {
when(mLayoutManager.onHoverEvent(MOTION_ACTION_HOVER_ENTER)).thenReturn(true);
boolean consumed = mCompositorViewHolder.onHoverEvent(MOTION_ACTION_HOVER_ENTER);
verify(mLayoutManager).onHoverEvent(MOTION_ACTION_HOVER_ENTER);
Assert.assertTrue(
"#onHoverEvent should return true if the LayoutManager consumes the event.",
consumed);
}
@Test
public void testInMotionOrdering() {
// With the 'defer in motion' experiment enabled, touch events are routed to android UI
// after being sent to native/web content.
List<EventSource> eventSequence = observeTouchAndMotionEvents();
mCompositorViewHolder.dispatchTouchEvent(MOTION_EVENT_DOWN);
assertEquals(
Arrays.asList(EventSource.TOUCH_EVENT_OBSERVER, EventSource.IN_MOTION),
eventSequence);
}
@Test
@Config(qualifiers = "sw600dp")
@DisableFeatures(ChromeFeatureList.DELAY_TEMP_STRIP_REMOVAL)
public void testSetBackgroundRunnable_NoDelay() {
int pendingFrameCount = 0;
int framesUntilHideBackground = 1;
boolean swappedCurrentSize = true;
HistogramWatcher histogramWatcher =
HistogramWatcher.newSingleRecordWatcher(
"Android.TabStrip.TimeToInitializeTabStateAfterBufferSwap");
// Mark that a frame has swapped, and the buffer has swapped once (still waiting on one).
mCompositorViewHolder.didSwapFrame(pendingFrameCount);
mCompositorViewHolder.didSwapBuffers(swappedCurrentSize, framesUntilHideBackground);
verifyBackgroundNotRemoved();
// Mark that the buffer has swapped a second time (and we're no longer waiting on one).
framesUntilHideBackground = 0;
mCompositorViewHolder.didSwapBuffers(swappedCurrentSize, framesUntilHideBackground);
verifyBackgroundRemoved();
// Verify the relevant histogram is recorded.
mTabModelSelector.markTabStateInitialized();
histogramWatcher.assertExpected(
"Should have recorded time to initialize tab state after buffer swap.");
}
@Test
@Config(qualifiers = "sw600dp")
@EnableFeatures(ChromeFeatureList.DELAY_TEMP_STRIP_REMOVAL)
public void testSetBackgroundRunnable_Delay_TabStateInitialized() {
int pendingFrameCount = 0;
int framesUntilHideBackground = 0;
boolean swappedCurrentSize = true;
HistogramWatcher histogramWatcher =
HistogramWatcher.newSingleRecordWatcher(
"Android.TabStrip.TimeToInitializeTabStateAfterBufferSwap");
// Mark a tab has restored, a frame has swapped, and the buffer has swapped enough times.
notifyTabRestored();
mCompositorViewHolder.didSwapFrame(pendingFrameCount);
mCompositorViewHolder.didSwapBuffers(swappedCurrentSize, framesUntilHideBackground);
verifyBackgroundNotRemoved();
// Mark the tab state as initialized and verify that the temp background is now removed.
mTabModelSelector.markTabStateInitialized();
verifyBackgroundRemoved();
// Verify the relevant histogram is recorded.
histogramWatcher.assertExpected(
"Should have recorded time to initialize tab state after buffer swap.");
}
@Test
@Config(qualifiers = "sw600dp")
@EnableFeatures(ChromeFeatureList.DELAY_TEMP_STRIP_REMOVAL)
public void testSetBackgroundRunnable_Delay_TimedOut() {
int pendingFrameCount = 0;
int framesUntilHideBackground = 0;
boolean swappedCurrentSize = true;
HistogramWatcher histogramWatcher =
HistogramWatcher.newSingleRecordWatcher(
"Android.TabStrip.TimeToInitializeTabStateAfterBufferSwap");
// Mark a tab has restored, a frame has swapped, and the buffer has swapped enough times.
notifyTabRestored();
mCompositorViewHolder.didSwapFrame(pendingFrameCount);
mCompositorViewHolder.didSwapBuffers(swappedCurrentSize, framesUntilHideBackground);
verifyBackgroundNotRemoved();
// Fake the timeout and verify that the temp background is now removed.
timeoutRunnable();
verifyBackgroundRemoved();
// Verify the relevant histogram is recorded.
mTabModelSelector.markTabStateInitialized();
histogramWatcher.assertExpected(
"Should have recorded time to initialize tab state after buffer swap.");
}
@Test
@Config(qualifiers = "sw600dp")
@EnableFeatures(ChromeFeatureList.DELAY_TEMP_STRIP_REMOVAL)
public void testSetBackgroundRunnable_Delay_CompositorNotReady() {
int pendingFrameCount = 0;
int framesUntilHideBackground = 1;
boolean swappedCurrentSize = true;
HistogramWatcher histogramWatcher =
HistogramWatcher.newSingleRecordWatcher(
"Android.TabStrip.TimeToBufferSwapAfterInitializeTabState");
// Mark the tab state as initialized and one frame has been swapped.
notifyTabRestored();
mTabModelSelector.markTabStateInitialized();
mCompositorViewHolder.didSwapFrame(pendingFrameCount);
mCompositorViewHolder.didSwapBuffers(swappedCurrentSize, framesUntilHideBackground);
timeoutRunnable();
verifyBackgroundNotRemoved();
// Mark the buffer has swapped enough times and verify the temp background is now removed.
framesUntilHideBackground = 0;
mCompositorViewHolder.didSwapBuffers(swappedCurrentSize, framesUntilHideBackground);
verifyBackgroundRemoved();
// Verify the relevant histogram is recorded.
histogramWatcher.assertExpected(
"Should have recorded time to buffer swap after initializing tab state.");
}
private void notifyTabRestored() {
// To avoid some complexities, we don't actually add a tab to the MockTabModel(Selector) and
// instead use the method called whenever the CompositorViewHolder is notified of a new tab.
mCompositorViewHolder.maybeInitializeSetBackgroundRunnableTimeout();
}
private static void runCurrentTasks() {
ShadowLooper.runUiThreadTasks();
}
private static void timeoutRunnable() {
// The timeout is implemented as a delayed task.
ShadowLooper.runUiThreadTasksIncludingDelayedTasks();
}
private void verifyBackgroundNotRemoved() {
runCurrentTasks();
verify(mCompositorView, never()).setBackgroundResource(anyInt());
}
private void verifyBackgroundRemoved() {
runCurrentTasks();
verify(mCompositorView, times(1)).setBackgroundResource(anyInt());
}
}