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 static androidx.core.view.ContentInfoCompat.SOURCE_INPUT_METHOD;
import static androidx.core.view.inputmethod.InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION;

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.os.Bundle;
import android.text.Selection;
import android.text.Spannable;
import android.util.Log;
import android.view.DragEvent;
import android.view.View;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputContentInfo;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.view.ContentInfoCompat;
import androidx.core.view.ViewCompat;
import androidx.core.view.inputmethod.InputConnectionCompat;
import androidx.core.view.inputmethod.InputContentInfoCompat;

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

    private static final String LOG_TAG = "ReceiveContent";

    /**
     * If the menu action is either "Paste" or "Paste as plain text" and the view has a
     * {@link androidx.core.view.OnReceiveContentListener}, use the listener to handle the paste.
     *
     * @return true if the action was handled; false otherwise
     */
    static boolean maybeHandleMenuActionViaPerformReceiveContent(@NonNull TextView view,
            int menuItemId) {
        if (!(menuItemId == android.R.id.paste || menuItemId == android.R.id.pasteAsPlainText)
                || ViewCompat.getOnReceiveContentMimeTypes(view) == null) {
            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((menuItemId == android.R.id.paste) ? 0 : FLAG_CONVERT_TO_PLAIN_TEXT)
                    .build();
            ViewCompat.performReceiveContent(view, payload);
        }
        return true;
    }

    /**
     * If the given 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 < 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() {}

        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;
        }

        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;
    }

    /**
     * Creates an {@link InputConnectionCompat.OnCommitContentListener} that uses
     * {@link ViewCompat#performReceiveContent} to insert content. The listener returned by this
     * function should be passed to {@link InputConnectionCompat#createWrapper} when creating the
     * {@link InputConnection} in {@link View#onCreateInputConnection}.
     */
    // TODO(b/178324480): Make this a public API on InputConnectionCompat
    @NonNull
    static InputConnectionCompat.OnCommitContentListener createOnCommitContentListener(
            @NonNull final View view) {
        return new InputConnectionCompat.OnCommitContentListener() {
            @Override
            public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags,
                    Bundle opts) {
                Bundle extras = opts;
                if (Build.VERSION.SDK_INT >= 25
                        && (flags & INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
                    try {
                        inputContentInfo.requestPermission();
                    } catch (Exception e) {
                        Log.w(LOG_TAG,
                                "Can't insert content from IME; requestPermission() failed", e);
                        return false;
                    }
                    // Permissions granted above are revoked automatically by the platform when the
                    // corresponding InputContentInfo object is garbage collected. To prevent
                    // this from happening prematurely (before the receiving app has had a chance
                    // to process the content), we set the InputContentInfo object into the
                    // extras of the payload passed to OnReceiveContentListener.
                    InputContentInfo inputContentInfoFmk =
                            (InputContentInfo) inputContentInfo.unwrap();
                    extras = (opts == null) ? new Bundle() : new Bundle(opts);
                    extras.putParcelable(EXTRA_INPUT_CONTENT_INFO, inputContentInfoFmk);
                }
                ClipData clip = new ClipData(inputContentInfo.getDescription(),
                        new ClipData.Item(inputContentInfo.getContentUri()));
                ContentInfoCompat payload = new ContentInfoCompat.Builder(clip, SOURCE_INPUT_METHOD)
                        .setLinkUri(inputContentInfo.getLinkUri())
                        .setExtras(extras)
                        .build();
                return ViewCompat.performReceiveContent(view, payload) == null;
            }
        };
    }

    /**
     * Key for extras in {@link ContentInfoCompat}, to hold the {@link InputContentInfo} object
     * passed by the IME. Apps should not access/read this object; it is only set in the extras
     * in order to prevent premature garbage collection of {@link InputContentInfo} which in
     * turn causes premature revocation of URI permissions.
     */
    private static final String EXTRA_INPUT_CONTENT_INFO =
            "androidx.core.view.extra.INPUT_CONTENT_INFO";
}