/* * Copyright (C) 2017 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.view.textclassifier; import android.annotation.IntDef; import android.annotation.IntRange; import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.StringDef; import android.annotation.WorkerThread; import android.os.LocaleList; import android.os.Looper; import android.os.Parcel; import android.os.Parcelable; import android.text.Spannable; import android.text.SpannableString; import android.text.style.URLSpan; import android.text.util.Linkify; import android.text.util.Linkify.LinkifyMask; import android.util.ArrayMap; import com.android.internal.annotations.GuardedBy; import com.android.internal.util.IndentingPrintWriter; import com.android.internal.util.Preconditions; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.text.BreakIterator; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; /** * Interface for providing text classification related features. *

* The TextClassifier may be used to understand the meaning of text, as well as generating predicted * next actions based on the text. * *

NOTE: Unless otherwise stated, methods of this interface are blocking * operations. Call on a worker thread. */ public interface TextClassifier { /** @hide */ String LOG_TAG = "androidtc"; /** @hide */ @Retention(RetentionPolicy.SOURCE) @IntDef(value = {LOCAL, SYSTEM, DEFAULT_SYSTEM}) @interface TextClassifierType {} // TODO: Expose as system APIs. /** Specifies a TextClassifier that runs locally in the app's process. @hide */ int LOCAL = 0; /** Specifies a TextClassifier that runs in the system process and serves all apps. @hide */ int SYSTEM = 1; /** Specifies the default TextClassifier that runs in the system process. @hide */ int DEFAULT_SYSTEM = 2; /** @hide */ static String typeToString(@TextClassifierType int type) { switch (type) { case LOCAL: return "Local"; case SYSTEM: return "System"; case DEFAULT_SYSTEM: return "Default system"; } return "Unknown"; } /** The TextClassifier failed to run. */ String TYPE_UNKNOWN = ""; /** The classifier ran, but didn't recognize a known entity. */ String TYPE_OTHER = "other"; /** E-mail address (e.g. "noreply@android.com"). */ String TYPE_EMAIL = "email"; /** Phone number (e.g. "555-123 456"). */ String TYPE_PHONE = "phone"; /** Physical address. */ String TYPE_ADDRESS = "address"; /** Web URL. */ String TYPE_URL = "url"; /** Time reference that is no more specific than a date. May be absolute such as "01/01/2000" or * relative like "tomorrow". **/ String TYPE_DATE = "date"; /** Time reference that includes a specific time. May be absolute such as "01/01/2000 5:30pm" or * relative like "tomorrow at 5:30pm". **/ String TYPE_DATE_TIME = "datetime"; /** Flight number in IATA format. */ String TYPE_FLIGHT_NUMBER = "flight"; /** * Word that users may be interested to look up for meaning. * @hide */ String TYPE_DICTIONARY = "dictionary"; /** @hide */ @Retention(RetentionPolicy.SOURCE) @StringDef(prefix = { "TYPE_" }, value = { TYPE_UNKNOWN, TYPE_OTHER, TYPE_EMAIL, TYPE_PHONE, TYPE_ADDRESS, TYPE_URL, TYPE_DATE, TYPE_DATE_TIME, TYPE_FLIGHT_NUMBER, TYPE_DICTIONARY }) @interface EntityType {} /** Designates that the text in question is editable. **/ String HINT_TEXT_IS_EDITABLE = "android.text_is_editable"; /** Designates that the text in question is not editable. **/ String HINT_TEXT_IS_NOT_EDITABLE = "android.text_is_not_editable"; /** @hide */ @Retention(RetentionPolicy.SOURCE) @StringDef(prefix = { "HINT_" }, value = {HINT_TEXT_IS_EDITABLE, HINT_TEXT_IS_NOT_EDITABLE}) @interface Hints {} /** @hide */ @Retention(RetentionPolicy.SOURCE) @StringDef({WIDGET_TYPE_TEXTVIEW, WIDGET_TYPE_EDITTEXT, WIDGET_TYPE_UNSELECTABLE_TEXTVIEW, WIDGET_TYPE_WEBVIEW, WIDGET_TYPE_EDIT_WEBVIEW, WIDGET_TYPE_CUSTOM_TEXTVIEW, WIDGET_TYPE_CUSTOM_EDITTEXT, WIDGET_TYPE_CUSTOM_UNSELECTABLE_TEXTVIEW, WIDGET_TYPE_NOTIFICATION, WIDGET_TYPE_CLIPBOARD, WIDGET_TYPE_UNKNOWN }) @interface WidgetType {} /** The widget involved in the text classification context is a standard * {@link android.widget.TextView}. */ String WIDGET_TYPE_TEXTVIEW = "textview"; /** The widget involved in the text classification context is a standard * {@link android.widget.EditText}. */ String WIDGET_TYPE_EDITTEXT = "edittext"; /** The widget involved in the text classification context is a standard non-selectable * {@link android.widget.TextView}. */ String WIDGET_TYPE_UNSELECTABLE_TEXTVIEW = "nosel-textview"; /** The widget involved in the text classification context is a standard * {@link android.webkit.WebView}. */ String WIDGET_TYPE_WEBVIEW = "webview"; /** The widget involved in the text classification context is a standard editable * {@link android.webkit.WebView}. */ String WIDGET_TYPE_EDIT_WEBVIEW = "edit-webview"; /** The widget involved in the text classification context is a custom text widget. */ String WIDGET_TYPE_CUSTOM_TEXTVIEW = "customview"; /** The widget involved in the text classification context is a custom editable text widget. */ String WIDGET_TYPE_CUSTOM_EDITTEXT = "customedit"; /** The widget involved in the text classification context is a custom non-selectable text * widget. */ String WIDGET_TYPE_CUSTOM_UNSELECTABLE_TEXTVIEW = "nosel-customview"; /** The widget involved in the text classification context is a notification */ String WIDGET_TYPE_NOTIFICATION = "notification"; /** The text classification context is for use with the system clipboard. */ String WIDGET_TYPE_CLIPBOARD = "clipboard"; /** The widget involved in the text classification context is of an unknown/unspecified type. */ String WIDGET_TYPE_UNKNOWN = "unknown"; /** * No-op TextClassifier. * This may be used to turn off TextClassifier features. */ TextClassifier NO_OP = new TextClassifier() { @Override public String toString() { return "TextClassifier.NO_OP"; } }; /** * Extra that is included on activity intents coming from a TextClassifier when * it suggests actions to its caller. *

* All {@link TextClassifier} implementations should make sure this extra exists in their * generated intents. */ String EXTRA_FROM_TEXT_CLASSIFIER = "android.view.textclassifier.extra.FROM_TEXT_CLASSIFIER"; /** * Returns suggested text selection start and end indices, recognized entity types, and their * associated confidence scores. The entity types are ordered from highest to lowest scoring. * *

NOTE: Call on a worker thread. * *

NOTE: If a TextClassifier has been destroyed, calls to this method should * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. * * @param request the text selection request */ @WorkerThread @NonNull default TextSelection suggestSelection(@NonNull TextSelection.Request request) { Objects.requireNonNull(request); Utils.checkMainThread(); return new TextSelection.Builder(request.getStartIndex(), request.getEndIndex()).build(); } /** * Returns suggested text selection start and end indices, recognized entity types, and their * associated confidence scores. The entity types are ordered from highest to lowest scoring. * *

NOTE: Call on a worker thread. * *

NOTE: If a TextClassifier has been destroyed, calls to this method should * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. * *

NOTE: Do not implement. The default implementation of this method calls * {@link #suggestSelection(TextSelection.Request)}. If that method calls this method, * a stack overflow error will happen. * * @param text text providing context for the selected text (which is specified * by the sub sequence starting at selectionStartIndex and ending at selectionEndIndex) * @param selectionStartIndex start index of the selected part of text * @param selectionEndIndex end index of the selected part of text * @param defaultLocales ordered list of locale preferences that may be used to * disambiguate the provided text. If no locale preferences exist, set this to null * or an empty locale list. * * @throws IllegalArgumentException if text is null; selectionStartIndex is negative; * selectionEndIndex is greater than text.length() or not greater than selectionStartIndex * * @see #suggestSelection(TextSelection.Request) */ @WorkerThread @NonNull default TextSelection suggestSelection( @NonNull CharSequence text, @IntRange(from = 0) int selectionStartIndex, @IntRange(from = 0) int selectionEndIndex, @Nullable LocaleList defaultLocales) { final TextSelection.Request request = new TextSelection.Request.Builder( text, selectionStartIndex, selectionEndIndex) .setDefaultLocales(defaultLocales) .build(); return suggestSelection(request); } /** * Classifies the specified text and returns a {@link TextClassification} object that can be * used to generate a widget for handling the classified text. * *

NOTE: Call on a worker thread. * *

NOTE: If a TextClassifier has been destroyed, calls to this method should * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. * * @param request the text classification request */ @WorkerThread @NonNull default TextClassification classifyText(@NonNull TextClassification.Request request) { Objects.requireNonNull(request); Utils.checkMainThread(); return TextClassification.EMPTY; } /** * Classifies the specified text and returns a {@link TextClassification} object that can be * used to generate a widget for handling the classified text. * *

NOTE: Call on a worker thread. * *

NOTE: Do not implement. The default implementation of this method calls * {@link #classifyText(TextClassification.Request)}. If that method calls this method, * a stack overflow error will happen. * *

NOTE: If a TextClassifier has been destroyed, calls to this method should * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. * * @param text text providing context for the text to classify (which is specified * by the sub sequence starting at startIndex and ending at endIndex) * @param startIndex start index of the text to classify * @param endIndex end index of the text to classify * @param defaultLocales ordered list of locale preferences that may be used to * disambiguate the provided text. If no locale preferences exist, set this to null * or an empty locale list. * * @throws IllegalArgumentException if text is null; startIndex is negative; * endIndex is greater than text.length() or not greater than startIndex * * @see #classifyText(TextClassification.Request) */ @WorkerThread @NonNull default TextClassification classifyText( @NonNull CharSequence text, @IntRange(from = 0) int startIndex, @IntRange(from = 0) int endIndex, @Nullable LocaleList defaultLocales) { final TextClassification.Request request = new TextClassification.Request.Builder( text, startIndex, endIndex) .setDefaultLocales(defaultLocales) .build(); return classifyText(request); } /** * Generates and returns a {@link TextLinks} that may be applied to the text to annotate it with * links information. * *

NOTE: Call on a worker thread. * *

NOTE: If a TextClassifier has been destroyed, calls to this method should * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. * * @param request the text links request * * @see #getMaxGenerateLinksTextLength() */ @WorkerThread @NonNull default TextLinks generateLinks(@NonNull TextLinks.Request request) { Objects.requireNonNull(request); Utils.checkMainThread(); return new TextLinks.Builder(request.getText().toString()).build(); } /** * Returns the maximal length of text that can be processed by generateLinks. * *

NOTE: If a TextClassifier has been destroyed, calls to this method should * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. * * @see #generateLinks(TextLinks.Request) */ @WorkerThread default int getMaxGenerateLinksTextLength() { return Integer.MAX_VALUE; } /** * Detects the language of the text in the given request. * *

NOTE: Call on a worker thread. * * *

NOTE: If a TextClassifier has been destroyed, calls to this method should * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. * * @param request the {@link TextLanguage} request. * @return the {@link TextLanguage} result. */ @WorkerThread @NonNull default TextLanguage detectLanguage(@NonNull TextLanguage.Request request) { Objects.requireNonNull(request); Utils.checkMainThread(); return TextLanguage.EMPTY; } /** * Suggests and returns a list of actions according to the given conversation. */ @WorkerThread @NonNull default ConversationActions suggestConversationActions( @NonNull ConversationActions.Request request) { Objects.requireNonNull(request); Utils.checkMainThread(); return new ConversationActions(Collections.emptyList(), null); } /** * NOTE: Use {@link #onTextClassifierEvent(TextClassifierEvent)} instead. *

* Reports a selection event. * *

NOTE: If a TextClassifier has been destroyed, calls to this method should * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. */ default void onSelectionEvent(@NonNull SelectionEvent event) { // TODO: Consider rerouting to onTextClassifierEvent() } /** * Reports a text classifier event. *

* NOTE: Call on a worker thread. * * @throws IllegalStateException if this TextClassifier has been destroyed. * @see #isDestroyed() */ default void onTextClassifierEvent(@NonNull TextClassifierEvent event) {} /** * Destroys this TextClassifier. * *

NOTE: If a TextClassifier has been destroyed, calls to its methods should * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. * *

Subsequent calls to this method are no-ops. */ default void destroy() {} /** * Returns whether or not this TextClassifier has been destroyed. * *

NOTE: If a TextClassifier has been destroyed, caller should not interact * with the classifier and an attempt to do so would throw an {@link IllegalStateException}. * However, this method should never throw an {@link IllegalStateException}. * * @see #destroy() */ default boolean isDestroyed() { return false; } /** @hide **/ default void dump(@NonNull IndentingPrintWriter printWriter) {} /** * Configuration object for specifying what entity types to identify. * * Configs are initially based on a predefined preset, and can be modified from there. */ final class EntityConfig implements Parcelable { private final List mIncludedTypes; private final List mExcludedTypes; private final List mHints; private final boolean mIncludeTypesFromTextClassifier; private EntityConfig( List includedEntityTypes, List excludedEntityTypes, List hints, boolean includeTypesFromTextClassifier) { mIncludedTypes = Objects.requireNonNull(includedEntityTypes); mExcludedTypes = Objects.requireNonNull(excludedEntityTypes); mHints = Objects.requireNonNull(hints); mIncludeTypesFromTextClassifier = includeTypesFromTextClassifier; } private EntityConfig(Parcel in) { mIncludedTypes = new ArrayList<>(); in.readStringList(mIncludedTypes); mExcludedTypes = new ArrayList<>(); in.readStringList(mExcludedTypes); List tmpHints = new ArrayList<>(); in.readStringList(tmpHints); mHints = Collections.unmodifiableList(tmpHints); mIncludeTypesFromTextClassifier = in.readByte() != 0; } @Override public void writeToParcel(Parcel parcel, int flags) { parcel.writeStringList(mIncludedTypes); parcel.writeStringList(mExcludedTypes); parcel.writeStringList(mHints); parcel.writeByte((byte) (mIncludeTypesFromTextClassifier ? 1 : 0)); } /** * Creates an EntityConfig. * * @param hints Hints for the TextClassifier to determine what types of entities to find. * * @deprecated Use {@link Builder} instead. */ @Deprecated public static EntityConfig createWithHints(@Nullable Collection hints) { return new EntityConfig.Builder() .includeTypesFromTextClassifier(true) .setHints(hints) .build(); } /** * Creates an EntityConfig. * * @param hints Hints for the TextClassifier to determine what types of entities to find * @param includedEntityTypes Entity types, e.g. {@link #TYPE_EMAIL}, to explicitly include * @param excludedEntityTypes Entity types, e.g. {@link #TYPE_PHONE}, to explicitly exclude * * * Note that if an entity has been excluded, the exclusion will take precedence. * * @deprecated Use {@link Builder} instead. */ @Deprecated public static EntityConfig create(@Nullable Collection hints, @Nullable Collection includedEntityTypes, @Nullable Collection excludedEntityTypes) { return new EntityConfig.Builder() .setIncludedTypes(includedEntityTypes) .setExcludedTypes(excludedEntityTypes) .setHints(hints) .includeTypesFromTextClassifier(true) .build(); } /** * Creates an EntityConfig with an explicit entity list. * * @param entityTypes Complete set of entities, e.g. {@link #TYPE_URL} to find. * * @deprecated Use {@link Builder} instead. */ @Deprecated public static EntityConfig createWithExplicitEntityList( @Nullable Collection entityTypes) { return new EntityConfig.Builder() .setIncludedTypes(entityTypes) .includeTypesFromTextClassifier(false) .build(); } /** * Returns a final list of entity types to find. * * @param entityTypes Entity types we think should be found before factoring in * includes/excludes * * This method is intended for use by TextClassifier implementations. */ public Collection resolveEntityListModifications( @NonNull Collection entityTypes) { final Set finalSet = new HashSet<>(); if (mIncludeTypesFromTextClassifier) { finalSet.addAll(entityTypes); } finalSet.addAll(mIncludedTypes); finalSet.removeAll(mExcludedTypes); return finalSet; } /** * Retrieves the list of hints. * * @return An unmodifiable collection of the hints. */ public Collection getHints() { return mHints; } /** * Return whether the client allows the text classifier to include its own list of * default types. If this function returns {@code true}, a default list of types suggested * from a text classifier will be taking into account. * *

NOTE: This method is intended for use by a text classifier. * * @see #resolveEntityListModifications(Collection) */ public boolean shouldIncludeTypesFromTextClassifier() { return mIncludeTypesFromTextClassifier; } @Override public int describeContents() { return 0; } public static final @android.annotation.NonNull Parcelable.Creator CREATOR = new Parcelable.Creator() { @Override public EntityConfig createFromParcel(Parcel in) { return new EntityConfig(in); } @Override public EntityConfig[] newArray(int size) { return new EntityConfig[size]; } }; /** Builder class to construct the {@link EntityConfig} object. */ public static final class Builder { @Nullable private Collection mIncludedTypes; @Nullable private Collection mExcludedTypes; @Nullable private Collection mHints; private boolean mIncludeTypesFromTextClassifier = true; /** * Sets a collection of types that are explicitly included. */ @NonNull public Builder setIncludedTypes(@Nullable Collection includedTypes) { mIncludedTypes = includedTypes; return this; } /** * Sets a collection of types that are explicitly excluded. */ @NonNull public Builder setExcludedTypes(@Nullable Collection excludedTypes) { mExcludedTypes = excludedTypes; return this; } /** * Specifies whether or not to include the types suggested by the text classifier. By * default, it is included. */ @NonNull public Builder includeTypesFromTextClassifier(boolean includeTypesFromTextClassifier) { mIncludeTypesFromTextClassifier = includeTypesFromTextClassifier; return this; } /** * Sets the hints for the TextClassifier to determine what types of entities to find. * These hints will only be used if {@link #includeTypesFromTextClassifier} is * set to be true. */ @NonNull public Builder setHints(@Nullable Collection hints) { mHints = hints; return this; } /** * Combines all of the options that have been set and returns a new {@link EntityConfig} * object. */ @NonNull public EntityConfig build() { return new EntityConfig( mIncludedTypes == null ? Collections.emptyList() : new ArrayList<>(mIncludedTypes), mExcludedTypes == null ? Collections.emptyList() : new ArrayList<>(mExcludedTypes), mHints == null ? Collections.emptyList() : Collections.unmodifiableList(new ArrayList<>(mHints)), mIncludeTypesFromTextClassifier); } } } /** * Utility functions for TextClassifier methods. * *

* * Intended to be used only for TextClassifier purposes. * @hide */ final class Utils { @GuardedBy("WORD_ITERATOR") private static final BreakIterator WORD_ITERATOR = BreakIterator.getWordInstance(); /** * @throws IllegalArgumentException if text is null; startIndex is negative; * endIndex is greater than text.length() or is not greater than startIndex; * options is null */ static void checkArgument(@NonNull CharSequence text, int startIndex, int endIndex) { Preconditions.checkArgument(text != null); Preconditions.checkArgument(startIndex >= 0); Preconditions.checkArgument(endIndex <= text.length()); Preconditions.checkArgument(endIndex > startIndex); } /** Returns if the length of the text is within the range. */ static boolean checkTextLength(CharSequence text, int maxLength) { int textLength = text.length(); return textLength >= 0 && textLength <= maxLength; } /** * Returns the substring of {@code text} that contains at least text from index * {@code start} (inclusive) to index {@code end} <(exclusive)/i> with the goal of * returning text that is at least {@code minimumLength}. If {@code text} is not long * enough, this will return {@code text}. This method returns text at word boundaries. * * @param text the source text * @param start the start index of text that must be included * @param end the end index of text that must be included * @param minimumLength minimum length of text to return if {@code text} is long enough */ public static String getSubString( String text, int start, int end, int minimumLength) { Preconditions.checkArgument(start >= 0); Preconditions.checkArgument(end <= text.length()); Preconditions.checkArgument(start <= end); if (text.length() < minimumLength) { return text; } final int length = end - start; if (length >= minimumLength) { return text.substring(start, end); } final int offset = (minimumLength - length) / 2; int iterStart = Math.max(0, Math.min(start - offset, text.length() - minimumLength)); int iterEnd = Math.min(text.length(), iterStart + minimumLength); synchronized (WORD_ITERATOR) { WORD_ITERATOR.setText(text); iterStart = WORD_ITERATOR.isBoundary(iterStart) ? iterStart : Math.max(0, WORD_ITERATOR.preceding(iterStart)); iterEnd = WORD_ITERATOR.isBoundary(iterEnd) ? iterEnd : Math.max(iterEnd, WORD_ITERATOR.following(iterEnd)); WORD_ITERATOR.setText(""); return text.substring(iterStart, iterEnd); } } /** * Generates links using legacy {@link Linkify}. */ public static TextLinks generateLegacyLinks(@NonNull TextLinks.Request request) { final String string = request.getText().toString(); final TextLinks.Builder links = new TextLinks.Builder(string); final Collection entities = request.getEntityConfig() .resolveEntityListModifications(Collections.emptyList()); if (entities.contains(TextClassifier.TYPE_URL)) { addLinks(links, string, TextClassifier.TYPE_URL); } if (entities.contains(TextClassifier.TYPE_PHONE)) { addLinks(links, string, TextClassifier.TYPE_PHONE); } if (entities.contains(TextClassifier.TYPE_EMAIL)) { addLinks(links, string, TextClassifier.TYPE_EMAIL); } // NOTE: Do not support MAP_ADDRESSES. Legacy version does not work well. return links.build(); } private static void addLinks( TextLinks.Builder links, String string, @EntityType String entityType) { final Spannable spannable = new SpannableString(string); if (Linkify.addLinks(spannable, linkMask(entityType))) { final URLSpan[] spans = spannable.getSpans(0, spannable.length(), URLSpan.class); for (URLSpan urlSpan : spans) { links.addLink( spannable.getSpanStart(urlSpan), spannable.getSpanEnd(urlSpan), entityScores(entityType), urlSpan); } } } @LinkifyMask private static int linkMask(@EntityType String entityType) { switch (entityType) { case TextClassifier.TYPE_URL: return Linkify.WEB_URLS; case TextClassifier.TYPE_PHONE: return Linkify.PHONE_NUMBERS; case TextClassifier.TYPE_EMAIL: return Linkify.EMAIL_ADDRESSES; default: // NOTE: Do not support MAP_ADDRESSES. Legacy version does not work well. return 0; } } private static Map entityScores(@EntityType String entityType) { final Map scores = new ArrayMap<>(); scores.put(entityType, 1f); return scores; } static void checkMainThread() { if (Looper.myLooper() == Looper.getMainLooper()) { Log.w(LOG_TAG, "TextClassifier called on main thread"); } } } }