TextLinks.java

/*
 * Copyright 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;

import static androidx.textclassifier.ConvertUtils.toPlatformEntityConfig;
import static androidx.textclassifier.ConvertUtils.unwrapLocalListCompat;

import android.annotation.SuppressLint;
import android.app.PendingIntent;
import android.os.Build;
import android.os.Bundle;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.SpannedString;
import android.text.method.MovementMethod;
import android.text.style.ClickableSpan;
import android.util.Log;
import android.view.View;
import android.widget.TextView;

import androidx.annotation.CallSuper;
import androidx.annotation.FloatRange;
import androidx.annotation.IntDef;
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.core.app.RemoteActionCompat;
import androidx.core.os.LocaleListCompat;
import androidx.core.util.Preconditions;
import androidx.textclassifier.TextClassifier.EntityConfig;
import androidx.textclassifier.TextClassifier.EntityType;
import androidx.textclassifier.widget.ToolbarController;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

/**
 * A collection of links, representing subsequences of text and the entity types (phone number,
 * address, url, etc) they may be.
 */
public final class TextLinks {

    private static final String LOG_TAG = "TextLinks";
    private static final String EXTRA_FULL_TEXT = "text";
    private static final String EXTRA_LINKS = "links";
    private static final String EXTRA_EXTRAS = "extras";

    private final CharSequence mFullText;
    private final List<TextLink> mLinks;
    private final Bundle mExtras;

    static final Executor sWorkerExecutor = Executors.newFixedThreadPool(1);
    static final MainThreadExecutor sMainThreadExecutor = new MainThreadExecutor();

    /** Status unknown. */
    public static final int STATUS_UNKNOWN = -1;
    /** Links were successfully applied to the text. */
    public static final int STATUS_LINKS_APPLIED = 0;
    /** No links exist to apply to text. Links count is zero. */
    public static final int STATUS_NO_LINKS_FOUND = 1;
    /** No links applied to text. The links were filtered out. */
    public static final int STATUS_NO_LINKS_APPLIED = 2;
    /** The specified text does not match the text used to generate the links. */
    public static final int STATUS_DIFFERENT_TEXT = 3;

    /** @hide */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    @Retention(RetentionPolicy.SOURCE)
    @IntDef(value = {
            STATUS_UNKNOWN,
            STATUS_LINKS_APPLIED,
            STATUS_NO_LINKS_FOUND,
            STATUS_NO_LINKS_APPLIED,
            STATUS_DIFFERENT_TEXT
    })
    public @interface Status {}

    /** Do not replace {@link ClickableSpan}s that exist where the {@link TextLinkSpan} needs to
     * be applied to. Do not apply the TextLinkSpan. **/
    public static final int APPLY_STRATEGY_IGNORE = 0;
    /** Replace any {@link ClickableSpan}s that exist where the {@link TextLinkSpan} needs to be
     * applied to. **/
    public static final int APPLY_STRATEGY_REPLACE = 1;

    /** @hide */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    @Retention(RetentionPolicy.SOURCE)
    @IntDef({APPLY_STRATEGY_IGNORE, APPLY_STRATEGY_REPLACE})
    public @interface ApplyStrategy {}

    TextLinks(CharSequence fullText, List<TextLink> links, Bundle extras) {
        mFullText = fullText;
        mLinks = Collections.unmodifiableList(links);
        mExtras = extras;
    }

    /**
     * Returns the text that was used to generate these links.
     */
    @NonNull
    public CharSequence getText() {
        return mFullText;
    }

    /**
     * Returns an unmodifiable Collection of the links.
     */
    @NonNull
    public Collection<TextLink> getLinks() {
        return mLinks;
    }

    /**
     * Returns the extended, vendor specific data.
     *
     * <p><b>NOTE: </b>Each call to this method returns a new bundle copy so clients should
     * prefer to hold a reference to the returned bundle rather than frequently calling this
     * method. Avoid updating the content of this bundle. On pre-O devices, the values in the
     * Bundle are not deep copied.
     */
    @NonNull
    public Bundle getExtras() {
        return BundleUtils.deepCopy(mExtras);
    }

    @Override
    @NonNull
    public String toString() {
        return String.format(Locale.US, "TextLinks{fullText=%s, links=%s}", mFullText, mLinks);
    }

