chromium/ui/android/java/src/org/chromium/ui/dragdrop/DragAndDropDelegateImpl.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.ui.dragdrop;

import android.content.ClipData;
import android.content.ClipData.Item;
import android.content.ClipDescription;
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.media.ThumbnailUtils;
import android.net.Uri;
import android.os.SystemClock;
import android.text.TextUtils;
import android.view.DragAndDropPermissions;
import android.view.DragEvent;
import android.view.View;
import android.view.View.DragShadowBuilder;
import android.view.ViewGroup;
import android.widget.ImageView;

import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.graphics.drawable.RoundedBitmapDrawable;
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;

import org.chromium.base.ContextUtils;
import org.chromium.base.MathUtils;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.ui.R;
import org.chromium.ui.accessibility.AccessibilityState;
import org.chromium.ui.base.MimeTypeUtils;
import org.chromium.ui.base.UiAndroidFeatureList;
import org.chromium.ui.base.UiAndroidFeatureMap;
import org.chromium.ui.dragdrop.AnimatedImageDragShadowBuilder.CursorOffset;
import org.chromium.ui.dragdrop.AnimatedImageDragShadowBuilder.DragShadowSpec;
import org.chromium.ui.dragdrop.DragDropMetricUtils.UrlIntentSource;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

/**
 * Drag and drop helper class in charge of building the clip data, wrapping calls to {@link
 * android.view.View#startDragAndDrop}. Also used for mocking out real function calls to Android.
 */
public class DragAndDropDelegateImpl implements DragAndDropDelegate, DragStateTracker {
    /**
     * Java Enum of AndroidDragTargetType used for histogram recording for
     * Android.DragDrop.FromWebContent.TargetType. This is used for histograms and should therefore
     * be treated as append-only. TODO (crbug.com/1484695) Revisit hists to capture drag and drop
     * source.
     */
    @IntDef({
        DragTargetType.INVALID,
        DragTargetType.TEXT,
        DragTargetType.IMAGE,
        DragTargetType.LINK,
        DragTargetType.BROWSER_CONTENT,
        DragTargetType.NUM_ENTRIES
    })
    @Retention(RetentionPolicy.SOURCE)
    @interface DragTargetType {
        int INVALID = 0;
        int TEXT = 1;
        int IMAGE = 2;
        int LINK = 3;
        int BROWSER_CONTENT = 4;

        int NUM_ENTRIES = 5;
    }

    private int mShadowWidth;
    private int mShadowHeight;
    private boolean mIsDragStarted;

    /** Whether the current drop has happened on top of the view this object tracks. */
    private boolean mIsDropOnView;

    /** The type of drag target from the view this object tracks. */
    private @DragTargetType int mDragTargetType;

    private float mDragStartXDp;
    private float mDragStartYDp;

    private long mDragStartSystemElapsedTime;

    private @Nullable DragAndDropBrowserDelegate mDragAndDropBrowserDelegate;

    // Implements ViewAndroidDelegate.DragAndDropDelegate

    /**
     * Create DragShadowBuilder and start {@link View#startDragAndDrop(ClipData, DragShadowBuilder,
     * Object, int)}.
     *
     * @param containerView The container view where the drag starts.
     * @param shadowImage The bitmap which represents the shadow image.
     * @param dropData The drop data presenting the drag target.
     * @param cursorOffsetX The x offset of the cursor w.r.t. to top-left corner of the drag-image.
     * @param cursorOffsetY The y offset of the cursor w.r.t. to top-left corner of the drag-image.
     * @param dragObjRectWidth The width of the drag object.
     * @param dragObjRectHeight The height of the drag object.
     */
    @Override
    public boolean startDragAndDrop(
            @NonNull View containerView,
            @NonNull Bitmap shadowImage,
            @NonNull DropDataAndroid dropData,
            @NonNull Context context,
            int cursorOffsetX,
            int cursorOffsetY,
            int dragObjRectWidth,
            int dragObjRectHeight) {
        if (isA11yStateEnabled()) return false;
        int windowWidth = containerView.getRootView().getWidth();
        int windowHeight = containerView.getRootView().getHeight();
        View.DragShadowBuilder dragShadowBuilder =
                createDragShadowBuilder(
                        containerView,
                        context,
                        shadowImage,
                        dropData.hasImage(),
                        windowWidth,
                        windowHeight,
                        cursorOffsetX,
                        cursorOffsetY,
                        dragObjRectWidth,
                        dragObjRectHeight);
        return startDragAndDropInternal(containerView, dragShadowBuilder, dropData);
    }

    @Override
    public boolean startDragAndDrop(
            @NonNull View containerView,
            @NonNull DragShadowBuilder dragShadowBuilder,
            @NonNull DropDataAndroid dropData) {
        if (isA11yStateEnabled()) return false;
        return startDragAndDropInternal(containerView, dragShadowBuilder, dropData);
    }

