chromium/chrome/android/javatests/src/org/chromium/chrome/browser/app/ContextMenuDragTest.java

// Copyright 2022 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.app;

import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Rect;
import android.view.DragEvent;
import android.view.View;
import android.view.View.DragShadowBuilder;

import androidx.test.core.app.ApplicationProvider;
import androidx.test.filters.SmallTest;

import org.junit.After;
import org.junit.AfterClass;
import org.junit.Assert;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

import org.chromium.base.ThreadUtils;
import org.chromium.base.test.util.Batch;
import org.chromium.base.test.util.CommandLineFlags;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.Features.EnableFeatures;
import org.chromium.chrome.browser.contextmenu.ContextMenuCoordinator;
import org.chromium.chrome.browser.firstrun.FirstRunStatus;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.test.ChromeJUnit4ClassRunner;
import org.chromium.chrome.test.ChromeTabbedActivityTestRule;
import org.chromium.chrome.test.batch.BlankCTATabInitialStateRule;
import org.chromium.chrome.test.util.ChromeApplicationTestUtils;
import org.chromium.chrome.test.util.browser.contextmenu.ContextMenuUtils;
import org.chromium.content_public.browser.test.util.DOMUtils;
import org.chromium.content_public.common.ContentFeatures;
import org.chromium.net.test.EmbeddedTestServer;
import org.chromium.ui.base.ViewAndroidDelegate;
import org.chromium.ui.dragdrop.DragAndDropDelegate;
import org.chromium.ui.dragdrop.DropDataAndroid;

import java.util.concurrent.TimeoutException;

/** Integration tests for drag interactions with context menu. */
@RunWith(ChromeJUnit4ClassRunner.class)
@CommandLineFlags.Add({
    ChromeSwitches.DISABLE_FIRST_RUN_EXPERIENCE,
    ChromeSwitches.FORCE_CONTEXT_MENU_POPUP
})
@EnableFeatures({ContentFeatures.TOUCH_DRAG_AND_CONTEXT_MENU})
@Batch(Batch.PER_CLASS)
public class ContextMenuDragTest {

    // Test distance
    private static final int TEST_MIN_DIST = 10;
    private static final String TEST_PATH =
            "/chrome/test/data/android/contextmenu/context_menu_test.html";
    private static final String TEST_IMAGE_ID = "testImage";

    @ClassRule
    public static ChromeTabbedActivityTestRule sActivityTestRule =
            new ChromeTabbedActivityTestRule();

    static TestDragAndDropDelegate sTestDragAndDropDelegate = new TestDragAndDropDelegate();

    @Rule
    public BlankCTATabInitialStateRule mTestRule =
            new BlankCTATabInitialStateRule(sActivityTestRule, false);

    private EmbeddedTestServer mTestServer;

    private ContextMenuCoordinator mContextMenu;
    private String mTestUrl;
    private Tab mTab;

    @BeforeClass
    public static void setupBeforeClass() {
        ThreadUtils.runOnUiThreadBlocking(() -> FirstRunStatus.setFirstRunFlowComplete(true));

        // Stop the real call to android View#startDragAndDrop. Test file do not have real touches
        // over the screen so there's no way to end the drag event properly. Doing this in
        // @BeforeClass since ViewAndroidDelegate is created earlier than @Before due to batching.
        sTestDragAndDropDelegate = new TestDragAndDropDelegate();
        ViewAndroidDelegate.setDragAndDropDelegateForTest(sTestDragAndDropDelegate);
    }

    @Before
    public void setUp() {
        mTestServer =
                EmbeddedTestServer.createAndStartServer(
                        ApplicationProvider.getApplicationContext());
        mTestUrl = mTestServer.getURL(TEST_PATH);

        sActivityTestRule.loadUrl(mTestUrl);
        mTab = sActivityTestRule.getActivity().getActivityTab();
        CriteriaHelper.pollUiThread(() -> mTab.isUserInteractable() && !mTab.isLoading());
        ChromeApplicationTestUtils.assertWaitForPageScaleFactorMatch(
                sActivityTestRule.getActivity(), 0.5f);
    }