    /**
     * Adds a TextLinks object to a Bundle that can be read back with the same parameters
     * to {@link #createFromBundle(Bundle)}.
     */
    @NonNull
    public Bundle toBundle() {
        final Bundle bundle = new Bundle();
        bundle.putString(EXTRA_FULL_TEXT, mFullText.toString());
        BundleUtils.putTextLinkList(bundle, EXTRA_LINKS, mLinks);
        bundle.putBundle(EXTRA_EXTRAS, mExtras);
        return bundle;
    }

    /**
     * Extracts an TextLinks object from a bundle that was added using {@link #toBundle()}.
     *
     * @throws IllegalArgumentException if the bundle is malformed.
     */
    @NonNull
    public static TextLinks createFromBundle(@NonNull Bundle bundle) {
        Bundle extras = bundle.getBundle(EXTRA_EXTRAS);
        return new TextLinks(
                bundle.getString(EXTRA_FULL_TEXT),
                BundleUtils.getTextLinkListOrThrow(bundle, EXTRA_LINKS),
                extras == null ? Bundle.EMPTY : extras);
    }

    /**
     * Annotates the given text with the generated links.
     *
     * <p><strong>NOTE: </strong>It may be necessary to set a LinkMovementMethod on the TextView
     * widget to properly handle links. See {@link TextView#setMovementMethod(MovementMethod)}.
     * It is also necessary that the TextView be focusable.
     * See {@link TextView#setFocusable(boolean)}} and
     * {@link TextView#setFocusableInTouchMode(boolean)}.
     *
     * @param text the text to apply the links to. Must match the original text
     * @param textClassifier the TextClassifier to use to classify a clicked link. Should usually
     *                       be the one used to generate the links
     * @param textLinksParams the param that specifies how the links should be applied
     *
     * @return the status code which indicates the operation is success or not.
     */
    @Status
    public int apply(
            @NonNull Spannable text,
            @NonNull TextClassifier textClassifier,
            @NonNull TextLinksParams textLinksParams) {
        Preconditions.checkNotNull(text);
        Preconditions.checkNotNull(textClassifier);
        Preconditions.checkNotNull(textLinksParams);

        return textLinksParams.apply(text, this, textClassifier);
    }

    /**
     * A link, identifying a substring of text and possible entity types for it.
     */
    public static final class TextLink {

        private static final String EXTRA_ENTITY_SCORES = "scores";
        private static final String EXTRA_START = "start";
        private static final String EXTRA_END = "end";

        private final EntityConfidence mEntityScores;
        private final int mStart;
        private final int mEnd;

        /**
         * Create a new TextLink.
         *
         * @throws IllegalArgumentException if entityScores is null or empty.
         * @hide
         */
        @VisibleForTesting
        @RestrictTo(RestrictTo.Scope.LIBRARY)
        TextLink(int start, int end, @NonNull Map<String, Float> entityScores) {
            Preconditions.checkNotNull(entityScores);
            Preconditions.checkArgument(!entityScores.isEmpty());
            Preconditions.checkArgument(start <= end);
            mStart = start;
            mEnd = end;
            mEntityScores = new EntityConfidence(entityScores);
        }

        /**
         * Returns the start index of this link in the original text.
         *
         * @return the start index.
         */
        public int getStart() {
            return mStart;
        }

        /**
         * Returns the end index of this link in the original text.
         *
         * @return the end index.
         */
        public int getEnd() {
            return mEnd;
        }

        /**
         * Returns the number of entity types that have confidence scores.
         *
         * @return the entity type count.
         */
        public int getEntityTypeCount() {
            return mEntityScores.getEntities().size();
        }

        /**
         * Returns the entity type at a given index. Entity types are sorted by confidence.
         *
         * @return the entity type at the provided index.
         */
        @NonNull public @EntityType String getEntityType(int index) {
            return mEntityScores.getEntities().get(index);
        }

        /**
         * Returns the confidence score for a particular entity type.
         *
         * @param entityType the entity type.
         */
        public @FloatRange(from = 0.0, to = 1.0) float getConfidenceScore(
                @EntityType String entityType) {
            return mEntityScores.getConfidenceScore(entityType);
        }

        @Override
        @NonNull
        public String toString() {
            return String.format(Locale.US,
                    "TextLink{start=%s, end=%s, entityScores=%s}",
                    mStart, mEnd, mEntityScores);
        }

        /**
         * Adds this TextLink to a Bundle that can be read back with the same parameters
         * to {@link #createFromBundle(Bundle)}.
         */
        @NonNull
        public Bundle toBundle() {
            final Bundle bundle = new Bundle();
            BundleUtils.putMap(bundle, EXTRA_ENTITY_SCORES, mEntityScores.getConfidenceMap());
            bundle.putInt(EXTRA_START, mStart);
            bundle.putInt(EXTRA_END, mEnd);
            return bundle;
        }

