chromium/chrome/android/junit/src/org/chromium/chrome/browser/contextmenu/ContextMenuCoordinatorTest.java

// Copyright 2019 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.contextmenu;

import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.mockito.Mockito.doReturn;

import static org.chromium.chrome.browser.contextmenu.ContextMenuItemProperties.MENU_ID;
import static org.chromium.chrome.browser.contextmenu.ContextMenuItemProperties.TEXT;

import android.app.Activity;
import android.graphics.Rect;
import android.util.Pair;
import android.view.View;

import androidx.annotation.Nullable;
import androidx.test.ext.junit.rules.ActivityScenarioRule;

import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.robolectric.annotation.Config;
import org.robolectric.annotation.Implementation;
import org.robolectric.annotation.Implements;
import org.robolectric.shadow.api.Shadow;
import org.robolectric.shadows.ShadowDialog;

import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.base.test.util.CommandLineFlags;
import org.chromium.base.test.util.DisabledTest;
import org.chromium.base.test.util.Features.DisableFeatures;
import org.chromium.base.test.util.Features.EnableFeatures;
import org.chromium.base.test.util.JniMocker;
import org.chromium.blink_public.common.ContextMenuDataMediaType;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.contextmenu.ChromeContextMenuItem.Item;
import org.chromium.chrome.browser.contextmenu.ChromeContextMenuPopulator.ContextMenuGroup;
import org.chromium.chrome.browser.contextmenu.ContextMenuCoordinator.ListItemType;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.components.browser_ui.widget.ContextMenuDialog;
import org.chromium.components.embedder_support.contextmenu.ContextMenuNativeDelegate;
import org.chromium.components.embedder_support.contextmenu.ContextMenuParams;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.common.ContentFeatures;
import org.chromium.ui.base.TestActivity;
import org.chromium.ui.base.ViewAndroidDelegate;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.dragdrop.DragStateTracker;
import org.chromium.ui.modelutil.MVCListAdapter.ListItem;
import org.chromium.ui.modelutil.MVCListAdapter.ModelList;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.url.GURL;

import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;

/** Unit tests for the context menu. Use density=mdpi so the screen density is 1. */
@RunWith(BaseRobolectricTestRunner.class)
@DisableFeatures({ContentFeatures.TOUCH_DRAG_AND_CONTEXT_MENU})
@EnableFeatures({ChromeFeatureList.CONTEXT_MENU_SYS_UI_MATCHES_ACTIVITY})
public class ContextMenuCoordinatorTest {
    private static final int TOP_CONTENT_OFFSET_PX = 17;

    /**
     * Shadow class used to capture the inputs for {@link
     * ContextMenuCoordinator#createContextMenuDialog}.
     */
    @Implements(ContextMenuDialog.class)
    public static class ShadowContextMenuDialog extends ShadowDialog {
        boolean mShouldRemoveScrim;
        @Nullable View mTouchEventDelegateView;
        Rect mRect;

        public ShadowContextMenuDialog() {}

        @Implementation
        protected void __constructor__(
                Activity ownerActivity,
                int theme,
                int topMarginPx,
                int bottomMarginPx,
                View layout,
                View contentView,
                boolean isPopup,
                boolean shouldRemoveScrim,
                @Nullable Integer popupMargin,
                @Nullable Integer desiredPopupContentWidth,
                @Nullable View touchEventDelegateView,
                Rect rect) {
            mShouldRemoveScrim = shouldRemoveScrim;
            mTouchEventDelegateView = touchEventDelegateView;
            mRect = rect;
        }

        @Override
        @Implementation
        public void show() {}

        @Override
        @Implementation
        public void dismiss() {}
    }

    /** No-op constructor for test cases that does not care of creation of real object. */
    @Implements(ContextMenuHeaderCoordinator.class)
    public static class ShadowContextMenuHeaderCoordinator {
        public ShadowContextMenuHeaderCoordinator() {}

        @Implementation
        public void __constructor__(
                Activity activity,
                ContextMenuParams params,
                Profile profile,
                ContextMenuNativeDelegate nativeDelegate) {}
    }

    /** Helper shadow to set the results for {@link Profile#fromWebContents}. */
    @Implements(Profile.class)
    public static class ShadowProfile {
        static Profile sProfileFromWebContents;

        @Implementation
        public static Profile fromWebContents(WebContents webContents) {
            return sProfileFromWebContents;
        }
    }

    @Rule public JniMocker mocker = new JniMocker();

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

