Home | History | Annotate | Download | only in interactions
      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