        @NonNull
        EntityConfidence getEntityScores() {
            return mEntityScores;
        }

        /**
         * Extracts a TextLink from a bundle that was added using {@link #toBundle()}.
         */
        @NonNull
        public static TextLink createFromBundle(@NonNull Bundle bundle) {
            return new TextLink(
                    bundle.getInt(EXTRA_START),
                    bundle.getInt(EXTRA_END),
                    BundleUtils.getFloatStringMapOrThrow(bundle, EXTRA_ENTITY_SCORES));
        }
    }

    /**
     * A request object for generating TextLinks.
     */
    public static final class Request {

        private static final String EXTRA_TEXT = "text";
        private static final String EXTRA_DEFAULT_LOCALES = "locales";
        private static final String EXTRA_ENTITY_CONFIG = "entity_config";
        private static final String EXTRA_REFERENCE_TIME = "reference_time";

        private final CharSequence mText;
        @Nullable private final LocaleListCompat mDefaultLocales;
        @NonNull private final EntityConfig mEntityConfig;
        @Nullable private Long mReferenceTime = null;
        @NonNull private final Bundle mExtras;

        Request(
                @NonNull CharSequence text,
                @Nullable LocaleListCompat defaultLocales,
                @Nullable EntityConfig entityConfig,
                @Nullable Long referenceTime,
                @NonNull Bundle extras) {
            mText = SpannedString.valueOf(text);
            mDefaultLocales = defaultLocales;
            mEntityConfig = entityConfig == null
                    ? new TextClassifier.EntityConfig.Builder().build()
                    : entityConfig;
            mReferenceTime = referenceTime;
            mExtras = extras;
        }

        /**
         * Returns the text to generate links for.
         */
        @NonNull
        public CharSequence getText() {
            return mText;
        }

        /**
         * @return ordered list of locale preferences that can be used to disambiguate
         *      the provided text
         */
        @Nullable
        public LocaleListCompat getDefaultLocales() {
            return mDefaultLocales;
        }

        /**
         * @return The config representing the set of entities to look for
         * @see Builder#setEntityConfig(EntityConfig)
         */
        @NonNull
        public EntityConfig getEntityConfig() {
            return mEntityConfig;
        }

        /**
         * @return reference time based on which relative dates (e.g. "tomorrow") should be
         *      interpreted. This should be milliseconds from the epoch of
         *      1970-01-01T00:00:00Z(UTC timezone).
         */
        @Nullable
        public Long getReferenceTime() {
            return mReferenceTime;
        }

        /**
         * Returns the extended, vendor specific data.
         *
         * <p><b>NOTE: </b>Each call to this method returns a new bundle copy so clients should
         * prefer to hold a reference to the returned bundle rather than frequently calling this
         * method. Avoid updating the content of this bundle. On pre-O devices, the values in the
         * Bundle are not deep copied.
         */
        @NonNull
        public Bundle getExtras() {
            return BundleUtils.deepCopy(mExtras);
        }

        /**
         * A builder for building TextLinks requests.
         */
        public static final class Builder {

            private final CharSequence mText;

            @Nullable private LocaleListCompat mDefaultLocales;
            @Nullable private EntityConfig mEntityConfig;
            @Nullable private Long mReferenceTime = null;
            @Nullable private Bundle mExtras;

            public Builder(@NonNull CharSequence text) {
                mText = Preconditions.checkNotNull(text);
            }

            /**
             * @param defaultLocales ordered list of locale preferences that may be used to
             *                       disambiguate the provided text. If no locale preferences exist,
             *                       set this to null or an empty locale list.
             * @return this builder
             */
            @NonNull
            public Builder setDefaultLocales(@Nullable LocaleListCompat defaultLocales) {
                mDefaultLocales = defaultLocales;
                return this;
            }

            /**
             * Sets the entity configuration to use. This determines what types of entities the
             * TextClassifier will look for.
             * Set to {@code null} for the default entity config and the TextClassifier will
             * automatically determine what links to generate.
             *
             * @return this builder
             */
            @NonNull
            public Builder setEntityConfig(@Nullable EntityConfig entityConfig) {
                mEntityConfig = entityConfig;
                return this;
            }

