1 /* 2 * Copyright (C) 2010 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 com.android.dialer.interactions; 17 18 import android.Manifest; 19 import android.annotation.SuppressLint; 20 import android.app.Activity; 21 import android.app.AlertDialog; 22 import android.app.Dialog; 23 import android.app.DialogFragment; 24 import android.app.FragmentManager; 25 import android.content.Context; 26 import android.content.CursorLoader; 27 import android.content.DialogInterface; 28 import android.content.Intent; 29 import android.content.Loader; 30 import android.content.Loader.OnLoadCompleteListener; 31 import android.content.pm.PackageManager; 32 import android.database.Cursor; 33 import android.net.Uri; 34 import android.os.Bundle; 35 import android.os.Parcel; 36 import android.os.Parcelable; 37 import android.provider.ContactsContract.CommonDataKinds.Phone; 38 import android.provider.ContactsContract.CommonDataKinds.SipAddress; 39 import android.provider.ContactsContract.Contacts; 40 import android.provider.ContactsContract.Data; 41 import android.provider.ContactsContract.RawContacts; 42 import android.support.annotation.IntDef; 43 import android.support.annotation.VisibleForTesting; 44 import android.support.v4.app.ActivityCompat; 45 import android.support.v4.content.ContextCompat; 46 import android.view.LayoutInflater; 47 import android.view.View; 48 import android.view.ViewGroup; 49 import android.widget.ArrayAdapter; 50 import android.widget.CheckBox; 51 import android.widget.ListAdapter; 52 import android.widget.TextView; 53 import com.android.contacts.common.Collapser; 54 import com.android.contacts.common.Collapser.Collapsible; 55 import com.android.contacts.common.MoreContactUtils; 56 import com.android.contacts.common.util.ContactDisplayUtils; 57 import com.android.dialer.callintent.CallInitiationType; 58 import com.android.dialer.callintent.CallIntentBuilder; 59 import com.android.dialer.callintent.CallIntentParser; 60 import com.android.dialer.callintent.CallSpecificAppData; 61 import com.android.dialer.common.Assert; 62 import com.android.dialer.common.LogUtil; 63 import com.android.dialer.logging.InteractionEvent; 64 import com.android.dialer.logging.Logger; 65 import com.android.dialer.util.DialerUtils; 66 import com.android.dialer.util.TransactionSafeActivity; 67 import java.lang.annotation.Retention; 68 import java.lang.annotation.RetentionPolicy; 69 import java.util.ArrayList; 70 import java.util.List; 71 72 /** 73 * Initiates phone calls or a text message. If there are multiple candidates, this class shows a 74 * dialog to pick one. Creating one of these interactions should be done through the static factory 75 * methods. 76 * 77 * <p>Note that this class initiates not only usual *phone* calls but also *SIP* calls. 78 * 79 * <p>TODO: clean up code and documents since it is quite confusing to use "phone numbers" or "phone 80 * calls" here while they can be SIP addresses or SIP calls (See also issue 5039627). 81 */ 82 public class PhoneNumberInteraction implements OnLoadCompleteListener<Cursor> { 83 84 private static final String TAG = PhoneNumberInteraction.class.getSimpleName(); 85 /** The identifier for a permissions request if one is generated. */ 86 public static final int REQUEST_READ_CONTACTS = 1; 87 public static final int REQUEST_CALL_PHONE = 2; 88 89 @VisibleForTesting 90 public static final String[] PHONE_NUMBER_PROJECTION = 91 new String[] { 92 Phone._ID, 93 Phone.NUMBER, 94 Phone.IS_SUPER_PRIMARY, 95 RawContacts.ACCOUNT_TYPE, 96 RawContacts.DATA_SET, 97 Phone.TYPE, 98 Phone.LABEL, 99 Phone.MIMETYPE, 100 Phone.CONTACT_ID, 101 }; 102 103 private static final String PHONE_NUMBER_SELECTION = 104 Data.MIMETYPE 105 + " IN ('" 106 + Phone.CONTENT_ITEM_TYPE 107 + "', " 108 + "'" 109 + SipAddress.CONTENT_ITEM_TYPE 110 + "') AND " 111 + Data.DATA1 112 + " NOT NULL"; 113 private static final int UNKNOWN_CONTACT_ID = -1; 114 private final Context mContext; 115 private final int mInteractionType; 116 private final CallSpecificAppData mCallSpecificAppData; 117 private long mContactId = UNKNOWN_CONTACT_ID; 118 private CursorLoader mLoader; 119 private boolean mIsVideoCall; 120 121 /** Error codes for interactions. */ 122 @Retention(RetentionPolicy.SOURCE) 123 @IntDef( 124 value = { 125 InteractionErrorCode.CONTACT_NOT_FOUND, 126 InteractionErrorCode.CONTACT_HAS_NO_NUMBER, 127 InteractionErrorCode.USER_LEAVING_ACTIVITY, 128 InteractionErrorCode.OTHER_ERROR 129 } 130 ) 131 public @interface InteractionErrorCode { 132 133 int CONTACT_NOT_FOUND = 1; 134 int CONTACT_HAS_NO_NUMBER = 2; 135 int OTHER_ERROR = 3; 136 int USER_LEAVING_ACTIVITY = 4; 137 } 138 139 /** 140 * Activities which use this class must implement this. They will be notified if there was an 141 * error performing the interaction. For example, this callback will be invoked on the activity if 142 * the contact URI provided points to a deleted contact, or to a contact without a phone number. 143 */ 144 public interface InteractionErrorListener { 145 interactionError(@nteractionErrorCode int interactionErrorCode)146 void interactionError(@InteractionErrorCode int interactionErrorCode); 147 } 148 149 /** 150 * Activities which use this class must implement this. They will be notified if the phone number 151 * disambiguation dialog is dismissed. 152 */ 153 public interface DisambigDialogDismissedListener { onDisambigDialogDismissed()154 void onDisambigDialogDismissed(); 155 } 156 PhoneNumberInteraction( Context context, int interactionType, boolean isVideoCall, CallSpecificAppData callSpecificAppData)157 private PhoneNumberInteraction( 158 Context context, 159 int interactionType, 160 boolean isVideoCall, 161 CallSpecificAppData callSpecificAppData) { 162 mContext = context; 163 mInteractionType = interactionType; 164 mCallSpecificAppData = callSpecificAppData; 165 mIsVideoCall = isVideoCall; 166 167 Assert.checkArgument(context instanceof InteractionErrorListener); 168 Assert.checkArgument(context instanceof DisambigDialogDismissedListener); 169 Assert.checkArgument(context instanceof ActivityCompat.OnRequestPermissionsResultCallback); 170 } 171 performAction( Context context, String phoneNumber, int interactionType, boolean isVideoCall, CallSpecificAppData callSpecificAppData)172 private static void performAction( 173 Context context, 174 String phoneNumber, 175 int interactionType, 176 boolean isVideoCall, 177 CallSpecificAppData callSpecificAppData) { 178 Intent intent; 179 switch (interactionType) { 180 case ContactDisplayUtils.INTERACTION_SMS: 181 intent = new Intent(Intent.ACTION_SENDTO, Uri.fromParts("sms", phoneNumber, null)); 182 break; 183 default: 184 intent = 185 new CallIntentBuilder(phoneNumber, callSpecificAppData) 186 .setIsVideoCall(isVideoCall) 187 .build(); 188 break; 189 } 190 DialerUtils.startActivityWithErrorToast(context, intent); 191 } 192 193 /** 194 * @param activity that is calling this interaction. This must be of type {@link 195 * TransactionSafeActivity} because we need to check on the activity state after the phone 196 * numbers have been queried for. The activity must implement {@link InteractionErrorListener} 197 * and {@link DisambigDialogDismissedListener}. 198 * @param isVideoCall {@code true} if the call is a video call, {@code false} otherwise. 199 */ startInteractionForPhoneCall( TransactionSafeActivity activity, Uri uri, boolean isVideoCall, CallSpecificAppData callSpecificAppData)200 public static void startInteractionForPhoneCall( 201 TransactionSafeActivity activity, 202 Uri uri, 203 boolean isVideoCall, 204 CallSpecificAppData callSpecificAppData) { 205 new PhoneNumberInteraction( 206 activity, ContactDisplayUtils.INTERACTION_CALL, isVideoCall, callSpecificAppData) 207 .startInteraction(uri); 208 } 209 performAction(String phoneNumber)210 private void performAction(String phoneNumber) { 211 PhoneNumberInteraction.performAction( 212 mContext, phoneNumber, mInteractionType, mIsVideoCall, mCallSpecificAppData); 213 } 214 215 /** 216 * Initiates the interaction to result in either a phone call or sms message for a contact. 217 * 218 * @param uri Contact Uri 219 */ startInteraction(Uri uri)220 private void startInteraction(Uri uri) { 221 // It's possible for a shortcut to have been created, and then permissions revoked. To avoid a 222 // crash when the user tries to use such a shortcut, check for this condition and ask the user 223 // for the permission. 224 if (ContextCompat.checkSelfPermission(mContext, Manifest.permission.CALL_PHONE) 225 != PackageManager.PERMISSION_GRANTED) { 226 LogUtil.i("PhoneNumberInteraction.startInteraction", "No phone permissions"); 227 ActivityCompat.requestPermissions( 228 (Activity) mContext, new String[] {Manifest.permission.CALL_PHONE}, REQUEST_CALL_PHONE); 229 return; 230 } 231 if (ContextCompat.checkSelfPermission(mContext, Manifest.permission.READ_CONTACTS) 232 != PackageManager.PERMISSION_GRANTED) { 233 LogUtil.i("PhoneNumberInteraction.startInteraction", "No contact permissions"); 234 ActivityCompat.requestPermissions( 235 (Activity) mContext, 236 new String[] {Manifest.permission.READ_CONTACTS}, 237 REQUEST_READ_CONTACTS); 238 return; 239 } 240 241 if (mLoader != null) { 242 mLoader.reset(); 243 } 244 final Uri queryUri; 245 final String inputUriAsString = uri.toString(); 246 if (inputUriAsString.startsWith(Contacts.CONTENT_URI.toString())) { 247 if (!inputUriAsString.endsWith(Contacts.Data.CONTENT_DIRECTORY)) { 248 queryUri = Uri.withAppendedPath(uri, Contacts.Data.CONTENT_DIRECTORY); 249 } else { 250 queryUri = uri; 251 } 252 } else if (inputUriAsString.startsWith(Data.CONTENT_URI.toString())) { 253 queryUri = uri; 254 } else { 255 throw new UnsupportedOperationException( 256 "Input Uri must be contact Uri or data Uri (input: \"" + uri + "\")"); 257 } 258 259 mLoader = 260 new CursorLoader( 261 mContext, queryUri, PHONE_NUMBER_PROJECTION, PHONE_NUMBER_SELECTION, null, null); 262 mLoader.registerListener(0, this); 263 mLoader.startLoading(); 264 } 265 266 @Override onLoadComplete(Loader<Cursor> loader, Cursor cursor)267 public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) { 268 if (cursor == null) { 269 LogUtil.i("PhoneNumberInteraction.onLoadComplete", "null cursor"); 270 interactionError(InteractionErrorCode.OTHER_ERROR); 271 return; 272 } 273 try { 274 ArrayList<PhoneItem> phoneList = new ArrayList<>(); 275 String primaryPhone = null; 276 if (!isSafeToCommitTransactions()) { 277 LogUtil.i("PhoneNumberInteraction.onLoadComplete", "not safe to commit transaction"); 278 interactionError(InteractionErrorCode.USER_LEAVING_ACTIVITY); 279 return; 280 } 281 if (cursor.moveToFirst()) { 282 int contactIdColumn = cursor.getColumnIndexOrThrow(Phone.CONTACT_ID); 283 int isSuperPrimaryColumn = cursor.getColumnIndexOrThrow(Phone.IS_SUPER_PRIMARY); 284 int phoneNumberColumn = cursor.getColumnIndexOrThrow(Phone.NUMBER); 285 int phoneIdColumn = cursor.getColumnIndexOrThrow(Phone._ID); 286 int accountTypeColumn = cursor.getColumnIndexOrThrow(RawContacts.ACCOUNT_TYPE); 287 int dataSetColumn = cursor.getColumnIndexOrThrow(RawContacts.DATA_SET); 288 int phoneTypeColumn = cursor.getColumnIndexOrThrow(Phone.TYPE); 289 int phoneLabelColumn = cursor.getColumnIndexOrThrow(Phone.LABEL); 290 int phoneMimeTpeColumn = cursor.getColumnIndexOrThrow(Phone.MIMETYPE); 291 do { 292 if (mContactId == UNKNOWN_CONTACT_ID) { 293 mContactId = cursor.getLong(contactIdColumn); 294 } 295 296 if (cursor.getInt(isSuperPrimaryColumn) != 0) { 297 // Found super primary, call it. 298 primaryPhone = cursor.getString(phoneNumberColumn); 299 } 300 301 PhoneItem item = new PhoneItem(); 302 item.id = cursor.getLong(phoneIdColumn); 303 item.phoneNumber = cursor.getString(phoneNumberColumn); 304 item.accountType = cursor.getString(accountTypeColumn); 305 item.dataSet = cursor.getString(dataSetColumn); 306 item.type = cursor.getInt(phoneTypeColumn); 307 item.label = cursor.getString(phoneLabelColumn); 308 item.mimeType = cursor.getString(phoneMimeTpeColumn); 309 310 phoneList.add(item); 311 } while (cursor.moveToNext()); 312 } else { 313 interactionError(InteractionErrorCode.CONTACT_NOT_FOUND); 314 return; 315 } 316 317 if (primaryPhone != null) { 318 performAction(primaryPhone); 319 return; 320 } 321 322 Collapser.collapseList(phoneList, mContext); 323 if (phoneList.size() == 0) { 324 interactionError(InteractionErrorCode.CONTACT_HAS_NO_NUMBER); 325 } else if (phoneList.size() == 1) { 326 PhoneItem item = phoneList.get(0); 327 performAction(item.phoneNumber); 328 } else { 329 // There are multiple candidates. Let the user choose one. 330 showDisambiguationDialog(phoneList); 331 } 332 } finally { 333 cursor.close(); 334 } 335 } 336 interactionError(@nteractionErrorCode int interactionErrorCode)337 private void interactionError(@InteractionErrorCode int interactionErrorCode) { 338 // mContext is really the activity -- see ctor docs. 339 ((InteractionErrorListener) mContext).interactionError(interactionErrorCode); 340 } 341 isSafeToCommitTransactions()342 private boolean isSafeToCommitTransactions() { 343 return !(mContext instanceof TransactionSafeActivity) 344 || ((TransactionSafeActivity) mContext).isSafeToCommitTransactions(); 345 } 346 347 @VisibleForTesting getLoader()348 /* package */ CursorLoader getLoader() { 349 return mLoader; 350 } 351 showDisambiguationDialog(ArrayList<PhoneItem> phoneList)352 private void showDisambiguationDialog(ArrayList<PhoneItem> phoneList) { 353 final Activity activity = (Activity) mContext; 354 if (activity.isDestroyed()) { 355 // Check whether the activity is still running 356 LogUtil.i("PhoneNumberInteraction.showDisambiguationDialog", "activity destroyed"); 357 return; 358 } 359 try { 360 PhoneDisambiguationDialogFragment.show( 361 activity.getFragmentManager(), 362 phoneList, 363 mInteractionType, 364 mIsVideoCall, 365 mCallSpecificAppData); 366 } catch (IllegalStateException e) { 367 // ignore to be safe. Shouldn't happen because we checked the 368 // activity wasn't destroyed, but to be safe. 369 LogUtil.e("PhoneNumberInteraction.showDisambiguationDialog", "caught exception", e); 370 } 371 } 372 373 /** A model object for capturing a phone number for a given contact. */ 374 @VisibleForTesting 375 /* package */ static class PhoneItem implements Parcelable, Collapsible<PhoneItem> { 376 377 public static final Parcelable.Creator<PhoneItem> CREATOR = 378 new Parcelable.Creator<PhoneItem>() { 379 @Override 380 public PhoneItem createFromParcel(Parcel in) { 381 return new PhoneItem(in); 382 } 383 384 @Override 385 public PhoneItem[] newArray(int size) { 386 return new PhoneItem[size]; 387 } 388 }; 389 long id; 390 String phoneNumber; 391 String accountType; 392 String dataSet; 393 long type; 394 String label; 395 /** {@link Phone#CONTENT_ITEM_TYPE} or {@link SipAddress#CONTENT_ITEM_TYPE}. */ 396 String mimeType; 397 PhoneItem()398 private PhoneItem() {} 399 PhoneItem(Parcel in)400 private PhoneItem(Parcel in) { 401 this.id = in.readLong(); 402 this.phoneNumber = in.readString(); 403 this.accountType = in.readString(); 404 this.dataSet = in.readString(); 405 this.type = in.readLong(); 406 this.label = in.readString(); 407 this.mimeType = in.readString(); 408 } 409 410 @Override writeToParcel(Parcel dest, int flags)411 public void writeToParcel(Parcel dest, int flags) { 412 dest.writeLong(id); 413 dest.writeString(phoneNumber); 414 dest.writeString(accountType); 415 dest.writeString(dataSet); 416 dest.writeLong(type); 417 dest.writeString(label); 418 dest.writeString(mimeType); 419 } 420 421 @Override describeContents()422 public int describeContents() { 423 return 0; 424 } 425 426 @Override collapseWith(PhoneItem phoneItem)427 public void collapseWith(PhoneItem phoneItem) { 428 // Just keep the number and id we already have. 429 } 430 431 @Override shouldCollapseWith(PhoneItem phoneItem, Context context)432 public boolean shouldCollapseWith(PhoneItem phoneItem, Context context) { 433 return MoreContactUtils.shouldCollapse( 434 Phone.CONTENT_ITEM_TYPE, phoneNumber, Phone.CONTENT_ITEM_TYPE, phoneItem.phoneNumber); 435 } 436 437 @Override toString()438 public String toString() { 439 return phoneNumber; 440 } 441 } 442 443 /** A list adapter that populates the list of contact's phone numbers. */ 444 private static class PhoneItemAdapter extends ArrayAdapter<PhoneItem> { 445 446 private final int mInteractionType; 447 PhoneItemAdapter(Context context, List<PhoneItem> list, int interactionType)448 PhoneItemAdapter(Context context, List<PhoneItem> list, int interactionType) { 449 super(context, R.layout.phone_disambig_item, android.R.id.text2, list); 450 mInteractionType = interactionType; 451 } 452 453 @Override getView(int position, View convertView, ViewGroup parent)454 public View getView(int position, View convertView, ViewGroup parent) { 455 final View view = super.getView(position, convertView, parent); 456 457 final PhoneItem item = getItem(position); 458 Assert.isNotNull(item, "Null item at position: %d", position); 459 final TextView typeView = (TextView) view.findViewById(android.R.id.text1); 460 CharSequence value = 461 ContactDisplayUtils.getLabelForCallOrSms( 462 (int) item.type, item.label, mInteractionType, getContext()); 463 464 typeView.setText(value); 465 return view; 466 } 467 } 468 469 /** 470 * {@link DialogFragment} used for displaying a dialog with a list of phone numbers of which one 471 * will be chosen to make a call or initiate an sms message. 472 * 473 * <p>It is recommended to use {@link #startInteractionForPhoneCall(TransactionSafeActivity, Uri, 474 * boolean, CallSpecificAppData)} instead of directly using this class, as those methods handle 475 * one or multiple data cases appropriately. 476 * 477 * <p>This fragment may only be attached to activities which implement {@link 478 * DisambigDialogDismissedListener}. 479 */ 480 @SuppressWarnings("WeakerAccess") // Made public to let the system reach this class 481 public static class PhoneDisambiguationDialogFragment extends DialogFragment 482 implements DialogInterface.OnClickListener, DialogInterface.OnDismissListener { 483 484 private static final String ARG_PHONE_LIST = "phoneList"; 485 private static final String ARG_INTERACTION_TYPE = "interactionType"; 486 private static final String ARG_IS_VIDEO_CALL = "is_video_call"; 487 488 private int mInteractionType; 489 private ListAdapter mPhonesAdapter; 490 private List<PhoneItem> mPhoneList; 491 private CallSpecificAppData mCallSpecificAppData; 492 private boolean mIsVideoCall; 493 PhoneDisambiguationDialogFragment()494 public PhoneDisambiguationDialogFragment() { 495 super(); 496 } 497 show( FragmentManager fragmentManager, ArrayList<PhoneItem> phoneList, int interactionType, boolean isVideoCall, CallSpecificAppData callSpecificAppData)498 public static void show( 499 FragmentManager fragmentManager, 500 ArrayList<PhoneItem> phoneList, 501 int interactionType, 502 boolean isVideoCall, 503 CallSpecificAppData callSpecificAppData) { 504 PhoneDisambiguationDialogFragment fragment = new PhoneDisambiguationDialogFragment(); 505 Bundle bundle = new Bundle(); 506 bundle.putParcelableArrayList(ARG_PHONE_LIST, phoneList); 507 bundle.putInt(ARG_INTERACTION_TYPE, interactionType); 508 bundle.putBoolean(ARG_IS_VIDEO_CALL, isVideoCall); 509 CallIntentParser.putCallSpecificAppData(bundle, callSpecificAppData); 510 fragment.setArguments(bundle); 511 fragment.show(fragmentManager, TAG); 512 } 513 514 @Override onCreateDialog(Bundle savedInstanceState)515 public Dialog onCreateDialog(Bundle savedInstanceState) { 516 final Activity activity = getActivity(); 517 Assert.checkState(activity instanceof DisambigDialogDismissedListener); 518 519 mPhoneList = getArguments().getParcelableArrayList(ARG_PHONE_LIST); 520 mInteractionType = getArguments().getInt(ARG_INTERACTION_TYPE); 521 mIsVideoCall = getArguments().getBoolean(ARG_IS_VIDEO_CALL); 522 mCallSpecificAppData = CallIntentParser.getCallSpecificAppData(getArguments()); 523 524 mPhonesAdapter = new PhoneItemAdapter(activity, mPhoneList, mInteractionType); 525 final LayoutInflater inflater = activity.getLayoutInflater(); 526 @SuppressLint("InflateParams") // Allowed since dialog view is not available yet 527 final View setPrimaryView = inflater.inflate(R.layout.set_primary_checkbox, null); 528 return new AlertDialog.Builder(activity) 529 .setAdapter(mPhonesAdapter, this) 530 .setTitle( 531 mInteractionType == ContactDisplayUtils.INTERACTION_SMS 532 ? R.string.sms_disambig_title 533 : R.string.call_disambig_title) 534 .setView(setPrimaryView) 535 .create(); 536 } 537 538 @Override onClick(DialogInterface dialog, int which)539 public void onClick(DialogInterface dialog, int which) { 540 final Activity activity = getActivity(); 541 if (activity == null) { 542 return; 543 } 544 final AlertDialog alertDialog = (AlertDialog) dialog; 545 if (mPhoneList.size() > which && which >= 0) { 546 final PhoneItem phoneItem = mPhoneList.get(which); 547 final CheckBox checkBox = (CheckBox) alertDialog.findViewById(R.id.setPrimary); 548 if (checkBox.isChecked()) { 549 if (mCallSpecificAppData.getCallInitiationType() == CallInitiationType.Type.SPEED_DIAL) { 550 Logger.get(getContext()) 551 .logInteraction( 552 InteractionEvent.Type.SPEED_DIAL_SET_DEFAULT_NUMBER_FOR_AMBIGUOUS_CONTACT); 553 } 554 555 // Request to mark the data as primary in the background. 556 final Intent serviceIntent = 557 ContactUpdateService.createSetSuperPrimaryIntent(activity, phoneItem.id); 558 activity.startService(serviceIntent); 559 } 560 561 PhoneNumberInteraction.performAction( 562 activity, phoneItem.phoneNumber, mInteractionType, mIsVideoCall, mCallSpecificAppData); 563 } else { 564 dialog.dismiss(); 565 } 566 } 567 568 @Override onDismiss(DialogInterface dialogInterface)569 public void onDismiss(DialogInterface dialogInterface) { 570 super.onDismiss(dialogInterface); 571 Activity activity = getActivity(); 572 if (activity != null) { 573 ((DisambigDialogDismissedListener) activity).onDisambigDialogDismissed(); 574 } 575 } 576 } 577 } 578