CallCardFragment.java revision d9e9c76c4828a56c31ee2dcc3164f5c760395b47
1/* 2 * Copyright (C) 2013 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 com.android.incallui; 18 19import android.animation.Animator; 20import android.animation.AnimatorListenerAdapter; 21import android.animation.AnimatorSet; 22import android.animation.ObjectAnimator; 23import android.animation.ValueAnimator; 24import android.app.Activity; 25import android.content.Context; 26import android.graphics.Point; 27import android.graphics.drawable.Drawable; 28import android.os.Bundle; 29import android.telephony.DisconnectCause; 30import android.text.TextUtils; 31import android.view.Display; 32import android.view.LayoutInflater; 33import android.view.View; 34import android.view.ViewGroup; 35import android.view.ViewTreeObserver; 36import android.view.ViewTreeObserver.OnGlobalLayoutListener; 37import android.view.accessibility.AccessibilityEvent; 38import android.view.animation.Interpolator; 39import android.view.animation.PathInterpolator; 40import android.widget.ImageButton; 41import android.widget.ImageView; 42import android.widget.TextView; 43 44import com.android.contacts.common.animation.AnimUtils; 45import com.android.contacts.common.util.ViewUtil; 46 47import java.util.List; 48 49/** 50 * Fragment for call card. 51 */ 52public class CallCardFragment extends BaseFragment<CallCardPresenter, CallCardPresenter.CallCardUi> 53 implements CallCardPresenter.CallCardUi { 54 55 private static final int REVEAL_ANIMATION_DURATION = 500; 56 private static final int SHRINK_ANIMATION_DURATION = 700; 57 58 // Primary caller info 59 private TextView mPhoneNumber; 60 private TextView mNumberLabel; 61 private TextView mPrimaryName; 62 private TextView mCallStateLabel; 63 private TextView mCallTypeLabel; 64 private View mCallNumberAndLabel; 65 private ImageView mPhoto; 66 private TextView mElapsedTime; 67 68 // Container view that houses the entire primary call card, including the call buttons 69 private View mPrimaryCallCardContainer; 70 // Container view that houses the primary call information 71 private View mPrimaryCallInfo; 72 private View mCallButtonsContainer; 73 74 // Secondary caller info 75 private View mSecondaryCallInfo; 76 private TextView mSecondaryCallName; 77 78 private View mEndCallButton; 79 private ImageButton mHandoffButton; 80 81 private final Interpolator mAnimationInterpolator = new PathInterpolator(0.4f, 0, 0.2f, 1); 82 83 // Cached DisplayMetrics density. 84 private float mDensity; 85 86 private float mTranslationOffset; 87 88 @Override 89 CallCardPresenter.CallCardUi getUi() { 90 return this; 91 } 92 93 @Override 94 CallCardPresenter createPresenter() { 95 return new CallCardPresenter(); 96 } 97 98 @Override 99 public void onCreate(Bundle savedInstanceState) { 100 super.onCreate(savedInstanceState); 101 } 102 103 104 @Override 105 public void onActivityCreated(Bundle savedInstanceState) { 106 super.onActivityCreated(savedInstanceState); 107 108 final CallList calls = CallList.getInstance(); 109 final Call call = calls.getFirstCall(); 110 getPresenter().init(getActivity(), call); 111 } 112 113 @Override 114 public View onCreateView(LayoutInflater inflater, ViewGroup container, 115 Bundle savedInstanceState) { 116 super.onCreateView(inflater, container, savedInstanceState); 117 118 mDensity = getResources().getDisplayMetrics().density; 119 mTranslationOffset = 120 getResources().getDimensionPixelSize(R.dimen.call_card_anim_translate_y_offset); 121 122 return inflater.inflate(R.layout.call_card, container, false); 123 } 124 125 @Override 126 public void onViewCreated(View view, Bundle savedInstanceState) { 127 super.onViewCreated(view, savedInstanceState); 128 129 mPhoneNumber = (TextView) view.findViewById(R.id.phoneNumber); 130 mPrimaryName = (TextView) view.findViewById(R.id.name); 131 mNumberLabel = (TextView) view.findViewById(R.id.label); 132 mSecondaryCallInfo = (View) view.findViewById(R.id.secondary_call_info); 133 mPhoto = (ImageView) view.findViewById(R.id.photo); 134 mCallStateLabel = (TextView) view.findViewById(R.id.callStateLabel); 135 mCallNumberAndLabel = view.findViewById(R.id.labelAndNumber); 136 mCallTypeLabel = (TextView) view.findViewById(R.id.callTypeLabel); 137 mElapsedTime = (TextView) view.findViewById(R.id.elapsedTime); 138 mPrimaryCallCardContainer = view.findViewById(R.id.primary_call_info_container); 139 mPrimaryCallInfo = view.findViewById(R.id.primary_call_banner); 140 mCallButtonsContainer = view.findViewById(R.id.callButtonFragment); 141 142 mEndCallButton = view.findViewById(R.id.endButton); 143 mEndCallButton.setOnClickListener(new View.OnClickListener() { 144 @Override 145 public void onClick(View v) { 146 getPresenter().endCallClicked(); 147 } 148 }); 149 ViewUtil.setupFloatingActionButton(mEndCallButton, getResources()); 150 151 mHandoffButton = (ImageButton) view.findViewById(R.id.handoffButton); 152 mHandoffButton.setOnClickListener(new View.OnClickListener() { 153 @Override public void onClick(View v) { 154 getPresenter().connectionHandoffClicked(); 155 } 156 }); 157 ViewUtil.setupFloatingActionButton(mHandoffButton, getResources()); 158 } 159 160 @Override 161 public void setVisible(boolean on) { 162 if (on) { 163 getView().setVisibility(View.VISIBLE); 164 } else { 165 getView().setVisibility(View.INVISIBLE); 166 } 167 } 168 169 public void setShowConnectionHandoff(boolean showConnectionHandoff) { 170 Log.v(this, "setShowConnectionHandoff: " + showConnectionHandoff); 171 } 172 173 @Override 174 public void setPrimaryName(String name, boolean nameIsNumber) { 175 if (TextUtils.isEmpty(name)) { 176 mPrimaryName.setText(""); 177 } else { 178 mPrimaryName.setText(name); 179 180 // Set direction of the name field 181 int nameDirection = View.TEXT_DIRECTION_INHERIT; 182 if (nameIsNumber) { 183 nameDirection = View.TEXT_DIRECTION_LTR; 184 } 185 mPrimaryName.setTextDirection(nameDirection); 186 } 187 } 188 189 @Override 190 public void setPrimaryImage(Drawable image) { 191 if (image != null) { 192 setDrawableToImageView(mPhoto, image); 193 } 194 } 195 196 @Override 197 public void setPrimaryPhoneNumber(String number) { 198 // Set the number 199 if (TextUtils.isEmpty(number)) { 200 mPhoneNumber.setText(""); 201 mPhoneNumber.setVisibility(View.GONE); 202 } else { 203 mPhoneNumber.setText(number); 204 mPhoneNumber.setVisibility(View.VISIBLE); 205 mPhoneNumber.setTextDirection(View.TEXT_DIRECTION_LTR); 206 } 207 } 208 209 @Override 210 public void setPrimaryLabel(String label) { 211 if (!TextUtils.isEmpty(label)) { 212 mNumberLabel.setText(label); 213 mNumberLabel.setVisibility(View.VISIBLE); 214 } else { 215 mNumberLabel.setVisibility(View.GONE); 216 } 217 218 } 219 220 @Override 221 public void setPrimary(String number, String name, boolean nameIsNumber, String label, 222 Drawable photo, boolean isConference, boolean isGeneric, boolean isSipCall) { 223 Log.d(this, "Setting primary call"); 224 225 if (isConference) { 226 name = getConferenceString(isGeneric); 227 photo = getConferencePhoto(isGeneric); 228 nameIsNumber = false; 229 } 230 231 // set the name field. 232 setPrimaryName(name, nameIsNumber); 233 234 if (TextUtils.isEmpty(number) && TextUtils.isEmpty(label)) { 235 mCallNumberAndLabel.setVisibility(View.GONE); 236 } else { 237 mCallNumberAndLabel.setVisibility(View.VISIBLE); 238 } 239 240 setPrimaryPhoneNumber(number); 241 242 // Set the label (Mobile, Work, etc) 243 setPrimaryLabel(label); 244 245 showInternetCallLabel(isSipCall); 246 247 setDrawableToImageView(mPhoto, photo); 248 } 249 250 @Override 251 public void setSecondary(boolean show, String name, boolean nameIsNumber, String label, 252 boolean isConference, boolean isGeneric) { 253 254 if (show) { 255 if (isConference) { 256 name = getConferenceString(isGeneric); 257 nameIsNumber = false; 258 } 259 260 showAndInitializeSecondaryCallInfo(); 261 mSecondaryCallName.setText(name); 262 263 int nameDirection = View.TEXT_DIRECTION_INHERIT; 264 if (nameIsNumber) { 265 nameDirection = View.TEXT_DIRECTION_LTR; 266 } 267 mSecondaryCallName.setTextDirection(nameDirection); 268 } else { 269 mSecondaryCallInfo.setVisibility(View.GONE); 270 } 271 } 272 273 @Override 274 public void setCallState(int state, int cause, boolean bluetoothOn, String gatewayLabel, 275 String gatewayNumber, boolean isWiFi, boolean isHandoffCapable, 276 boolean isHandoffPending) { 277 String callStateLabel = null; 278 279 if (Call.State.isDialing(state) && !TextUtils.isEmpty(gatewayLabel)) { 280 // Provider info: (e.g. "Calling via <gatewayLabel>") 281 callStateLabel = gatewayLabel; 282 } else { 283 callStateLabel = getCallStateLabelFromState(state, cause); 284 } 285 286 Log.v(this, "setCallState " + callStateLabel); 287 Log.v(this, "DisconnectCause " + DisconnectCause.toString(cause)); 288 Log.v(this, "bluetooth on " + bluetoothOn); 289 Log.v(this, "gateway " + gatewayLabel + gatewayNumber); 290 Log.v(this, "isWiFi " + isWiFi); 291 Log.v(this, "isHandoffCapable " + isHandoffCapable); 292 Log.v(this, "isHandoffPending " + isHandoffPending); 293 294 // Update the call state label. 295 if (!TextUtils.isEmpty(callStateLabel)) { 296 mCallStateLabel.setText(callStateLabel); 297 mCallStateLabel.setVisibility(View.VISIBLE); 298 } else { 299 mCallStateLabel.setVisibility(View.GONE); 300 } 301 302 if (Call.State.INCOMING == state) { 303 setBluetoothOn(bluetoothOn); 304 } 305 306 mHandoffButton.setEnabled(isHandoffCapable && !isHandoffPending); 307 mHandoffButton.setVisibility(mHandoffButton.isEnabled() ? View.VISIBLE : View.GONE); 308 mHandoffButton.setImageResource(isWiFi ? 309 R.drawable.ic_in_call_wifi : R.drawable.ic_in_call_pstn); 310 } 311 312 private void showInternetCallLabel(boolean show) { 313 if (show) { 314 final String label = getView().getContext().getString( 315 R.string.incall_call_type_label_sip); 316 mCallTypeLabel.setVisibility(View.VISIBLE); 317 mCallTypeLabel.setText(label); 318 } else { 319 mCallTypeLabel.setVisibility(View.GONE); 320 } 321 } 322 323 @Override 324 public void setPrimaryCallElapsedTime(boolean show, String callTimeElapsed) { 325 if (show) { 326 if (mElapsedTime.getVisibility() != View.VISIBLE) { 327 AnimUtils.fadeIn(mElapsedTime, AnimUtils.DEFAULT_DURATION); 328 } 329 mElapsedTime.setText(callTimeElapsed); 330 } else { 331 // hide() animation has no effect if it is already hidden. 332 AnimUtils.fadeOut(mElapsedTime, AnimUtils.DEFAULT_DURATION); 333 } 334 } 335 336 private void setDrawableToImageView(ImageView view, Drawable photo) { 337 if (photo == null) { 338 photo = view.getResources().getDrawable(R.drawable.picture_unknown); 339 } 340 341 final Drawable current = view.getDrawable(); 342 if (current == null) { 343 view.setImageDrawable(photo); 344 AnimUtils.fadeIn(mElapsedTime, AnimUtils.DEFAULT_DURATION); 345 } else { 346 AnimationUtils.startCrossFade(view, current, photo); 347 view.setVisibility(View.VISIBLE); 348 } 349 } 350 351 private String getConferenceString(boolean isGeneric) { 352 Log.v(this, "isGenericString: " + isGeneric); 353 final int resId = isGeneric ? R.string.card_title_in_call : R.string.card_title_conf_call; 354 return getView().getResources().getString(resId); 355 } 356 357 private Drawable getConferencePhoto(boolean isGeneric) { 358 Log.v(this, "isGenericPhoto: " + isGeneric); 359 final int resId = isGeneric ? R.drawable.picture_dialing : R.drawable.picture_conference; 360 return getView().getResources().getDrawable(resId); 361 } 362 363 private void setBluetoothOn(boolean onOff) { 364 // Also, display a special icon (alongside the "Incoming call" 365 // label) if there's an incoming call and audio will be routed 366 // to bluetooth when you answer it. 367 final int bluetoothIconId = R.drawable.ic_in_call_bt_dk; 368 369 if (onOff) { 370 mCallStateLabel.setCompoundDrawablesWithIntrinsicBounds(bluetoothIconId, 0, 0, 0); 371 mCallStateLabel.setCompoundDrawablePadding((int) (mDensity * 5)); 372 } else { 373 // Clear out any icons 374 mCallStateLabel.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); 375 } 376 } 377 378 /** 379 * Gets the call state label based on the state of the call and 380 * cause of disconnect 381 */ 382 private String getCallStateLabelFromState(int state, int cause) { 383 final Context context = getView().getContext(); 384 String callStateLabel = null; // Label to display as part of the call banner 385 386 if (Call.State.IDLE == state) { 387 // "Call state" is meaningless in this state. 388 389 } else if (Call.State.ACTIVE == state) { 390 // We normally don't show a "call state label" at all in 391 // this state (but see below for some special cases). 392 393 } else if (Call.State.ONHOLD == state) { 394 callStateLabel = context.getString(R.string.card_title_on_hold); 395 } else if (Call.State.DIALING == state) { 396 callStateLabel = context.getString(R.string.card_title_dialing); 397 } else if (Call.State.REDIALING == state) { 398 callStateLabel = context.getString(R.string.card_title_redialing); 399 } else if (Call.State.INCOMING == state || Call.State.CALL_WAITING == state) { 400 callStateLabel = context.getString(R.string.card_title_incoming_call); 401 402 } else if (Call.State.DISCONNECTING == state) { 403 // While in the DISCONNECTING state we display a "Hanging up" 404 // message in order to make the UI feel more responsive. (In 405 // GSM it's normal to see a delay of a couple of seconds while 406 // negotiating the disconnect with the network, so the "Hanging 407 // up" state at least lets the user know that we're doing 408 // something. This state is currently not used with CDMA.) 409 callStateLabel = context.getString(R.string.card_title_hanging_up); 410 411 } else if (Call.State.DISCONNECTED == state) { 412 callStateLabel = getCallFailedString(cause); 413 414 } else { 415 Log.wtf(this, "updateCallStateWidgets: unexpected call: " + state); 416 } 417 418 return callStateLabel; 419 } 420 421 /** 422 * Maps the disconnect cause to a resource string. 423 * 424 * @param cause disconnect cause as defined in {@link DisconnectCause} 425 */ 426 private String getCallFailedString(int cause) { 427 int resID = R.string.card_title_call_ended; 428 429 // TODO: The card *title* should probably be "Call ended" in all 430 // cases, but if the DisconnectCause was an error condition we should 431 // probably also display the specific failure reason somewhere... 432 433 switch (cause) { 434 case DisconnectCause.BUSY: 435 resID = R.string.callFailed_userBusy; 436 break; 437 438 case DisconnectCause.CONGESTION: 439 resID = R.string.callFailed_congestion; 440 break; 441 442 case DisconnectCause.TIMED_OUT: 443 resID = R.string.callFailed_timedOut; 444 break; 445 446 case DisconnectCause.SERVER_UNREACHABLE: 447 resID = R.string.callFailed_server_unreachable; 448 break; 449 450 case DisconnectCause.NUMBER_UNREACHABLE: 451 resID = R.string.callFailed_number_unreachable; 452 break; 453 454 case DisconnectCause.INVALID_CREDENTIALS: 455 resID = R.string.callFailed_invalid_credentials; 456 break; 457 458 case DisconnectCause.SERVER_ERROR: 459 resID = R.string.callFailed_server_error; 460 break; 461 462 case DisconnectCause.OUT_OF_NETWORK: 463 resID = R.string.callFailed_out_of_network; 464 break; 465 466 case DisconnectCause.LOST_SIGNAL: 467 case DisconnectCause.CDMA_DROP: 468 resID = R.string.callFailed_noSignal; 469 break; 470 471 case DisconnectCause.LIMIT_EXCEEDED: 472 resID = R.string.callFailed_limitExceeded; 473 break; 474 475 case DisconnectCause.POWER_OFF: 476 resID = R.string.callFailed_powerOff; 477 break; 478 479 case DisconnectCause.ICC_ERROR: 480 resID = R.string.callFailed_simError; 481 break; 482 483 case DisconnectCause.OUT_OF_SERVICE: 484 resID = R.string.callFailed_outOfService; 485 break; 486 487 case DisconnectCause.INVALID_NUMBER: 488 case DisconnectCause.UNOBTAINABLE_NUMBER: 489 resID = R.string.callFailed_unobtainable_number; 490 break; 491 492 default: 493 resID = R.string.card_title_call_ended; 494 break; 495 } 496 return this.getView().getContext().getString(resID); 497 } 498 499 private void showAndInitializeSecondaryCallInfo() { 500 mSecondaryCallInfo.setVisibility(View.VISIBLE); 501 502 // mSecondaryCallName is initialized here (vs. onViewCreated) because it is inaccesible 503 // until mSecondaryCallInfo is inflated in the call above. 504 if (mSecondaryCallName == null) { 505 mSecondaryCallName = (TextView) getView().findViewById(R.id.secondaryCallName); 506 } 507 mSecondaryCallInfo.setOnClickListener(new View.OnClickListener() { 508 @Override 509 public void onClick(View v) { 510 getPresenter().secondaryInfoClicked(); 511 } 512 }); 513 } 514 515 public void dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { 516 if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { 517 dispatchPopulateAccessibilityEvent(event, mPrimaryName); 518 dispatchPopulateAccessibilityEvent(event, mPhoneNumber); 519 return; 520 } 521 dispatchPopulateAccessibilityEvent(event, mCallStateLabel); 522 dispatchPopulateAccessibilityEvent(event, mPrimaryName); 523 dispatchPopulateAccessibilityEvent(event, mPhoneNumber); 524 dispatchPopulateAccessibilityEvent(event, mCallTypeLabel); 525 dispatchPopulateAccessibilityEvent(event, mSecondaryCallName); 526 527 return; 528 } 529 530 @Override 531 public void setEndCallButtonEnabled(boolean enabled) { 532 mEndCallButton.setVisibility(enabled ? View.VISIBLE : View.GONE); 533 } 534 535 private void dispatchPopulateAccessibilityEvent(AccessibilityEvent event, View view) { 536 if (view == null) return; 537 final List<CharSequence> eventText = event.getText(); 538 int size = eventText.size(); 539 view.dispatchPopulateAccessibilityEvent(event); 540 // if no text added write null to keep relative position 541 if (size == eventText.size()) { 542 eventText.add(null); 543 } 544 } 545 546 public void animateForNewOutgoingCall() { 547 final ViewGroup parent = (ViewGroup) mPrimaryCallCardContainer.getParent(); 548 549 final ViewTreeObserver observer = getView().getViewTreeObserver(); 550 observer.addOnGlobalLayoutListener(new OnGlobalLayoutListener() { 551 @Override 552 public void onGlobalLayout() { 553 final ViewTreeObserver observer = getView().getViewTreeObserver(); 554 if (!observer.isAlive()) { 555 return; 556 } 557 observer.removeOnGlobalLayoutListener(this); 558 559 final int originalHeight = mPrimaryCallCardContainer.getHeight(); 560 final LayoutIgnoringListener listener = new LayoutIgnoringListener(); 561 mPrimaryCallCardContainer.addOnLayoutChangeListener(listener); 562 563 // Prepare the state of views before the circular reveal animation 564 mPrimaryCallCardContainer.setBottom(parent.getHeight()); 565 mEndCallButton.setTranslationY(200); 566 mCallButtonsContainer.setAlpha(0); 567 mCallStateLabel.setAlpha(0); 568 mPrimaryName.setAlpha(0); 569 mCallTypeLabel.setAlpha(0); 570 mCallNumberAndLabel.setAlpha(0); 571 572 final Animator revealAnimator = getRevealAnimator(); 573 final Animator shrinkAnimator = 574 getShrinkAnimator(parent.getHeight(), originalHeight); 575 576 final AnimatorSet set = new AnimatorSet(); 577 set.playSequentially(revealAnimator, shrinkAnimator); 578 set.addListener(new AnimatorListenerAdapter() { 579 @Override 580 public void onAnimationCancel(Animator animation) { 581 mPrimaryCallCardContainer.removeOnLayoutChangeListener(listener); 582 } 583 584 @Override 585 public void onAnimationEnd(Animator animation) { 586 mPrimaryCallCardContainer.removeOnLayoutChangeListener(listener); 587 } 588 }); 589 set.start(); 590 } 591 }); 592 } 593 594 /** 595 * Animator that performs the upwards shrinking animation of the blue call card scrim. 596 * At the start of the animation, each child view is moved downwards by a pre-specified amount 597 * and then translated upwards together with the scrim. 598 */ 599 private Animator getShrinkAnimator(int startHeight, int endHeight) { 600 final Animator shrinkAnimator = 601 ObjectAnimator.ofInt(mPrimaryCallCardContainer, "bottom", 602 startHeight, endHeight); 603 shrinkAnimator.setDuration(SHRINK_ANIMATION_DURATION); 604 shrinkAnimator.addListener(new AnimatorListenerAdapter() { 605 @Override 606 public void onAnimationStart(Animator animation) { 607 assignTranslateAnimation(mCallStateLabel, 1); 608 assignTranslateAnimation(mPrimaryName, 2); 609 assignTranslateAnimation(mCallNumberAndLabel, 3); 610 assignTranslateAnimation(mCallTypeLabel, 4); 611 assignTranslateAnimation(mCallButtonsContainer, 5); 612 613 mEndCallButton.animate().translationY(0) 614 .setDuration(SHRINK_ANIMATION_DURATION); 615 } 616 }); 617 shrinkAnimator.setInterpolator(mAnimationInterpolator); 618 return shrinkAnimator; 619 } 620 621 private Animator getRevealAnimator() { 622 final Activity activity = getActivity(); 623 final View view = activity.getWindow().getDecorView(); 624 final Display display = activity.getWindowManager().getDefaultDisplay(); 625 final Point size = new Point(); 626 display.getSize(size); 627 628 final ValueAnimator valueAnimator = view.createRevealAnimator(size.x / 2, size.y / 2, 629 0, Math.max(size.x, size.y)); 630 valueAnimator.setDuration(REVEAL_ANIMATION_DURATION); 631 return valueAnimator; 632 } 633 634 private void assignTranslateAnimation(View view, int offset) { 635 view.setTranslationY(mTranslationOffset * offset); 636 view.animate().translationY(0).alpha(1).withLayer() 637 .setDuration(SHRINK_ANIMATION_DURATION).setInterpolator(mAnimationInterpolator); 638 } 639 640 private final class LayoutIgnoringListener implements View.OnLayoutChangeListener { 641 @Override 642 public void onLayoutChange(View v, 643 int left, 644 int top, 645 int right, 646 int bottom, 647 int oldLeft, 648 int oldTop, 649 int oldRight, 650 int oldBottom) { 651 v.setLeft(oldLeft); 652 v.setRight(oldRight); 653 v.setTop(oldTop); 654 v.setBottom(oldBottom); 655 } 656 } 657} 658