            /**
             * @param referenceTime reference time based on which relative dates (e.g. "tomorrow")
             *      should be interpreted. This should usually be the time when the text was
             *      originally composed and should be milliseconds from the epoch of
             *      1970-01-01T00:00:00Z(UTC timezone). For example, if there is a message saying
             *      "see you 10 days later", and the message was composed yesterday, text classifier
             *      will then realize it is indeed means 9 days later from now and generate a link
             *      accordingly. If no reference time or {@code null} is set, now is used.
             *
             * @return this builder
             */
            @NonNull
            public TextLinks.Request.Builder setReferenceTime(
                    @Nullable Long referenceTime) {
                mReferenceTime = referenceTime;
                return this;
            }

            /**
             * Sets the extended, vendor specific data.
             */
            @NonNull
            public Builder setExtras(@Nullable Bundle extras) {
                mExtras = extras;
                return this;
            }

            /**
             * Builds and returns the request object.
             */
            @NonNull
            public Request build() {
                return new Request(mText, mDefaultLocales, mEntityConfig, mReferenceTime,
                        mExtras == null ? Bundle.EMPTY : mExtras);
            }

        }

        /**
         * Adds this Request to a Bundle that can be read back with the same parameters
         * to {@link #createFromBundle(Bundle)}.
         */
        @NonNull
        public Bundle toBundle() {
            final Bundle bundle = new Bundle();
            bundle.putCharSequence(EXTRA_TEXT, mText);
            bundle.putBundle(EXTRA_ENTITY_CONFIG, mEntityConfig.toBundle());
            BundleUtils.putLocaleList(bundle, EXTRA_DEFAULT_LOCALES, mDefaultLocales);
            BundleUtils.putLong(bundle, EXTRA_REFERENCE_TIME, mReferenceTime);
            bundle.putBundle(EXTRA_EXTRAS, mExtras);
            return bundle;
        }

        /**
         * Extracts a Request from a bundle that was added using {@link #toBundle()}.
         */
        @NonNull
        public static Request createFromBundle(@NonNull Bundle bundle) {
            return new Builder(bundle.getCharSequence(EXTRA_TEXT))
                    .setDefaultLocales(BundleUtils.getLocaleList(bundle, EXTRA_DEFAULT_LOCALES))
                    .setEntityConfig(
                            EntityConfig.createFromBundle(bundle.getBundle(EXTRA_ENTITY_CONFIG)))
                    .setReferenceTime(BundleUtils.getLong(bundle, EXTRA_REFERENCE_TIME))
                    .setExtras(bundle.getBundle(EXTRA_EXTRAS))
                    .build();
        }

        /** @hide */
        @RestrictTo(RestrictTo.Scope.LIBRARY)
        @RequiresApi(28)
        @NonNull
        android.view.textclassifier.TextLinks.Request toPlatform() {
            return new android.view.textclassifier.TextLinks.Request.Builder(getText())
                    .setDefaultLocales(unwrapLocalListCompat(getDefaultLocales()))
                    .setEntityConfig(toPlatformEntityConfig(getEntityConfig()))
                    .build();
        }

        /** @hide */
        @RestrictTo(RestrictTo.Scope.LIBRARY)
        @RequiresApi(28)
        @NonNull
        static TextLinks.Request fromPlatform(
                @NonNull android.view.textclassifier.TextLinks.Request request) {
            return new TextLinks.Request.Builder(request.getText())
                    .setDefaultLocales(ConvertUtils.wrapLocalList(request.getDefaultLocales()))
                    .setEntityConfig(
                            TextClassifier.EntityConfig.fromPlatform(request.getEntityConfig()))
                    .build();
        }
    }

    /**
     * A factory to create spans from TextLinks.
     */
    public interface SpanFactory {

        /** Creates a span from a text link. */
        TextLinkSpan createSpan(@NonNull TextLinkSpanData textLinkSpanData);
    }

    /**
     * Contains necessary data for {@link TextLinkSpan}.
     */
    public static class TextLinkSpanData {
        @NonNull
        private final TextLink mTextLink;
        @NonNull
        private final TextClassifier mTextClassifier;
        @Nullable
        private final Long mReferenceTime;

        /**
         * @hide
         */
        @RestrictTo(RestrictTo.Scope.LIBRARY)
        TextLinkSpanData(
                @NonNull TextLink textLink,
                @NonNull TextClassifier textClassifier,
                @Nullable Long referenceTime) {
            mTextLink = Preconditions.checkNotNull(textLink);
            mTextClassifier = Preconditions.checkNotNull(textClassifier);
            mReferenceTime = referenceTime;
        }