    private static boolean isA11yStateEnabled() {
        // Drag and drop is disabled when gesture related a11y service is enabled.
        // See https://crbug.com/1250067.
        return AccessibilityState.isAnyAccessibilityServiceEnabled();
    }

    private boolean startDragAndDropInternal(
            @NonNull View containerView,
            @NonNull DragShadowBuilder dragShadowBuilder,
            @NonNull DropDataAndroid dropData) {
        ClipData clipdata = buildClipData(dropData);
        if (clipdata == null) {
            return false;
        }

        mIsDragStarted = true;
        mDragStartSystemElapsedTime = SystemClock.elapsedRealtime();
        mDragTargetType = getDragTargetType(dropData);

        Object myLocalState = null;
        if (mDragAndDropBrowserDelegate != null
                && mDragAndDropBrowserDelegate.getSupportDropInChrome()) {
            myLocalState = dropData;
        }
        return containerView.startDragAndDrop(
                clipdata, dragShadowBuilder, myLocalState, buildFlags(dropData));
    }

    @Override
    public void setDragAndDropBrowserDelegate(DragAndDropBrowserDelegate delegate) {
        mDragAndDropBrowserDelegate = delegate;
    }

    // Implements DragStateTracker
    @Override
    public boolean isDragStarted() {
        return mIsDragStarted;
    }

    @Override
    public int getDragShadowWidth() {
        return mShadowWidth;
    }

    @Override
    public int getDragShadowHeight() {
        return mShadowHeight;
    }

    @Override
    public void destroy() {
        reset();
        mDragAndDropBrowserDelegate = null;
    }

    // Implements View.OnDragListener
    @Override
    public boolean onDrag(View view, DragEvent dragEvent) {
        if (!mIsDragStarted) {
            if (mDragAndDropBrowserDelegate != null
                    && mDragAndDropBrowserDelegate.getSupportDropInChrome()
                    && dragEvent.getAction() == DragEvent.ACTION_DROP) {
                onDropFromOutside(dragEvent);
            }
            return false;
        }

        switch (dragEvent.getAction()) {
            case DragEvent.ACTION_DRAG_STARTED:
                onDragStarted(dragEvent);
                break;
            case DragEvent.ACTION_DROP:
                onDrop(dragEvent);
                break;
            case DragEvent.ACTION_DRAG_ENDED:
                onDragEnd(dragEvent);
                reset();
                break;
            default:
                // No action needed for other types of drag actions.
                break;
        }
        // Return false so this listener does not consume the drag event of the view it listened to.
        return false;
    }

    /**
     * Builds the clipData from the dragged data based on the data's type, it will return null when
     * the input DropData cannot be converted into ClipData (e.g. dragged item is image and there's
     * no content provider to handle it)
     *
     * @param dropData The data to be dropped.
     * @return ClipData based on the dropData type.
     */
    @Nullable
    protected ClipData buildClipData(DropDataAndroid dropData) {
        @DragTargetType int type = getDragTargetType(dropData);
        switch (type) {
            case DragTargetType.TEXT:
                return new ClipData(
                        null,
                        new String[] {
                            ClipDescription.MIMETYPE_TEXT_PLAIN, MimeTypeUtils.CHROME_MIMETYPE_TEXT
                        },
                        new Item(dropData.text));
            case DragTargetType.IMAGE:
                Uri cachedUri = DropDataProviderUtils.cacheImageData(dropData);
                // If there's no content provider we shouldn't start the drag.
                if (cachedUri == null) {
                    return null;
                }
                return ClipData.newUri(
                        ContextUtils.getApplicationContext().getContentResolver(), null, cachedUri);
            case DragTargetType.LINK:
                if (mDragAndDropBrowserDelegate != null) {
                    Intent intent =
                            mDragAndDropBrowserDelegate.createUrlIntent(
                                    dropData.gurl.getSpec(), UrlIntentSource.LINK);
                    if (intent != null) {
                        return new ClipData(
                                null,
                                new String[] {
                                    ClipDescription.MIMETYPE_TEXT_PLAIN,
                                    ClipDescription.MIMETYPE_TEXT_INTENT,
                                    MimeTypeUtils.CHROME_MIMETYPE_LINK
                                },
                                new Item(getTextForLinkData(dropData), intent, null));
                    }
                }
                return ClipData.newPlainText(null, getTextForLinkData(dropData));
            case DragTargetType.BROWSER_CONTENT:
                return mDragAndDropBrowserDelegate.buildClipData(dropData);
            case DragTargetType.INVALID:
                return null;
            case DragTargetType.NUM_ENTRIES:
            default:
                assert false : "Should not be reached!";
                return null;
        }
    }

