TextClassifier.java revision 6563833cf3c79e8cd211e32357422ae899674437
1/*
2 * Copyright (C) 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.IntDef;
20import android.annotation.IntRange;
21import android.annotation.NonNull;
22import android.annotation.Nullable;
23import android.annotation.StringDef;
24import android.annotation.WorkerThread;
25import android.os.LocaleList;
26import android.os.Looper;
27import android.os.Parcel;
28import android.os.Parcelable;
29import android.text.Spannable;
30import android.text.SpannableString;
31import android.text.style.URLSpan;
32import android.text.util.Linkify;
33import android.text.util.Linkify.LinkifyMask;
34import android.util.ArrayMap;
35import android.util.ArraySet;
36import android.util.Slog;
37
38import com.android.internal.util.Preconditions;
39
40import java.lang.annotation.Retention;
41import java.lang.annotation.RetentionPolicy;
42import java.util.ArrayList;
43import java.util.Collection;
44import java.util.Collections;
45import java.util.List;
46import java.util.Map;
47
48/**
49 * Interface for providing text classification related features.
50 *
51 * <p><strong>NOTE: </strong>Unless otherwise stated, methods of this interface are blocking
52 * operations. Call on a worker thread.
53 */
54public interface TextClassifier {
55
56    /** @hide */
57    String DEFAULT_LOG_TAG = "androidtc";
58
59
60    /** @hide */
61    @Retention(RetentionPolicy.SOURCE)
62    @IntDef(value = {LOCAL, SYSTEM})
63    @interface TextClassifierType {}  // TODO: Expose as system APIs.
64    /** Specifies a TextClassifier that runs locally in the app's process. @hide */
65    int LOCAL = 0;
66    /** Specifies a TextClassifier that runs in the system process and serves all apps. @hide */
67    int SYSTEM = 1;
68
69    /** The TextClassifier failed to run. */
70    String TYPE_UNKNOWN = "";
71    /** The classifier ran, but didn't recognize a known entity. */
72    String TYPE_OTHER = "other";
73    /** E-mail address (e.g. "noreply@android.com"). */
74    String TYPE_EMAIL = "email";
75    /** Phone number (e.g. "555-123 456"). */
76    String TYPE_PHONE = "phone";
77    /** Physical address. */
78    String TYPE_ADDRESS = "address";
79    /** Web URL. */
80    String TYPE_URL = "url";
81    /** Time reference that is no more specific than a date. May be absolute such as "01/01/2000" or
82     * relative like "tomorrow". **/
83    String TYPE_DATE = "date";
84    /** Time reference that includes a specific time. May be absolute such as "01/01/2000 5:30pm" or
85     * relative like "tomorrow at 5:30pm". **/
86    String TYPE_DATE_TIME = "datetime";
87    /** Flight number in IATA format. */
88    String TYPE_FLIGHT_NUMBER = "flight";
89
90    /** @hide */
91    @Retention(RetentionPolicy.SOURCE)
92    @StringDef(prefix = { "TYPE_" }, value = {
93            TYPE_UNKNOWN,
94            TYPE_OTHER,
95            TYPE_EMAIL,
96            TYPE_PHONE,
97            TYPE_ADDRESS,
98            TYPE_URL,
99            TYPE_DATE,
100            TYPE_DATE_TIME,
101            TYPE_FLIGHT_NUMBER,
102    })
103    @interface EntityType {}
104
105    /** Designates that the text in question is editable. **/
106    String HINT_TEXT_IS_EDITABLE = "android.text_is_editable";
107    /** Designates that the text in question is not editable. **/
108    String HINT_TEXT_IS_NOT_EDITABLE = "android.text_is_not_editable";
109
110    /** @hide */
111    @Retention(RetentionPolicy.SOURCE)
112    @StringDef(prefix = { "HINT_" }, value = {HINT_TEXT_IS_EDITABLE, HINT_TEXT_IS_NOT_EDITABLE})
113    @interface Hints {}
114
115    /**
116     * No-op TextClassifier.
117     * This may be used to turn off TextClassifier features.
118     */
119    TextClassifier NO_OP = new TextClassifier() {};
120
121    /**
122     * Returns suggested text selection start and end indices, recognized entity types, and their
123     * associated confidence scores. The entity types are ordered from highest to lowest scoring.
124     *
125     * <p><strong>NOTE: </strong>Call on a worker thread.
126     *
127     * @param text text providing context for the selected text (which is specified
128     *      by the sub sequence starting at selectionStartIndex and ending at selectionEndIndex)
129     * @param selectionStartIndex start index of the selected part of text
130     * @param selectionEndIndex end index of the selected part of text
131     * @param options optional input parameters
132     *
133     * @throws IllegalArgumentException if text is null; selectionStartIndex is negative;
134     *      selectionEndIndex is greater than text.length() or not greater than selectionStartIndex
135     *
136     * @see #suggestSelection(CharSequence, int, int)
137     */
138    @WorkerThread
139    @NonNull
140    default TextSelection suggestSelection(
141            @NonNull CharSequence text,
142            @IntRange(from = 0) int selectionStartIndex,
143            @IntRange(from = 0) int selectionEndIndex,
144            @Nullable TextSelection.Options options) {
145        Utils.validate(text, selectionStartIndex, selectionEndIndex, false);
146        return new TextSelection.Builder(selectionStartIndex, selectionEndIndex).build();
147    }
148
149    /**
150     * Returns suggested text selection start and end indices, recognized entity types, and their
151     * associated confidence scores. The entity types are ordered from highest to lowest scoring.
152     *
153     * <p><b>NOTE:</b> Do not implement. The default implementation of this method calls
154     * {@link #suggestSelection(CharSequence, int, int, TextSelection.Options)}. If that method
155     * calls this method, a stack overflow error will happen.
156     *
157     * <p><strong>NOTE: </strong>Call on a worker thread.
158     *
159     * @param text text providing context for the selected text (which is specified
160     *      by the sub sequence starting at selectionStartIndex and ending at selectionEndIndex)
161     * @param selectionStartIndex start index of the selected part of text
162     * @param selectionEndIndex end index of the selected part of text
163     *
164     * @throws IllegalArgumentException if text is null; selectionStartIndex is negative;
165     *      selectionEndIndex is greater than text.length() or not greater than selectionStartIndex
166     *
167     * @see #suggestSelection(CharSequence, int, int, TextSelection.Options)
168     */
169    @WorkerThread
170    @NonNull
171    default TextSelection suggestSelection(
172            @NonNull CharSequence text,
173            @IntRange(from = 0) int selectionStartIndex,
174            @IntRange(from = 0) int selectionEndIndex) {
175        return suggestSelection(text, selectionStartIndex, selectionEndIndex,
176                (TextSelection.Options) null);
177    }
178
179    /**
180     * See {@link #suggestSelection(CharSequence, int, int)} or
181     * {@link #suggestSelection(CharSequence, int, int, TextSelection.Options)}.
182     *
183     * <p><strong>NOTE: </strong>Call on a worker thread.
184     *
185     * <p><b>NOTE:</b> Do not implement. The default implementation of this method calls
186     * {@link #suggestSelection(CharSequence, int, int, TextSelection.Options)}. If that method
187     * calls this method, a stack overflow error will happen.
188     */
189    @WorkerThread
190    @NonNull
191    default TextSelection suggestSelection(
192            @NonNull CharSequence text,
193            @IntRange(from = 0) int selectionStartIndex,
194            @IntRange(from = 0) int selectionEndIndex,
195            @Nullable LocaleList defaultLocales) {
196        final TextSelection.Options options = (defaultLocales != null)
197                ? new TextSelection.Options().setDefaultLocales(defaultLocales)
198                : null;
199        return suggestSelection(text, selectionStartIndex, selectionEndIndex, options);
200    }
201
202    /**
203     * Classifies the specified text and returns a {@link TextClassification} object that can be
204     * used to generate a widget for handling the classified text.
205     *
206     * <p><strong>NOTE: </strong>Call on a worker thread.
207     *
208     * @param text text providing context for the text to classify (which is specified
209     *      by the sub sequence starting at startIndex and ending at endIndex)
210     * @param startIndex start index of the text to classify
211     * @param endIndex end index of the text to classify
212     * @param options optional input parameters
213     *
214     * @throws IllegalArgumentException if text is null; startIndex is negative;
215     *      endIndex is greater than text.length() or not greater than startIndex
216     *
217     * @see #classifyText(CharSequence, int, int)
218     */
219    @WorkerThread
220    @NonNull
221    default TextClassification classifyText(
222            @NonNull CharSequence text,
223            @IntRange(from = 0) int startIndex,
224            @IntRange(from = 0) int endIndex,
225            @Nullable TextClassification.Options options) {
226        Utils.validate(text, startIndex, endIndex, false);
227        return TextClassification.EMPTY;
228    }
229
230    /**
231     * Classifies the specified text and returns a {@link TextClassification} object that can be
232     * used to generate a widget for handling the classified text.
233     *
234     * <p><strong>NOTE: </strong>Call on a worker thread.
235     *
236     * <p><b>NOTE:</b> Do not implement. The default implementation of this method calls
237     * {@link #classifyText(CharSequence, int, int, TextClassification.Options)}. If that method
238     * calls this method, a stack overflow error will happen.
239     *
240     * @param text text providing context for the text to classify (which is specified
241     *      by the sub sequence starting at startIndex and ending at endIndex)
242     * @param startIndex start index of the text to classify
243     * @param endIndex end index of the text to classify
244     *
245     * @throws IllegalArgumentException if text is null; startIndex is negative;
246     *      endIndex is greater than text.length() or not greater than startIndex
247     *
248     * @see #classifyText(CharSequence, int, int, TextClassification.Options)
249     */
250    @WorkerThread
251    @NonNull
252    default TextClassification classifyText(
253            @NonNull CharSequence text,
254            @IntRange(from = 0) int startIndex,
255            @IntRange(from = 0) int endIndex) {
256        return classifyText(text, startIndex, endIndex, (TextClassification.Options) null);
257    }
258
259    /**
260     * See {@link #classifyText(CharSequence, int, int, TextClassification.Options)} or
261     * {@link #classifyText(CharSequence, int, int)}.
262     *
263     * <p><strong>NOTE: </strong>Call on a worker thread.
264     *
265     * <p><b>NOTE:</b> Do not implement. The default implementation of this method calls
266     * {@link #classifyText(CharSequence, int, int, TextClassification.Options)}. If that method
267     * calls this method, a stack overflow error will happen.
268     */
269    @WorkerThread
270    @NonNull
271    default TextClassification classifyText(
272            @NonNull CharSequence text,
273            @IntRange(from = 0) int startIndex,
274            @IntRange(from = 0) int endIndex,
275            @Nullable LocaleList defaultLocales) {
276        final TextClassification.Options options = (defaultLocales != null)
277                ? new TextClassification.Options().setDefaultLocales(defaultLocales)
278                : null;
279        return classifyText(text, startIndex, endIndex, options);
280    }
281
282    /**
283     * Generates and returns a {@link TextLinks} that may be applied to the text to annotate it with
284     * links information.
285     *
286     * <p><strong>NOTE: </strong>Call on a worker thread.
287     *
288     * @param text the text to generate annotations for
289     * @param options configuration for link generation
290     *
291     * @throws IllegalArgumentException if text is null or the text is too long for the
292     *      TextClassifier implementation.
293     *
294     * @see #generateLinks(CharSequence)
295     * @see #getMaxGenerateLinksTextLength()
296     */
297    @WorkerThread
298    default TextLinks generateLinks(
299            @NonNull CharSequence text, @Nullable TextLinks.Options options) {
300        Utils.validate(text, false);
301        return new TextLinks.Builder(text.toString()).build();
302    }
303
304    /**
305     * Generates and returns a {@link TextLinks} that may be applied to the text to annotate it with
306     * links information.
307     *
308     * <p><strong>NOTE: </strong>Call on a worker thread.
309     *
310     * <p><b>NOTE:</b> Do not implement. The default implementation of this method calls
311     * {@link #generateLinks(CharSequence, TextLinks.Options)}. If that method calls this method,
312     * a stack overflow error will happen.
313     *
314     * @param text the text to generate annotations for
315     *
316     * @throws IllegalArgumentException if text is null or the text is too long for the
317     *      TextClassifier implementation.
318     *
319     * @see #generateLinks(CharSequence, TextLinks.Options)
320     * @see #getMaxGenerateLinksTextLength()
321     */
322    @WorkerThread
323    default TextLinks generateLinks(@NonNull CharSequence text) {
324        return generateLinks(text, null);
325    }
326
327    /**
328     * Returns the maximal length of text that can be processed by generateLinks.
329     *
330     * @see #generateLinks(CharSequence)
331     * @see #generateLinks(CharSequence, TextLinks.Options)
332     */
333    @WorkerThread
334    default int getMaxGenerateLinksTextLength() {
335        return Integer.MAX_VALUE;
336    }
337
338    /**
339     * Returns a helper for logging TextClassifier related events.
340     *
341     * @param config logger configuration
342     */
343    @WorkerThread
344    default Logger getLogger(@NonNull Logger.Config config) {
345        Preconditions.checkNotNull(config);
346        return Logger.DISABLED;
347    }
348
349    /**
350     * Configuration object for specifying what entities to identify.
351     *
352     * Configs are initially based on a predefined preset, and can be modified from there.
353     */
354    final class EntityConfig implements Parcelable {
355        private final Collection<String> mHints;
356        private final Collection<String> mExcludedEntityTypes;
357        private final Collection<String> mIncludedEntityTypes;
358        private final boolean mUseHints;
359
360        private EntityConfig(boolean useHints, Collection<String> hints,
361                Collection<String> includedEntityTypes, Collection<String> excludedEntityTypes) {
362            mHints = hints == null
363                    ? Collections.EMPTY_LIST
364                    : Collections.unmodifiableCollection(new ArraySet<>(hints));
365            mExcludedEntityTypes = excludedEntityTypes == null
366                    ? Collections.EMPTY_LIST : new ArraySet<>(excludedEntityTypes);
367            mIncludedEntityTypes = includedEntityTypes == null
368                    ? Collections.EMPTY_LIST : new ArraySet<>(includedEntityTypes);
369            mUseHints = useHints;
370        }
371
372        /**
373         * Creates an EntityConfig.
374         *
375         * @param hints Hints for the TextClassifier to determine what types of entities to find.
376         */
377        public static EntityConfig create(@Nullable Collection<String> hints) {
378            return new EntityConfig(/* useHints */ true, hints,
379                    /* includedEntityTypes */null, /* excludedEntityTypes */ null);
380        }
381
382        /**
383         * Creates an EntityConfig.
384         *
385         * @param hints Hints for the TextClassifier to determine what types of entities to find
386         * @param includedEntityTypes Entity types, e.g. {@link #TYPE_EMAIL}, to explicitly include
387         * @param excludedEntityTypes Entity types, e.g. {@link #TYPE_PHONE}, to explicitly exclude
388         *
389         *
390         * Note that if an entity has been excluded, the exclusion will take precedence.
391         */
392        public static EntityConfig create(@Nullable Collection<String> hints,
393                @Nullable Collection<String> includedEntityTypes,
394                @Nullable Collection<String> excludedEntityTypes) {
395            return new EntityConfig(/* useHints */ true, hints,
396                    includedEntityTypes, excludedEntityTypes);
397        }
398
399        /**
400         * Creates an EntityConfig with an explicit entity list.
401         *
402         * @param entityTypes Complete set of entities, e.g. {@link #TYPE_URL} to find.
403         *
404         */
405        public static EntityConfig createWithEntityList(@Nullable Collection<String> entityTypes) {
406            return new EntityConfig(/* useHints */ false, /* hints */ null,
407                    /* includedEntityTypes */ entityTypes, /* excludedEntityTypes */ null);
408        }
409
410        /**
411         * Returns a list of the final set of entities to find.
412         *
413         * @param entities Entities we think should be found before factoring in includes/excludes
414         *
415         * This method is intended for use by TextClassifier implementations.
416         */
417        public List<String> resolveEntityListModifications(@NonNull Collection<String> entities) {
418            final ArrayList<String> finalList = new ArrayList<>();
419            if (mUseHints) {
420                for (String entity : entities) {
421                    if (!mExcludedEntityTypes.contains(entity)) {
422                        finalList.add(entity);
423                    }
424                }
425            }
426            for (String entity : mIncludedEntityTypes) {
427                if (!mExcludedEntityTypes.contains(entity) && !finalList.contains(entity)) {
428                    finalList.add(entity);
429                }
430            }
431            return finalList;
432        }
433
434        /**
435         * Retrieves the list of hints.
436         *
437         * @return An unmodifiable collection of the hints.
438         */
439        public Collection<String> getHints() {
440            return mHints;
441        }
442
443        @Override
444        public int describeContents() {
445            return 0;
446        }
447
448        @Override
449        public void writeToParcel(Parcel dest, int flags) {
450            dest.writeStringList(new ArrayList<>(mHints));
451            dest.writeStringList(new ArrayList<>(mExcludedEntityTypes));
452            dest.writeStringList(new ArrayList<>(mIncludedEntityTypes));
453            dest.writeInt(mUseHints ? 1 : 0);
454        }
455
456        public static final Parcelable.Creator<EntityConfig> CREATOR =
457                new Parcelable.Creator<EntityConfig>() {
458                    @Override
459                    public EntityConfig createFromParcel(Parcel in) {
460                        return new EntityConfig(in);
461                    }
462
463                    @Override
464                    public EntityConfig[] newArray(int size) {
465                        return new EntityConfig[size];
466                    }
467                };
468
469        private EntityConfig(Parcel in) {
470            mHints = new ArraySet<>(in.createStringArrayList());
471            mExcludedEntityTypes = new ArraySet<>(in.createStringArrayList());
472            mIncludedEntityTypes = new ArraySet<>(in.createStringArrayList());
473            mUseHints = in.readInt() == 1;
474        }
475    }
476
477    /**
478     * Utility functions for TextClassifier methods.
479     *
480     * <ul>
481     *  <li>Provides validation of input parameters to TextClassifier methods
482     * </ul>
483     *
484     * Intended to be used only in this package.
485     * @hide
486     */
487    final class Utils {
488
489        /**
490         * @throws IllegalArgumentException if text is null; startIndex is negative;
491         *      endIndex is greater than text.length() or is not greater than startIndex;
492         *      options is null
493         */
494        public static void validate(
495                @NonNull CharSequence text, int startIndex, int endIndex,
496                boolean allowInMainThread) {
497            Preconditions.checkArgument(text != null);
498            Preconditions.checkArgument(startIndex >= 0);
499            Preconditions.checkArgument(endIndex <= text.length());
500            Preconditions.checkArgument(endIndex > startIndex);
501            checkMainThread(allowInMainThread);
502        }
503
504        /**
505         * @throws IllegalArgumentException if text is null or options is null
506         */
507        public static void validate(@NonNull CharSequence text, boolean allowInMainThread) {
508            Preconditions.checkArgument(text != null);
509            checkMainThread(allowInMainThread);
510        }
511
512        /**
513         * @throws IllegalArgumentException if text is null; the text is too long or options is null
514         */
515        public static void validate(@NonNull CharSequence text, int maxLength,
516                boolean allowInMainThread) {
517            validate(text, allowInMainThread);
518            Preconditions.checkArgumentInRange(text.length(), 0, maxLength, "text.length()");
519        }
520
521        /**
522         * Generates links using legacy {@link Linkify}.
523         */
524        public static TextLinks generateLegacyLinks(
525                @NonNull CharSequence text, @NonNull TextLinks.Options options) {
526            final String string = Preconditions.checkNotNull(text).toString();
527            final TextLinks.Builder links = new TextLinks.Builder(string);
528
529            final List<String> entities = Preconditions.checkNotNull(options).getEntityConfig()
530                    .resolveEntityListModifications(Collections.emptyList());
531            if (entities.contains(TextClassifier.TYPE_URL)) {
532                addLinks(links, string, TextClassifier.TYPE_URL);
533            }
534            if (entities.contains(TextClassifier.TYPE_PHONE)) {
535                addLinks(links, string, TextClassifier.TYPE_PHONE);
536            }
537            if (entities.contains(TextClassifier.TYPE_EMAIL)) {
538                addLinks(links, string, TextClassifier.TYPE_EMAIL);
539            }
540            // NOTE: Do not support MAP_ADDRESSES. Legacy version does not work well.
541            return links.build();
542        }
543
544        private static void addLinks(
545                TextLinks.Builder links, String string, @EntityType String entityType) {
546            final Spannable spannable = new SpannableString(string);
547            if (Linkify.addLinks(spannable, linkMask(entityType))) {
548                final URLSpan[] spans = spannable.getSpans(0, spannable.length(), URLSpan.class);
549                for (URLSpan urlSpan : spans) {
550                    links.addLink(
551                            spannable.getSpanStart(urlSpan),
552                            spannable.getSpanEnd(urlSpan),
553                            entityScores(entityType),
554                            urlSpan);
555                }
556            }
557        }
558
559        @LinkifyMask
560        private static int linkMask(@EntityType String entityType) {
561            switch (entityType) {
562                case TextClassifier.TYPE_URL:
563                    return Linkify.WEB_URLS;
564                case TextClassifier.TYPE_PHONE:
565                    return Linkify.PHONE_NUMBERS;
566                case TextClassifier.TYPE_EMAIL:
567                    return Linkify.EMAIL_ADDRESSES;
568                default:
569                    // NOTE: Do not support MAP_ADDRESSES. Legacy version does not work well.
570                    return 0;
571            }
572        }
573
574        private static Map<String, Float> entityScores(@EntityType String entityType) {
575            final Map<String, Float> scores = new ArrayMap<>();
576            scores.put(entityType, 1f);
577            return scores;
578        }
579
580        private static void checkMainThread(boolean allowInMainThread) {
581            if (!allowInMainThread && Looper.myLooper() == Looper.getMainLooper()) {
582                Slog.w(DEFAULT_LOG_TAG, "TextClassifier called on main thread");
583            }
584        }
585    }
586}
587