ToolbarController.java

/*
 * Copyright (C) 2018 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.textclassifier.widget;

import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;

import android.app.PendingIntent;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Intent;
import android.graphics.Color;
import android.graphics.Rect;
import android.os.Build;
import android.text.Layout;
import android.text.Spannable;
import android.text.TextPaint;
import android.text.TextUtils;
import android.text.style.BackgroundColorSpan;
import android.text.style.CharacterStyle;
import android.util.Log;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewTreeObserver;
import android.widget.PopupWindow;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.RestrictTo;
import androidx.annotation.UiThread;
import androidx.annotation.VisibleForTesting;
import androidx.appcompat.view.menu.MenuBuilder;
import androidx.collection.ArrayMap;
import androidx.core.app.RemoteActionCompat;
import androidx.core.internal.view.SupportMenu;
import androidx.core.util.Preconditions;
import androidx.textclassifier.R;

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

/**
 * Controls displaying of actions in the floating toolbar.
 *
 * @hide
 */
@RestrictTo(LIBRARY_GROUP_PREFIX)
@RequiresApi(Build.VERSION_CODES.M)
@UiThread
public final class ToolbarController {

    private static final String LOG_TAG = "ToolbarController";
    private static final int ORDER_START = 50;
    private static final int ALPHA = 20;
    private static final int HIGHLIGHT_DELAY_MS = 80;

    private final TextView mTextView;
    private final Rect mContentRect;
    private final IFloatingToolbar mToolbar;
    private final BackgroundSpan mHighlight;

    private static WeakReference<ToolbarController> sInstance = new WeakReference<>(null);
    private static FloatingToolbarFactory sFloatingToolbarFactory =
            textView -> new FloatingToolbar(textView);

    /**
     * Returns the singleton instance of the toolbar controller and associates it with the specified
     * textView. If the toolbar was initially associated with a different textView, the toolbar will
     * be dismissed before associating it with the newly specified textView.
     */
    public static ToolbarController getInstance(TextView textView) {
        final ToolbarController controller = sInstance.get();
        if (controller == null) {
            sInstance = new WeakReference<>(new ToolbarController(textView));
        } else if (controller.mTextView != textView) {
            logv("New textView. Dismissing previous toolbar.");
            dismissImmediately(controller.mToolbar);
            sInstance = new WeakReference<>(new ToolbarController(textView));
        }
        return sInstance.get();
    }

    private ToolbarController(TextView textView) {
        mTextView = Preconditions.checkNotNull(textView);
        mContentRect = new Rect();
        mHighlight = new BackgroundSpan(withAlpha(mTextView.getHighlightColor()));
        mToolbar = sFloatingToolbarFactory.create(textView);
        mToolbar.setOnMenuItemClickListener(new OnMenuItemClickListener(mToolbar));
        mToolbar.setDismissOnMenuItemClick(true);
    }

    /**
     * Sets a factory that creates an instance of floating toolbar.
     */
    public static void setFloatingToolbarFactory(
            @NonNull FloatingToolbarFactory floatingToolbarFactory) {
        sFloatingToolbarFactory = Preconditions.checkNotNull(floatingToolbarFactory);
    }

    /**
     * Shows the floating toolbar with the specified actions.
     *
     * <p>This controller also adds standard items (e.g. Copy, Share) to the toolbar in addition to
     * the specified actions.
     *
     * @param actions actions to show in the toolbar
     * @param start text start index for positioning the toolbar;
     *              must be less at least 0 and less than end index
     * @param end text end index for positioning the toolbar;
     *            the toolbar will not be shown this index is invalid for the associated textView
     */
    public void show(List<RemoteActionCompat> actions, int start, int end) {
        Preconditions.checkNotNull(actions);
        Preconditions.checkArgumentInRange(start, 0, end - 1, "start");

        final CharSequence text = mTextView.getText();
        if (text == null || end > text.length()) {
            Log.d(LOG_TAG, "Cannot show link toolbar. Invalid text indices");
            return;
        }

        logv("About to show new toolbar state. Dismissing old state");
        dismissImmediately(mToolbar);
        final SupportMenu menu = createMenu(mTextView, mHighlight, actions);
        if (canShowToolbar(mTextView, true) && menu.hasVisibleItems()) {
            setListeners(mTextView, start, end, mToolbar);
            setHighlight(mTextView, mHighlight, start, end, mToolbar);
            updateRectCoordinates(mContentRect, mTextView, start, end);
            mToolbar.setContentRect(mContentRect);
            mToolbar.setMenu(menu);
            mToolbar.show();
            logv("Showing toolbar");
        }
    }

