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