chromium/ui/android/java/src/org/chromium/ui/widget/TextViewWithClickableSpans.java

// Copyright 2014 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.widget;

import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Bundle;
import android.text.Layout;
import android.text.SpannableString;
import android.text.style.ClickableSpan;
import android.util.AttributeSet;
import android.view.Menu;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.View;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.PopupMenu;

import androidx.annotation.VisibleForTesting;

import org.chromium.ui.accessibility.AccessibilityState;

/**
 * ClickableSpan isn't accessible by default, so we create a subclass of TextView that tries to
 * handle the case where a user clicks on a view and not directly on one of the clickable spans. We
 * do nothing if it's a touch event directly on a ClickableSpan. Otherwise if there's only one
 * ClickableSpan, we activate it. If there's more than one, we pop up a PopupMenu to disambiguate.
 */
public class TextViewWithClickableSpans extends TextViewWithLeading
        implements View.OnLongClickListener {
    private PopupMenu mDisambiguationMenu;

    public TextViewWithClickableSpans(Context context) {
        super(context);
        init();
    }

    public TextViewWithClickableSpans(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    private void init() {
        // This disables the saving/restoring since the saved text may be in the wrong language
        // (if the user just changed system language), and restoring spans doesn't work anyway.
        // See crbug.com/533362
        setSaveEnabled(false);
        setOnLongClickListener(this);
    }

    @Override
    public boolean onLongClick(View v) {
        assert v == this;
        if (!AccessibilityState.isTouchExplorationEnabled()) {
            // If no accessibility services that requested touch exploration are enabled, then this
            // view should not consume the long click action.
            return false;
        }
        openDisambiguationMenu();
        return true;
    }

    @Override
    public final void setOnLongClickListener(View.OnLongClickListener listener) {
        // Ensure that no one changes the long click listener to anything but this view.
        assert listener == this || listener == null;
        super.setOnLongClickListener(listener);
    }

    @Override
    public boolean performAccessibilityAction(int action, Bundle arguments) {
        // BrailleBack will generate an accessibility click event directly
        // on this view, make sure we handle that correctly.
        if (action == AccessibilityNodeInfo.ACTION_CLICK) {
            handleAccessibilityClick();
            return true;
        }
        return super.performAccessibilityAction(action, arguments);
    }

    @Override
    @SuppressLint("ClickableViewAccessibility")
    public boolean onTouchEvent(MotionEvent event) {
        boolean superResult = super.onTouchEvent(event);

        if (event.getAction() != MotionEvent.ACTION_UP
                && AccessibilityState.isTouchExplorationEnabled()
                && !touchIntersectsAnyClickableSpans(event)) {
            handleAccessibilityClick();
            return true;
        }

        return superResult;
    }

    /**
     * Determines whether the motion event intersects with any of the ClickableSpan(s) within the
     * text.
     *
     * @param event The motion event to compare the spans against.
     * @return Whether the motion event intersected any clickable spans.
     */
    protected boolean touchIntersectsAnyClickableSpans(MotionEvent event) {
        // This logic is borrowed from android.text.method.LinkMovementMethod.
        //
        // ClickableSpan doesn't stop propagation of the event in its click handler,
        // so we should only try to simplify clicking on a clickable span if the touch event
        // isn't already over a clickable span.

        CharSequence text = getText();
        if (!(text instanceof SpannableString)) return false;
        SpannableString spannable = (SpannableString) text;

        int x = (int) event.getX();
        int y = (int) event.getY();

        x -= getTotalPaddingLeft();
        y -= getTotalPaddingTop();

        x += getScrollX();
        y += getScrollY();

        Layout layout = getLayout();
        int line = layout.getLineForVertical(y);
        int off = layout.getOffsetForHorizontal(line, x);

        ClickableSpan[] clickableSpans = spannable.getSpans(off, off, ClickableSpan.class);
        return clickableSpans.length > 0;
    }

    /** Returns the ClickableSpans in this TextView's text. */
    @VisibleForTesting
    public ClickableSpan[] getClickableSpans() {
        CharSequence text = getText();
        if (!(text instanceof SpannableString)) return null;

        SpannableString spannable = (SpannableString) text;
        return spannable.getSpans(0, spannable.length(), ClickableSpan.class);
    }

    private void handleAccessibilityClick() {
        ClickableSpan[] clickableSpans = getClickableSpans();
        if (clickableSpans == null || clickableSpans.length == 0) {
            return;
        } else if (clickableSpans.length == 1) {
            clickableSpans[0].onClick(this);
        } else {
            openDisambiguationMenu();
        }
    }

    private void openDisambiguationMenu() {
        ClickableSpan[] clickableSpans = getClickableSpans();
        if (clickableSpans == null || clickableSpans.length == 0 || mDisambiguationMenu != null) {
            return;
        }

        SpannableString spannable = (SpannableString) getText();
        mDisambiguationMenu = new PopupMenu(getContext(), this);
        Menu menu = mDisambiguationMenu.getMenu();
        for (final ClickableSpan clickableSpan : clickableSpans) {
            CharSequence itemText =
                    spannable.subSequence(
                            spannable.getSpanStart(clickableSpan),
                            spannable.getSpanEnd(clickableSpan));
            MenuItem menuItem = menu.add(itemText);
            menuItem.setOnMenuItemClickListener(
                    new MenuItem.OnMenuItemClickListener() {
                        @Override
                        public boolean onMenuItemClick(MenuItem menuItem) {
                            clickableSpan.onClick(TextViewWithClickableSpans.this);
                            return true;
                        }
                    });
        }

        mDisambiguationMenu.setOnDismissListener(
                new PopupMenu.OnDismissListener() {
                    @Override
                    public void onDismiss(PopupMenu menu) {
                        mDisambiguationMenu = null;
                    }
                });
        mDisambiguationMenu.show();
    }
}