    protected int buildFlags(DropDataAndroid dropData) {
        if (dropData.hasBrowserContent()) {
            int flag = View.DRAG_FLAG_GLOBAL | View.DRAG_FLAG_OPAQUE;
            return mDragAndDropBrowserDelegate == null
                    ? flag
                    : mDragAndDropBrowserDelegate.buildFlags(flag, dropData);
        }
        int flag = 0;
        if (dropData.isPlainText() || dropData.hasLink()) {
            flag |= View.DRAG_FLAG_GLOBAL;
        }
        if (dropData.hasImage()) {
            flag |= View.DRAG_FLAG_GLOBAL | View.DRAG_FLAG_GLOBAL_URI_READ;
            if (mDragAndDropBrowserDelegate != null
                    && mDragAndDropBrowserDelegate.getSupportAnimatedImageDragShadow()) {
                flag |= View.DRAG_FLAG_OPAQUE;
            }

        }
        return flag;
    }

    protected View.DragShadowBuilder createDragShadowBuilder(
            View containerView,
            Context context,
            Bitmap shadowImage,
            boolean isImage,
            int windowWidth,
            int windowHeight,
            int cursorOffsetX,
            int cursorOffsetY,
            int dragObjRectWidth,
            int dragObjRectHeight) {
        ImageView imageView = new ImageView(context);
        if (isImage) {
            // If drag shadow image is an 1*1 image, it is not considered as a valid drag shadow.
            // In such cases, use a globe icon as placeholder instead. See
            // https://crbug.com/1304433.
            if (shadowImage.getHeight() <= 1 && shadowImage.getWidth() <= 1) {
                Drawable globeIcon =
                        AppCompatResources.getDrawable(context, R.drawable.ic_globe_24dp);
                assert globeIcon != null;

                final int minSize =
                        AnimatedImageDragShadowBuilder.getDragShadowMinSize(context.getResources());
                mShadowWidth = minSize;
                mShadowHeight = minSize;
                imageView.setLayoutParams(new ViewGroup.LayoutParams(minSize, minSize));
                imageView.setScaleType(ImageView.ScaleType.FIT_CENTER);
                imageView.setImageDrawable(globeIcon);
            } else {
                DragShadowSpec dragShadowSpec =
                        AnimatedImageDragShadowBuilder.getDragShadowSpec(
                                context,
                                shadowImage.getWidth(),
                                shadowImage.getHeight(),
                                windowWidth,
                                windowHeight);
                updateShadowSizeWithBorder(
                        context, dragShadowSpec.targetWidth, dragShadowSpec.targetHeight);
                if (mDragAndDropBrowserDelegate != null
                        && mDragAndDropBrowserDelegate.getSupportAnimatedImageDragShadow()) {
                    assert dragObjRectWidth != 0;
                    assert dragObjRectHeight != 0;
                    CursorOffset cursorOffset =
                            AnimatedImageDragShadowBuilder.adjustCursorOffset(
                                    cursorOffsetX,
                                    cursorOffsetY,
                                    dragObjRectWidth,
                                    dragObjRectHeight,
                                    dragShadowSpec);
                    return new AnimatedImageDragShadowBuilder(
                            containerView,
                            context,
                            shadowImage,
                            cursorOffset.x,
                            cursorOffset.y,
                            dragShadowSpec);
                } else {
                    updateShadowImage(
                            context,
                            shadowImage,
                            imageView,
                            dragShadowSpec.targetWidth,
                            dragShadowSpec.targetHeight);
                }
            }
        } else {
            mShadowWidth = shadowImage.getWidth();
            mShadowHeight = shadowImage.getHeight();
            imageView.setImageBitmap(shadowImage);
        }
        imageView.layout(0, 0, mShadowWidth, mShadowHeight);

        return new DragShadowBuilder(imageView);
    }

    /**
     * Helper function to update the drag shadow: 1. Resize and center crop shadowImage to target
     * size; 2. Round corners to 8dp; 3. Add 1dp border.
     */
    private void updateShadowImage(
            Context context,
            Bitmap shadowImage,
            ImageView imageView,
            int targetWidth,
            int targetHeight) {
        Resources res = context.getResources();
        shadowImage =
                ThumbnailUtils.extractThumbnail(
                        shadowImage,
                        targetWidth,
                        targetHeight,
                        ThumbnailUtils.OPTIONS_RECYCLE_INPUT);
        RoundedBitmapDrawable drawable = RoundedBitmapDrawableFactory.create(res, shadowImage);
        drawable.setCornerRadius(
                res.getDimensionPixelSize(R.dimen.drag_shadow_border_corner_radius));
        imageView.setImageDrawable(drawable);
        imageView.setBackgroundResource(R.drawable.drag_shadow_background);
        int borderSize = res.getDimensionPixelSize(R.dimen.drag_shadow_border_size);
        imageView.setPadding(borderSize, borderSize, borderSize, borderSize);
    }