    @Mock ContextMenuNativeDelegate mNativeDelegate;
    @Mock WebContents mWebContentsMock;

    private ContextMenuCoordinator mCoordinator;
    private Activity mActivity;
    private final Profile mProfile = Mockito.mock(Profile.class);

    @Before
    public void setUpTest() {
        mActivityScenarioRule.getScenario().onActivity((activity) -> mActivity = activity);
        mCoordinator = new ContextMenuCoordinator(TOP_CONTENT_OFFSET_PX, mNativeDelegate);
        MockitoAnnotations.initMocks(this);
        ShadowProfile.sProfileFromWebContents = mProfile;
    }

    @Test
    public void testGetItemListWithImageLink() {
        final ContextMenuParams params =
                new ContextMenuParams(
                        0,
                        ContextMenuDataMediaType.IMAGE,
                        GURL.emptyGURL(),
                        GURL.emptyGURL(),
                        "",
                        GURL.emptyGURL(),
                        GURL.emptyGURL(),
                        "",
                        null,
                        false,
                        0,
                        0,
                        0,
                        false,
                        /* additionalNavigationParams= */ null);
        List<Pair<Integer, ModelList>> rawItems = new ArrayList<>();
        // Link items
        ModelList groupOne = new ModelList();
        groupOne.add(createListItem(Item.OPEN_IN_NEW_TAB));
        groupOne.add(createListItem(Item.OPEN_IN_INCOGNITO_TAB));
        groupOne.add(createListItem(Item.SAVE_LINK_AS));
        groupOne.add(createShareListItem(Item.SHARE_LINK));
        rawItems.add(new Pair<>(ContextMenuGroup.LINK, groupOne));
        // Image Items
        ModelList groupTwo = new ModelList();
        groupTwo.add(createListItem(Item.OPEN_IMAGE_IN_NEW_TAB));
        groupTwo.add(createListItem(Item.SAVE_IMAGE));
        groupTwo.add(createShareListItem(Item.SHARE_IMAGE));
        rawItems.add(new Pair<>(ContextMenuGroup.IMAGE, groupTwo));

        mCoordinator.initializeHeaderCoordinatorForTesting(
                mActivity, params, mProfile, mNativeDelegate);
        ModelList itemList = mCoordinator.getItemList(mActivity, rawItems, (i) -> {}, true);

        assertThat(itemList.get(0).type, equalTo(ListItemType.HEADER));
        assertThat(itemList.get(1).type, equalTo(ListItemType.DIVIDER));
        assertThat(itemList.get(2).type, equalTo(ListItemType.CONTEXT_MENU_ITEM));
        assertThat(itemList.get(3).type, equalTo(ListItemType.CONTEXT_MENU_ITEM));
        assertThat(itemList.get(4).type, equalTo(ListItemType.CONTEXT_MENU_ITEM));
        assertThat(itemList.get(5).type, equalTo(ListItemType.CONTEXT_MENU_ITEM_WITH_ICON_BUTTON));
        assertThat(itemList.get(6).type, equalTo(ListItemType.DIVIDER));
        assertThat(itemList.get(7).type, equalTo(ListItemType.CONTEXT_MENU_ITEM));
        assertThat(itemList.get(8).type, equalTo(ListItemType.CONTEXT_MENU_ITEM));
        assertThat(itemList.get(9).type, equalTo(ListItemType.CONTEXT_MENU_ITEM_WITH_ICON_BUTTON));
    }

    @Test
    public void testGetItemListWithLink() {
        // We're testing it for a link, but the mediaType in params is image. That's because if it
        // isn't image or video, the header mediator tries to get a favicon for us and calls
        // ProfileManager.getLastUsedRegularProfile(), which throws an exception because native
        // isn't
        // initialized. mediaType here doesn't have any effect on what we're testing.
        final ContextMenuParams params =
                new ContextMenuParams(
                        0,
                        ContextMenuDataMediaType.IMAGE,
                        GURL.emptyGURL(),
                        GURL.emptyGURL(),
                        "",
                        GURL.emptyGURL(),
                        GURL.emptyGURL(),
                        "",
                        null,
                        false,
                        0,
                        0,
                        0,
                        false,
                        /* additionalNavigationParams= */ null);
        List<Pair<Integer, ModelList>> rawItems = new ArrayList<>();
        // Link items
        ModelList groupOne = new ModelList();
        groupOne.add(createListItem(Item.OPEN_IN_NEW_TAB));
        groupOne.add(createListItem(Item.OPEN_IN_INCOGNITO_TAB));
        groupOne.add(createListItem(Item.SAVE_LINK_AS));
        groupOne.add(createShareListItem(Item.SHARE_LINK));
        rawItems.add(new Pair<>(ContextMenuGroup.LINK, groupOne));

        mCoordinator.initializeHeaderCoordinatorForTesting(
                mActivity, params, mProfile, mNativeDelegate);
        ModelList itemList = mCoordinator.getItemList(mActivity, rawItems, (i) -> {}, true);

        assertThat(itemList.get(0).type, equalTo(ListItemType.HEADER));
        assertThat(itemList.get(1).type, equalTo(ListItemType.DIVIDER));
        assertThat(itemList.get(2).type, equalTo(ListItemType.CONTEXT_MENU_ITEM));
        assertThat(itemList.get(3).type, equalTo(ListItemType.CONTEXT_MENU_ITEM));
        assertThat(itemList.get(4).type, equalTo(ListItemType.CONTEXT_MENU_ITEM));
        assertThat(itemList.get(5).type, equalTo(ListItemType.CONTEXT_MENU_ITEM_WITH_ICON_BUTTON));
    }