        @NonNull
        public TextLink getTextLink() {
            return mTextLink;
        }

        /**
         * TODO: Make it public once we confirm how should we represent a datetime.
         * @hide
         */
        @RestrictTo(RestrictTo.Scope.LIBRARY)
        @Nullable
        public Long getReferenceTime() {
            return mReferenceTime;
        }

        /**
         * @hide
         */
        @RestrictTo(RestrictTo.Scope.LIBRARY)
        TextClassifier getTextClassifier() {
            return mTextClassifier;
        }
    }

    /**
     * A ClickableSpan for a TextLink.
     * <p>
     * You can implement the {@link #onClick(View)} function to specify the on click behavior of
     * the span.
     */
    public abstract static class TextLinkSpan extends ClickableSpan {
        private TextLinkSpanData mTextLinkSpanData;

        public TextLinkSpan(@NonNull TextLinkSpanData textLinkSpanData) {
            mTextLinkSpanData = Preconditions.checkNotNull(textLinkSpanData);
        }

        /**
         * Returns the data that is relevant to this span.
         */
        @NonNull
        public final TextLinkSpanData getTextLinkSpanData() {
            return mTextLinkSpanData;
        }
    }

    /**
     * The default implementation of {@link TextLinkSpan}.
     * <p>
     * When this span is clicked, text classifier will be used to classify the text in the span and
     * suggest possible actions. The suggested actions will be presented to users eventually.
     * You can change the way how the suggested actions are presented to user by overriding
     * {@link #onTextClassificationResult(TextView, TextClassification)}.
     */
    public static class DefaultTextLinkSpan extends TextLinkSpan {

        /**
         * Constructs a DefaultTextLinkSpan.
         *
         * @param textLinkSpanData The data object that contains data of this span, like the text
         *                         link.
         */
        public DefaultTextLinkSpan(@NonNull TextLinkSpanData textLinkSpanData) {
            super(textLinkSpanData);
        }

        /**
         * Performs the click action associated with this span.
         * <p>
         * Subclass implementations should always call through to the superclass implementation.
         */
        @Override
        @CallSuper
        @SuppressLint("SyntheticAccessor")
        public void onClick(@NonNull View widget) {
            if (!(widget instanceof TextView)) {
                return;
            }

            final TextView textView = (TextView) widget;
            final CharSequence text = textView.getText();

            if (!(text instanceof Spanned)) {
                return;
            }

            final Spanned spanned = (Spanned) text;
            final int start = spanned.getSpanStart(this);
            final int end = spanned.getSpanEnd(this);
            if (start < 0 || start >= end || end > text.length()) {
                Log.d(LOG_TAG, "Cannot show link toolbar. Invalid text indices");
                return;
            }

            final TextClassification.Request request =
                    new TextClassification.Request.Builder(text, start, end)
                            .setReferenceTime(getTextLinkSpanData().getReferenceTime())
                            .setDefaultLocales(getLocales(textView))
                            .build();
            // TODO: Truncate the text.
            sWorkerExecutor.execute(new ClassifyTextRunnable(textView, this, request, spanned));
        }

        private static class ClassifyTextRunnable implements Runnable {
            private WeakReference<TextView> mTextView;
            private DefaultTextLinkSpan mTextLinkSpan;
            private TextClassification.Request mRequest;
            private Spanned mClassifiedSpan;

            private ClassifyTextRunnable(
                    TextView textView,
                    DefaultTextLinkSpan textLinkSpan,
                    TextClassification.Request request, Spanned classifiedSpan) {
                mTextView = new WeakReference<>(textView);
                mTextLinkSpan = textLinkSpan;
                mRequest = request;
                mClassifiedSpan = classifiedSpan;
            }

            @Override
            @SuppressLint("SyntheticAccessor")
            public void run() {
                final TextClassifier classifier =
                        mTextLinkSpan.getTextLinkSpanData().getTextClassifier();
                final TextClassification textClassification = classifier.classifyText(mRequest);
                sMainThreadExecutor.execute(new Runnable() {
                    @Override
                    public void run() {
                        TextView textView = mTextView.get();
                        if (textView == null) {
                            return;
                        }
                        if (textView.getText() != mClassifiedSpan) {
                            Log.d(LOG_TAG, "Text has changed from the classified text. "
                                    + "Ignoring.");
                            return;
                        }
                        mTextLinkSpan.onTextClassificationResult(textView, textClassification);
                    }
                });
            }
        }

