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