    @Test
    public void testGetItemListWithVideo() {
        final ContextMenuParams params =
                new ContextMenuParams(
                        0,
                        ContextMenuDataMediaType.VIDEO,
                        GURL.emptyGURL(),
                        GURL.emptyGURL(),
                        "",
                        GURL.emptyGURL(),
                        GURL.emptyGURL(),
                        "",
                        null,
                        false,
                        0,
                        0,
                        0,
                        false,
                        /* additionalNavigationParams= */ null);
        List<Pair<Integer, ModelList>> rawItems = new ArrayList<>();
        // Video items
        ModelList groupOne = new ModelList();
        groupOne.add(createListItem(Item.SAVE_VIDEO));
        rawItems.add(new Pair<>(ContextMenuGroup.LINK, groupOne));

        mCoordinator.initializeHeaderCoordinatorForTesting(
                mActivity, params, mProfile, mNativeDelegate);
        ModelList itemList = mCoordinator.getItemList(mActivity, rawItems, (i) -> {}, true);

        assertThat(itemList.get(0).type, equalTo(ListItemType.HEADER));
        assertThat(itemList.get(1).type, equalTo(ListItemType.DIVIDER));
        assertThat(itemList.get(2).type, equalTo(ListItemType.CONTEXT_MENU_ITEM));
    }

    @Test
    @Config(
            shadows = {ShadowContextMenuDialog.class},
            qualifiers = "mdpi")
    public void testCreateContextMenuDialog() {
        ContextMenuDialog dialog = createContextMenuDialogForTest(/* isPopup= */ false);
        ShadowContextMenuDialog shadowDialog = (ShadowContextMenuDialog) Shadow.extract(dialog);

        Assert.assertFalse("Dialog should have scrim behind.", shadowDialog.mShouldRemoveScrim);
    }

    @Test
    @DisabledTest(message = "crbug.com/1444964")
    @EnableFeatures({ContentFeatures.TOUCH_DRAG_AND_CONTEXT_MENU})
    @Config(
            shadows = {ShadowContextMenuDialog.class},
            qualifiers = "mdpi")
    @CommandLineFlags.Add(ChromeSwitches.FORCE_CONTEXT_MENU_POPUP)
    public void testCreateContextMenuDialog_PopupStyle() {
        ContextMenuDialog dialog = createContextMenuDialogForTest(/* isPopup= */ true);
        ShadowContextMenuDialog shadowDialog = (ShadowContextMenuDialog) Shadow.extract(dialog);

        Assert.assertTrue("Dialog should remove scrim behind.", shadowDialog.mShouldRemoveScrim);
        Assert.assertNotNull(
                "TouchEventDelegateView should not be null when drag drop is enabled.",
                shadowDialog.mTouchEventDelegateView);
    }

    @Test
    public void testGetContextMenuTriggerRectFromWeb() {
        final int shadowImgWidth = 50;
        final int shadowImgHeight = 40;
        setupMocksForDragShadowImage(true, shadowImgWidth, shadowImgHeight);

        final int centerX = 100;
        final int centerY = 200;
        Rect rect =
                ContextMenuCoordinator.getContextMenuTriggerRectFromWeb(
                        mWebContentsMock, centerX, centerY);

        Assert.assertEquals("rect.left does not match.", /*100 - 50 / 2 =*/ 75, rect.left);
        Assert.assertEquals("rect.right does not match.", /*100 + 50 / 2 =*/ 125, rect.right);
        Assert.assertEquals("rect.top does not match.", /*200 - 40 / 2 =*/ 180, rect.top);
        Assert.assertEquals("rect.bottom does not match.", /*200 + 40 / 2 =*/ 220, rect.bottom);
    }

