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