TextLinks.java revision ae82e7ad280e55dca22014c6abc857372229f89c
1/*
2 * Copyright 2017 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package android.view.textclassifier;
18
19import android.annotation.FloatRange;
20import android.annotation.IntDef;
21import android.annotation.NonNull;
22import android.annotation.Nullable;
23import android.content.Context;
24import android.os.LocaleList;
25import android.os.Parcel;
26import android.os.Parcelable;
27import android.text.Spannable;
28import android.text.method.MovementMethod;
29import android.text.style.ClickableSpan;
30import android.text.style.URLSpan;
31import android.text.util.Linkify;
32import android.text.util.Linkify.LinkifyMask;
33import android.view.View;
34import android.view.textclassifier.TextClassifier.EntityType;
35import android.widget.TextView;
36
37import com.android.internal.annotations.VisibleForTesting;
38import com.android.internal.annotations.VisibleForTesting.Visibility;
39import com.android.internal.util.Preconditions;
40
41import java.lang.annotation.Retention;
42import java.lang.annotation.RetentionPolicy;
43import java.util.ArrayList;
44import java.util.Collection;
45import java.util.Collections;
46import java.util.List;
47import java.util.Locale;
48import java.util.Map;
49import java.util.function.Function;
50
51/**
52 * A collection of links, representing subsequences of text and the entity types (phone number,
53 * address, url, etc) they may be.
54 */
55public final class TextLinks implements Parcelable {
56
57    /**
58     * Return status of an attempt to apply TextLinks to text.
59     * @hide
60     */
61    @Retention(RetentionPolicy.SOURCE)
62    @IntDef({STATUS_LINKS_APPLIED, STATUS_NO_LINKS_FOUND, STATUS_NO_LINKS_APPLIED,
63            STATUS_DIFFERENT_TEXT})
64    public @interface Status {}
65
66    /** Links were successfully applied to the text. */
67    public static final int STATUS_LINKS_APPLIED = 0;
68
69    /** No links exist to apply to text. Links count is zero. */
70    public static final int STATUS_NO_LINKS_FOUND = 1;
71
72    /** No links applied to text. The links were filtered out. */
73    public static final int STATUS_NO_LINKS_APPLIED = 2;
74
75    /** The specified text does not match the text used to generate the links. */
76    public static final int STATUS_DIFFERENT_TEXT = 3;
77
78    /** @hide */
79    @Retention(RetentionPolicy.SOURCE)
80    @IntDef({APPLY_STRATEGY_IGNORE, APPLY_STRATEGY_REPLACE})
81    public @interface ApplyStrategy {}
82
83    /**
84     * Do not replace {@link ClickableSpan}s that exist where the {@link TextLinkSpan} needs to
85     * be applied to. Do not apply the TextLinkSpan.
86     */
87    public static final int APPLY_STRATEGY_IGNORE = 0;
88
89    /**
90     * Replace any {@link ClickableSpan}s that exist where the {@link TextLinkSpan} needs to be
91     * applied to.
92     */
93    public static final int APPLY_STRATEGY_REPLACE = 1;
94
95    private final String mFullText;
96    private final List<TextLink> mLinks;
97
98    private TextLinks(String fullText, ArrayList<TextLink> links) {
99        mFullText = fullText;
100        mLinks = Collections.unmodifiableList(links);
101    }
102
103    /**
104     * Returns the text that was used to generate these links.
105     * @hide
106     */
107    @NonNull
108    public String getText() {
109        return mFullText;
110    }
111
112    /**
113     * Returns an unmodifiable Collection of the links.
114     */
115    @NonNull
116    public Collection<TextLink> getLinks() {
117        return mLinks;
118    }
119
120    /**
121     * Annotates the given text with the generated links. It will fail if the provided text doesn't
122     * match the original text used to create the TextLinks.
123     *
124     * <p><strong>NOTE: </strong>It may be necessary to set a LinkMovementMethod on the TextView
125     * widget to properly handle links. See {@link TextView#setMovementMethod(MovementMethod)}
126     *
127     * @param text the text to apply the links to. Must match the original text
128     * @param applyStrategy the apply strategy used to determine how to apply links to text.
129     *      e.g {@link TextLinks#APPLY_STRATEGY_IGNORE}
130     * @param spanFactory a custom span factory for converting TextLinks to TextLinkSpans.
131     *      Set to {@code null} to use the default span factory.
132     *
133     * @return a status code indicating whether or not the links were successfully applied
134     *      e.g. {@link #STATUS_LINKS_APPLIED}
135     */
136    @Status
137    public int apply(
138            @NonNull Spannable text,
139            @ApplyStrategy int applyStrategy,
140            @Nullable Function<TextLink, TextLinkSpan> spanFactory) {
141        Preconditions.checkNotNull(text);
142        return new TextLinksParams.Builder()
143                .setApplyStrategy(applyStrategy)
144                .setSpanFactory(spanFactory)
145                .build()
146                .apply(text, this);
147    }
148
149    @Override
150    public String toString() {
151        return String.format(Locale.US, "TextLinks{fullText=%s, links=%s}", mFullText, mLinks);
152    }
153
154    @Override
155    public int describeContents() {
156        return 0;
157    }
158
159    @Override
160    public void writeToParcel(Parcel dest, int flags) {
161        dest.writeString(mFullText);
162        dest.writeTypedList(mLinks);
163    }
164
165    public static final Parcelable.Creator<TextLinks> CREATOR =
166            new Parcelable.Creator<TextLinks>() {
167                @Override
168                public TextLinks createFromParcel(Parcel in) {
169                    return new TextLinks(in);
170                }
171
172                @Override
173                public TextLinks[] newArray(int size) {
174                    return new TextLinks[size];
175                }
176            };
177
178    private TextLinks(Parcel in) {
179        mFullText = in.readString();
180        mLinks = in.createTypedArrayList(TextLink.CREATOR);
181    }
182
183    /**
184     * A link, identifying a substring of text and possible entity types for it.
185     */
186    public static final class TextLink implements Parcelable {
187        private final EntityConfidence mEntityScores;
188        private final int mStart;
189        private final int mEnd;
190        @Nullable final URLSpan mUrlSpan;
191
192        /**
193         * Create a new TextLink.
194         *
195         * @param start The start index of the identified subsequence
196         * @param end The end index of the identified subsequence
197         * @param entityScores A mapping of entity type to confidence score
198         * @param urlSpan An optional URLSpan to delegate to. NOTE: Not parcelled
199         *
200         * @throws IllegalArgumentException if entityScores is null or empty
201         */
202        TextLink(int start, int end, Map<String, Float> entityScores,
203                @Nullable URLSpan urlSpan) {
204            Preconditions.checkNotNull(entityScores);
205            Preconditions.checkArgument(!entityScores.isEmpty());
206            Preconditions.checkArgument(start <= end);
207            mStart = start;
208            mEnd = end;
209            mEntityScores = new EntityConfidence(entityScores);
210            mUrlSpan = urlSpan;
211        }
212
213        /**
214         * Returns the start index of this link in the original text.
215         *
216         * @return the start index
217         */
218        public int getStart() {
219            return mStart;
220        }
221
222        /**
223         * Returns the end index of this link in the original text.
224         *
225         * @return the end index
226         */
227        public int getEnd() {
228            return mEnd;
229        }
230
231        /**
232         * Returns the number of entity types that have confidence scores.
233         *
234         * @return the entity count
235         */
236        public int getEntityCount() {
237            return mEntityScores.getEntities().size();
238        }
239
240        /**
241         * Returns the entity type at a given index. Entity types are sorted by confidence.
242         *
243         * @return the entity type at the provided index
244         */
245        @NonNull public @EntityType String getEntity(int index) {
246            return mEntityScores.getEntities().get(index);
247        }
248
249        /**
250         * Returns the confidence score for a particular entity type.
251         *
252         * @param entityType the entity type
253         */
254        public @FloatRange(from = 0.0, to = 1.0) float getConfidenceScore(
255                @EntityType String entityType) {
256            return mEntityScores.getConfidenceScore(entityType);
257        }
258
259        @Override
260        public String toString() {
261            return String.format(Locale.US,
262                    "TextLink{start=%s, end=%s, entityScores=%s, urlSpan=%s}",
263                    mStart, mEnd, mEntityScores, mUrlSpan);
264        }
265
266        @Override
267        public int describeContents() {
268            return 0;
269        }
270
271        @Override
272        public void writeToParcel(Parcel dest, int flags) {
273            mEntityScores.writeToParcel(dest, flags);
274            dest.writeInt(mStart);
275            dest.writeInt(mEnd);
276        }
277
278        public static final Parcelable.Creator<TextLink> CREATOR =
279                new Parcelable.Creator<TextLink>() {
280                    @Override
281                    public TextLink createFromParcel(Parcel in) {
282                        return new TextLink(in);
283                    }
284
285                    @Override
286                    public TextLink[] newArray(int size) {
287                        return new TextLink[size];
288                    }
289                };
290
291        private TextLink(Parcel in) {
292            mEntityScores = EntityConfidence.CREATOR.createFromParcel(in);
293            mStart = in.readInt();
294            mEnd = in.readInt();
295            mUrlSpan = null;
296        }
297    }
298
299    /**
300     * A request object for generating TextLinks.
301     */
302    public static final class Request implements Parcelable {
303
304        private final CharSequence mText;
305        @Nullable private final LocaleList mDefaultLocales;
306        @Nullable private final TextClassifier.EntityConfig mEntityConfig;
307        private final boolean mLegacyFallback;
308        private String mCallingPackageName;
309
310        private Request(
311                CharSequence text,
312                LocaleList defaultLocales,
313                TextClassifier.EntityConfig entityConfig,
314                boolean legacyFallback,
315                String callingPackageName) {
316            mText = text;
317            mDefaultLocales = defaultLocales;
318            mEntityConfig = entityConfig;
319            mLegacyFallback = legacyFallback;
320            mCallingPackageName = callingPackageName;
321        }
322
323        /**
324         * Returns the text to generate links for.
325         */
326        @NonNull
327        public CharSequence getText() {
328            return mText;
329        }
330
331        /**
332         * @return ordered list of locale preferences that can be used to disambiguate
333         *      the provided text
334         */
335        @Nullable
336        public LocaleList getDefaultLocales() {
337            return mDefaultLocales;
338        }
339
340        /**
341         * @return The config representing the set of entities to look for
342         * @see #setEntityConfig(TextClassifier.EntityConfig)
343         */
344        @Nullable
345        public TextClassifier.EntityConfig getEntityConfig() {
346            return mEntityConfig;
347        }
348
349        /**
350         * Returns whether the TextClassifier can fallback to legacy links if smart linkify is
351         * disabled.
352         * <strong>Note: </strong>This is not parcelled.
353         * @hide
354         */
355        public boolean isLegacyFallback() {
356            return mLegacyFallback;
357        }
358
359        /**
360         * Sets the name of the package that requested the links to get generated.
361         */
362        void setCallingPackageName(@Nullable String callingPackageName) {
363            mCallingPackageName = callingPackageName;
364        }
365
366        /**
367         * A builder for building TextLinks requests.
368         */
369        public static final class Builder {
370
371            private final CharSequence mText;
372
373            @Nullable private LocaleList mDefaultLocales;
374            @Nullable private TextClassifier.EntityConfig mEntityConfig;
375            private boolean mLegacyFallback = true; // Use legacy fall back by default.
376            private String mCallingPackageName;
377
378            public Builder(@NonNull CharSequence text) {
379                mText = Preconditions.checkNotNull(text);
380            }
381
382            /**
383             * @param defaultLocales ordered list of locale preferences that may be used to
384             *                       disambiguate the provided text. If no locale preferences exist,
385             *                       set this to null or an empty locale list.
386             * @return this builder
387             */
388            @NonNull
389            public Builder setDefaultLocales(@Nullable LocaleList defaultLocales) {
390                mDefaultLocales = defaultLocales;
391                return this;
392            }
393
394            /**
395             * Sets the entity configuration to use. This determines what types of entities the
396             * TextClassifier will look for.
397             * Set to {@code null} for the default entity config and teh TextClassifier will
398             * automatically determine what links to generate.
399             *
400             * @return this builder
401             */
402            @NonNull
403            public Builder setEntityConfig(@Nullable TextClassifier.EntityConfig entityConfig) {
404                mEntityConfig = entityConfig;
405                return this;
406            }
407
408            /**
409             * Sets whether the TextClassifier can fallback to legacy links if smart linkify is
410             * disabled.
411             *
412             * <p><strong>Note: </strong>This is not parcelled.
413             *
414             * @return this builder
415             * @hide
416             */
417            @NonNull
418            public Builder setLegacyFallback(boolean legacyFallback) {
419                mLegacyFallback = legacyFallback;
420                return this;
421            }
422
423            /**
424             * Sets the name of the package that requested the links to get generated.
425             *
426             * @return this builder
427             * @hide
428             */
429            @NonNull
430            public Builder setCallingPackageName(@Nullable String callingPackageName) {
431                mCallingPackageName = callingPackageName;
432                return this;
433            }
434
435            /**
436             * Builds and returns the request object.
437             */
438            @NonNull
439            public Request build() {
440                return new Request(
441                        mText, mDefaultLocales, mEntityConfig,
442                        mLegacyFallback, mCallingPackageName);
443            }
444
445        }
446
447        /**
448         * @return the name of the package that requested the links to get generated.
449         * TODO: make available as system API
450         * @hide
451         */
452        @Nullable
453        public String getCallingPackageName() {
454            return mCallingPackageName;
455        }
456
457        @Override
458        public int describeContents() {
459            return 0;
460        }
461
462        @Override
463        public void writeToParcel(Parcel dest, int flags) {
464            dest.writeString(mText.toString());
465            dest.writeInt(mDefaultLocales != null ? 1 : 0);
466            if (mDefaultLocales != null) {
467                mDefaultLocales.writeToParcel(dest, flags);
468            }
469            dest.writeInt(mEntityConfig != null ? 1 : 0);
470            if (mEntityConfig != null) {
471                mEntityConfig.writeToParcel(dest, flags);
472            }
473            dest.writeString(mCallingPackageName);
474        }
475
476        public static final Parcelable.Creator<Request> CREATOR =
477                new Parcelable.Creator<Request>() {
478                    @Override
479                    public Request createFromParcel(Parcel in) {
480                        return new Request(in);
481                    }
482
483                    @Override
484                    public Request[] newArray(int size) {
485                        return new Request[size];
486                    }
487                };
488
489        private Request(Parcel in) {
490            mText = in.readString();
491            mDefaultLocales = in.readInt() == 0 ? null : LocaleList.CREATOR.createFromParcel(in);
492            mEntityConfig = in.readInt() == 0
493                    ? null : TextClassifier.EntityConfig.CREATOR.createFromParcel(in);
494            mLegacyFallback = true;
495            mCallingPackageName = in.readString();
496        }
497    }
498
499    /**
500     * A ClickableSpan for a TextLink.
501     *
502     * <p>Applies only to TextViews.
503     */
504    public static class TextLinkSpan extends ClickableSpan {
505
506        private final TextLink mTextLink;
507
508        public TextLinkSpan(@NonNull TextLink textLink) {
509            mTextLink = textLink;
510        }
511
512        @Override
513        public void onClick(View widget) {
514            if (widget instanceof TextView) {
515                final TextView textView = (TextView) widget;
516                final Context context = textView.getContext();
517                if (TextClassificationManager.getSettings(context).isSmartLinkifyEnabled()) {
518                    if (textView.requestFocus()) {
519                        textView.requestActionMode(this);
520                    } else {
521                        // If textView can not take focus, then simply handle the click as it will
522                        // be difficult to get rid of the floating action mode.
523                        textView.handleClick(this);
524                    }
525                } else {
526                    if (mTextLink.mUrlSpan != null) {
527                        mTextLink.mUrlSpan.onClick(textView);
528                    } else {
529                        textView.handleClick(this);
530                    }
531                }
532            }
533        }
534
535        public final TextLink getTextLink() {
536            return mTextLink;
537        }
538
539        /** @hide */
540        @VisibleForTesting(visibility = Visibility.PRIVATE)
541        @Nullable
542        public final String getUrl() {
543            if (mTextLink.mUrlSpan != null) {
544                return mTextLink.mUrlSpan.getURL();
545            }
546            return null;
547        }
548    }
549
550    /**
551     * A builder to construct a TextLinks instance.
552     */
553    public static final class Builder {
554        private final String mFullText;
555        private final ArrayList<TextLink> mLinks;
556
557        /**
558         * Create a new TextLinks.Builder.
559         *
560         * @param fullText The full text to annotate with links
561         */
562        public Builder(@NonNull String fullText) {
563            mFullText = Preconditions.checkNotNull(fullText);
564            mLinks = new ArrayList<>();
565        }
566
567        /**
568         * Adds a TextLink.
569         *
570         * @param start The start index of the identified subsequence
571         * @param end The end index of the identified subsequence
572         * @param entityScores A mapping of entity type to confidence score
573         *
574         * @throws IllegalArgumentException if entityScores is null or empty.
575         */
576        @NonNull
577        public Builder addLink(int start, int end, Map<String, Float> entityScores) {
578            mLinks.add(new TextLink(start, end, entityScores, null));
579            return this;
580        }
581
582        /**
583         * @see #addLink(int, int, Map)
584         * @param urlSpan An optional URLSpan to delegate to. NOTE: Not parcelled.
585         */
586        @NonNull
587        Builder addLink(int start, int end, Map<String, Float> entityScores,
588                @Nullable URLSpan urlSpan) {
589            mLinks.add(new TextLink(start, end, entityScores, urlSpan));
590            return this;
591        }
592
593        /**
594         * Removes all {@link TextLink}s.
595         */
596        @NonNull
597        public Builder clearTextLinks() {
598            mLinks.clear();
599            return this;
600        }
601
602        /**
603         * Constructs a TextLinks instance.
604         *
605         * @return the constructed TextLinks
606         */
607        @NonNull
608        public TextLinks build() {
609            return new TextLinks(mFullText, mLinks);
610        }
611    }
612
613    // TODO: Remove once apps can build against the latest sdk.
614    /**
615     * Optional input parameters for generating TextLinks.
616     * @hide
617     */
618    public static final class Options {
619
620        @Nullable private final TextClassificationSessionId mSessionId;
621        @Nullable private final Request mRequest;
622        @Nullable private LocaleList mDefaultLocales;
623        @Nullable private TextClassifier.EntityConfig mEntityConfig;
624        private boolean mLegacyFallback;
625
626        private @ApplyStrategy int mApplyStrategy;
627        private Function<TextLink, TextLinkSpan> mSpanFactory;
628
629        private String mCallingPackageName;
630
631        public Options() {
632            this(null, null);
633        }
634
635        private Options(
636                @Nullable TextClassificationSessionId sessionId, @Nullable Request request) {
637            mSessionId = sessionId;
638            mRequest = request;
639        }
640
641        /** Helper to create Options from a Request. */
642        public static Options from(TextClassificationSessionId sessionId, Request request) {
643            final Options options = new Options(sessionId, request);
644            options.setDefaultLocales(request.getDefaultLocales());
645            options.setEntityConfig(request.getEntityConfig());
646            return options;
647        }
648
649        /** Returns a new options object based on the specified link mask. */
650        public static Options fromLinkMask(@LinkifyMask int mask) {
651            final List<String> entitiesToFind = new ArrayList<>();
652
653            if ((mask & Linkify.WEB_URLS) != 0) {
654                entitiesToFind.add(TextClassifier.TYPE_URL);
655            }
656            if ((mask & Linkify.EMAIL_ADDRESSES) != 0) {
657                entitiesToFind.add(TextClassifier.TYPE_EMAIL);
658            }
659            if ((mask & Linkify.PHONE_NUMBERS) != 0) {
660                entitiesToFind.add(TextClassifier.TYPE_PHONE);
661            }
662            if ((mask & Linkify.MAP_ADDRESSES) != 0) {
663                entitiesToFind.add(TextClassifier.TYPE_ADDRESS);
664            }
665
666            return new Options().setEntityConfig(
667                    TextClassifier.EntityConfig.createWithEntityList(entitiesToFind));
668        }
669
670        /** @param defaultLocales ordered list of locale preferences. */
671        public Options setDefaultLocales(@Nullable LocaleList defaultLocales) {
672            mDefaultLocales = defaultLocales;
673            return this;
674        }
675
676        /** @param entityConfig definition of which entity types to look for. */
677        public Options setEntityConfig(@Nullable TextClassifier.EntityConfig entityConfig) {
678            mEntityConfig = entityConfig;
679            return this;
680        }
681
682        /** @param applyStrategy strategy to use when resolving conflicts. */
683        public Options setApplyStrategy(@ApplyStrategy int applyStrategy) {
684            checkValidApplyStrategy(applyStrategy);
685            mApplyStrategy = applyStrategy;
686            return this;
687        }
688
689        /** @param spanFactory factory for converting TextLink to TextLinkSpan. */
690        public Options setSpanFactory(@Nullable Function<TextLink, TextLinkSpan> spanFactory) {
691            mSpanFactory = spanFactory;
692            return this;
693        }
694
695        @Nullable
696        public LocaleList getDefaultLocales() {
697            return mDefaultLocales;
698        }
699
700        @Nullable
701        public TextClassifier.EntityConfig getEntityConfig() {
702            return mEntityConfig;
703        }
704
705        @ApplyStrategy
706        public int getApplyStrategy() {
707            return mApplyStrategy;
708        }
709
710        @Nullable
711        public Function<TextLink, TextLinkSpan> getSpanFactory() {
712            return mSpanFactory;
713        }
714
715        @Nullable
716        public Request getRequest() {
717            return mRequest;
718        }
719
720        @Nullable
721        public TextClassificationSessionId getSessionId() {
722            return mSessionId;
723        }
724
725        private static void checkValidApplyStrategy(int applyStrategy) {
726            if (applyStrategy != APPLY_STRATEGY_IGNORE && applyStrategy != APPLY_STRATEGY_REPLACE) {
727                throw new IllegalArgumentException(
728                        "Invalid apply strategy. See TextLinks.ApplyStrategy for options.");
729            }
730        }
731    }
732}
733