    @Test
    public void testGetContextMenuTriggerRectFromWeb_DragNotStarted() {
        setupMocksForDragShadowImage(false, 50, 40);

        final int centerX = 100;
        final int centerY = 200;
        Rect rect =
                ContextMenuCoordinator.getContextMenuTriggerRectFromWeb(
                        mWebContentsMock, centerX, centerY);

        // Rect should be a point when drag not started.
        Assert.assertEquals("rect.left does not match.", centerX, rect.left);
        Assert.assertEquals("rect.right does not match.", centerX, rect.right);
        Assert.assertEquals("rect.top does not match.", centerY, rect.top);
        Assert.assertEquals("rect.bottom does not match.", centerY, rect.bottom);
    }

    @Test
    public void testGetContextMenuTriggerRectFromWeb_NoViewAndroidDelegate() {
        final int centerX = 100;
        final int centerY = 200;
        Rect rect =
                ContextMenuCoordinator.getContextMenuTriggerRectFromWeb(
                        mWebContentsMock, centerX, centerY);

        // Rect should be a point when no ViewAndroidDelegate attached to web content.
        Assert.assertEquals("rect.left does not match.", centerX, rect.left);
        Assert.assertEquals("rect.right does not match.", centerX, rect.right);
        Assert.assertEquals("rect.top does not match.", centerY, rect.top);
        Assert.assertEquals("rect.bottom does not match.", centerY, rect.bottom);
    }

    @Test
    @DisabledTest(message = "crbug.com/1444964")
    @DisableFeatures(ContentFeatures.TOUCH_DRAG_AND_CONTEXT_MENU)
    @Config(
            shadows = {
                ShadowContextMenuDialog.class,
                ShadowContextMenuHeaderCoordinator.class,
                ShadowProfile.class
            },
            qualifiers = "mdpi")
    public void testDisplayMenu() {
        final int triggeringTouchXDp = 100;
        final int triggeringTouchYDp = 200;
        ContextMenuDialog dialog =
                displayContextMenuDialogAtLocation(triggeringTouchXDp, triggeringTouchYDp);
        ShadowContextMenuDialog shadowDialog = Shadow.extract(dialog);

        ContextMenuListView listView = mCoordinator.getListViewForTest();
        Assert.assertNotNull("List view should not be null.", listView);
        Assert.assertFalse(
                "Fading edge should not be enabled.", listView.isVerticalFadingEdgeEnabled());

        // Verify rect is calculated correctly. Note that the calculation done below assume the
        // density is 1.0.
        Rect rect = shadowDialog.mRect;
        Assert.assertEquals("rect.left for ContextMenuDialog does not match.", 100, rect.left);
        Assert.assertEquals("rect.right for ContextMenuDialog does not match.", 100, rect.right);
        Assert.assertEquals(
                "rect.top for ContextMenuDialog does not match.", /*200 + 17 =*/ 217, rect.top);
        Assert.assertEquals(
                "rect.bottom for ContextMenuDialog does not match.",
                /*200 + 17 =*/ 217,
                rect.bottom);
    }

    @Test
    @DisabledTest(message = "crbug.com/1444964")
    @EnableFeatures({ContentFeatures.TOUCH_DRAG_AND_CONTEXT_MENU})
    @Config(
            shadows = {
                ShadowContextMenuDialog.class,
                ShadowContextMenuHeaderCoordinator.class,
                ShadowProfile.class
            },
            qualifiers = "mdpi")
    @CommandLineFlags.Add(ChromeSwitches.FORCE_CONTEXT_MENU_POPUP)
    public void testDisplayMenu_DragEnabled() {
        final int shadowImgWidth = 50;
        final int shadowImgHeight = 40;
        setupMocksForDragShadowImage(true, shadowImgWidth, shadowImgHeight);

        final int triggeringTouchXDp = 100;
        final int triggeringTouchYDp = 200;
        ContextMenuDialog dialog =
                displayContextMenuDialogAtLocation(triggeringTouchXDp, triggeringTouchYDp);
        ShadowContextMenuDialog shadowDialog = Shadow.extract(dialog);

        ContextMenuListView listView = mCoordinator.getListViewForTest();
        Assert.assertNotNull("List view should not be null.", listView);
        Assert.assertTrue("Fading edge should be enabled.", listView.isVerticalFadingEdgeEnabled());
        Assert.assertEquals(
                "Fading edge size is wrong.",
                mActivity
                        .getResources()
                        .getDimensionPixelSize(R.dimen.context_menu_fading_edge_size),
                listView.getVerticalFadingEdgeLength());

        // Verify rect is calculated correctly.
        Rect rect = shadowDialog.mRect;
        Assert.assertEquals(
                "rect.left for ContextMenuDialog does not match.", /*100 - 50 / 2 =*/
                75,
                rect.left);
        Assert.assertEquals(
                "rect.right for ContextMenuDialog does not match.",
                /*100 + 50 / 2 =*/ 125,
                rect.right);
        Assert.assertEquals(
                "rect.top for ContextMenuDialog does not match.",
                /*200 + 17 - 40 / 2 =*/ 197,
                rect.top);
        Assert.assertEquals(
                "rect.bottom for ContextMenuDialog does not match.",
                /*200 + 17 + 40 / 2 =*/ 237,
                rect.bottom);
    }

