1 /* 2 * Copyright (C) 2015 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 17 package com.android.contacts.common.dialog; 18 19 import android.animation.Animator; 20 import android.animation.AnimatorListenerAdapter; 21 import android.app.Activity; 22 import android.content.Context; 23 import android.content.Intent; 24 import android.content.SharedPreferences; 25 import android.net.Uri; 26 import android.os.Bundle; 27 import android.preference.PreferenceManager; 28 import android.telecom.PhoneAccount; 29 import android.telecom.PhoneAccountHandle; 30 import android.telecom.TelecomManager; 31 import android.text.Editable; 32 import android.text.InputFilter; 33 import android.text.TextUtils; 34 import android.text.TextWatcher; 35 import android.util.Log; 36 import android.view.View; 37 import android.view.ViewGroup; 38 import android.view.ViewTreeObserver; 39 import android.view.inputmethod.InputMethodManager; 40 import android.widget.AdapterView; 41 import android.widget.ArrayAdapter; 42 import android.widget.EditText; 43 import android.widget.ListView; 44 import android.widget.QuickContactBadge; 45 import android.widget.TextView; 46 47 import com.android.contacts.common.CallUtil; 48 import com.android.contacts.common.ContactPhotoManager; 49 import com.android.contacts.common.R; 50 import com.android.contacts.common.compat.CompatUtils; 51 import com.android.contacts.common.compat.PhoneAccountSdkCompat; 52 import com.android.contacts.common.compat.telecom.TelecomManagerCompat; 53 import com.android.contacts.common.util.UriUtils; 54 import com.android.phone.common.animation.AnimUtils; 55 56 import java.nio.charset.Charset; 57 import java.util.ArrayList; 58 import java.util.List; 59 60 /** 61 * Implements a dialog which prompts for a call subject for an outgoing call. The dialog includes 62 * a pop up list of historical call subjects. 63 */ 64 public class CallSubjectDialog extends Activity { 65 private static final String TAG = "CallSubjectDialog"; 66 private static final int CALL_SUBJECT_LIMIT = 16; 67 private static final int CALL_SUBJECT_HISTORY_SIZE = 5; 68 69 private static final int REQUEST_SUBJECT = 1001; 70 71 public static final String PREF_KEY_SUBJECT_HISTORY_COUNT = "subject_history_count"; 72 public static final String PREF_KEY_SUBJECT_HISTORY_ITEM = "subject_history_item"; 73 74 /** 75 * Activity intent argument bundle keys: 76 */ 77 public static final String ARG_PHOTO_ID = "PHOTO_ID"; 78 public static final String ARG_PHOTO_URI = "PHOTO_URI"; 79 public static final String ARG_CONTACT_URI = "CONTACT_URI"; 80 public static final String ARG_NAME_OR_NUMBER = "NAME_OR_NUMBER"; 81 public static final String ARG_IS_BUSINESS = "IS_BUSINESS"; 82 public static final String ARG_NUMBER = "NUMBER"; 83 public static final String ARG_DISPLAY_NUMBER = "DISPLAY_NUMBER"; 84 public static final String ARG_NUMBER_LABEL = "NUMBER_LABEL"; 85 public static final String ARG_PHONE_ACCOUNT_HANDLE = "PHONE_ACCOUNT_HANDLE"; 86 87 private int mAnimationDuration; 88 private Charset mMessageEncoding; 89 private View mBackgroundView; 90 private View mDialogView; 91 private QuickContactBadge mContactPhoto; 92 private TextView mNameView; 93 private TextView mNumberView; 94 private EditText mCallSubjectView; 95 private TextView mCharacterLimitView; 96 private View mHistoryButton; 97 private View mSendAndCallButton; 98 private ListView mSubjectList; 99 100 private int mLimit = CALL_SUBJECT_LIMIT; 101 private int mPhotoSize; 102 private SharedPreferences mPrefs; 103 private List<String> mSubjectHistory; 104 105 private long mPhotoID; 106 private Uri mPhotoUri; 107 private Uri mContactUri; 108 private String mNameOrNumber; 109 private boolean mIsBusiness; 110 private String mNumber; 111 private String mDisplayNumber; 112 private String mNumberLabel; 113 private PhoneAccountHandle mPhoneAccountHandle; 114 115 /** 116 * Handles changes to the text in the subject box. Ensures the character limit is updated. 117 */ 118 private final TextWatcher mTextWatcher = new TextWatcher() { 119 @Override 120 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 121 // no-op 122 } 123 124 @Override 125 public void onTextChanged(CharSequence s, int start, int before, int count) { 126 updateCharacterLimit(); 127 } 128 129 @Override 130 public void afterTextChanged(Editable s) { 131 // no-op 132 } 133 }; 134 135 /** 136 * Click listener which handles user clicks outside of the dialog. 137 */ 138 private View.OnClickListener mBackgroundListener = new View.OnClickListener() { 139 @Override 140 public void onClick(View v) { 141 finish(); 142 } 143 }; 144 145 /** 146 * Handles displaying the list of past call subjects. 147 */ 148 private final View.OnClickListener mHistoryOnClickListener = new View.OnClickListener() { 149 @Override 150 public void onClick(View v) { 151 hideSoftKeyboard(CallSubjectDialog.this, mCallSubjectView); 152 showCallHistory(mSubjectList.getVisibility() == View.GONE); 153 } 154 }; 155 156 /** 157 * Handles starting a call with a call subject specified. 158 */ 159 private final View.OnClickListener mSendAndCallOnClickListener = new View.OnClickListener() { 160 @Override 161 public void onClick(View v) { 162 String subject = mCallSubjectView.getText().toString(); 163 Intent intent = CallUtil.getCallWithSubjectIntent(mNumber, mPhoneAccountHandle, 164 subject); 165 166 TelecomManagerCompat.placeCall( 167 CallSubjectDialog.this, 168 (TelecomManager) getSystemService(Context.TELECOM_SERVICE), 169 intent); 170 171 mSubjectHistory.add(subject); 172 saveSubjectHistory(mSubjectHistory); 173 finish(); 174 } 175 }; 176 177 /** 178 * Handles auto-hiding the call history when user clicks in the call subject field to give it 179 * focus. 180 */ 181 private final View.OnClickListener mCallSubjectClickListener = new View.OnClickListener() { 182 @Override 183 public void onClick(View v) { 184 if (mSubjectList.getVisibility() == View.VISIBLE) { 185 showCallHistory(false); 186 } 187 } 188 }; 189 190 /** 191 * Item click listener which handles user clicks on the items in the list view. Dismisses 192 * the activity, returning the subject to the caller and closing the activity with the 193 * {@link Activity#RESULT_OK} result code. 194 */ 195 private AdapterView.OnItemClickListener mItemClickListener = 196 new AdapterView.OnItemClickListener() { 197 @Override 198 public void onItemClick(AdapterView<?> arg0, View view, int position, long arg3) { 199 mCallSubjectView.setText(mSubjectHistory.get(position)); 200 showCallHistory(false); 201 } 202 }; 203 204 /** 205 * Show the call subject dialog given a phone number to dial (e.g. from the dialpad). 206 * 207 * @param activity The activity. 208 * @param number The number to dial. 209 */ start(Activity activity, String number)210 public static void start(Activity activity, String number) { 211 start(activity, 212 -1 /* photoId */, 213 null /* photoUri */, 214 null /* contactUri */, 215 number /* nameOrNumber */, 216 false /* isBusiness */, 217 number /* number */, 218 null /* displayNumber */, 219 null /* numberLabel */, 220 null /* phoneAccountHandle */); 221 } 222 223 /** 224 * Creates a call subject dialog. 225 * 226 * @param activity The current activity. 227 * @param photoId The photo ID (used to populate contact photo). 228 * @param photoUri The photo Uri (used to populate contact photo). 229 * @param contactUri The Contact URI (used so quick contact can be invoked from contact photo). 230 * @param nameOrNumber The name or number of the callee. 231 * @param isBusiness {@code true} if a business is being called (used for contact photo). 232 * @param number The raw number to dial. 233 * @param displayNumber The number to dial, formatted for display. 234 * @param numberLabel The label for the number (if from a contact). 235 * @param phoneAccountHandle The phone account handle. 236 */ start(Activity activity, long photoId, Uri photoUri, Uri contactUri, String nameOrNumber, boolean isBusiness, String number, String displayNumber, String numberLabel, PhoneAccountHandle phoneAccountHandle)237 public static void start(Activity activity, long photoId, Uri photoUri, Uri contactUri, 238 String nameOrNumber, boolean isBusiness, String number, String displayNumber, 239 String numberLabel, PhoneAccountHandle phoneAccountHandle) { 240 Bundle arguments = new Bundle(); 241 arguments.putLong(ARG_PHOTO_ID, photoId); 242 arguments.putParcelable(ARG_PHOTO_URI, photoUri); 243 arguments.putParcelable(ARG_CONTACT_URI, contactUri); 244 arguments.putString(ARG_NAME_OR_NUMBER, nameOrNumber); 245 arguments.putBoolean(ARG_IS_BUSINESS, isBusiness); 246 arguments.putString(ARG_NUMBER, number); 247 arguments.putString(ARG_DISPLAY_NUMBER, displayNumber); 248 arguments.putString(ARG_NUMBER_LABEL, numberLabel); 249 arguments.putParcelable(ARG_PHONE_ACCOUNT_HANDLE, phoneAccountHandle); 250 start(activity, arguments); 251 } 252 253 /** 254 * Shows the call subject dialog given a Bundle containing all the arguments required to 255 * display the dialog (e.g. from Quick Contacts). 256 * 257 * @param activity The activity. 258 * @param arguments The arguments bundle. 259 */ start(Activity activity, Bundle arguments)260 public static void start(Activity activity, Bundle arguments) { 261 Intent intent = new Intent(activity, CallSubjectDialog.class); 262 intent.putExtras(arguments); 263 activity.startActivity(intent); 264 } 265 266 /** 267 * Creates the dialog, inflating the layout and populating it with the name and phone number. 268 * 269 * @param savedInstanceState The last saved instance state of the Fragment, 270 * or null if this is a freshly created Fragment. 271 * 272 * @return Dialog instance. 273 */ 274 @Override onCreate(Bundle savedInstanceState)275 public void onCreate(Bundle savedInstanceState) { 276 super.onCreate(savedInstanceState); 277 mAnimationDuration = getResources().getInteger(R.integer.call_subject_animation_duration); 278 mPrefs = PreferenceManager.getDefaultSharedPreferences(this); 279 mPhotoSize = getResources().getDimensionPixelSize( 280 R.dimen.call_subject_dialog_contact_photo_size); 281 readArguments(); 282 loadConfiguration(); 283 mSubjectHistory = loadSubjectHistory(mPrefs); 284 285 setContentView(R.layout.dialog_call_subject); 286 getWindow().setLayout(ViewGroup.LayoutParams.MATCH_PARENT, 287 ViewGroup.LayoutParams.MATCH_PARENT); 288 mBackgroundView = findViewById(R.id.call_subject_dialog); 289 mBackgroundView.setOnClickListener(mBackgroundListener); 290 mDialogView = findViewById(R.id.dialog_view); 291 mContactPhoto = (QuickContactBadge) findViewById(R.id.contact_photo); 292 mNameView = (TextView) findViewById(R.id.name); 293 mNumberView = (TextView) findViewById(R.id.number); 294 mCallSubjectView = (EditText) findViewById(R.id.call_subject); 295 mCallSubjectView.addTextChangedListener(mTextWatcher); 296 mCallSubjectView.setOnClickListener(mCallSubjectClickListener); 297 InputFilter[] filters = new InputFilter[1]; 298 filters[0] = new InputFilter.LengthFilter(mLimit); 299 mCallSubjectView.setFilters(filters); 300 mCharacterLimitView = (TextView) findViewById(R.id.character_limit); 301 mHistoryButton = findViewById(R.id.history_button); 302 mHistoryButton.setOnClickListener(mHistoryOnClickListener); 303 mHistoryButton.setVisibility(mSubjectHistory.isEmpty() ? View.GONE : View.VISIBLE); 304 mSendAndCallButton = findViewById(R.id.send_and_call_button); 305 mSendAndCallButton.setOnClickListener(mSendAndCallOnClickListener); 306 mSubjectList = (ListView) findViewById(R.id.subject_list); 307 mSubjectList.setOnItemClickListener(mItemClickListener); 308 mSubjectList.setVisibility(View.GONE); 309 310 updateContactInfo(); 311 updateCharacterLimit(); 312 } 313 314 /** 315 * Populates the contact info fields based on the current contact information. 316 */ updateContactInfo()317 private void updateContactInfo() { 318 if (mContactUri != null) { 319 setPhoto(mPhotoID, mPhotoUri, mContactUri, mNameOrNumber, mIsBusiness); 320 } else { 321 mContactPhoto.setVisibility(View.GONE); 322 } 323 mNameView.setText(mNameOrNumber); 324 if (!TextUtils.isEmpty(mNumberLabel) && !TextUtils.isEmpty(mDisplayNumber)) { 325 mNumberView.setVisibility(View.VISIBLE); 326 mNumberView.setText(getString(R.string.call_subject_type_and_number, 327 mNumberLabel, mDisplayNumber)); 328 } else { 329 mNumberView.setVisibility(View.GONE); 330 mNumberView.setText(null); 331 } 332 } 333 334 /** 335 * Reads arguments from the fragment arguments and populates the necessary instance variables. 336 */ readArguments()337 private void readArguments() { 338 Bundle arguments = getIntent().getExtras(); 339 if (arguments == null) { 340 Log.e(TAG, "Arguments cannot be null."); 341 return; 342 } 343 mPhotoID = arguments.getLong(ARG_PHOTO_ID); 344 mPhotoUri = arguments.getParcelable(ARG_PHOTO_URI); 345 mContactUri = arguments.getParcelable(ARG_CONTACT_URI); 346 mNameOrNumber = arguments.getString(ARG_NAME_OR_NUMBER); 347 mIsBusiness = arguments.getBoolean(ARG_IS_BUSINESS); 348 mNumber = arguments.getString(ARG_NUMBER); 349 mDisplayNumber = arguments.getString(ARG_DISPLAY_NUMBER); 350 mNumberLabel = arguments.getString(ARG_NUMBER_LABEL); 351 mPhoneAccountHandle = arguments.getParcelable(ARG_PHONE_ACCOUNT_HANDLE); 352 } 353 354 /** 355 * Updates the character limit display, coloring the text RED when the limit is reached or 356 * exceeded. 357 */ updateCharacterLimit()358 private void updateCharacterLimit() { 359 String subjectText = mCallSubjectView.getText().toString(); 360 final int length; 361 362 // If a message encoding is specified, use that to count bytes in the message. 363 if (mMessageEncoding != null) { 364 length = subjectText.getBytes(mMessageEncoding).length; 365 } else { 366 // No message encoding specified, so just count characters entered. 367 length = subjectText.length(); 368 } 369 370 mCharacterLimitView.setText( 371 getString(R.string.call_subject_limit, length, mLimit)); 372 if (length >= mLimit) { 373 mCharacterLimitView.setTextColor(getResources().getColor( 374 R.color.call_subject_limit_exceeded)); 375 } else { 376 mCharacterLimitView.setTextColor(getResources().getColor( 377 R.color.dialtacts_secondary_text_color)); 378 } 379 } 380 381 /** 382 * Sets the photo on the quick contact photo. 383 * 384 * @param photoId 385 * @param photoUri 386 * @param contactUri 387 * @param displayName 388 * @param isBusiness 389 */ setPhoto(long photoId, Uri photoUri, Uri contactUri, String displayName, boolean isBusiness)390 private void setPhoto(long photoId, Uri photoUri, Uri contactUri, String displayName, 391 boolean isBusiness) { 392 mContactPhoto.assignContactUri(contactUri); 393 if (CompatUtils.isLollipopCompatible()) { 394 mContactPhoto.setOverlay(null); 395 } 396 397 int contactType; 398 if (isBusiness) { 399 contactType = ContactPhotoManager.TYPE_BUSINESS; 400 } else { 401 contactType = ContactPhotoManager.TYPE_DEFAULT; 402 } 403 404 String lookupKey = null; 405 if (contactUri != null) { 406 lookupKey = UriUtils.getLookupKeyFromUri(contactUri); 407 } 408 409 ContactPhotoManager.DefaultImageRequest 410 request = new ContactPhotoManager.DefaultImageRequest( 411 displayName, lookupKey, contactType, true /* isCircular */); 412 413 if (photoId == 0 && photoUri != null) { 414 ContactPhotoManager.getInstance(this).loadPhoto(mContactPhoto, photoUri, 415 mPhotoSize, false /* darkTheme */, true /* isCircular */, request); 416 } else { 417 ContactPhotoManager.getInstance(this).loadThumbnail(mContactPhoto, photoId, 418 false /* darkTheme */, true /* isCircular */, request); 419 } 420 } 421 422 /** 423 * Loads the subject history from shared preferences. 424 * 425 * @param prefs Shared preferences. 426 * @return List of subject history strings. 427 */ loadSubjectHistory(SharedPreferences prefs)428 public static List<String> loadSubjectHistory(SharedPreferences prefs) { 429 int historySize = prefs.getInt(PREF_KEY_SUBJECT_HISTORY_COUNT, 0); 430 List<String> subjects = new ArrayList(historySize); 431 432 for (int ix = 0 ; ix < historySize; ix++) { 433 String historyItem = prefs.getString(PREF_KEY_SUBJECT_HISTORY_ITEM + ix, null); 434 if (!TextUtils.isEmpty(historyItem)) { 435 subjects.add(historyItem); 436 } 437 } 438 439 return subjects; 440 } 441 442 /** 443 * Saves the subject history list to shared prefs, removing older items so that there are only 444 * {@link #CALL_SUBJECT_HISTORY_SIZE} items at most. 445 * 446 * @param history The history. 447 */ saveSubjectHistory(List<String> history)448 private void saveSubjectHistory(List<String> history) { 449 // Remove oldest subject(s). 450 while (history.size() > CALL_SUBJECT_HISTORY_SIZE) { 451 history.remove(0); 452 } 453 454 SharedPreferences.Editor editor = mPrefs.edit(); 455 int historyCount = 0; 456 for (String subject : history) { 457 if (!TextUtils.isEmpty(subject)) { 458 editor.putString(PREF_KEY_SUBJECT_HISTORY_ITEM + historyCount, 459 subject); 460 historyCount++; 461 } 462 } 463 editor.putInt(PREF_KEY_SUBJECT_HISTORY_COUNT, historyCount); 464 editor.apply(); 465 } 466 467 /** 468 * Hide software keyboard for the given {@link View}. 469 */ hideSoftKeyboard(Context context, View view)470 public void hideSoftKeyboard(Context context, View view) { 471 InputMethodManager imm = (InputMethodManager) context.getSystemService( 472 Context.INPUT_METHOD_SERVICE); 473 if (imm != null) { 474 imm.hideSoftInputFromWindow(view.getWindowToken(), InputMethodManager.HIDE_NOT_ALWAYS); 475 } 476 } 477 478 /** 479 * Hides or shows the call history list. 480 * 481 * @param show {@code true} if the call history should be shown, {@code false} otherwise. 482 */ showCallHistory(final boolean show)483 private void showCallHistory(final boolean show) { 484 // Bail early if the visibility has not changed. 485 if ((show && mSubjectList.getVisibility() == View.VISIBLE) || 486 (!show && mSubjectList.getVisibility() == View.GONE)) { 487 return; 488 } 489 490 final int dialogStartingBottom = mDialogView.getBottom(); 491 if (show) { 492 // Showing the subject list; bind the list of history items to the list and show it. 493 ArrayAdapter<String> adapter = new ArrayAdapter<String>(CallSubjectDialog.this, 494 R.layout.call_subject_history_list_item, mSubjectHistory); 495 mSubjectList.setAdapter(adapter); 496 mSubjectList.setVisibility(View.VISIBLE); 497 } else { 498 // Hiding the subject list. 499 mSubjectList.setVisibility(View.GONE); 500 } 501 502 // Use a ViewTreeObserver so that we can animate between the pre-layout and post-layout 503 // states. 504 final ViewTreeObserver observer = mBackgroundView.getViewTreeObserver(); 505 observer.addOnPreDrawListener( 506 new ViewTreeObserver.OnPreDrawListener() { 507 @Override 508 public boolean onPreDraw() { 509 // We don't want to continue getting called. 510 if (observer.isAlive()) { 511 observer.removeOnPreDrawListener(this); 512 } 513 514 // Determine the amount the dialog has shifted due to the relayout. 515 int shiftAmount = dialogStartingBottom - mDialogView.getBottom(); 516 517 // If the dialog needs to be shifted, do that now. 518 if (shiftAmount != 0) { 519 // Start animation in translated state and animate to translationY 0. 520 mDialogView.setTranslationY(shiftAmount); 521 mDialogView.animate() 522 .translationY(0) 523 .setInterpolator(AnimUtils.EASE_OUT_EASE_IN) 524 .setDuration(mAnimationDuration) 525 .start(); 526 } 527 528 if (show) { 529 // Show the subhect list. 530 mSubjectList.setTranslationY(mSubjectList.getHeight()); 531 532 mSubjectList.animate() 533 .translationY(0) 534 .setInterpolator(AnimUtils.EASE_OUT_EASE_IN) 535 .setDuration(mAnimationDuration) 536 .setListener(new AnimatorListenerAdapter() { 537 @Override 538 public void onAnimationEnd(Animator animation) { 539 super.onAnimationEnd(animation); 540 } 541 542 @Override 543 public void onAnimationStart(Animator animation) { 544 super.onAnimationStart(animation); 545 mSubjectList.setVisibility(View.VISIBLE); 546 } 547 }) 548 .start(); 549 } else { 550 // Hide the subject list. 551 mSubjectList.setTranslationY(0); 552 553 mSubjectList.animate() 554 .translationY(mSubjectList.getHeight()) 555 .setInterpolator(AnimUtils.EASE_OUT_EASE_IN) 556 .setDuration(mAnimationDuration) 557 .setListener(new AnimatorListenerAdapter() { 558 @Override 559 public void onAnimationEnd(Animator animation) { 560 super.onAnimationEnd(animation); 561 mSubjectList.setVisibility(View.GONE); 562 } 563 564 @Override 565 public void onAnimationStart(Animator animation) { 566 super.onAnimationStart(animation); 567 } 568 }) 569 .start(); 570 } 571 return true; 572 } 573 } 574 ); 575 } 576 577 /** 578 * Loads the message encoding and maximum message length from the phone account extras for the 579 * current phone account. 580 */ loadConfiguration()581 private void loadConfiguration() { 582 // Only attempt to load configuration from the phone account extras if the SDK is N or 583 // later. If we've got a prior SDK the default encoding and message length will suffice. 584 int sdk = android.os.Build.VERSION.SDK_INT; 585 if(sdk <= android.os.Build.VERSION_CODES.M) { 586 return; 587 } 588 589 if (mPhoneAccountHandle == null) { 590 return; 591 } 592 593 TelecomManager telecomManager = 594 (TelecomManager) getSystemService(Context.TELECOM_SERVICE); 595 final PhoneAccount account = telecomManager.getPhoneAccount(mPhoneAccountHandle); 596 597 Bundle phoneAccountExtras = PhoneAccountSdkCompat.getExtras(account); 598 if (phoneAccountExtras == null) { 599 return; 600 } 601 602 // Get limit, if provided; otherwise default to existing value. 603 mLimit = phoneAccountExtras 604 .getInt(PhoneAccountSdkCompat.EXTRA_CALL_SUBJECT_MAX_LENGTH, mLimit); 605 606 // Get charset; default to none (e.g. count characters 1:1). 607 String charsetName = phoneAccountExtras.getString( 608 PhoneAccountSdkCompat.EXTRA_CALL_SUBJECT_CHARACTER_ENCODING); 609 610 if (!TextUtils.isEmpty(charsetName)) { 611 try { 612 mMessageEncoding = Charset.forName(charsetName); 613 } catch (java.nio.charset.UnsupportedCharsetException uce) { 614 // Character set was invalid; log warning and fallback to none. 615 Log.w(TAG, "Invalid charset: " + charsetName); 616 mMessageEncoding = null; 617 } 618 } else { 619 // No character set specified, so count characters 1:1. 620 mMessageEncoding = null; 621 } 622 } 623 } 624