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