TextClassifier.java revision 6563833cf3c79e8cd211e32357422ae899674437
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; 36import android.util.Slog; 37 38import com.android.internal.util.Preconditions; 39 40import java.lang.annotation.Retention; 41import java.lang.annotation.RetentionPolicy; 42import java.util.ArrayList; 43import java.util.Collection; 44import java.util.Collections; 45import java.util.List; 46import java.util.Map; 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 /** 116 * No-op TextClassifier. 117 * This may be used to turn off TextClassifier features. 118 */ 119 TextClassifier NO_OP = new TextClassifier() {}; 120 121 /** 122 * Returns suggested text selection start and end indices, recognized entity types, and their 123 * associated confidence scores. The entity types are ordered from highest to lowest scoring. 124 * 125 * <p><strong>NOTE: </strong>Call on a worker thread. 126 * 127 * @param text text providing context for the selected text (which is specified 128 * by the sub sequence starting at selectionStartIndex and ending at selectionEndIndex) 129 * @param selectionStartIndex start index of the selected part of text 130 * @param selectionEndIndex end index of the selected part of text 131 * @param options optional input parameters 132 * 133 * @throws IllegalArgumentException if text is null; selectionStartIndex is negative; 134 * selectionEndIndex is greater than text.length() or not greater than selectionStartIndex 135 * 136 * @see #suggestSelection(CharSequence, int, int) 137 */ 138 @WorkerThread 139 @NonNull 140 default TextSelection suggestSelection( 141 @NonNull CharSequence text, 142 @IntRange(from = 0) int selectionStartIndex, 143 @IntRange(from = 0) int selectionEndIndex, 144 @Nullable TextSelection.Options options) { 145 Utils.validate(text, selectionStartIndex, selectionEndIndex, false); 146 return new TextSelection.Builder(selectionStartIndex, selectionEndIndex).build(); 147 } 148 149 /** 150 * Returns suggested text selection start and end indices, recognized entity types, and their 151 * associated confidence scores. The entity types are ordered from highest to lowest scoring. 152 * 153 * <p><b>NOTE:</b> Do not implement. The default implementation of this method calls 154 * {@link #suggestSelection(CharSequence, int, int, TextSelection.Options)}. If that method 155 * calls this method, a stack overflow error will happen. 156 * 157 * <p><strong>NOTE: </strong>Call on a worker thread. 158 * 159 * @param text text providing context for the selected text (which is specified 160 * by the sub sequence starting at selectionStartIndex and ending at selectionEndIndex) 161 * @param selectionStartIndex start index of the selected part of text 162 * @param selectionEndIndex end index of the selected part of text 163 * 164 * @throws IllegalArgumentException if text is null; selectionStartIndex is negative; 165 * selectionEndIndex is greater than text.length() or not greater than selectionStartIndex 166 * 167 * @see #suggestSelection(CharSequence, int, int, TextSelection.Options) 168 */ 169 @WorkerThread 170 @NonNull 171 default TextSelection suggestSelection( 172 @NonNull CharSequence text, 173 @IntRange(from = 0) int selectionStartIndex, 174 @IntRange(from = 0) int selectionEndIndex) { 175 return suggestSelection(text, selectionStartIndex, selectionEndIndex, 176 (TextSelection.Options) null); 177 } 178 179 /** 180 * See {@link #suggestSelection(CharSequence, int, int)} or 181 * {@link #suggestSelection(CharSequence, int, int, TextSelection.Options)}. 182 * 183 * <p><strong>NOTE: </strong>Call on a worker thread. 184 * 185 * <p><b>NOTE:</b> Do not implement. The default implementation of this method calls 186 * {@link #suggestSelection(CharSequence, int, int, TextSelection.Options)}. If that method 187 * calls this method, a stack overflow error will happen. 188 */ 189 @WorkerThread 190 @NonNull 191 default TextSelection suggestSelection( 192 @NonNull CharSequence text, 193 @IntRange(from = 0) int selectionStartIndex, 194 @IntRange(from = 0) int selectionEndIndex, 195 @Nullable LocaleList defaultLocales) { 196 final TextSelection.Options options = (defaultLocales != null) 197 ? new TextSelection.Options().setDefaultLocales(defaultLocales) 198 : null; 199 return suggestSelection(text, selectionStartIndex, selectionEndIndex, options); 200 } 201 202 /** 203 * Classifies the specified text and returns a {@link TextClassification} object that can be 204 * used to generate a widget for handling the classified text. 205 * 206 * <p><strong>NOTE: </strong>Call on a worker thread. 207 * 208 * @param text text providing context for the text to classify (which is specified 209 * by the sub sequence starting at startIndex and ending at endIndex) 210 * @param startIndex start index of the text to classify 211 * @param endIndex end index of the text to classify 212 * @param options optional input parameters 213 * 214 * @throws IllegalArgumentException if text is null; startIndex is negative; 215 * endIndex is greater than text.length() or not greater than startIndex 216 * 217 * @see #classifyText(CharSequence, int, int) 218 */ 219 @WorkerThread 220 @NonNull 221 default TextClassification classifyText( 222 @NonNull CharSequence text, 223 @IntRange(from = 0) int startIndex, 224 @IntRange(from = 0) int endIndex, 225 @Nullable TextClassification.Options options) { 226 Utils.validate(text, startIndex, endIndex, false); 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(CharSequence, int, int, TextClassification.Options)}. If that method 238 * calls this method, a stack overflow error will happen. 239 * 240 * @param text text providing context for the text to classify (which is specified 241 * by the sub sequence starting at startIndex and ending at endIndex) 242 * @param startIndex start index of the text to classify 243 * @param endIndex end index of the text to classify 244 * 245 * @throws IllegalArgumentException if text is null; startIndex is negative; 246 * endIndex is greater than text.length() or not greater than startIndex 247 * 248 * @see #classifyText(CharSequence, int, int, TextClassification.Options) 249 */ 250 @WorkerThread 251 @NonNull 252 default TextClassification classifyText( 253 @NonNull CharSequence text, 254 @IntRange(from = 0) int startIndex, 255 @IntRange(from = 0) int endIndex) { 256 return classifyText(text, startIndex, endIndex, (TextClassification.Options) null); 257 } 258 259 /** 260 * See {@link #classifyText(CharSequence, int, int, TextClassification.Options)} or 261 * {@link #classifyText(CharSequence, int, int)}. 262 * 263 * <p><strong>NOTE: </strong>Call on a worker thread. 264 * 265 * <p><b>NOTE:</b> Do not implement. The default implementation of this method calls 266 * {@link #classifyText(CharSequence, int, int, TextClassification.Options)}. If that method 267 * calls this method, a stack overflow error will happen. 268 */ 269 @WorkerThread 270 @NonNull 271 default TextClassification classifyText( 272 @NonNull CharSequence text, 273 @IntRange(from = 0) int startIndex, 274 @IntRange(from = 0) int endIndex, 275 @Nullable LocaleList defaultLocales) { 276 final TextClassification.Options options = (defaultLocales != null) 277 ? new TextClassification.Options().setDefaultLocales(defaultLocales) 278 : null; 279 return classifyText(text, startIndex, endIndex, options); 280 } 281 282 /** 283 * Generates and returns a {@link TextLinks} that may be applied to the text to annotate it with 284 * links information. 285 * 286 * <p><strong>NOTE: </strong>Call on a worker thread. 287 * 288 * @param text the text to generate annotations for 289 * @param options configuration for link generation 290 * 291 * @throws IllegalArgumentException if text is null or the text is too long for the 292 * TextClassifier implementation. 293 * 294 * @see #generateLinks(CharSequence) 295 * @see #getMaxGenerateLinksTextLength() 296 */ 297 @WorkerThread 298 default TextLinks generateLinks( 299 @NonNull CharSequence text, @Nullable TextLinks.Options options) { 300 Utils.validate(text, false); 301 return new TextLinks.Builder(text.toString()).build(); 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 * <p><b>NOTE:</b> Do not implement. The default implementation of this method calls 311 * {@link #generateLinks(CharSequence, TextLinks.Options)}. If that method calls this method, 312 * a stack overflow error will happen. 313 * 314 * @param text the text to generate annotations for 315 * 316 * @throws IllegalArgumentException if text is null or the text is too long for the 317 * TextClassifier implementation. 318 * 319 * @see #generateLinks(CharSequence, TextLinks.Options) 320 * @see #getMaxGenerateLinksTextLength() 321 */ 322 @WorkerThread 323 default TextLinks generateLinks(@NonNull CharSequence text) { 324 return generateLinks(text, null); 325 } 326 327 /** 328 * Returns the maximal length of text that can be processed by generateLinks. 329 * 330 * @see #generateLinks(CharSequence) 331 * @see #generateLinks(CharSequence, TextLinks.Options) 332 */ 333 @WorkerThread 334 default int getMaxGenerateLinksTextLength() { 335 return Integer.MAX_VALUE; 336 } 337 338 /** 339 * Returns a helper for logging TextClassifier related events. 340 * 341 * @param config logger configuration 342 */ 343 @WorkerThread 344 default Logger getLogger(@NonNull Logger.Config config) { 345 Preconditions.checkNotNull(config); 346 return Logger.DISABLED; 347 } 348 349 /** 350 * Configuration object for specifying what entities to identify. 351 * 352 * Configs are initially based on a predefined preset, and can be modified from there. 353 */ 354 final class EntityConfig implements Parcelable { 355 private final Collection<String> mHints; 356 private final Collection<String> mExcludedEntityTypes; 357 private final Collection<String> mIncludedEntityTypes; 358 private final boolean mUseHints; 359 360 private EntityConfig(boolean useHints, Collection<String> hints, 361 Collection<String> includedEntityTypes, Collection<String> excludedEntityTypes) { 362 mHints = hints == null 363 ? Collections.EMPTY_LIST 364 : Collections.unmodifiableCollection(new ArraySet<>(hints)); 365 mExcludedEntityTypes = excludedEntityTypes == null 366 ? Collections.EMPTY_LIST : new ArraySet<>(excludedEntityTypes); 367 mIncludedEntityTypes = includedEntityTypes == null 368 ? Collections.EMPTY_LIST : new ArraySet<>(includedEntityTypes); 369 mUseHints = useHints; 370 } 371 372 /** 373 * Creates an EntityConfig. 374 * 375 * @param hints Hints for the TextClassifier to determine what types of entities to find. 376 */ 377 public static EntityConfig create(@Nullable Collection<String> hints) { 378 return new EntityConfig(/* useHints */ true, hints, 379 /* includedEntityTypes */null, /* excludedEntityTypes */ null); 380 } 381 382 /** 383 * Creates an EntityConfig. 384 * 385 * @param hints Hints for the TextClassifier to determine what types of entities to find 386 * @param includedEntityTypes Entity types, e.g. {@link #TYPE_EMAIL}, to explicitly include 387 * @param excludedEntityTypes Entity types, e.g. {@link #TYPE_PHONE}, to explicitly exclude 388 * 389 * 390 * Note that if an entity has been excluded, the exclusion will take precedence. 391 */ 392 public static EntityConfig create(@Nullable Collection<String> hints, 393 @Nullable Collection<String> includedEntityTypes, 394 @Nullable Collection<String> excludedEntityTypes) { 395 return new EntityConfig(/* useHints */ true, hints, 396 includedEntityTypes, excludedEntityTypes); 397 } 398 399 /** 400 * Creates an EntityConfig with an explicit entity list. 401 * 402 * @param entityTypes Complete set of entities, e.g. {@link #TYPE_URL} to find. 403 * 404 */ 405 public static EntityConfig createWithEntityList(@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 List<String> resolveEntityListModifications(@NonNull Collection<String> entities) { 418 final ArrayList<String> finalList = new ArrayList<>(); 419 if (mUseHints) { 420 for (String entity : entities) { 421 if (!mExcludedEntityTypes.contains(entity)) { 422 finalList.add(entity); 423 } 424 } 425 } 426 for (String entity : mIncludedEntityTypes) { 427 if (!mExcludedEntityTypes.contains(entity) && !finalList.contains(entity)) { 428 finalList.add(entity); 429 } 430 } 431 return finalList; 432 } 433 434 /** 435 * Retrieves the list of hints. 436 * 437 * @return An unmodifiable collection of the hints. 438 */ 439 public Collection<String> getHints() { 440 return mHints; 441 } 442 443 @Override 444 public int describeContents() { 445 return 0; 446 } 447 448 @Override 449 public void writeToParcel(Parcel dest, int flags) { 450 dest.writeStringList(new ArrayList<>(mHints)); 451 dest.writeStringList(new ArrayList<>(mExcludedEntityTypes)); 452 dest.writeStringList(new ArrayList<>(mIncludedEntityTypes)); 453 dest.writeInt(mUseHints ? 1 : 0); 454 } 455 456 public static final Parcelable.Creator<EntityConfig> CREATOR = 457 new Parcelable.Creator<EntityConfig>() { 458 @Override 459 public EntityConfig createFromParcel(Parcel in) { 460 return new EntityConfig(in); 461 } 462 463 @Override 464 public EntityConfig[] newArray(int size) { 465 return new EntityConfig[size]; 466 } 467 }; 468 469 private EntityConfig(Parcel in) { 470 mHints = new ArraySet<>(in.createStringArrayList()); 471 mExcludedEntityTypes = new ArraySet<>(in.createStringArrayList()); 472 mIncludedEntityTypes = new ArraySet<>(in.createStringArrayList()); 473 mUseHints = in.readInt() == 1; 474 } 475 } 476 477 /** 478 * Utility functions for TextClassifier methods. 479 * 480 * <ul> 481 * <li>Provides validation of input parameters to TextClassifier methods 482 * </ul> 483 * 484 * Intended to be used only in this package. 485 * @hide 486 */ 487 final class Utils { 488 489 /** 490 * @throws IllegalArgumentException if text is null; startIndex is negative; 491 * endIndex is greater than text.length() or is not greater than startIndex; 492 * options is null 493 */ 494 public static void validate( 495 @NonNull CharSequence text, int startIndex, int endIndex, 496 boolean allowInMainThread) { 497 Preconditions.checkArgument(text != null); 498 Preconditions.checkArgument(startIndex >= 0); 499 Preconditions.checkArgument(endIndex <= text.length()); 500 Preconditions.checkArgument(endIndex > startIndex); 501 checkMainThread(allowInMainThread); 502 } 503 504 /** 505 * @throws IllegalArgumentException if text is null or options is null 506 */ 507 public static void validate(@NonNull CharSequence text, boolean allowInMainThread) { 508 Preconditions.checkArgument(text != null); 509 checkMainThread(allowInMainThread); 510 } 511 512 /** 513 * @throws IllegalArgumentException if text is null; the text is too long or options is null 514 */ 515 public static void validate(@NonNull CharSequence text, int maxLength, 516 boolean allowInMainThread) { 517 validate(text, allowInMainThread); 518 Preconditions.checkArgumentInRange(text.length(), 0, maxLength, "text.length()"); 519 } 520 521 /** 522 * Generates links using legacy {@link Linkify}. 523 */ 524 public static TextLinks generateLegacyLinks( 525 @NonNull CharSequence text, @NonNull TextLinks.Options options) { 526 final String string = Preconditions.checkNotNull(text).toString(); 527 final TextLinks.Builder links = new TextLinks.Builder(string); 528 529 final List<String> entities = Preconditions.checkNotNull(options).getEntityConfig() 530 .resolveEntityListModifications(Collections.emptyList()); 531 if (entities.contains(TextClassifier.TYPE_URL)) { 532 addLinks(links, string, TextClassifier.TYPE_URL); 533 } 534 if (entities.contains(TextClassifier.TYPE_PHONE)) { 535 addLinks(links, string, TextClassifier.TYPE_PHONE); 536 } 537 if (entities.contains(TextClassifier.TYPE_EMAIL)) { 538 addLinks(links, string, TextClassifier.TYPE_EMAIL); 539 } 540 // NOTE: Do not support MAP_ADDRESSES. Legacy version does not work well. 541 return links.build(); 542 } 543 544 private static void addLinks( 545 TextLinks.Builder links, String string, @EntityType String entityType) { 546 final Spannable spannable = new SpannableString(string); 547 if (Linkify.addLinks(spannable, linkMask(entityType))) { 548 final URLSpan[] spans = spannable.getSpans(0, spannable.length(), URLSpan.class); 549 for (URLSpan urlSpan : spans) { 550 links.addLink( 551 spannable.getSpanStart(urlSpan), 552 spannable.getSpanEnd(urlSpan), 553 entityScores(entityType), 554 urlSpan); 555 } 556 } 557 } 558 559 @LinkifyMask 560 private static int linkMask(@EntityType String entityType) { 561 switch (entityType) { 562 case TextClassifier.TYPE_URL: 563 return Linkify.WEB_URLS; 564 case TextClassifier.TYPE_PHONE: 565 return Linkify.PHONE_NUMBERS; 566 case TextClassifier.TYPE_EMAIL: 567 return Linkify.EMAIL_ADDRESSES; 568 default: 569 // NOTE: Do not support MAP_ADDRESSES. Legacy version does not work well. 570 return 0; 571 } 572 } 573 574 private static Map<String, Float> entityScores(@EntityType String entityType) { 575 final Map<String, Float> scores = new ArrayMap<>(); 576 scores.put(entityType, 1f); 577 return scores; 578 } 579 580 private static void checkMainThread(boolean allowInMainThread) { 581 if (!allowInMainThread && Looper.myLooper() == Looper.getMainLooper()) { 582 Slog.w(DEFAULT_LOG_TAG, "TextClassifier called on main thread"); 583 } 584 } 585 } 586} 587