    private ListItem createListItem(@Item int item) {
        final PropertyModel model =
                new PropertyModel.Builder(ContextMenuItemProperties.ALL_KEYS)
                        .with(MENU_ID, ChromeContextMenuItem.getMenuId(item))
                        .with(
                                TEXT,
                                ChromeContextMenuItem.getTitle(mActivity, mProfile, item, false))
                        .build();
        return new ListItem(ListItemType.CONTEXT_MENU_ITEM, model);
    }

    private ListItem createShareListItem(@Item int item) {
        final PropertyModel model =
                new PropertyModel.Builder(ContextMenuItemWithIconButtonProperties.ALL_KEYS)
                        .with(MENU_ID, ChromeContextMenuItem.getMenuId(item))
                        .with(
                                TEXT,
                                ChromeContextMenuItem.getTitle(mActivity, mProfile, item, false))
                        .build();
        return new ListItem(ListItemType.CONTEXT_MENU_ITEM_WITH_ICON_BUTTON, model);
    }

    private ContextMenuDialog createContextMenuDialogForTest(boolean isPopup) {
        View contentView = Mockito.mock(View.class);
        View rootView = Mockito.mock(View.class);
        View webContentView = Mockito.mock(View.class);

        return ContextMenuCoordinator.createContextMenuDialog(
                mActivity,
                rootView,
                contentView,
                isPopup,
                0,
                0,
                0,
                0,
                webContentView,
                new Rect(0, 0, 0, 0));
    }

    private ContextMenuDialog displayContextMenuDialogAtLocation(
            int triggeringTouchXDp, int triggeringTouchYDp) {
        final ContextMenuParams params =
                new ContextMenuParams(
                        0,
                        ContextMenuDataMediaType.IMAGE,
                        GURL.emptyGURL(),
                        GURL.emptyGURL(),
                        "",
                        GURL.emptyGURL(),
                        GURL.emptyGURL(),
                        "",
                        null,
                        false,
                        triggeringTouchXDp,
                        triggeringTouchYDp,
                        0,
                        false,
                        /* additionalNavigationParams= */ null);

        final WindowAndroid windowAndroid = Mockito.mock(WindowAndroid.class);
        doReturn(new WeakReference<Activity>(mActivity)).when(windowAndroid).getActivity();

        List<Pair<Integer, ModelList>> rawItems = new ArrayList<>();

        mCoordinator.displayMenu(
                windowAndroid, mWebContentsMock, params, rawItems, null, null, null);

        ContextMenuDialog dialog = mCoordinator.getDialogForTest();
        Assert.assertNotNull("ContextMenuDialog is null", dialog);
        return dialog;
    }

    private void setupMocksForDragShadowImage(
            boolean isDragging, int dragShadowWidth, int dragShadowHeight) {
        final ViewAndroidDelegate viewAndroidDelegate = Mockito.mock(ViewAndroidDelegate.class);
        final DragStateTracker dragStateTracker = Mockito.mock(DragStateTracker.class);
        doReturn(viewAndroidDelegate).when(mWebContentsMock).getViewAndroidDelegate();
        doReturn(dragStateTracker).when(viewAndroidDelegate).getDragStateTracker();

        doReturn(isDragging).when(dragStateTracker).isDragStarted();
        doReturn(dragShadowWidth).when(dragStateTracker).getDragShadowWidth();
        doReturn(dragShadowHeight).when(dragStateTracker).getDragShadowHeight();
    }
}