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