1 /* 2 * Copyright (C) 2018 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 package android.view.contentcapture; 17 18 import static android.view.contentcapture.ContentCaptureHelper.getSanitizedString; 19 import static android.view.contentcapture.ContentCaptureManager.DEBUG; 20 import static android.view.contentcapture.ContentCaptureManager.NO_SESSION_ID; 21 22 import android.annotation.IntDef; 23 import android.annotation.NonNull; 24 import android.annotation.Nullable; 25 import android.annotation.SystemApi; 26 import android.graphics.Insets; 27 import android.os.Parcel; 28 import android.os.Parcelable; 29 import android.text.Selection; 30 import android.text.Spannable; 31 import android.text.SpannableString; 32 import android.util.Log; 33 import android.view.autofill.AutofillId; 34 import android.view.inputmethod.BaseInputConnection; 35 36 import com.android.internal.util.Preconditions; 37 38 import java.io.PrintWriter; 39 import java.lang.annotation.Retention; 40 import java.lang.annotation.RetentionPolicy; 41 import java.util.ArrayList; 42 import java.util.List; 43 44 /** @hide */ 45 @SystemApi 46 public final class ContentCaptureEvent implements Parcelable { 47 48 private static final String TAG = ContentCaptureEvent.class.getSimpleName(); 49 50 /** @hide */ 51 public static final int TYPE_SESSION_FINISHED = -2; 52 /** @hide */ 53 public static final int TYPE_SESSION_STARTED = -1; 54 55 /** 56 * Called when a node has been added to the screen and is visible to the user. 57 * 58 * <p>The metadata of the node is available through {@link #getViewNode()}. 59 */ 60 public static final int TYPE_VIEW_APPEARED = 1; 61 62 /** 63 * Called when one or more nodes have been removed from the screen and is not visible to the 64 * user anymore. 65 * 66 * <p>To get the id(s), first call {@link #getIds()} - if it returns {@code null}, then call 67 * {@link #getId()}. 68 */ 69 public static final int TYPE_VIEW_DISAPPEARED = 2; 70 71 /** 72 * Called when the text of a node has been changed. 73 * 74 * <p>The id of the node is available through {@link #getId()}, and the new text is 75 * available through {@link #getText()}. 76 */ 77 public static final int TYPE_VIEW_TEXT_CHANGED = 3; 78 79 /** 80 * Called before events (such as {@link #TYPE_VIEW_APPEARED} and/or 81 * {@link #TYPE_VIEW_DISAPPEARED}) representing a view hierarchy are sent. 82 * 83 * <p><b>NOTE</b>: there is no guarantee this event will be sent. For example, it's not sent 84 * if the initial view hierarchy doesn't initially have any view that's important for content 85 * capture. 86 */ 87 public static final int TYPE_VIEW_TREE_APPEARING = 4; 88 89 /** 90 * Called after events (such as {@link #TYPE_VIEW_APPEARED} and/or 91 * {@link #TYPE_VIEW_DISAPPEARED}) representing a view hierarchy were sent. 92 * 93 * <p><b>NOTE</b>: there is no guarantee this event will be sent. For example, it's not sent 94 * if the initial view hierarchy doesn't initially have any view that's important for content 95 * capture. 96 */ 97 public static final int TYPE_VIEW_TREE_APPEARED = 5; 98 99 /** 100 * Called after a call to 101 * {@link ContentCaptureSession#setContentCaptureContext(ContentCaptureContext)}. 102 * 103 * <p>The passed context is available through {@link #getContentCaptureContext()}. 104 */ 105 public static final int TYPE_CONTEXT_UPDATED = 6; 106 107 /** 108 * Called after the session is ready, typically after the activity resumed and the 109 * initial views appeared 110 */ 111 public static final int TYPE_SESSION_RESUMED = 7; 112 113 /** 114 * Called after the session is paused, typically after the activity paused and the 115 * views disappeared. 116 */ 117 public static final int TYPE_SESSION_PAUSED = 8; 118 119 /** 120 * Called when the view's insets are changed. The new insets associated with the 121 * event may then be retrieved by calling {@link #getInsets()} 122 */ 123 public static final int TYPE_VIEW_INSETS_CHANGED = 9; 124 125 /** @hide */ 126 @IntDef(prefix = { "TYPE_" }, value = { 127 TYPE_VIEW_APPEARED, 128 TYPE_VIEW_DISAPPEARED, 129 TYPE_VIEW_TEXT_CHANGED, 130 TYPE_VIEW_TREE_APPEARING, 131 TYPE_VIEW_TREE_APPEARED, 132 TYPE_CONTEXT_UPDATED, 133 TYPE_SESSION_PAUSED, 134 TYPE_SESSION_RESUMED, 135 TYPE_VIEW_INSETS_CHANGED 136 }) 137 @Retention(RetentionPolicy.SOURCE) 138 public @interface EventType{} 139 140 /** @hide */ 141 public static final int MAX_INVALID_VALUE = -1; 142 143 private final int mSessionId; 144 private final int mType; 145 private final long mEventTime; 146 private @Nullable AutofillId mId; 147 private @Nullable ArrayList<AutofillId> mIds; 148 private @Nullable ViewNode mNode; 149 private @Nullable CharSequence mText; 150 private int mParentSessionId = NO_SESSION_ID; 151 private @Nullable ContentCaptureContext mClientContext; 152 private @Nullable Insets mInsets; 153 154 private int mComposingStart = MAX_INVALID_VALUE; 155 private int mComposingEnd = MAX_INVALID_VALUE; 156 private int mSelectionStartIndex = MAX_INVALID_VALUE; 157 private int mSelectionEndIndex = MAX_INVALID_VALUE; 158 159 /** Only used in the main Content Capture session, no need to parcel */ 160 private boolean mTextHasComposingSpan; 161 162 /** @hide */ ContentCaptureEvent(int sessionId, int type, long eventTime)163 public ContentCaptureEvent(int sessionId, int type, long eventTime) { 164 mSessionId = sessionId; 165 mType = type; 166 mEventTime = eventTime; 167 } 168 169 /** @hide */ ContentCaptureEvent(int sessionId, int type)170 public ContentCaptureEvent(int sessionId, int type) { 171 this(sessionId, type, System.currentTimeMillis()); 172 } 173 174 /** @hide */ setAutofillId(@onNull AutofillId id)175 public ContentCaptureEvent setAutofillId(@NonNull AutofillId id) { 176 mId = Preconditions.checkNotNull(id); 177 return this; 178 } 179 180 /** @hide */ setAutofillIds(@onNull ArrayList<AutofillId> ids)181 public ContentCaptureEvent setAutofillIds(@NonNull ArrayList<AutofillId> ids) { 182 mIds = Preconditions.checkNotNull(ids); 183 return this; 184 } 185 186 /** 187 * Adds an autofill id to the this event, merging the single id into a list if necessary. 188 * 189 * @hide 190 */ addAutofillId(@onNull AutofillId id)191 public ContentCaptureEvent addAutofillId(@NonNull AutofillId id) { 192 Preconditions.checkNotNull(id); 193 if (mIds == null) { 194 mIds = new ArrayList<>(); 195 if (mId == null) { 196 Log.w(TAG, "addAutofillId(" + id + ") called without an initial id"); 197 } else { 198 mIds.add(mId); 199 mId = null; 200 } 201 } 202 mIds.add(id); 203 return this; 204 } 205 206 /** 207 * Used by {@link #TYPE_SESSION_STARTED} and {@link #TYPE_SESSION_FINISHED}. 208 * 209 * @hide 210 */ setParentSessionId(int parentSessionId)211 public ContentCaptureEvent setParentSessionId(int parentSessionId) { 212 mParentSessionId = parentSessionId; 213 return this; 214 } 215 216 /** 217 * Used by {@link #TYPE_SESSION_STARTED} and {@link #TYPE_SESSION_FINISHED}. 218 * 219 * @hide 220 */ setClientContext(@onNull ContentCaptureContext clientContext)221 public ContentCaptureEvent setClientContext(@NonNull ContentCaptureContext clientContext) { 222 mClientContext = clientContext; 223 return this; 224 } 225 226 /** @hide */ 227 @NonNull getSessionId()228 public int getSessionId() { 229 return mSessionId; 230 } 231 232 /** 233 * Used by {@link #TYPE_SESSION_STARTED} and {@link #TYPE_SESSION_FINISHED}. 234 * 235 * @hide 236 */ 237 @Nullable getParentSessionId()238 public int getParentSessionId() { 239 return mParentSessionId; 240 } 241 242 /** 243 * Gets the {@link ContentCaptureContext} set calls to 244 * {@link ContentCaptureSession#setContentCaptureContext(ContentCaptureContext)}. 245 * 246 * <p>Only set on {@link #TYPE_CONTEXT_UPDATED} events. 247 */ 248 @Nullable getContentCaptureContext()249 public ContentCaptureContext getContentCaptureContext() { 250 return mClientContext; 251 } 252 253 /** @hide */ 254 @NonNull setViewNode(@onNull ViewNode node)255 public ContentCaptureEvent setViewNode(@NonNull ViewNode node) { 256 mNode = Preconditions.checkNotNull(node); 257 return this; 258 } 259 260 /** @hide */ 261 @NonNull setText(@ullable CharSequence text)262 public ContentCaptureEvent setText(@Nullable CharSequence text) { 263 mText = text; 264 return this; 265 } 266 267 /** @hide */ 268 @NonNull setComposingIndex(int start, int end)269 public ContentCaptureEvent setComposingIndex(int start, int end) { 270 mComposingStart = start; 271 mComposingEnd = end; 272 return this; 273 } 274 275 /** @hide */ 276 @NonNull hasComposingSpan()277 public boolean hasComposingSpan() { 278 return mComposingStart > MAX_INVALID_VALUE; 279 } 280 281 /** @hide */ 282 @NonNull setSelectionIndex(int start, int end)283 public ContentCaptureEvent setSelectionIndex(int start, int end) { 284 mSelectionStartIndex = start; 285 mSelectionEndIndex = end; 286 return this; 287 } 288 hasSameComposingSpan(@onNull ContentCaptureEvent other)289 boolean hasSameComposingSpan(@NonNull ContentCaptureEvent other) { 290 return mComposingStart == other.mComposingStart && mComposingEnd == other.mComposingEnd; 291 } 292 hasSameSelectionSpan(@onNull ContentCaptureEvent other)293 boolean hasSameSelectionSpan(@NonNull ContentCaptureEvent other) { 294 return mSelectionStartIndex == other.mSelectionStartIndex 295 && mSelectionEndIndex == other.mSelectionEndIndex; 296 } 297 getComposingStart()298 private int getComposingStart() { 299 return mComposingStart; 300 } 301 getComposingEnd()302 private int getComposingEnd() { 303 return mComposingEnd; 304 } 305 getSelectionStart()306 private int getSelectionStart() { 307 return mSelectionStartIndex; 308 } 309 getSelectionEnd()310 private int getSelectionEnd() { 311 return mSelectionEndIndex; 312 } 313 restoreComposingSpan()314 private void restoreComposingSpan() { 315 if (mComposingStart <= MAX_INVALID_VALUE 316 || mComposingEnd <= MAX_INVALID_VALUE) { 317 return; 318 } 319 if (mText instanceof Spannable) { 320 BaseInputConnection.setComposingSpans((Spannable) mText, mComposingStart, 321 mComposingEnd); 322 } else { 323 Log.w(TAG, "Text is not a Spannable."); 324 } 325 } 326 restoreSelectionSpans()327 private void restoreSelectionSpans() { 328 if (mSelectionStartIndex <= MAX_INVALID_VALUE 329 || mSelectionEndIndex <= MAX_INVALID_VALUE) { 330 return; 331 } 332 333 if (mText instanceof SpannableString) { 334 SpannableString ss = (SpannableString) mText; 335 ss.setSpan(Selection.SELECTION_START, mSelectionStartIndex, mSelectionStartIndex, 0); 336 ss.setSpan(Selection.SELECTION_END, mSelectionEndIndex, mSelectionEndIndex, 0); 337 } else { 338 Log.w(TAG, "Text is not a SpannableString."); 339 } 340 } 341 342 /** @hide */ 343 @NonNull setInsets(@onNull Insets insets)344 public ContentCaptureEvent setInsets(@NonNull Insets insets) { 345 mInsets = insets; 346 return this; 347 } 348 349 /** 350 * Gets the type of the event. 351 * 352 * @return one of {@link #TYPE_VIEW_APPEARED}, {@link #TYPE_VIEW_DISAPPEARED}, 353 * {@link #TYPE_VIEW_TEXT_CHANGED}, {@link #TYPE_VIEW_TREE_APPEARING}, 354 * {@link #TYPE_VIEW_TREE_APPEARED}, {@link #TYPE_CONTEXT_UPDATED}, 355 * {@link #TYPE_SESSION_RESUMED}, or {@link #TYPE_SESSION_PAUSED}. 356 */ getType()357 public @EventType int getType() { 358 return mType; 359 } 360 361 /** 362 * Gets when the event was generated, in millis since epoch. 363 */ getEventTime()364 public long getEventTime() { 365 return mEventTime; 366 } 367 368 /** 369 * Gets the whole metadata of the node associated with the event. 370 * 371 * <p>Only set on {@link #TYPE_VIEW_APPEARED} events. 372 */ 373 @Nullable getViewNode()374 public ViewNode getViewNode() { 375 return mNode; 376 } 377 378 /** 379 * Gets the {@link AutofillId} of the node associated with the event. 380 * 381 * <p>Only set on {@link #TYPE_VIEW_DISAPPEARED} (when the event contains just one node - if 382 * it contains more than one, this method returns {@code null} and the actual ids should be 383 * retrived by {@link #getIds()}) and {@link #TYPE_VIEW_TEXT_CHANGED} events. 384 */ 385 @Nullable getId()386 public AutofillId getId() { 387 return mId; 388 } 389 390 /** 391 * Gets the {@link AutofillId AutofillIds} of the nodes associated with the event. 392 * 393 * <p>Only set on {@link #TYPE_VIEW_DISAPPEARED}, when the event contains more than one node 394 * (if it contains just one node, it's returned by {@link #getId()} instead. 395 */ 396 @Nullable getIds()397 public List<AutofillId> getIds() { 398 return mIds; 399 } 400 401 /** 402 * Gets the current text of the node associated with the event. 403 * 404 * <p>Only set on {@link #TYPE_VIEW_TEXT_CHANGED} events. 405 */ 406 @Nullable getText()407 public CharSequence getText() { 408 return mText; 409 } 410 411 /** 412 * Gets the rectangle of the insets associated with the event. Valid insets will only be 413 * returned if the type of the event is {@link #TYPE_VIEW_INSETS_CHANGED}, otherwise they 414 * will be null. 415 */ 416 @Nullable getInsets()417 public Insets getInsets() { 418 return mInsets; 419 } 420 421 /** 422 * Merges event of the same type, either {@link #TYPE_VIEW_TEXT_CHANGED} 423 * or {@link #TYPE_VIEW_DISAPPEARED}. 424 * 425 * @hide 426 */ mergeEvent(@onNull ContentCaptureEvent event)427 public void mergeEvent(@NonNull ContentCaptureEvent event) { 428 Preconditions.checkNotNull(event); 429 final int eventType = event.getType(); 430 if (mType != eventType) { 431 Log.e(TAG, "mergeEvent(" + getTypeAsString(eventType) + ") cannot be merged " 432 + "with different eventType=" + getTypeAsString(mType)); 433 return; 434 } 435 436 if (eventType == TYPE_VIEW_DISAPPEARED) { 437 final List<AutofillId> ids = event.getIds(); 438 final AutofillId id = event.getId(); 439 if (ids != null) { 440 if (id != null) { 441 Log.w(TAG, "got TYPE_VIEW_DISAPPEARED event with both id and ids: " + event); 442 } 443 for (int i = 0; i < ids.size(); i++) { 444 addAutofillId(ids.get(i)); 445 } 446 return; 447 } 448 if (id != null) { 449 addAutofillId(id); 450 return; 451 } 452 throw new IllegalArgumentException("mergeEvent(): got " 453 + "TYPE_VIEW_DISAPPEARED event with neither id or ids: " + event); 454 } else if (eventType == TYPE_VIEW_TEXT_CHANGED) { 455 setText(event.getText()); 456 setComposingIndex(event.getComposingStart(), event.getComposingEnd()); 457 setSelectionIndex(event.getSelectionStart(), event.getSelectionEnd()); 458 } else { 459 Log.e(TAG, "mergeEvent(" + getTypeAsString(eventType) 460 + ") does not support this event type."); 461 } 462 } 463 464 /** @hide */ dump(@onNull PrintWriter pw)465 public void dump(@NonNull PrintWriter pw) { 466 pw.print("type="); pw.print(getTypeAsString(mType)); 467 pw.print(", time="); pw.print(mEventTime); 468 if (mId != null) { 469 pw.print(", id="); pw.print(mId); 470 } 471 if (mIds != null) { 472 pw.print(", ids="); pw.print(mIds); 473 } 474 if (mNode != null) { 475 pw.print(", mNode.id="); pw.print(mNode.getAutofillId()); 476 } 477 if (mSessionId != NO_SESSION_ID) { 478 pw.print(", sessionId="); pw.print(mSessionId); 479 } 480 if (mParentSessionId != NO_SESSION_ID) { 481 pw.print(", parentSessionId="); pw.print(mParentSessionId); 482 } 483 if (mText != null) { 484 pw.print(", text="); pw.println(getSanitizedString(mText)); 485 } 486 if (mClientContext != null) { 487 pw.print(", context="); mClientContext.dump(pw); pw.println(); 488 } 489 if (mInsets != null) { 490 pw.print(", insets="); pw.println(mInsets); 491 } 492 if (mComposingStart > MAX_INVALID_VALUE) { 493 pw.print(", composing("); pw.print(mComposingStart); 494 pw.print(", "); pw.print(mComposingEnd); pw.print(")"); 495 } 496 if (mSelectionStartIndex > MAX_INVALID_VALUE) { 497 pw.print(", selection("); pw.print(mSelectionStartIndex); 498 pw.print(", "); pw.print(mSelectionEndIndex); pw.print(")"); 499 } 500 } 501 502 @NonNull 503 @Override toString()504 public String toString() { 505 final StringBuilder string = new StringBuilder("ContentCaptureEvent[type=") 506 .append(getTypeAsString(mType)); 507 string.append(", session=").append(mSessionId); 508 if (mType == TYPE_SESSION_STARTED && mParentSessionId != NO_SESSION_ID) { 509 string.append(", parent=").append(mParentSessionId); 510 } 511 if (mId != null) { 512 string.append(", id=").append(mId); 513 } 514 if (mIds != null) { 515 string.append(", ids=").append(mIds); 516 } 517 if (mNode != null) { 518 final String className = mNode.getClassName(); 519 string.append(", class=").append(className); 520 string.append(", id=").append(mNode.getAutofillId()); 521 if (mNode.getText() != null) { 522 string.append(", text=") 523 .append(DEBUG ? mNode.getText() : getSanitizedString(mNode.getText())); 524 } 525 } 526 if (mText != null) { 527 string.append(", text=") 528 .append(DEBUG ? mText : getSanitizedString(mText)); 529 } 530 if (mClientContext != null) { 531 string.append(", context=").append(mClientContext); 532 } 533 if (mInsets != null) { 534 string.append(", insets=").append(mInsets); 535 } 536 if (mComposingStart > MAX_INVALID_VALUE) { 537 string.append(", composing=[") 538 .append(mComposingStart).append(",").append(mComposingEnd).append("]"); 539 } 540 if (mSelectionStartIndex > MAX_INVALID_VALUE) { 541 string.append(", selection=[") 542 .append(mSelectionStartIndex).append(",") 543 .append(mSelectionEndIndex).append("]"); 544 } 545 return string.append(']').toString(); 546 } 547 548 @Override describeContents()549 public int describeContents() { 550 return 0; 551 } 552 553 @Override writeToParcel(Parcel parcel, int flags)554 public void writeToParcel(Parcel parcel, int flags) { 555 parcel.writeInt(mSessionId); 556 parcel.writeInt(mType); 557 parcel.writeLong(mEventTime); 558 parcel.writeParcelable(mId, flags); 559 parcel.writeTypedList(mIds); 560 ViewNode.writeToParcel(parcel, mNode, flags); 561 parcel.writeCharSequence(mText); 562 if (mType == TYPE_SESSION_STARTED || mType == TYPE_SESSION_FINISHED) { 563 parcel.writeInt(mParentSessionId); 564 } 565 if (mType == TYPE_SESSION_STARTED || mType == TYPE_CONTEXT_UPDATED) { 566 parcel.writeParcelable(mClientContext, flags); 567 } 568 if (mType == TYPE_VIEW_INSETS_CHANGED) { 569 parcel.writeParcelable(mInsets, flags); 570 } 571 if (mType == TYPE_VIEW_TEXT_CHANGED) { 572 parcel.writeInt(mComposingStart); 573 parcel.writeInt(mComposingEnd); 574 parcel.writeInt(mSelectionStartIndex); 575 parcel.writeInt(mSelectionEndIndex); 576 } 577 } 578 579 public static final @android.annotation.NonNull Parcelable.Creator<ContentCaptureEvent> CREATOR = 580 new Parcelable.Creator<ContentCaptureEvent>() { 581 582 @Override 583 @NonNull 584 public ContentCaptureEvent createFromParcel(Parcel parcel) { 585 final int sessionId = parcel.readInt(); 586 final int type = parcel.readInt(); 587 final long eventTime = parcel.readLong(); 588 final ContentCaptureEvent event = new ContentCaptureEvent(sessionId, type, eventTime); 589 final AutofillId id = parcel.readParcelable(null); 590 if (id != null) { 591 event.setAutofillId(id); 592 } 593 final ArrayList<AutofillId> ids = parcel.createTypedArrayList(AutofillId.CREATOR); 594 if (ids != null) { 595 event.setAutofillIds(ids); 596 } 597 final ViewNode node = ViewNode.readFromParcel(parcel); 598 if (node != null) { 599 event.setViewNode(node); 600 } 601 event.setText(parcel.readCharSequence()); 602 if (type == TYPE_SESSION_STARTED || type == TYPE_SESSION_FINISHED) { 603 event.setParentSessionId(parcel.readInt()); 604 } 605 if (type == TYPE_SESSION_STARTED || type == TYPE_CONTEXT_UPDATED) { 606 event.setClientContext(parcel.readParcelable(null)); 607 } 608 if (type == TYPE_VIEW_INSETS_CHANGED) { 609 event.setInsets(parcel.readParcelable(null)); 610 } 611 if (type == TYPE_VIEW_TEXT_CHANGED) { 612 event.setComposingIndex(parcel.readInt(), parcel.readInt()); 613 event.restoreComposingSpan(); 614 event.setSelectionIndex(parcel.readInt(), parcel.readInt()); 615 event.restoreSelectionSpans(); 616 } 617 return event; 618 } 619 620 @Override 621 @NonNull 622 public ContentCaptureEvent[] newArray(int size) { 623 return new ContentCaptureEvent[size]; 624 } 625 }; 626 627 /** @hide */ getTypeAsString(@ventType int type)628 public static String getTypeAsString(@EventType int type) { 629 switch (type) { 630 case TYPE_SESSION_STARTED: 631 return "SESSION_STARTED"; 632 case TYPE_SESSION_FINISHED: 633 return "SESSION_FINISHED"; 634 case TYPE_SESSION_RESUMED: 635 return "SESSION_RESUMED"; 636 case TYPE_SESSION_PAUSED: 637 return "SESSION_PAUSED"; 638 case TYPE_VIEW_APPEARED: 639 return "VIEW_APPEARED"; 640 case TYPE_VIEW_DISAPPEARED: 641 return "VIEW_DISAPPEARED"; 642 case TYPE_VIEW_TEXT_CHANGED: 643 return "VIEW_TEXT_CHANGED"; 644 case TYPE_VIEW_TREE_APPEARING: 645 return "VIEW_TREE_APPEARING"; 646 case TYPE_VIEW_TREE_APPEARED: 647 return "VIEW_TREE_APPEARED"; 648 case TYPE_CONTEXT_UPDATED: 649 return "CONTEXT_UPDATED"; 650 case TYPE_VIEW_INSETS_CHANGED: 651 return "VIEW_INSETS_CHANGED"; 652 default: 653 return "UKNOWN_TYPE: " + type; 654 } 655 } 656 } 657