TextClassifier.java revision 5a03094ebc91df1c64a2232be648ac3ed26657ce
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;
36
37import com.android.internal.util.Preconditions;
38
39import java.lang.annotation.Retention;
40import java.lang.annotation.RetentionPolicy;
41import java.util.ArrayList;
42import java.util.Collection;
43import java.util.Collections;
44import java.util.HashSet;
45import java.util.Map;
46import java.util.Set;
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    /** @hide */
116    @Retention(RetentionPolicy.SOURCE)
117    @StringDef({WIDGET_TYPE_TEXTVIEW, WIDGET_TYPE_WEBVIEW, WIDGET_TYPE_EDITTEXT,
118            WIDGET_TYPE_EDIT_WEBVIEW, WIDGET_TYPE_CUSTOM_TEXTVIEW, WIDGET_TYPE_CUSTOM_EDITTEXT,
119            WIDGET_TYPE_CUSTOM_UNSELECTABLE_TEXTVIEW, WIDGET_TYPE_UNKNOWN})
120    @interface WidgetType {}
121
122    /** The widget involved in the text classification session is a standard
123     * {@link android.widget.TextView}. */
124    String WIDGET_TYPE_TEXTVIEW = "textview";
125    /** The widget involved in the text classification session is a standard
126     * {@link android.widget.EditText}. */
127    String WIDGET_TYPE_EDITTEXT = "edittext";
128    /** The widget involved in the text classification session is a standard non-selectable
129     * {@link android.widget.TextView}. */
130    String WIDGET_TYPE_UNSELECTABLE_TEXTVIEW = "nosel-textview";
131    /** The widget involved in the text classification session is a standard
132     * {@link android.webkit.WebView}. */
133    String WIDGET_TYPE_WEBVIEW = "webview";
134    /** The widget involved in the text classification session is a standard editable
135     * {@link android.webkit.WebView}. */
136    String WIDGET_TYPE_EDIT_WEBVIEW = "edit-webview";
137    /** The widget involved in the text classification session is a custom text widget. */
138    String WIDGET_TYPE_CUSTOM_TEXTVIEW = "customview";
139    /** The widget involved in the text classification session is a custom editable text widget. */
140    String WIDGET_TYPE_CUSTOM_EDITTEXT = "customedit";
141    /** The widget involved in the text classification session is a custom non-selectable text
142     * widget. */
143    String WIDGET_TYPE_CUSTOM_UNSELECTABLE_TEXTVIEW = "nosel-customview";
144    /** The widget involved in the text classification session is of an unknown/unspecified type. */
145    String WIDGET_TYPE_UNKNOWN = "unknown";
146
147    /**
148     * No-op TextClassifier.
149     * This may be used to turn off TextClassifier features.
150     */
151    TextClassifier NO_OP = new TextClassifier() {};
152
153    /**
154     * Returns suggested text selection start and end indices, recognized entity types, and their
155     * associated confidence scores. The entity types are ordered from highest to lowest scoring.
156     *
157     * <p><strong>NOTE: </strong>Call on a worker thread.
158     *
159     * <p><strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to this method should
160     * throw an {@link IllegalStateException}. See {@link #isDestroyed()}.
161     *
162     * @param request the text selection request
163     */
164    @WorkerThread
165    @NonNull
166    default TextSelection suggestSelection(@NonNull TextSelection.Request request) {
167        Preconditions.checkNotNull(request);
168        Utils.checkMainThread();
169        return new TextSelection.Builder(request.getStartIndex(), request.getEndIndex()).build();
170    }
171
172    /**
173     * Returns suggested text selection start and end indices, recognized entity types, and their
174     * associated confidence scores. The entity types are ordered from highest to lowest scoring.
175     *
176     * <p><strong>NOTE: </strong>Call on a worker thread.
177     *
178     * <p><strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to this method should
179     * throw an {@link IllegalStateException}. See {@link #isDestroyed()}.
180     *
181     * <p><b>NOTE:</b> Do not implement. The default implementation of this method calls
182     * {@link #suggestSelection(TextSelection.Request)}. If that method calls this method,
183     * a stack overflow error will happen.
184     *
185     * @param text text providing context for the selected text (which is specified
186     *      by the sub sequence starting at selectionStartIndex and ending at selectionEndIndex)
187     * @param selectionStartIndex start index of the selected part of text
188     * @param selectionEndIndex end index of the selected part of text
189     * @param defaultLocales ordered list of locale preferences that may be used to
190     *      disambiguate the provided text. If no locale preferences exist, set this to null
191     *      or an empty locale list.
192     *
193     * @throws IllegalArgumentException if text is null; selectionStartIndex is negative;
194     *      selectionEndIndex is greater than text.length() or not greater than selectionStartIndex
195     *
196     * @see #suggestSelection(TextSelection.Request)
197     */
198    @WorkerThread
199    @NonNull
200    default TextSelection suggestSelection(
201            @NonNull CharSequence text,
202            @IntRange(from = 0) int selectionStartIndex,
203            @IntRange(from = 0) int selectionEndIndex,
204            @Nullable LocaleList defaultLocales) {
205        final TextSelection.Request request = new TextSelection.Request.Builder(
206                text, selectionStartIndex, selectionEndIndex)
207                .setDefaultLocales(defaultLocales)
208                .build();
209        return suggestSelection(request);
210    }
211
212    // TODO: Remove once apps can build against the latest sdk.
213    /** @hide */
214    default TextSelection suggestSelection(
215            @NonNull CharSequence text,
216            @IntRange(from = 0) int selectionStartIndex,
217            @IntRange(from = 0) int selectionEndIndex,
218            @Nullable TextSelection.Options options) {
219        final TextSelection.Request request = options.getRequest() != null
220                ? options.getRequest()
221                : new TextSelection.Request.Builder(
222                        text, selectionStartIndex, selectionEndIndex)
223                        .setDefaultLocales(options.getDefaultLocales())
224                        .build();
225        return suggestSelection(request);
226    }
227
228    /**
229     * Classifies the specified text and returns a {@link TextClassification} object that can be
230     * used to generate a widget for handling the classified text.
231     *
232     * <p><strong>NOTE: </strong>Call on a worker thread.
233     *
234     * <strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to this method should
235     * throw an {@link IllegalStateException}. See {@link #isDestroyed()}.
236     *
237     * @param request the text classification request
238     */
239    @WorkerThread
240    @NonNull
241    default TextClassification classifyText(@NonNull TextClassification.Request request) {
242        Preconditions.checkNotNull(request);
243        Utils.checkMainThread();
244        return TextClassification.EMPTY;
245    }
246
247    /**
248     * Classifies the specified text and returns a {@link TextClassification} object that can be
249     * used to generate a widget for handling the classified text.
250     *
251     * <p><strong>NOTE: </strong>Call on a worker thread.
252     *
253     * <p><b>NOTE:</b> Do not implement. The default implementation of this method calls
254     * {@link #classifyText(TextClassification.Request)}. If that method calls this method,
255     * a stack overflow error will happen.
256     *
257     * <strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to this method should
258     * throw an {@link IllegalStateException}. See {@link #isDestroyed()}.
259     *
260     * @param text text providing context for the text to classify (which is specified
261     *      by the sub sequence starting at startIndex and ending at endIndex)
262     * @param startIndex start index of the text to classify
263     * @param endIndex end index of the text to classify
264     * @param defaultLocales ordered list of locale preferences that may be used to
265     *      disambiguate the provided text. If no locale preferences exist, set this to null
266     *      or an empty locale list.
267     *
268     * @throws IllegalArgumentException if text is null; startIndex is negative;
269     *      endIndex is greater than text.length() or not greater than startIndex
270     *
271     * @see #classifyText(TextClassification.Request)
272     */
273    @WorkerThread
274    @NonNull
275    default TextClassification classifyText(
276            @NonNull CharSequence text,
277            @IntRange(from = 0) int startIndex,
278            @IntRange(from = 0) int endIndex,
279            @Nullable LocaleList defaultLocales) {
280        final TextClassification.Request request = new TextClassification.Request.Builder(
281                text, startIndex, endIndex)
282                .setDefaultLocales(defaultLocales)
283                .build();
284        return classifyText(request);
285    }
286
287    // TODO: Remove once apps can build against the latest sdk.
288    /** @hide */
289    default TextClassification classifyText(
290            @NonNull CharSequence text,
291            @IntRange(from = 0) int startIndex,
292            @IntRange(from = 0) int endIndex,
293            @Nullable TextClassification.Options options) {
294        final TextClassification.Request request = options.getRequest() != null
295                ? options.getRequest()
296                : new TextClassification.Request.Builder(
297                        text, startIndex, endIndex)
298                        .setDefaultLocales(options.getDefaultLocales())
299                        .setReferenceTime(options.getReferenceTime())
300                        .build();
301        return classifyText(request);
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     * <strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to this method should
311     * throw an {@link IllegalStateException}. See {@link #isDestroyed()}.
312     *
313     * @param request the text links request
314     *
315     * @see #getMaxGenerateLinksTextLength()
316     */
317    @WorkerThread
318    @NonNull
319    default TextLinks generateLinks(@NonNull TextLinks.Request request) {
320        Preconditions.checkNotNull(request);
321        Utils.checkMainThread();
322        return new TextLinks.Builder(request.getText().toString()).build();
323    }
324
325    // TODO: Remove once apps can build against the latest sdk.
326    /** @hide */
327    default TextLinks generateLinks(
328            @NonNull CharSequence text, @Nullable TextLinks.Options options) {
329        final TextLinks.Request request = options.getRequest() != null
330                ? options.getRequest()
331                : new TextLinks.Request.Builder(text)
332                        .setDefaultLocales(options.getDefaultLocales())
333                        .setEntityConfig(options.getEntityConfig())
334                        .build();
335        return generateLinks(request);
336    }
337
338    /**
339     * Returns the maximal length of text that can be processed by generateLinks.
340     *
341     * <strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to this method should
342     * throw an {@link IllegalStateException}. See {@link #isDestroyed()}.
343     *
344     * @see #generateLinks(TextLinks.Request)
345     */
346    @WorkerThread
347    default int getMaxGenerateLinksTextLength() {
348        return Integer.MAX_VALUE;
349    }
350
351    /**
352     * Reports a selection event.
353     *
354     * <strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to this method should
355     * throw an {@link IllegalStateException}. See {@link #isDestroyed()}.
356     */
357    default void onSelectionEvent(@NonNull SelectionEvent event) {}
358
359    /**
360     * Destroys this TextClassifier.
361     *
362     * <strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to its methods should
363     * throw an {@link IllegalStateException}. See {@link #isDestroyed()}.
364     *
365     * <p>Subsequent calls to this method are no-ops.
366     */
367    default void destroy() {}
368
369    /**
370     * Returns whether or not this TextClassifier has been destroyed.
371     *
372     * <strong>NOTE: </strong>If a TextClassifier has been destroyed, caller should not interact
373     * with the classifier and an attempt to do so would throw an {@link IllegalStateException}.
374     * However, this method should never throw an {@link IllegalStateException}.
375     *
376     * @see #destroy()
377     */
378    default boolean isDestroyed() {
379        return false;
380    }
381
382    /**
383     * Configuration object for specifying what entities to identify.
384     *
385     * Configs are initially based on a predefined preset, and can be modified from there.
386     */
387    final class EntityConfig implements Parcelable {
388        private final Collection<String> mHints;
389        private final Collection<String> mExcludedEntityTypes;
390        private final Collection<String> mIncludedEntityTypes;
391        private final boolean mUseHints;
392
393        private EntityConfig(boolean useHints, Collection<String> hints,
394                Collection<String> includedEntityTypes, Collection<String> excludedEntityTypes) {
395            mHints = hints == null
396                    ? Collections.EMPTY_LIST
397                    : Collections.unmodifiableCollection(new ArraySet<>(hints));
398            mExcludedEntityTypes = excludedEntityTypes == null
399                    ? Collections.EMPTY_LIST : new ArraySet<>(excludedEntityTypes);
400            mIncludedEntityTypes = includedEntityTypes == null
401                    ? Collections.EMPTY_LIST : new ArraySet<>(includedEntityTypes);
402            mUseHints = useHints;
403        }
404
405        /**
406         * Creates an EntityConfig.
407         *
408         * @param hints Hints for the TextClassifier to determine what types of entities to find.
409         */
410        public static EntityConfig createWithHints(@Nullable Collection<String> hints) {
411            return new EntityConfig(/* useHints */ true, hints,
412                    /* includedEntityTypes */null, /* excludedEntityTypes */ null);
413        }
414
415        // TODO: Remove once apps can build against the latest sdk.
416        /** @hide */
417        public static EntityConfig create(@Nullable Collection<String> hints) {
418            return createWithHints(hints);
419        }
420
421        /**
422         * Creates an EntityConfig.
423         *
424         * @param hints Hints for the TextClassifier to determine what types of entities to find
425         * @param includedEntityTypes Entity types, e.g. {@link #TYPE_EMAIL}, to explicitly include
426         * @param excludedEntityTypes Entity types, e.g. {@link #TYPE_PHONE}, to explicitly exclude
427         *
428         *
429         * Note that if an entity has been excluded, the exclusion will take precedence.
430         */
431        public static EntityConfig create(@Nullable Collection<String> hints,
432                @Nullable Collection<String> includedEntityTypes,
433                @Nullable Collection<String> excludedEntityTypes) {
434            return new EntityConfig(/* useHints */ true, hints,
435                    includedEntityTypes, excludedEntityTypes);
436        }
437
438        /**
439         * Creates an EntityConfig with an explicit entity list.
440         *
441         * @param entityTypes Complete set of entities, e.g. {@link #TYPE_URL} to find.
442         *
443         */
444        public static EntityConfig createWithExplicitEntityList(
445                @Nullable Collection<String> entityTypes) {
446            return new EntityConfig(/* useHints */ false, /* hints */ null,
447                    /* includedEntityTypes */ entityTypes, /* excludedEntityTypes */ null);
448        }
449
450        // TODO: Remove once apps can build against the latest sdk.
451        /** @hide */
452        public static EntityConfig createWithEntityList(@Nullable Collection<String> entityTypes) {
453            return createWithExplicitEntityList(entityTypes);
454        }
455
456        /**
457         * Returns a list of the final set of entities to find.
458         *
459         * @param entities Entities we think should be found before factoring in includes/excludes
460         *
461         * This method is intended for use by TextClassifier implementations.
462         */
463        public Collection<String> resolveEntityListModifications(
464                @NonNull Collection<String> entities) {
465            final Set<String> finalSet = new HashSet();
466            if (mUseHints) {
467                finalSet.addAll(entities);
468            }
469            finalSet.addAll(mIncludedEntityTypes);
470            finalSet.removeAll(mExcludedEntityTypes);
471            return finalSet;
472        }
473
474        /**
475         * Retrieves the list of hints.
476         *
477         * @return An unmodifiable collection of the hints.
478         */
479        public Collection<String> getHints() {
480            return mHints;
481        }
482
483        @Override
484        public int describeContents() {
485            return 0;
486        }
487
488        @Override
489        public void writeToParcel(Parcel dest, int flags) {
490            dest.writeStringList(new ArrayList<>(mHints));
491            dest.writeStringList(new ArrayList<>(mExcludedEntityTypes));
492            dest.writeStringList(new ArrayList<>(mIncludedEntityTypes));
493            dest.writeInt(mUseHints ? 1 : 0);
494        }
495
496        public static final Parcelable.Creator<EntityConfig> CREATOR =
497                new Parcelable.Creator<EntityConfig>() {
498                    @Override
499                    public EntityConfig createFromParcel(Parcel in) {
500                        return new EntityConfig(in);
501                    }
502
503                    @Override
504                    public EntityConfig[] newArray(int size) {
505                        return new EntityConfig[size];
506                    }
507                };
508
509        private EntityConfig(Parcel in) {
510            mHints = new ArraySet<>(in.createStringArrayList());
511            mExcludedEntityTypes = new ArraySet<>(in.createStringArrayList());
512            mIncludedEntityTypes = new ArraySet<>(in.createStringArrayList());
513            mUseHints = in.readInt() == 1;
514        }
515    }
516
517    /**
518     * Utility functions for TextClassifier methods.
519     *
520     * <ul>
521     *  <li>Provides validation of input parameters to TextClassifier methods
522     * </ul>
523     *
524     * Intended to be used only in this package.
525     * @hide
526     */
527    final class Utils {
528
529        /**
530         * @throws IllegalArgumentException if text is null; startIndex is negative;
531         *      endIndex is greater than text.length() or is not greater than startIndex;
532         *      options is null
533         */
534        static void checkArgument(@NonNull CharSequence text, int startIndex, int endIndex) {
535            Preconditions.checkArgument(text != null);
536            Preconditions.checkArgument(startIndex >= 0);
537            Preconditions.checkArgument(endIndex <= text.length());
538            Preconditions.checkArgument(endIndex > startIndex);
539        }
540
541        static void checkTextLength(CharSequence text, int maxLength) {
542            Preconditions.checkArgumentInRange(text.length(), 0, maxLength, "text.length()");
543        }
544
545        /**
546         * Generates links using legacy {@link Linkify}.
547         */
548        public static TextLinks generateLegacyLinks(@NonNull TextLinks.Request request) {
549            final String string = request.getText().toString();
550            final TextLinks.Builder links = new TextLinks.Builder(string);
551
552            final Collection<String> entities = request.getEntityConfig()
553                    .resolveEntityListModifications(Collections.emptyList());
554            if (entities.contains(TextClassifier.TYPE_URL)) {
555                addLinks(links, string, TextClassifier.TYPE_URL);
556            }
557            if (entities.contains(TextClassifier.TYPE_PHONE)) {
558                addLinks(links, string, TextClassifier.TYPE_PHONE);
559            }
560            if (entities.contains(TextClassifier.TYPE_EMAIL)) {
561                addLinks(links, string, TextClassifier.TYPE_EMAIL);
562            }
563            // NOTE: Do not support MAP_ADDRESSES. Legacy version does not work well.
564            return links.build();
565        }
566
567        private static void addLinks(
568                TextLinks.Builder links, String string, @EntityType String entityType) {
569            final Spannable spannable = new SpannableString(string);
570            if (Linkify.addLinks(spannable, linkMask(entityType))) {
571                final URLSpan[] spans = spannable.getSpans(0, spannable.length(), URLSpan.class);
572                for (URLSpan urlSpan : spans) {
573                    links.addLink(
574                            spannable.getSpanStart(urlSpan),
575                            spannable.getSpanEnd(urlSpan),
576                            entityScores(entityType),
577                            urlSpan);
578                }
579            }
580        }
581
582        @LinkifyMask
583        private static int linkMask(@EntityType String entityType) {
584            switch (entityType) {
585                case TextClassifier.TYPE_URL:
586                    return Linkify.WEB_URLS;
587                case TextClassifier.TYPE_PHONE:
588                    return Linkify.PHONE_NUMBERS;
589                case TextClassifier.TYPE_EMAIL:
590                    return Linkify.EMAIL_ADDRESSES;
591                default:
592                    // NOTE: Do not support MAP_ADDRESSES. Legacy version does not work well.
593                    return 0;
594            }
595        }
596
597        private static Map<String, Float> entityScores(@EntityType String entityType) {
598            final Map<String, Float> scores = new ArrayMap<>();
599            scores.put(entityType, 1f);
600            return scores;
601        }
602
603        static void checkMainThread() {
604            if (Looper.myLooper() == Looper.getMainLooper()) {
605                Log.w(DEFAULT_LOG_TAG, "TextClassifier called on main thread");
606            }
607        }
608    }
609}
610