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 interactionError(@nteractionErrorCode int interactionErrorCode)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 { onDisambigDialogDismissed()151 void onDisambigDialogDismissed(); 152 } 153 PhoneNumberInteraction( Context context, int interactionType, boolean isVideoCall, CallSpecificAppData callSpecificAppData)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 performAction( Context context, String phoneNumber, int interactionType, boolean isVideoCall, CallSpecificAppData callSpecificAppData)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 */ startInteractionForPhoneCall( TransactionSafeActivity activity, Uri uri, boolean isVideoCall, CallSpecificAppData callSpecificAppData)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 performAction(String phoneNumber)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 */ startInteraction(Uri uri)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 onLoadComplete(Loader<Cursor> loader, Cursor cursor)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 interactionError(@nteractionErrorCode int interactionErrorCode)334 private void interactionError(@InteractionErrorCode int interactionErrorCode) { 335 // mContext is really the activity -- see ctor docs. 336 ((InteractionErrorListener) mContext).interactionError(interactionErrorCode); 337 } 338 isSafeToCommitTransactions()339 private boolean isSafeToCommitTransactions() { 340 return !(mContext instanceof TransactionSafeActivity) 341 || ((TransactionSafeActivity) mContext).isSafeToCommitTransactions(); 342 } 343 344 @VisibleForTesting getLoader()345 /* package */ CursorLoader getLoader() { 346 return mLoader; 347 } 348 showDisambiguationDialog(ArrayList<PhoneItem> phoneList)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 PhoneItem()395 private PhoneItem() {} 396 PhoneItem(Parcel in)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 writeToParcel(Parcel dest, int flags)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 describeContents()419 public int describeContents() { 420 return 0; 421 } 422 423 @Override collapseWith(PhoneItem phoneItem)424 public void collapseWith(PhoneItem phoneItem) { 425 // Just keep the number and id we already have. 426 } 427 428 @Override shouldCollapseWith(PhoneItem phoneItem, Context context)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 toString()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 PhoneItemAdapter(Context context, List<PhoneItem> list, int interactionType)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 getView(int position, View convertView, ViewGroup parent)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 PhoneDisambiguationDialogFragment()491 public PhoneDisambiguationDialogFragment() { 492 super(); 493 } 494 show( FragmentManager fragmentManager, ArrayList<PhoneItem> phoneList, int interactionType, boolean isVideoCall, CallSpecificAppData callSpecificAppData)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 onCreateDialog(Bundle savedInstanceState)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 onClick(DialogInterface dialog, int which)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 onDismiss(DialogInterface dialogInterface)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