AppCompatReceiveContentHelper.java

/*
 * Copyright 2021 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.appcompat.widget;

import static androidx.core.view.ContentInfoCompat.FLAG_CONVERT_TO_PLAIN_TEXT;
import static androidx.core.view.ContentInfoCompat.SOURCE_CLIPBOARD;
import static androidx.core.view.ContentInfoCompat.SOURCE_DRAG_AND_DROP;

import android.app.Activity;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.ContextWrapper;
import android.os.Build;
import android.text.Selection;
import android.text.Spannable;
import android.util.Log;
import android.view.DragEvent;
import android.view.View;
import android.widget.TextView;

import androidx.annotation.DoNotInline;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.view.ContentInfoCompat;
import androidx.core.view.ViewCompat;

/**
 * Common code for handling content via {@link ViewCompat#performReceiveContent}.
 */
final class AppCompatReceiveContentHelper {
    private AppCompatReceiveContentHelper() {}

    private static final String LOG_TAG = "ReceiveContent";

    /**
     * If the SDK is <= 30 and the view has a {@link androidx.core.view.OnReceiveContentListener},
     * use the listener to handle the "Paste" and "Paste as plain text" actions.
     *
     * @return true if the action was handled; false otherwise
     */
    static boolean maybeHandleMenuActionViaPerformReceiveContent(@NonNull TextView view,
            int actionId) {
        if (Build.VERSION.SDK_INT >= 31
                || ViewCompat.getOnReceiveContentMimeTypes(view) == null
                || !(actionId == android.R.id.paste || actionId == android.R.id.pasteAsPlainText)) {
            return false;
        }
        ClipboardManager cm = (ClipboardManager) view.getContext().getSystemService(
                Context.CLIPBOARD_SERVICE);
        ClipData clip = (cm == null) ? null : cm.getPrimaryClip();
        if (clip != null && clip.getItemCount() > 0) {
            ContentInfoCompat payload = new ContentInfoCompat.Builder(clip, SOURCE_CLIPBOARD)
                    .setFlags((actionId == android.R.id.paste) ? 0 : FLAG_CONVERT_TO_PLAIN_TEXT)
                    .build();
            ViewCompat.performReceiveContent(view, payload);
        }
        return true;
    }

    /**
     * If the SDK is <= 30 (but >= 24) and the view has a
     * {@link androidx.core.view.OnReceiveContentListener}, try to handle drag-and-drop via the
     * listener.
     *
     * @return true if the event was handled; false otherwise
     */
    static boolean maybeHandleDragEventViaPerformReceiveContent(@NonNull View view,
            @NonNull DragEvent event) {
        if (Build.VERSION.SDK_INT >= 31
                || Build.VERSION.SDK_INT < 24
                || event.getLocalState() != null
                || ViewCompat.getOnReceiveContentMimeTypes(view) == null) {
            return false;
        }
        // We make a best effort to find the activity for this view by unwrapping the context.
        // If we are not able to find it, we can't provide default drag-and-drop handling via
        // OnReceiveContentListener. If that happens an app can still implement custom handling
        // using an OnDragListener or by overriding onDragEvent().
        final Activity activity = tryGetActivity(view);
        if (activity == null) {
            Log.i(LOG_TAG, "Can't handle drop: no activity: view=" + view);
            return false;
        }
        if (event.getAction() == DragEvent.ACTION_DRAG_STARTED) {
            // We need onDragEvent to return true for ACTION_DRAG_STARTED in order to be notified
            // of further drag events for the current drag action. TextView has the appropriate
            // logic to return true for ACTION_DRAG_STARTED if the TextView is editable. Other
            // widgets don't have default handling for drag-and-drop, so we return true ourselves
            // here.
            return !(view instanceof TextView);
        }
        if (event.getAction() == DragEvent.ACTION_DROP) {
            return (view instanceof TextView)
                    ? OnDropApi24Impl.onDropForTextView(event, (TextView) view, activity)
                    : OnDropApi24Impl.onDropForView(event, view, activity);
        }
        return false;
    }

    @RequiresApi(24) // For Activity.requestDragAndDropPermissions()
    private static final class OnDropApi24Impl {
        private OnDropApi24Impl() {}

        @DoNotInline
        static boolean onDropForTextView(@NonNull DragEvent event, @NonNull TextView view,
                @NonNull Activity activity) {
            activity.requestDragAndDropPermissions(event);
            final int offset = view.getOffsetForPosition(event.getX(), event.getY());
            view.beginBatchEdit();
            try {
                Selection.setSelection((Spannable) view.getText(), offset);
                final ContentInfoCompat payload = new ContentInfoCompat.Builder(
                        event.getClipData(), SOURCE_DRAG_AND_DROP).build();
                ViewCompat.performReceiveContent(view, payload);
            } finally {
                view.endBatchEdit();
            }
            return true;
        }

        @DoNotInline
        static boolean onDropForView(@NonNull DragEvent event, @NonNull View view,
                @NonNull Activity activity) {
            activity.requestDragAndDropPermissions(event);
            final ContentInfoCompat payload = new ContentInfoCompat.Builder(
                    event.getClipData(), SOURCE_DRAG_AND_DROP).build();
            ViewCompat.performReceiveContent(view, payload);
            return true;
        }
    }

    /**
     * Attempts to find the activity for the given view by unwrapping the view's context. This is
     * a "best effort" approach that's not guaranteed to get the activity, since a view's context
     * is not necessarily an activity.
     *
     * @param view The target view.
     * @return The activity if found; null otherwise.
     */
    @Nullable
    static Activity tryGetActivity(@NonNull View view) {
        Context context = view.getContext();
        while (context instanceof ContextWrapper) {
            if (context instanceof Activity) {
                return (Activity) context;
            }
            context = ((ContextWrapper) context).getBaseContext();
        }
        return null;
    }
}