    @After
    public void tearDown() {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    if (mContextMenu != null) mContextMenu.dismiss();
                });
        sTestDragAndDropDelegate.reset();
    }

    @AfterClass
    public static void tearDownAfterClass() {
        ThreadUtils.runOnUiThreadBlocking(() -> FirstRunStatus.setFirstRunFlowComplete(false));
    }

    @Test
    @SmallTest
    public void testTriggerContextMenu_Image() throws TimeoutException {
        longPressOpenContextMenu(TEST_IMAGE_ID);
        DropDataAndroid data = getDropData();
        Assert.assertTrue("Drop data should have Image.", data.hasImage());
    }

    @Test
    @SmallTest
    @CommandLineFlags.Add({
        "enable-features=" + ContentFeatures.TOUCH_DRAG_AND_CONTEXT_MENU + "<Study",
        "force-fieldtrials=Study/Group",
        "force-fieldtrial-params=Study.Group:DragAndDropMovementThresholdDipParam/" + TEST_MIN_DIST
    })
    public void testTriggerContextMenuWithDrag() throws TimeoutException {
        longPressOpenContextMenu(TEST_IMAGE_ID);

        final Rect location = DOMUtils.getNodeBounds(mTab.getWebContents(), TEST_IMAGE_ID);
        final int jitterRange = 1;

        // Clank is not forwarding drag start event to blink; instead, browser only remembers the
        // first drag events as the starting point of context menu.
        DragEvent event1 =
                mockDragEvent(
                        location.centerX(), location.centerY(), DragEvent.ACTION_DRAG_LOCATION);
        DragEvent event2 =
                mockDragEvent(
                        location.centerX() + jitterRange,
                        location.centerY() + jitterRange,
                        DragEvent.ACTION_DRAG_LOCATION);
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mTab.getContentView().onDragEvent(event1);
                    mTab.getContentView().onDragEvent(event2);
                });
        assertContextMenuShowing(true);

        final int minDragThresholdPx =
                (int)
                                (sActivityTestRule
                                                .getActivity()
                                                .getResources()
                                                .getDisplayMetrics()
                                                .density
                                        * TEST_MIN_DIST)
                        + 1;
        DragEvent event3 =
                mockDragEvent(
                        location.centerX() + minDragThresholdPx,
                        location.centerY() + minDragThresholdPx,
                        DragEvent.ACTION_DRAG_LOCATION);
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mTab.getContentView().onDragEvent(event3);
                });
        assertContextMenuShowing(false);
    }

    private void longPressOpenContextMenu(String nodeId) throws TimeoutException {
        mContextMenu = ContextMenuUtils.openContextMenu(mTab, nodeId);
        Assert.assertNotNull("Context menu is null.", mContextMenu);
        assertContextMenuShowing(true);
    }

    private void assertContextMenuShowing(boolean showing) {
        Assert.assertNotNull("Context menu dialog is null.", mContextMenu.getDialogForTest());
        Assert.assertEquals(
                "Context menu dialog is not showing.",
                showing,
                mContextMenu.getDialogForTest().isShowing());
    }

    private DropDataAndroid getDropData() {
        Assert.assertEquals(
                "#startDragAndDrop is not called.",
                1,
                sTestDragAndDropDelegate.startDragAndDropCallCount);
        Assert.assertNotNull("DropDataAndroid is null.", sTestDragAndDropDelegate.lastDropData);
        return sTestDragAndDropDelegate.lastDropData;
    }

    private DragEvent mockDragEvent(int x, int y, int actionType) {
        DragEvent event = mock(DragEvent.class);
        doReturn(actionType).when(event).getAction();
        doReturn((float) x).when(event).getX();
        doReturn((float) y).when(event).getY();
        return event;
    }

    // Test impl for ViewAndroidDelegate.DragAndDropDelegate as compromise that mockito does not
    // work well with @BeforeClass.
    static class TestDragAndDropDelegate implements DragAndDropDelegate {
        public DropDataAndroid lastDropData;
        public int startDragAndDropCallCount;

        @Override
        public boolean startDragAndDrop(
                View containerView,
                Bitmap shadowImage,
                DropDataAndroid dropData,
                Context context,
                int cursorOffsetX,
                int cursorOffsetY,
                int dragObjRectWidth,
                int dragObjRectHeight) {
            return startDragAndDrop(containerView, null, dropData);
        }

        @Override
        public boolean startDragAndDrop(
                View containerView, DragShadowBuilder dragShadowBuilder, DropDataAndroid dropData) {
            lastDropData = dropData;
            startDragAndDropCallCount += 1;

            return true;
        }

        void reset() {
            lastDropData = null;
            startDragAndDropCallCount = 0;
        }
    }
}