    @VisibleForTesting
    boolean isToolbarShowing() {
        return mToolbar.isShowing();
    }

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    static void dismissImmediately(IFloatingToolbar toolbar) {
        toolbar.hide();
        toolbar.dismiss();
    }

    /**
     * Returns true if the textView should be allowed to show a toolbar. Otherwise, returns false.
     *
     * @param textView the textView
     * @param assumeWindowFocus if true, this method assumes the window in which the textView is in
     *                          has focus. Should typically be set to {@code true} unless the caller
     *                          knows the window does not have focus.
     */
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    static boolean canShowToolbar(TextView textView, boolean assumeWindowFocus) {
        final boolean viewFocus = textView.hasFocus();
        final boolean viewAttached = textView.isAttachedToWindow();
        final boolean canShowToolbar = assumeWindowFocus && viewFocus && viewAttached;
        if (!canShowToolbar) {
            logv(String.format("canShowToolbar=false. "
                    + "Reason: windowFocus=%b, viewFocus=%b, viewAttached=%b",
                    assumeWindowFocus, viewFocus, viewAttached));
        }
        return canShowToolbar;
    }

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    static int withAlpha(int color) {
        return Color.argb(ALPHA, Color.red(color), Color.green(color), Color.blue(color));
    }

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    @Nullable
    static String getHighlightedText(TextView textView, BackgroundSpan highlight) {
        final CharSequence text = textView.getText();
        if (text instanceof Spannable) {
            final Spannable spannable = (Spannable) text;
            final int start = spannable.getSpanStart(highlight);
            final int end = spannable.getSpanEnd(highlight);
            final int min = Math.max(0, Math.min(start, end));
            final int max = Math.max(0, Math.max(start, end));
            return textView.getText().subSequence(min, max).toString();
        }
        return null;
    }

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    static void removeHighlight(TextView textView) {
        final CharSequence text = textView.getText();
        if (text instanceof Spannable) {
            final Spannable spannable = (Spannable) text;
            final BackgroundSpan[] spans =
                    spannable.getSpans(0, text.length(), BackgroundSpan.class);
            for (BackgroundSpan span : spans) {
                spannable.removeSpan(span);
            }
        }
    }

    private static void setHighlight(
            final TextView textView, final BackgroundSpan highlight,
            final int start, final int end, final IFloatingToolbar toolbar) {
        final CharSequence text = textView.getText();
        if (text instanceof Spannable) {
            removeHighlight(textView);
            final String originalText = text.toString();
            textView.postDelayed(new Runnable() {
                @Override
                public void run() {
                    if (canShowToolbar(textView, true)
                            && originalText.equals(textView.getText().toString())
                            && toolbar.isShowing()) {
                        ((Spannable) text).setSpan(highlight, start, end, 0);
                    }
                }
            }, HIGHLIGHT_DELAY_MS);
        }
    }

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    static void updateRectCoordinates(Rect rect, TextView textView, int startIndex, int endIndex) {
        final int[] startXY = getCoordinates(textView, startIndex, /* startCoordinate= */ true);
        final int[] endXY = getCoordinates(textView, endIndex, /* startCoordinate= */false);
        rect.set(startXY[0], startXY[1], endXY[0], endXY[1]);
        rect.sort();
    }

    private static int[] getCoordinates(TextView textView, int index, boolean startCoordinate) {
        final Layout layout = textView.getLayout();
        final int line = layout.getLineForOffset(index);
        final int x = (int) layout.getPrimaryHorizontal(index);
        final int y = startCoordinate ? layout.getLineTop(line) : layout.getLineBottom(line);
        final int[] xy = new int[2];
        textView.getLocationOnScreen(xy);
        return new int[]{
                x + textView.getTotalPaddingLeft() - textView.getScrollX() + xy[0],
                y + textView.getTotalPaddingTop() - textView.getScrollY() + xy[1]};
    }

