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