        /**
         * Invoked when the classification of the text of this span is available.
         * <p>
         * When user clicks on this span, text classifier will be used to classify the text of
         * this span. Once text classifier has the result, this callback will be invoked. If the
         * text in the textview is changed during the text classification, this won't be invoked.
         * <p>
         * You can change the way how the suggested actions are presented to user by overriding
         * this function.
         *
         * @param textView the textview that this span is attached to
         * @param textClassification the text classification result of the text in this span.
         */
        @UiThread
        public void onTextClassificationResult(
                @NonNull TextView textView, @NonNull TextClassification textClassification) {
            List<RemoteActionCompat> actions = textClassification.getActions();
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                final Spanned spanned = SpannableString.valueOf(textView.getText());
                final int start = spanned.getSpanStart(this);
                final int end = spanned.getSpanEnd(this);
                ToolbarController.getInstance(textView).show(actions, start, end);
                return;
            }

            if (!actions.isEmpty()) {
                try {
                    actions.get(0).getActionIntent().send();
                } catch (PendingIntent.CanceledException e) {
                    Log.e(LOG_TAG, "Error handling TextLinkSpan click", e);
                }
                return;
            }
        }

        private LocaleListCompat getLocales(TextView textView) {
            if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
                return ConvertUtils.wrapLocalList(textView.getTextLocales());
            } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
                return LocaleListCompat.create(textView.getTextLocale());
            }
            return LocaleListCompat.create(Locale.getDefault());
        }
    }

    /**
     * A builder to construct a TextLinks instance.
     */
    public static final class Builder {
        private final CharSequence mFullText;
        private final ArrayList<TextLink> mLinks;
        @Nullable private Bundle mExtras;

        /**
         * Create a new TextLinks.Builder.
         *
         * @param fullText The full text to annotate with links.
         */
        public Builder(@NonNull CharSequence fullText) {
            mFullText = Preconditions.checkNotNull(fullText);
            mLinks = new ArrayList<>();
        }

        /**
         * Adds a TextLink.
         *
         * @return this instance.
         *
         * @throws IllegalArgumentException if entityScores is null or empty.
         */
        @NonNull
        public Builder addLink(int start, int end, @NonNull Map<String, Float> entityScores) {
            mLinks.add(new TextLink(start, end, Preconditions.checkNotNull(entityScores)));
            return this;
        }

        /**
         * @hide
         */
        @NonNull
        Builder addLink(TextLink link) {
            mLinks.add(Preconditions.checkNotNull(link));
            return this;
        }

        /**
         * Sets the extended, vendor specific data.
         */
        @NonNull
        public Builder setExtras(@Nullable Bundle extras) {
            mExtras = extras;
            return this;
        }

        /**
         * Removes all {@link TextLink}s.
         */
        // TODO: Hide.
        @NonNull
        public Builder clearTextLinks() {
            mLinks.clear();
            return this;
        }

        /**
         * Constructs a TextLinks instance.
         *
         * @return the constructed TextLinks.
         */
        @NonNull
        public TextLinks build() {
            return new TextLinks(mFullText, mLinks,
                    mExtras == null ? Bundle.EMPTY : BundleUtils.deepCopy(mExtras));
        }
    }

    /** @hide */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    @RequiresApi(28)
    @NonNull
    // TODO: In Q, we should make getText public and use it here.
    static TextLinks fromPlatform(
            @NonNull android.view.textclassifier.TextLinks textLinks,
            @NonNull CharSequence requestText) {
        Preconditions.checkNotNull(textLinks);
        Preconditions.checkNotNull(requestText);

        Collection<android.view.textclassifier.TextLinks.TextLink> links = textLinks.getLinks();
        TextLinks.Builder builder = new TextLinks.Builder(requestText.toString());
        for (android.view.textclassifier.TextLinks.TextLink link : links) {
            builder.addLink(link.getStart(), link.getEnd(),
                    ConvertUtils.createFloatMapFromTextLinks(link));
        }
        return builder.build();
    }

    /** @hide */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    @RequiresApi(28)
    @NonNull
    android.view.textclassifier.TextLinks toPlatform() {
        android.view.textclassifier.TextLinks.Builder builder =
                new android.view.textclassifier.TextLinks.Builder((String) getText());
        for (TextLink textLink : getLinks()) {
            builder.addLink(
                    textLink.getStart(),
                    textLink.getEnd(),
                    textLink.getEntityScores().getConfidenceMap());
        }
        return builder.build();
    }
}