    private static SupportMenu createMenu(
            final TextView textView,
            final BackgroundSpan highlight,
            List<RemoteActionCompat> actions) {
        final MenuBuilder menu = new MenuBuilder(textView.getContext());
        final int size = actions.size();
        final Map<MenuItem, PendingIntent> menuActions = new ArrayMap<>(size);
        for (int i = 0; i < size; i++) {
            final RemoteActionCompat action = actions.get(i);
            final MenuItem item = menu.add(
                    FloatingToolbar.MENU_ID_SMART_ACTION  /* groupId */,
                    i == 0 ? FloatingToolbar.MENU_ID_SMART_ACTION : i  /* itemId */,
                    i == 0 ? 0 : ORDER_START + i  /* order */,
                    action.getTitle()  /* title */);
            if (action.shouldShowIcon()) {
                item.setIcon(action.getIcon().loadDrawable(textView.getContext()));
            }
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                item.setContentDescription(action.getContentDescription());
            }
            item.setShowAsAction(i == 0
                    ? MenuItem.SHOW_AS_ACTION_ALWAYS
                    : MenuItem.SHOW_AS_ACTION_NEVER);
            menuActions.put(item, action.getActionIntent());
        }

        menu.add(Menu.NONE, android.R.id.copy, 1,
                android.R.string.copy)
                .setAlphabeticShortcut('c')
                .setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
        menu.add(Menu.NONE, android.R.id.shareText, 2,
                R.string.abc_share)
                .setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM);

        menu.setCallback(new MenuBuilder.Callback() {
            @Override
            public boolean onMenuItemSelected(MenuBuilder menu, MenuItem item) {
                final PendingIntent intent = menuActions.get(item);
                if (intent != null) {
                    try {
                        intent.send();
                    } catch (PendingIntent.CanceledException e) {
                        Log.e(LOG_TAG, "Error performing smart action", e);
                    }
                } else {
                    switch (item.getItemId()) {
                        case android.R.id.copy:
                            copyText();
                            break;
                        case android.R.id.shareText:
                            shareText();
                            break;
                    }
                }
                return true;
            }

            @Override
            public void onMenuModeChange(MenuBuilder menu) {}

            private void copyText() {
                final ClipboardManager clipboard =
                        textView.getContext().getSystemService(ClipboardManager.class);
                final String text = getHighlightedText(textView, highlight);
                if (clipboard != null && !TextUtils.isEmpty(text)) {
                    try {
                        clipboard.setPrimaryClip(ClipData.newPlainText(null, text));
                    } catch (Throwable t) {
                        Log.d(LOG_TAG, "Error copying text: " + t.getMessage());
                    }
                }
            }

            private void shareText() {
                final String text = getHighlightedText(textView, highlight);
                if (!TextUtils.isEmpty(text)) {
                    final Intent sharingIntent = new Intent(android.content.Intent.ACTION_SEND);
                    sharingIntent.setType("text/plain");
                    sharingIntent.putExtra(android.content.Intent.EXTRA_TEXT, text);
                    textView.getContext().startActivity(Intent.createChooser(sharingIntent, null));
                }
            }
        });
        return menu;
    }

    /* To enable verbose logging. Run the following command:
     * adb shell setprop log.tag.ToolbarController VERBOSE && adb shell stop && adb shell start
     */
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    static void logv(String message) {
        if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) {
            Log.v(LOG_TAG, message);
        }
    }

    private static void setListeners(
            TextView textView, int start, int end, IFloatingToolbar toolbar) {
        toolbar.setOnDismissListener(
                new OnToolbarDismissListener(
                        textView,
                        new TextViewListener(toolbar, textView, start, end),
                        new ActionModeCallback(
                                toolbar,
                                textView.getCustomSelectionActionModeCallback(),
                                /* preferMe= */ false),
                        new ActionModeCallback(
                                toolbar,
                                textView.getCustomInsertionActionModeCallback(),
                                /* preferMe= */ true)));
    }

    /**
     * Listens for several TextView events to reposition or dismiss the toolbar.
     */
    private static final class TextViewListener implements
            ViewTreeObserver.OnPreDrawListener,
            ViewTreeObserver.OnWindowFocusChangeListener,
            ViewTreeObserver.OnGlobalFocusChangeListener,
            ViewTreeObserver.OnWindowAttachListener {

        private static final long THROTTLE_DELAY_MS = 300;

        private final IFloatingToolbar mToolbar;
        private final TextView mTextView;
        private final Rect mContentRect;
        private final Rect mTempRect;
        private final int mStart;
        private final int mEnd;

        private long mLastUpdateTimeMs = System.currentTimeMillis() - THROTTLE_DELAY_MS;

        TextViewListener(IFloatingToolbar toolbar, TextView textView, int start, int end) {
            mToolbar = Preconditions.checkNotNull(toolbar);
            mTextView = Preconditions.checkNotNull(textView);
            mContentRect = new Rect();
            mTempRect = new Rect();
            mStart = start;
            mEnd = end;
        }

        @Override
        public boolean onPreDraw() {
            final long now = System.currentTimeMillis();
            if (!maybeDismissToolbar(true, "onPreDraw")
                    && mToolbar.isShowing()
                    && now - mLastUpdateTimeMs >= THROTTLE_DELAY_MS) {
                updateRectCoordinates(mTempRect, mTextView, mStart, mEnd);
                if (!mTempRect.equals(mContentRect)) {
                    // View moved.
                    mContentRect.set(mTempRect);
                    mToolbar.setContentRect(mContentRect);
                    mToolbar.updateLayout();
                    mLastUpdateTimeMs = now;
                }
            }
            return true;
        }

        @Override
        public void onWindowFocusChanged(boolean hasFocus) {
            maybeDismissToolbar(hasFocus, "onWindowFocusChanged");
        }

        @Override
        public void onGlobalFocusChanged(View oldFocus, View newFocus) {
            maybeDismissToolbar(true, "onGlobalFocusChanged");
        }

        @Override
        public void onWindowAttached() {
            maybeDismissToolbar(true, "onWindowAttached");
        }

        @Override
        public void onWindowDetached() {
            maybeDismissToolbar(true, "onWindowDetached");
        }

        private boolean maybeDismissToolbar(boolean assumeWindowFocus, String caller) {
            if (canShowToolbar(mTextView, assumeWindowFocus)) {
                return false;
            }
            logv("TextViewListener." + caller + ": Dismissing toolbar.");
            dismissImmediately(mToolbar);
            return true;
        }
    }

    /**
     * Wraps a textView's action mode callback so the toolbar can react to action mode updates.
     */
    private static final class ActionModeCallback extends ActionMode.Callback2 {

        private final IFloatingToolbar mToolbar;
        @Nullable final ActionMode.Callback mOriginalCallback;
        private final boolean mPreferMe;

        ActionModeCallback(
                IFloatingToolbar toolbar,
                @Nullable ActionMode.Callback originalCallback,
                boolean preferMe) {
            mToolbar = Preconditions.checkNotNull(toolbar);
            mOriginalCallback = originalCallback;
            mPreferMe = preferMe;
        }

        @Override
        public boolean onCreateActionMode(ActionMode actionMode, Menu menu) {
            if (actionMode.getType() == ActionMode.TYPE_FLOATING) {
                if (mPreferMe) {
                    // Don't start the original action mode if this action mode should be preferred.
                    return false;
                }

                // Dismiss the toolbar if the textView starts a floating action mode.
                // NOTE that TextView by default starts a selection/insertion action mode if no
                // custom callback is set.
                if (mOriginalCallback == null
                        || mOriginalCallback.onCreateActionMode(actionMode, menu)) {
                    logv("ActionModeCallback: Dismissing toolbar. hasCallback="
                            + (mOriginalCallback != null));
                    dismissImmediately(mToolbar);
                    return true;
                }
                return false;
            }
            return mOriginalCallback.onCreateActionMode(actionMode, menu);
        }

        @Override
        public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) {
            // If another toolbar is showing, this toolbar should not be showing.
            mToolbar.dismiss();

            return mOriginalCallback == null
                    || mOriginalCallback.onPrepareActionMode(actionMode, menu);
        }

        @Override
        public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) {
            return mOriginalCallback != null
                    && mOriginalCallback.onActionItemClicked(actionMode, menuItem);
        }

        @Override
        public void onDestroyActionMode(ActionMode actionMode) {
            if (mOriginalCallback != null) {
                mOriginalCallback.onDestroyActionMode(actionMode);
            }
        }

        @Override
        public void onGetContentRect(ActionMode mode, View view, Rect outRect) {
            if (mOriginalCallback instanceof ActionMode.Callback2) {
                ((ActionMode.Callback2) mOriginalCallback).onGetContentRect(mode, view, outRect);
            }
        }
    }

    private static final class OnToolbarDismissListener implements PopupWindow.OnDismissListener {

        private final TextView mTextView;
        private final ViewTreeObserver mObserver;
        private final TextViewListener mTextViewListener;
        private final ActionModeCallback mSelectionCallback;
        private final ActionModeCallback mInsertionCallback;

        OnToolbarDismissListener(
                TextView textView,
                TextViewListener textViewListener,
                ActionModeCallback selectionCallback,
                ActionModeCallback insertionCallback) {
            mTextView = Preconditions.checkNotNull(textView);
            mObserver = mTextView.getViewTreeObserver();
            mTextViewListener = Preconditions.checkNotNull(textViewListener);
            registerListeners();
            mSelectionCallback = Preconditions.checkNotNull(selectionCallback);
            mInsertionCallback = Preconditions.checkNotNull(insertionCallback);
            setCallbacks();
        }

        private void registerListeners() {
            mObserver.addOnPreDrawListener(mTextViewListener);
            mObserver.addOnWindowFocusChangeListener(mTextViewListener);
            mObserver.addOnGlobalFocusChangeListener(mTextViewListener);
            mObserver.addOnWindowAttachListener(mTextViewListener);
        }

        private void unregisterListeners() {
            mObserver.removeOnPreDrawListener(mTextViewListener);
            mObserver.removeOnWindowFocusChangeListener(mTextViewListener);
            mObserver.removeOnGlobalFocusChangeListener(mTextViewListener);
            mObserver.removeOnWindowAttachListener(mTextViewListener);
        }

        private void setCallbacks() {
            mTextView.setCustomSelectionActionModeCallback(mSelectionCallback);
            mTextView.setCustomInsertionActionModeCallback(mInsertionCallback);
        }

        private void clearCallbacks() {
            if (mSelectionCallback == mTextView.getCustomSelectionActionModeCallback()) {
                mTextView.setCustomSelectionActionModeCallback(
                        mSelectionCallback.mOriginalCallback);
            }
            if (mInsertionCallback == mTextView.getCustomInsertionActionModeCallback()) {
                mTextView.setCustomInsertionActionModeCallback(
                        mInsertionCallback.mOriginalCallback);
            }
        }

        @Override
        public void onDismiss() {
            removeHighlight(mTextView);
            unregisterListeners();
            clearCallbacks();
        }
    }

    private static final class OnMenuItemClickListener implements MenuItem.OnMenuItemClickListener {

        private final IFloatingToolbar mToolbar;

        OnMenuItemClickListener(IFloatingToolbar toolbar) {
            mToolbar = Preconditions.checkNotNull(toolbar);
        }

        @Override
        public boolean onMenuItemClick(MenuItem item) {
            final Menu menu = mToolbar.getMenu();
            if (menu != null) {
                return menu.performIdentifierAction(item.getItemId(), 0);
            }
            return false;
        }
    }

    /**
     * BackgroundColorSpan that is used to indicate the part of the text that is the subject of the
     * showing toolbar.
     */
    @VisibleForTesting
    static final class BackgroundSpan extends BackgroundColorSpan {

        private static final CharacterStyle NON_PARCELABLE_UNDERLYING = new CharacterStyle() {
            @Override
            public void updateDrawState(TextPaint textPaint) {}
        };

        BackgroundSpan(int color) {
            super(color);
        }

        @Override
        public CharacterStyle getUnderlying() {
            // Prevent this span from being parceled.
            return NON_PARCELABLE_UNDERLYING;
        }
    }

    public interface FloatingToolbarFactory {
        @NonNull
        IFloatingToolbar create(@NonNull TextView textView);
    }
}