chromium/components/browser_ui/widget/android/java/src/org/chromium/components/browser_ui/widget/ContextMenuDialogUnitTest.java

// Copyright 2021 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.components.browser_ui.widget;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;

import android.app.ActionBar.LayoutParams;
import android.app.Activity;
import android.content.Context;
import android.graphics.Rect;
import android.view.DragEvent;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.FrameLayout;
import android.widget.PopupWindow;
import android.widget.TextView;

import org.junit.After;
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.Mock;
import org.mockito.Mockito;
import org.mockito.Spy;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;
import org.robolectric.Robolectric;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.annotation.LooperMode;
import org.robolectric.shadows.ShadowPhoneWindow;

import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.ui.accessibility.AccessibilityState;
import org.chromium.ui.dragdrop.DragEventDispatchHelper.DragEventDispatchDestination;
import org.chromium.ui.widget.UiWidgetFactory;

/** Unit test for {@link ContextMenuDialog}. */
@RunWith(BaseRobolectricTestRunner.class)
@Config(
        manifest = Config.NONE,
        shadows = {ShadowPhoneWindow.class})
@LooperMode(LooperMode.Mode.LEGACY)
public class ContextMenuDialogUnitTest {
    private static final int DIALOG_SIZE_DIP = 50;

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

    ContextMenuDialog mDialog;

    Activity mActivity;
    FrameLayout mMenuContentView;
    View mRootView;
    TestDragDispatchingDestinationView mSpyDragDispatchingDestinationView;

    @Mock UiWidgetFactory mMockUiWidgetFactory;
    @Spy PopupWindow mSpyPopupWindow;

    @Before
    public void setup() {
        mActivity = Robolectric.buildActivity(Activity.class).setup().get();
        mRootView = new FrameLayout(mActivity);
        TextView textView = new TextView(mActivity);
        textView.setText("Test String");
        mMenuContentView = new FrameLayout(mActivity);
        mMenuContentView.addView(textView);
        mActivity.setContentView(
                mRootView, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));

        mSpyPopupWindow = Mockito.spy(UiWidgetFactory.getInstance().createPopupWindow(mActivity));
        mSpyDragDispatchingDestinationView =
                Mockito.spy(new TestDragDispatchingDestinationView(mActivity));
        UiWidgetFactory.setInstance(mMockUiWidgetFactory);
        Mockito.when(mMockUiWidgetFactory.createPopupWindow(any())).thenReturn(mSpyPopupWindow);
        Mockito.doNothing()
                .when(mSpyPopupWindow)
                .showAtLocation(any(View.class), anyInt(), anyInt(), anyInt());
        Mockito.doNothing().when(mSpyPopupWindow).dismiss();

