TextClassifier.java revision ab669a01a7cbb77107e4335f4940f4e39da0e2d7
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 /** 213 * Classifies the specified text and returns a {@link TextClassification} object that can be 214 * used to generate a widget for handling the classified text. 215 * 216 * <p><strong>NOTE: </strong>Call on a worker thread. 217 * 218 * <strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to this method should 219 * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. 220 * 221 * @param request the text classification request 222 */ 223 @WorkerThread 224 @NonNull 225 default TextClassification classifyText(@NonNull TextClassification.Request request) { 226 Preconditions.checkNotNull(request); 227 Utils.checkMainThread(); 228 return TextClassification.EMPTY; 229 } 230 231 /** 232 * Classifies the specified text and returns a {@link TextClassification} object that can be 233 * used to generate a widget for handling the classified text. 234 * 235 * <p><strong>NOTE: </strong>Call on a worker thread. 236 * 237 * <p><b>NOTE:</b> Do not implement. The default implementation of this method calls 238 * {@link #classifyText(TextClassification.Request)}. If that method calls this method, 239 * a stack overflow error will happen. 240 * 241 * <strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to this method should 242 * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. 243 * 244 * @param text text providing context for the text to classify (which is specified 245 * by the sub sequence starting at startIndex and ending at endIndex) 246 * @param startIndex start index of the text to classify 247 * @param endIndex end index of the text to classify 248 * @param defaultLocales ordered list of locale preferences that may be used to 249 * disambiguate the provided text. If no locale preferences exist, set this to null 250 * or an empty locale list. 251 * 252 * @throws IllegalArgumentException if text is null; startIndex is negative; 253 * endIndex is greater than text.length() or not greater than startIndex 254 * 255 * @see #classifyText(TextClassification.Request) 256 */ 257 @WorkerThread 258 @NonNull 259 default TextClassification classifyText( 260 @NonNull CharSequence text, 261 @IntRange(from = 0) int startIndex, 262 @IntRange(from = 0) int endIndex, 263 @Nullable LocaleList defaultLocales) { 264 final TextClassification.Request request = new TextClassification.Request.Builder( 265 text, startIndex, endIndex) 266 .setDefaultLocales(defaultLocales) 267 .build(); 268 return classifyText(request); 269 } 270 271 /** 272 * Generates and returns a {@link TextLinks} that may be applied to the text to annotate it with 273 * links information. 274 * 275 * <p><strong>NOTE: </strong>Call on a worker thread. 276 * 277 * <strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to this method should 278 * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. 279 * 280 * @param request the text links request 281 * 282 * @see #getMaxGenerateLinksTextLength() 283 */ 284 @WorkerThread 285 @NonNull 286 default TextLinks generateLinks(@NonNull TextLinks.Request request) { 287 Preconditions.checkNotNull(request); 288 Utils.checkMainThread(); 289 return new TextLinks.Builder(request.getText().toString()).build(); 290 } 291 292 /** 293 * Returns the maximal length of text that can be processed by generateLinks. 294 * 295 * <strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to this method should 296 * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. 297 * 298 * @see #generateLinks(TextLinks.Request) 299 */ 300 @WorkerThread 301 default int getMaxGenerateLinksTextLength() { 302 return Integer.MAX_VALUE; 303 } 304 305 /** 306 * Returns a helper for logging TextClassifier related events. 307 * 308 * @param config logger configuration 309 * @hide 310 */ 311 @WorkerThread 312 default Logger getLogger(@NonNull Logger.Config config) { 313 Preconditions.checkNotNull(config); 314 return Logger.DISABLED; 315 } 316 317 /** 318 * Reports a selection event. 319 * 320 * <strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to this method should 321 * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. 322 */ 323 default void onSelectionEvent(@NonNull SelectionEvent event) {} 324 325 /** 326 * Destroys this TextClassifier. 327 * 328 * <strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to its methods should 329 * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. 330 * 331 * <p>Subsequent calls to this method are no-ops. 332 */ 333 default void destroy() {} 334 335 /** 336 * Returns whether or not this TextClassifier has been destroyed. 337 * 338 * <strong>NOTE: </strong>If a TextClassifier has been destroyed, caller should not interact 339 * with the classifier and an attempt to do so would throw an {@link IllegalStateException}. 340 * However, this method should never throw an {@link IllegalStateException}. 341 * 342 * @see #destroy() 343 */ 344 default boolean isDestroyed() { 345 return false; 346 } 347 348 /** 349 * Configuration object for specifying what entities to identify. 350 * 351 * Configs are initially based on a predefined preset, and can be modified from there. 352 */ 353 final class EntityConfig implements Parcelable { 354 private final Collection<String> mHints; 355 private final Collection<String> mExcludedEntityTypes; 356 private final Collection<String> mIncludedEntityTypes; 357 private final boolean mUseHints; 358 359 private EntityConfig(boolean useHints, Collection<String> hints, 360 Collection<String> includedEntityTypes, Collection<String> excludedEntityTypes) { 361 mHints = hints == null 362 ? Collections.EMPTY_LIST 363 : Collections.unmodifiableCollection(new ArraySet<>(hints)); 364 mExcludedEntityTypes = excludedEntityTypes == null 365 ? Collections.EMPTY_LIST : new ArraySet<>(excludedEntityTypes); 366 mIncludedEntityTypes = includedEntityTypes == null 367 ? Collections.EMPTY_LIST : new ArraySet<>(includedEntityTypes); 368 mUseHints = useHints; 369 } 370 371 /** 372 * Creates an EntityConfig. 373 * 374 * @param hints Hints for the TextClassifier to determine what types of entities to find. 375 */ 376 public static EntityConfig createWithHints(@Nullable Collection<String> hints) { 377 return new EntityConfig(/* useHints */ true, hints, 378 /* includedEntityTypes */null, /* excludedEntityTypes */ null); 379 } 380 381 /** 382 * Creates an EntityConfig. 383 * 384 * @param hints Hints for the TextClassifier to determine what types of entities to find 385 * @param includedEntityTypes Entity types, e.g. {@link #TYPE_EMAIL}, to explicitly include 386 * @param excludedEntityTypes Entity types, e.g. {@link #TYPE_PHONE}, to explicitly exclude 387 * 388 * 389 * Note that if an entity has been excluded, the exclusion will take precedence. 390 */ 391 public static EntityConfig create(@Nullable Collection<String> hints, 392 @Nullable Collection<String> includedEntityTypes, 393 @Nullable Collection<String> excludedEntityTypes) { 394 return new EntityConfig(/* useHints */ true, hints, 395 includedEntityTypes, excludedEntityTypes); 396 } 397 398 /** 399 * Creates an EntityConfig with an explicit entity list. 400 * 401 * @param entityTypes Complete set of entities, e.g. {@link #TYPE_URL} to find. 402 * 403 */ 404 public static EntityConfig createWithExplicitEntityList( 405 @Nullable Collection<String> entityTypes) { 406 return new EntityConfig(/* useHints */ false, /* hints */ null, 407 /* includedEntityTypes */ entityTypes, /* excludedEntityTypes */ null); 408 } 409 410 /** 411 * Returns a list of the final set of entities to find. 412 * 413 * @param entities Entities we think should be found before factoring in includes/excludes 414 * 415 * This method is intended for use by TextClassifier implementations. 416 */ 417 public Collection<String> resolveEntityListModifications( 418 @NonNull Collection<String> entities) { 419 final Set<String> finalSet = new HashSet(); 420 if (mUseHints) { 421 finalSet.addAll(entities); 422 } 423 finalSet.addAll(mIncludedEntityTypes); 424 finalSet.removeAll(mExcludedEntityTypes); 425 return finalSet; 426 } 427 428 /** 429 * Retrieves the list of hints. 430 * 431 * @return An unmodifiable collection of the hints. 432 */ 433 public Collection<String> getHints() { 434 return mHints; 435 } 436 437 @Override 438 public int describeContents() { 439 return 0; 440 } 441 442 @Override 443 public void writeToParcel(Parcel dest, int flags) { 444 dest.writeStringList(new ArrayList<>(mHints)); 445 dest.writeStringList(new ArrayList<>(mExcludedEntityTypes)); 446 dest.writeStringList(new ArrayList<>(mIncludedEntityTypes)); 447 dest.writeInt(mUseHints ? 1 : 0); 448 } 449 450 public static final Parcelable.Creator<EntityConfig> CREATOR = 451 new Parcelable.Creator<EntityConfig>() { 452 @Override 453 public EntityConfig createFromParcel(Parcel in) { 454 return new EntityConfig(in); 455 } 456 457 @Override 458 public EntityConfig[] newArray(int size) { 459 return new EntityConfig[size]; 460 } 461 }; 462 463 private EntityConfig(Parcel in) { 464 mHints = new ArraySet<>(in.createStringArrayList()); 465 mExcludedEntityTypes = new ArraySet<>(in.createStringArrayList()); 466 mIncludedEntityTypes = new ArraySet<>(in.createStringArrayList()); 467 mUseHints = in.readInt() == 1; 468 } 469 } 470 471 /** 472 * Utility functions for TextClassifier methods. 473 * 474 * <ul> 475 * <li>Provides validation of input parameters to TextClassifier methods 476 * </ul> 477 * 478 * Intended to be used only in this package. 479 * @hide 480 */ 481 final class Utils { 482 483 /** 484 * @throws IllegalArgumentException if text is null; startIndex is negative; 485 * endIndex is greater than text.length() or is not greater than startIndex; 486 * options is null 487 */ 488 static void checkArgument(@NonNull CharSequence text, int startIndex, int endIndex) { 489 Preconditions.checkArgument(text != null); 490 Preconditions.checkArgument(startIndex >= 0); 491 Preconditions.checkArgument(endIndex <= text.length()); 492 Preconditions.checkArgument(endIndex > startIndex); 493 } 494 495 static void checkTextLength(CharSequence text, int maxLength) { 496 Preconditions.checkArgumentInRange(text.length(), 0, maxLength, "text.length()"); 497 } 498 499 /** 500 * Generates links using legacy {@link Linkify}. 501 */ 502 public static TextLinks generateLegacyLinks(@NonNull TextLinks.Request request) { 503 final String string = request.getText().toString(); 504 final TextLinks.Builder links = new TextLinks.Builder(string); 505 506 final Collection<String> entities = request.getEntityConfig() 507 .resolveEntityListModifications(Collections.emptyList()); 508 if (entities.contains(TextClassifier.TYPE_URL)) { 509 addLinks(links, string, TextClassifier.TYPE_URL); 510 } 511 if (entities.contains(TextClassifier.TYPE_PHONE)) { 512 addLinks(links, string, TextClassifier.TYPE_PHONE); 513 } 514 if (entities.contains(TextClassifier.TYPE_EMAIL)) { 515 addLinks(links, string, TextClassifier.TYPE_EMAIL); 516 } 517 // NOTE: Do not support MAP_ADDRESSES. Legacy version does not work well. 518 return links.build(); 519 } 520 521 private static void addLinks( 522 TextLinks.Builder links, String string, @EntityType String entityType) { 523 final Spannable spannable = new SpannableString(string); 524 if (Linkify.addLinks(spannable, linkMask(entityType))) { 525 final URLSpan[] spans = spannable.getSpans(0, spannable.length(), URLSpan.class); 526 for (URLSpan urlSpan : spans) { 527 links.addLink( 528 spannable.getSpanStart(urlSpan), 529 spannable.getSpanEnd(urlSpan), 530 entityScores(entityType), 531 urlSpan); 532 } 533 } 534 } 535 536 @LinkifyMask 537 private static int linkMask(@EntityType String entityType) { 538 switch (entityType) { 539 case TextClassifier.TYPE_URL: 540 return Linkify.WEB_URLS; 541 case TextClassifier.TYPE_PHONE: 542 return Linkify.PHONE_NUMBERS; 543 case TextClassifier.TYPE_EMAIL: 544 return Linkify.EMAIL_ADDRESSES; 545 default: 546 // NOTE: Do not support MAP_ADDRESSES. Legacy version does not work well. 547 return 0; 548 } 549 } 550 551 private static Map<String, Float> entityScores(@EntityType String entityType) { 552 final Map<String, Float> scores = new ArrayMap<>(); 553 scores.put(entityType, 1f); 554 return scores; 555 } 556 557 static void checkMainThread() { 558 if (Looper.myLooper() == Looper.getMainLooper()) { 559 Log.w(DEFAULT_LOG_TAG, "TextClassifier called on main thread"); 560 } 561 } 562 } 563} 564