TextClassifier.java revision 4dd4314744022566af2c53cd5bb79d19faae21a0
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_EDITTEXT, WIDGET_TYPE_UNSELECTABLE_TEXTVIEW, 118 WIDGET_TYPE_WEBVIEW, WIDGET_TYPE_EDIT_WEBVIEW, WIDGET_TYPE_CUSTOM_TEXTVIEW, 119 WIDGET_TYPE_CUSTOM_EDITTEXT, WIDGET_TYPE_CUSTOM_UNSELECTABLE_TEXTVIEW, 120 WIDGET_TYPE_UNKNOWN}) 121 @interface WidgetType {} 122 123 /** The widget involved in the text classification session is a standard 124 * {@link android.widget.TextView}. */ 125 String WIDGET_TYPE_TEXTVIEW = "textview"; 126 /** The widget involved in the text classification session is a standard 127 * {@link android.widget.EditText}. */ 128 String WIDGET_TYPE_EDITTEXT = "edittext"; 129 /** The widget involved in the text classification session is a standard non-selectable 130 * {@link android.widget.TextView}. */ 131 String WIDGET_TYPE_UNSELECTABLE_TEXTVIEW = "nosel-textview"; 132 /** The widget involved in the text classification session is a standard 133 * {@link android.webkit.WebView}. */ 134 String WIDGET_TYPE_WEBVIEW = "webview"; 135 /** The widget involved in the text classification session is a standard editable 136 * {@link android.webkit.WebView}. */ 137 String WIDGET_TYPE_EDIT_WEBVIEW = "edit-webview"; 138 /** The widget involved in the text classification session is a custom text widget. */ 139 String WIDGET_TYPE_CUSTOM_TEXTVIEW = "customview"; 140 /** The widget involved in the text classification session is a custom editable text widget. */ 141 String WIDGET_TYPE_CUSTOM_EDITTEXT = "customedit"; 142 /** The widget involved in the text classification session is a custom non-selectable text 143 * widget. */ 144 String WIDGET_TYPE_CUSTOM_UNSELECTABLE_TEXTVIEW = "nosel-customview"; 145 /** The widget involved in the text classification session is of an unknown/unspecified type. */ 146 String WIDGET_TYPE_UNKNOWN = "unknown"; 147 148 /** 149 * No-op TextClassifier. 150 * This may be used to turn off TextClassifier features. 151 */ 152 TextClassifier NO_OP = new TextClassifier() {}; 153 154 /** 155 * Returns suggested text selection start and end indices, recognized entity types, and their 156 * associated confidence scores. The entity types are ordered from highest to lowest scoring. 157 * 158 * <p><strong>NOTE: </strong>Call on a worker thread. 159 * 160 * <p><strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to this method should 161 * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. 162 * 163 * @param request the text selection request 164 */ 165 @WorkerThread 166 @NonNull 167 default TextSelection suggestSelection(@NonNull TextSelection.Request request) { 168 Preconditions.checkNotNull(request); 169 Utils.checkMainThread(); 170 return new TextSelection.Builder(request.getStartIndex(), request.getEndIndex()).build(); 171 } 172 173 /** 174 * Returns suggested text selection start and end indices, recognized entity types, and their 175 * associated confidence scores. The entity types are ordered from highest to lowest scoring. 176 * 177 * <p><strong>NOTE: </strong>Call on a worker thread. 178 * 179 * <p><strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to this method should 180 * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. 181 * 182 * <p><b>NOTE:</b> Do not implement. The default implementation of this method calls 183 * {@link #suggestSelection(TextSelection.Request)}. If that method calls this method, 184 * a stack overflow error will happen. 185 * 186 * @param text text providing context for the selected text (which is specified 187 * by the sub sequence starting at selectionStartIndex and ending at selectionEndIndex) 188 * @param selectionStartIndex start index of the selected part of text 189 * @param selectionEndIndex end index of the selected part of text 190 * @param defaultLocales ordered list of locale preferences that may be used to 191 * disambiguate the provided text. If no locale preferences exist, set this to null 192 * or an empty locale list. 193 * 194 * @throws IllegalArgumentException if text is null; selectionStartIndex is negative; 195 * selectionEndIndex is greater than text.length() or not greater than selectionStartIndex 196 * 197 * @see #suggestSelection(TextSelection.Request) 198 */ 199 @WorkerThread 200 @NonNull 201 default TextSelection suggestSelection( 202 @NonNull CharSequence text, 203 @IntRange(from = 0) int selectionStartIndex, 204 @IntRange(from = 0) int selectionEndIndex, 205 @Nullable LocaleList defaultLocales) { 206 final TextSelection.Request request = new TextSelection.Request.Builder( 207 text, selectionStartIndex, selectionEndIndex) 208 .setDefaultLocales(defaultLocales) 209 .build(); 210 return suggestSelection(request); 211 } 212 213 // TODO: Remove once apps can build against the latest sdk. 214 /** @hide */ 215 default TextSelection suggestSelection( 216 @NonNull CharSequence text, 217 @IntRange(from = 0) int selectionStartIndex, 218 @IntRange(from = 0) int selectionEndIndex, 219 @Nullable TextSelection.Options options) { 220 if (options == null) { 221 return suggestSelection(new TextSelection.Request.Builder( 222 text, selectionStartIndex, selectionEndIndex).build()); 223 } else if (options.getRequest() != null) { 224 return suggestSelection(options.getRequest()); 225 } else { 226 return suggestSelection( 227 new TextSelection.Request.Builder(text, selectionStartIndex, selectionEndIndex) 228 .setDefaultLocales(options.getDefaultLocales()) 229 .build()); 230 } 231 } 232 233 /** 234 * Classifies the specified text and returns a {@link TextClassification} object that can be 235 * used to generate a widget for handling the classified text. 236 * 237 * <p><strong>NOTE: </strong>Call on a worker thread. 238 * 239 * <strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to this method should 240 * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. 241 * 242 * @param request the text classification request 243 */ 244 @WorkerThread 245 @NonNull 246 default TextClassification classifyText(@NonNull TextClassification.Request request) { 247 Preconditions.checkNotNull(request); 248 Utils.checkMainThread(); 249 return TextClassification.EMPTY; 250 } 251 252 /** 253 * Classifies the specified text and returns a {@link TextClassification} object that can be 254 * used to generate a widget for handling the classified text. 255 * 256 * <p><strong>NOTE: </strong>Call on a worker thread. 257 * 258 * <p><b>NOTE:</b> Do not implement. The default implementation of this method calls 259 * {@link #classifyText(TextClassification.Request)}. If that method calls this method, 260 * a stack overflow error will happen. 261 * 262 * <strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to this method should 263 * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. 264 * 265 * @param text text providing context for the text to classify (which is specified 266 * by the sub sequence starting at startIndex and ending at endIndex) 267 * @param startIndex start index of the text to classify 268 * @param endIndex end index of the text to classify 269 * @param defaultLocales ordered list of locale preferences that may be used to 270 * disambiguate the provided text. If no locale preferences exist, set this to null 271 * or an empty locale list. 272 * 273 * @throws IllegalArgumentException if text is null; startIndex is negative; 274 * endIndex is greater than text.length() or not greater than startIndex 275 * 276 * @see #classifyText(TextClassification.Request) 277 */ 278 @WorkerThread 279 @NonNull 280 default TextClassification classifyText( 281 @NonNull CharSequence text, 282 @IntRange(from = 0) int startIndex, 283 @IntRange(from = 0) int endIndex, 284 @Nullable LocaleList defaultLocales) { 285 final TextClassification.Request request = new TextClassification.Request.Builder( 286 text, startIndex, endIndex) 287 .setDefaultLocales(defaultLocales) 288 .build(); 289 return classifyText(request); 290 } 291 292 // TODO: Remove once apps can build against the latest sdk. 293 /** @hide */ 294 default TextClassification classifyText( 295 @NonNull CharSequence text, 296 @IntRange(from = 0) int startIndex, 297 @IntRange(from = 0) int endIndex, 298 @Nullable TextClassification.Options options) { 299 if (options == null) { 300 return classifyText( 301 new TextClassification.Request.Builder(text, startIndex, endIndex).build()); 302 } else if (options.getRequest() != null) { 303 return classifyText(options.getRequest()); 304 } else { 305 return classifyText(new TextClassification.Request.Builder(text, startIndex, endIndex) 306 .setDefaultLocales(options.getDefaultLocales()) 307 .setReferenceTime(options.getReferenceTime()) 308 .build()); 309 } 310 } 311 312 /** 313 * Generates and returns a {@link TextLinks} that may be applied to the text to annotate it with 314 * links information. 315 * 316 * <p><strong>NOTE: </strong>Call on a worker thread. 317 * 318 * <strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to this method should 319 * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. 320 * 321 * @param request the text links request 322 * 323 * @see #getMaxGenerateLinksTextLength() 324 */ 325 @WorkerThread 326 @NonNull 327 default TextLinks generateLinks(@NonNull TextLinks.Request request) { 328 Preconditions.checkNotNull(request); 329 Utils.checkMainThread(); 330 return new TextLinks.Builder(request.getText().toString()).build(); 331 } 332 333 // TODO: Remove once apps can build against the latest sdk. 334 /** @hide */ 335 default TextLinks generateLinks( 336 @NonNull CharSequence text, @Nullable TextLinks.Options options) { 337 if (options == null) { 338 return generateLinks(new TextLinks.Request.Builder(text).build()); 339 } else if (options.getRequest() != null) { 340 return generateLinks(options.getRequest()); 341 } else { 342 return generateLinks(new TextLinks.Request.Builder(text) 343 .setDefaultLocales(options.getDefaultLocales()) 344 .setEntityConfig(options.getEntityConfig()) 345 .build()); 346 } 347 } 348 349 /** 350 * Returns the maximal length of text that can be processed by generateLinks. 351 * 352 * <strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to this method should 353 * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. 354 * 355 * @see #generateLinks(TextLinks.Request) 356 */ 357 @WorkerThread 358 default int getMaxGenerateLinksTextLength() { 359 return Integer.MAX_VALUE; 360 } 361 362 /** 363 * Reports a selection event. 364 * 365 * <strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to this method should 366 * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. 367 */ 368 default void onSelectionEvent(@NonNull SelectionEvent event) {} 369 370 /** 371 * Destroys this TextClassifier. 372 * 373 * <strong>NOTE: </strong>If a TextClassifier has been destroyed, calls to its methods should 374 * throw an {@link IllegalStateException}. See {@link #isDestroyed()}. 375 * 376 * <p>Subsequent calls to this method are no-ops. 377 */ 378 default void destroy() {} 379 380 /** 381 * Returns whether or not this TextClassifier has been destroyed. 382 * 383 * <strong>NOTE: </strong>If a TextClassifier has been destroyed, caller should not interact 384 * with the classifier and an attempt to do so would throw an {@link IllegalStateException}. 385 * However, this method should never throw an {@link IllegalStateException}. 386 * 387 * @see #destroy() 388 */ 389 default boolean isDestroyed() { 390 return false; 391 } 392 393 /** 394 * Configuration object for specifying what entities to identify. 395 * 396 * Configs are initially based on a predefined preset, and can be modified from there. 397 */ 398 final class EntityConfig implements Parcelable { 399 private final Collection<String> mHints; 400 private final Collection<String> mExcludedEntityTypes; 401 private final Collection<String> mIncludedEntityTypes; 402 private final boolean mUseHints; 403 404 private EntityConfig(boolean useHints, Collection<String> hints, 405 Collection<String> includedEntityTypes, Collection<String> excludedEntityTypes) { 406 mHints = hints == null 407 ? Collections.EMPTY_LIST 408 : Collections.unmodifiableCollection(new ArraySet<>(hints)); 409 mExcludedEntityTypes = excludedEntityTypes == null 410 ? Collections.EMPTY_LIST : new ArraySet<>(excludedEntityTypes); 411 mIncludedEntityTypes = includedEntityTypes == null 412 ? Collections.EMPTY_LIST : new ArraySet<>(includedEntityTypes); 413 mUseHints = useHints; 414 } 415 416 /** 417 * Creates an EntityConfig. 418 * 419 * @param hints Hints for the TextClassifier to determine what types of entities to find. 420 */ 421 public static EntityConfig createWithHints(@Nullable Collection<String> hints) { 422 return new EntityConfig(/* useHints */ true, hints, 423 /* includedEntityTypes */null, /* excludedEntityTypes */ null); 424 } 425 426 // TODO: Remove once apps can build against the latest sdk. 427 /** @hide */ 428 public static EntityConfig create(@Nullable Collection<String> hints) { 429 return createWithHints(hints); 430 } 431 432 /** 433 * Creates an EntityConfig. 434 * 435 * @param hints Hints for the TextClassifier to determine what types of entities to find 436 * @param includedEntityTypes Entity types, e.g. {@link #TYPE_EMAIL}, to explicitly include 437 * @param excludedEntityTypes Entity types, e.g. {@link #TYPE_PHONE}, to explicitly exclude 438 * 439 * 440 * Note that if an entity has been excluded, the exclusion will take precedence. 441 */ 442 public static EntityConfig create(@Nullable Collection<String> hints, 443 @Nullable Collection<String> includedEntityTypes, 444 @Nullable Collection<String> excludedEntityTypes) { 445 return new EntityConfig(/* useHints */ true, hints, 446 includedEntityTypes, excludedEntityTypes); 447 } 448 449 /** 450 * Creates an EntityConfig with an explicit entity list. 451 * 452 * @param entityTypes Complete set of entities, e.g. {@link #TYPE_URL} to find. 453 * 454 */ 455 public static EntityConfig createWithExplicitEntityList( 456 @Nullable Collection<String> entityTypes) { 457 return new EntityConfig(/* useHints */ false, /* hints */ null, 458 /* includedEntityTypes */ entityTypes, /* excludedEntityTypes */ null); 459 } 460 461 // TODO: Remove once apps can build against the latest sdk. 462 /** @hide */ 463 public static EntityConfig createWithEntityList(@Nullable Collection<String> entityTypes) { 464 return createWithExplicitEntityList(entityTypes); 465 } 466 467 /** 468 * Returns a list of the final set of entities to find. 469 * 470 * @param entities Entities we think should be found before factoring in includes/excludes 471 * 472 * This method is intended for use by TextClassifier implementations. 473 */ 474 public Collection<String> resolveEntityListModifications( 475 @NonNull Collection<String> entities) { 476 final Set<String> finalSet = new HashSet(); 477 if (mUseHints) { 478 finalSet.addAll(entities); 479 } 480 finalSet.addAll(mIncludedEntityTypes); 481 finalSet.removeAll(mExcludedEntityTypes); 482 return finalSet; 483 } 484 485 /** 486 * Retrieves the list of hints. 487 * 488 * @return An unmodifiable collection of the hints. 489 */ 490 public Collection<String> getHints() { 491 return mHints; 492 } 493 494 @Override 495 public int describeContents() { 496 return 0; 497 } 498 499 @Override 500 public void writeToParcel(Parcel dest, int flags) { 501 dest.writeStringList(new ArrayList<>(mHints)); 502 dest.writeStringList(new ArrayList<>(mExcludedEntityTypes)); 503 dest.writeStringList(new ArrayList<>(mIncludedEntityTypes)); 504 dest.writeInt(mUseHints ? 1 : 0); 505 } 506 507 public static final Parcelable.Creator<EntityConfig> CREATOR = 508 new Parcelable.Creator<EntityConfig>() { 509 @Override 510 public EntityConfig createFromParcel(Parcel in) { 511 return new EntityConfig(in); 512 } 513 514 @Override 515 public EntityConfig[] newArray(int size) { 516 return new EntityConfig[size]; 517 } 518 }; 519 520 private EntityConfig(Parcel in) { 521 mHints = new ArraySet<>(in.createStringArrayList()); 522 mExcludedEntityTypes = new ArraySet<>(in.createStringArrayList()); 523 mIncludedEntityTypes = new ArraySet<>(in.createStringArrayList()); 524 mUseHints = in.readInt() == 1; 525 } 526 } 527 528 /** 529 * Utility functions for TextClassifier methods. 530 * 531 * <ul> 532 * <li>Provides validation of input parameters to TextClassifier methods 533 * </ul> 534 * 535 * Intended to be used only in this package. 536 * @hide 537 */ 538 final class Utils { 539 540 /** 541 * @throws IllegalArgumentException if text is null; startIndex is negative; 542 * endIndex is greater than text.length() or is not greater than startIndex; 543 * options is null 544 */ 545 static void checkArgument(@NonNull CharSequence text, int startIndex, int endIndex) { 546 Preconditions.checkArgument(text != null); 547 Preconditions.checkArgument(startIndex >= 0); 548 Preconditions.checkArgument(endIndex <= text.length()); 549 Preconditions.checkArgument(endIndex > startIndex); 550 } 551 552 static void checkTextLength(CharSequence text, int maxLength) { 553 Preconditions.checkArgumentInRange(text.length(), 0, maxLength, "text.length()"); 554 } 555 556 /** 557 * Generates links using legacy {@link Linkify}. 558 */ 559 public static TextLinks generateLegacyLinks(@NonNull TextLinks.Request request) { 560 final String string = request.getText().toString(); 561 final TextLinks.Builder links = new TextLinks.Builder(string); 562 563 final Collection<String> entities = request.getEntityConfig() 564 .resolveEntityListModifications(Collections.emptyList()); 565 if (entities.contains(TextClassifier.TYPE_URL)) { 566 addLinks(links, string, TextClassifier.TYPE_URL); 567 } 568 if (entities.contains(TextClassifier.TYPE_PHONE)) { 569 addLinks(links, string, TextClassifier.TYPE_PHONE); 570 } 571 if (entities.contains(TextClassifier.TYPE_EMAIL)) { 572 addLinks(links, string, TextClassifier.TYPE_EMAIL); 573 } 574 // NOTE: Do not support MAP_ADDRESSES. Legacy version does not work well. 575 return links.build(); 576 } 577 578 private static void addLinks( 579 TextLinks.Builder links, String string, @EntityType String entityType) { 580 final Spannable spannable = new SpannableString(string); 581 if (Linkify.addLinks(spannable, linkMask(entityType))) { 582 final URLSpan[] spans = spannable.getSpans(0, spannable.length(), URLSpan.class); 583 for (URLSpan urlSpan : spans) { 584 links.addLink( 585 spannable.getSpanStart(urlSpan), 586 spannable.getSpanEnd(urlSpan), 587 entityScores(entityType), 588 urlSpan); 589 } 590 } 591 } 592 593 @LinkifyMask 594 private static int linkMask(@EntityType String entityType) { 595 switch (entityType) { 596 case TextClassifier.TYPE_URL: 597 return Linkify.WEB_URLS; 598 case TextClassifier.TYPE_PHONE: 599 return Linkify.PHONE_NUMBERS; 600 case TextClassifier.TYPE_EMAIL: 601 return Linkify.EMAIL_ADDRESSES; 602 default: 603 // NOTE: Do not support MAP_ADDRESSES. Legacy version does not work well. 604 return 0; 605 } 606 } 607 608 private static Map<String, Float> entityScores(@EntityType String entityType) { 609 final Map<String, Float> scores = new ArrayMap<>(); 610 scores.put(entityType, 1f); 611 return scores; 612 } 613 614 static void checkMainThread() { 615 if (Looper.myLooper() == Looper.getMainLooper()) { 616 Log.w(DEFAULT_LOG_TAG, "TextClassifier called on main thread"); 617 } 618 } 619 } 620} 621