        View mockContentView = Mockito.mock(ViewGroup.class);
        Mockito.when(mockContentView.getMeasuredHeight()).thenReturn(DIALOG_SIZE_DIP);
        Mockito.when(mockContentView.getMeasuredWidth()).thenReturn(DIALOG_SIZE_DIP);
        Mockito.doReturn(mockContentView).when(mSpyPopupWindow).getContentView();
    }

    @After
    public void tearDown() {
        AccessibilityState.setIsScreenReaderEnabledForTesting(false);
        UiWidgetFactory.setInstance(null);
        mActivity.finish();
    }

    @Test
    public void testCreate_usePopupStyle() {
        mDialog = createContextMenuDialog(/* isPopup= */ false, /* shouldRemoveScrim= */ true);
        mDialog.show();

        ShadowPhoneWindow window = (ShadowPhoneWindow) Shadows.shadowOf(mDialog.getWindow());
        Assert.assertTrue(
                "FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS not in window flags.",
                window.getFlag(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS));
        Assert.assertTrue(
                "FLAG_NOT_TOUCH_MODAL not in window flags.",
                window.getFlag(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL));
        Assert.assertFalse(
                "FLAG_DIM_BEHIND is in flags.",
                window.getFlag(WindowManager.LayoutParams.FLAG_DIM_BEHIND));

        Assert.assertEquals(
                "Dialog status bar color should match activity status bar color.",
                mActivity.getWindow().getStatusBarColor(),
                mDialog.getWindow().getStatusBarColor());
        Assert.assertEquals(
                "Dialog navigation bar color should match activity navigation bar color.",
                mActivity.getWindow().getNavigationBarColor(),
                mDialog.getWindow().getNavigationBarColor());
    }

    @Test
    public void testCreateDialog_useRegularStyle() {
        mDialog = createContextMenuDialog(/* isPopup= */ false, /* shouldRemoveScrim= */ false);
        mDialog.show();

        // Only checks the flag is unset to make sure the setup for |shouldRemoveScrim| is not ran.
        ShadowPhoneWindow window = (ShadowPhoneWindow) Shadows.shadowOf(mDialog.getWindow());
        Assert.assertFalse(
                "FLAG_NOT_TOUCH_MODAL is in window flags.",
                window.getFlag(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL));
    }

    @Test
    public void testCreateDialog_dontMatchSysUi() {
        mDialog =
                createContextMenuDialog(
                        /* isPopup= */ false,
                        /* shouldRemoveScrim= */ false,
                        /* shouldSysUiMatchActivity */ false);
        mDialog.show();

        // Only checks the flag is unset to make sure the setup for |shouldSysUiMatchActivity| is
        // not ran.
        ShadowPhoneWindow window = (ShadowPhoneWindow) Shadows.shadowOf(mDialog.getWindow());
        Assert.assertFalse(
                "FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS is in window flags.",
                window.getFlag(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS));
        Assert.assertFalse(
                "FLAG_NOT_TOUCH_MODAL is in window flags.",
                window.getFlag(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL));
    }

    @Test
    public void testShowPopupWindow() {
        mDialog = createContextMenuDialog(/* isPopup= */ true, /* shouldRemoveScrim= */ false);
        mDialog.show();
        requestLayoutForRootView();

        final ArgumentCaptor<Integer> gravityCaptor = ArgumentCaptor.forClass(Integer.class);
        Mockito.verify(mSpyPopupWindow)
                .showAtLocation(
                        eq(mRootView.getRootView()), gravityCaptor.capture(), anyInt(), anyInt());

        Assert.assertEquals(
                "Popup gravity should have Gravity.START.",
                Gravity.START,
                (gravityCaptor.getValue() & Gravity.START));
        Assert.assertEquals(
                "Popup gravity should have Gravity.TOP.",
                Gravity.TOP,
                (gravityCaptor.getValue() & Gravity.TOP),
                Gravity.TOP);

        mDialog.dismiss();
        Mockito.verify(mSpyPopupWindow).dismiss();
    }

    @Test
    public void testShowPopupWindow_2ndLayout() {
        mDialog = createContextMenuDialog(/* isPopup= */ true, /* shouldRemoveScrim= */ false);
        mDialog.show();
        // Change layout params and request layout so #onLayoutChange is triggered.
        requestLayoutForRootView();
        Mockito.verify(mSpyPopupWindow)
                .showAtLocation(eq(mRootView.getRootView()), anyInt(), anyInt(), anyInt());

        // Mock up popup window is showing.
        Mockito.doReturn(true).when(mSpyPopupWindow).isShowing();

        requestLayoutForRootView();
        Mockito.verify(mSpyPopupWindow).dismiss();
    }

    /**
     * Inspired by https://crbug.com/1281011. If popup context menu is dismissed before
     * #onLayoutRequest for the root view, popup menu should not get invoked.
     */
    @Test
    public void testShowPopupWindow_BeforeOnLayout() {
        mDialog = createContextMenuDialog(/* isPopup= */ true, /* shouldRemoveScrim= */ false);
        mDialog.show();

        mDialog.dismiss();
        // Spy popup is not invoked because the dialog does not manage to create the popup window.
        Mockito.verify(mSpyPopupWindow, Mockito.times(0)).dismiss();
    }

    @Test
    public void testShowPopupWindow_NotFocusableInA11y() throws Exception {
        AccessibilityState.setIsScreenReaderEnabledForTesting(true);

        mDialog = createContextMenuDialog(/* isPopup= */ true, /* shouldRemoveScrim= */ false);
        mDialog.show();
        // Change layout params and request layout so #onLayoutChange is triggered.
        requestLayoutForRootView();

        Mockito.verify(mSpyPopupWindow).setFocusable(eq(true));
    }

    @Test
    public void testDispatchTouchToDelegate() {
        mDialog = createContextMenuDialog(/* isPopup= */ true, /* shouldRemoveScrim= */ true);
        mDialog.show();
        requestLayoutForRootView();
        Mockito.verify(mSpyPopupWindow)
                .showAtLocation(eq(mRootView.getRootView()), anyInt(), anyInt(), anyInt());
        Mockito.doReturn(true).when(mSpyDragDispatchingDestinationView).isAttachedToWindow();

        // common motion events other than ACTION_DOWN should be forwarded to touch event delegate.
        int[] motionEvenActions =
                new int[] {
                    MotionEvent.ACTION_CANCEL,
                    MotionEvent.ACTION_HOVER_ENTER,
                    MotionEvent.ACTION_HOVER_EXIT,
                    MotionEvent.ACTION_HOVER_MOVE,
                    MotionEvent.ACTION_MOVE,
                    MotionEvent.ACTION_OUTSIDE,
                    MotionEvent.ACTION_POINTER_DOWN,
                    MotionEvent.ACTION_POINTER_UP,
                    MotionEvent.ACTION_SCROLL,
                    MotionEvent.ACTION_UP
                };
        for (int actionType : motionEvenActions) {
            MotionEvent event = createMockMotionEventWithActionType(actionType);
            mDialog.onTouchEvent(event);
            Mockito.verify(
                            mSpyDragDispatchingDestinationView,
                            Mockito.description("Action" + actionType))
                    .dispatchTouchEvent(eq(event));
        }

        // ACTION_DOWN should dismiss the dialog and the popup window.
        MotionEvent downEvent = createMockMotionEventWithActionType(MotionEvent.ACTION_DOWN);
        mDialog.onTouchEvent(downEvent);
        Mockito.verify(mSpyDragDispatchingDestinationView, Mockito.times(0))
                .dispatchTouchEvent(eq(downEvent));
        Mockito.verify(mSpyPopupWindow).dismiss();
    }

    @Test
    public void testDispatchDragEvents() {
        mDialog = createContextMenuDialog(/* isPopup= */ true, /* shouldRemoveScrim= */ true);
        mDialog.show();
        requestLayoutForRootView();
        Mockito.verify(mSpyPopupWindow)
                .showAtLocation(eq(mRootView.getRootView()), anyInt(), anyInt(), anyInt());
        Assert.assertNotNull("OnDragListener is null.", mDialog.getOnDragListenerForTesting());

        final DragEvent mockDragEvent = Mockito.mock(DragEvent.class);
        Mockito.doReturn(DragEvent.ACTION_DRAG_LOCATION).when(mockDragEvent).getAction();

        Mockito.doReturn(true).when(mSpyDragDispatchingDestinationView).isAttachedToWindow();
        mDialog.getOnDragListenerForTesting().onDrag(mRootView, mockDragEvent);
        Mockito.verify(mSpyDragDispatchingDestinationView, Mockito.times(1))
                .onDragEventWithOffset(eq(mockDragEvent), anyInt(), anyInt());

        final DragEvent mockDragEvent2 = Mockito.mock(DragEvent.class);
        Mockito.doReturn(false).when(mSpyDragDispatchingDestinationView).isAttachedToWindow();
        mDialog.getOnDragListenerForTesting().onDrag(mRootView, mockDragEvent2);
        Mockito.verify(mSpyDragDispatchingDestinationView, Mockito.times(0))
                .onDragEventWithOffset(eq(mockDragEvent2), anyInt(), anyInt());
    }

    private ContextMenuDialog createContextMenuDialog(boolean isPopup, boolean shouldRemoveScrim) {
        return createContextMenuDialog(isPopup, shouldRemoveScrim, true);
    }

    private ContextMenuDialog createContextMenuDialog(
            boolean isPopup, boolean shouldRemoveScrim, boolean shouldSysUiMatchActivity) {
        return new ContextMenuDialog(
                mActivity,
                0,
                ContextMenuDialog.NO_CUSTOM_MARGIN,
                ContextMenuDialog.NO_CUSTOM_MARGIN,
                mRootView,
                mMenuContentView,
                isPopup,
                shouldRemoveScrim,
                shouldSysUiMatchActivity,
                0,
                0,
                mSpyDragDispatchingDestinationView,
                new Rect(0, 0, 0, 0));
    }

    private void requestLayoutForRootView() {
        // Change layout params and request layout so #onLayoutChange is triggered.
        mRootView.setRight(mRootView.getRight() + 1);
        mRootView.requestLayout();
    }

    private MotionEvent createMockMotionEventWithActionType(int actionType) {
        MotionEvent motionEvent = Mockito.mock(MotionEvent.class);
        Mockito.doReturn(actionType).when(motionEvent).getAction();

        return motionEvent;
    }

    static class TestDragDispatchingDestinationView extends View
            implements DragEventDispatchDestination {
        public TestDragDispatchingDestinationView(Context context) {
            super(context);
        }

        @Override
        public View view() {
            return this;
        }

        @Override
        public boolean onDragEventWithOffset(DragEvent event, int dx, int dy) {
            return false;
        }
    }
}