    // Enlarge the shadow image by twice the size of the border.
    private void updateShadowSizeWithBorder(Context context, int targetWidth, int targetHeight) {
        Resources res = context.getResources();
        int borderSize = res.getDimensionPixelSize(R.dimen.drag_shadow_border_size);
        mShadowWidth = targetWidth + borderSize * 2;
        mShadowHeight = targetHeight + borderSize * 2;
    }

    private void onDragStarted(DragEvent dragStartEvent) {
        mDragStartXDp = dragStartEvent.getX();
        mDragStartYDp = dragStartEvent.getY();
    }

    private void onDrop(DragEvent dropEvent) {
        mIsDropOnView = true;

        final int dropDistance =
                Math.round(
                        MathUtils.distance(
                                mDragStartXDp, mDragStartYDp, dropEvent.getX(), dropEvent.getY()));
        RecordHistogram.recordExactLinearHistogram(
                "Android.DragDrop.FromWebContent.DropInWebContent.DistanceDip", dropDistance, 51);

        long dropDuration = SystemClock.elapsedRealtime() - mDragStartSystemElapsedTime;
        RecordHistogram.recordMediumTimesHistogram(
                "Android.DragDrop.FromWebContent.DropInWebContent.Duration", dropDuration);
    }

    private void onDropFromOutside(DragEvent dropEvent) {
        if (mDragAndDropBrowserDelegate == null) {
            return;
        }
        DragAndDropPermissions dragAndDropPermissions =
                mDragAndDropBrowserDelegate.getDragAndDropPermissions(dropEvent);
        if (dragAndDropPermissions == null) {
            return;
        }
        // TODO(shuyng): Read image data in background thread.
        dragAndDropPermissions.release();
    }

    private void onDragEnd(DragEvent dragEndEvent) {
        boolean dragResult = dragEndEvent.getResult();

        // Only record metrics when drop does not happen for ContentView.
        if (!mIsDropOnView) {
            assert mDragStartSystemElapsedTime > 0;
            assert mDragTargetType != DragTargetType.INVALID;
            long dragDuration = SystemClock.elapsedRealtime() - mDragStartSystemElapsedTime;
            recordDragDurationAndResult(dragDuration, dragResult);
            recordDragTargetType(mDragTargetType);
        }
        // Allow drop into ContentView when files are supported by clank.
        boolean imageInUse =
                !mIsDropOnView
                        || UiAndroidFeatureMap.isEnabled(UiAndroidFeatureList.DRAG_DROP_FILES);
        DropDataProviderUtils.clearImageCache(imageInUse && dragResult);
    }

    /**
     * Return the {@link DragTargetType} based on the content of DropDataAndroid. The result will
     * bias plain text > image > link. TODO(crbug.com/40823936): Manage the ClipData bias with
     * EventForwarder in one place.
     */
    static @DragTargetType int getDragTargetType(DropDataAndroid dropDataAndroid) {
        if (dropDataAndroid.hasBrowserContent()) {
            return DragTargetType.BROWSER_CONTENT;
        } else if (dropDataAndroid.isPlainText()) {
            return DragTargetType.TEXT;
        } else if (dropDataAndroid.hasImage()) {
            return DragTargetType.IMAGE;
        } else if (dropDataAndroid.hasLink()) {
            return DragTargetType.LINK;
        } else {
            return DragTargetType.INVALID;
        }
    }

    /** Return the text to be dropped when {@link DropDataAndroid} contains a link. */
    static String getTextForLinkData(DropDataAndroid dropData) {
        assert dropData.hasLink();
        if (TextUtils.isEmpty(dropData.text)) return dropData.gurl.getSpec();
        return dropData.text + "\n" + dropData.gurl.getSpec();
    }

    private void reset() {
        mShadowHeight = 0;
        mShadowWidth = 0;
        mDragTargetType = DragTargetType.INVALID;
        mIsDragStarted = false;
        mIsDropOnView = false;
        mDragStartSystemElapsedTime = -1;
    }

    private void recordDragTargetType(@DragTargetType int type) {
        RecordHistogram.recordEnumeratedHistogram(
                "Android.DragDrop.FromWebContent.TargetType", type, DragTargetType.NUM_ENTRIES);
    }

    private void recordDragDurationAndResult(long duration, boolean result) {
        String histogramPrefix = "Android.DragDrop.FromWebContent.Duration.";
        String suffix = result ? "Success" : "Canceled";
        RecordHistogram.recordMediumTimesHistogram(histogramPrefix + suffix, duration);
    }

    @VisibleForTesting
    float getDragStartXDp() {
        return mDragStartXDp;
    }

    @VisibleForTesting
    float getDragStartYDp() {
        return mDragStartYDp;
    }
}