blob: a38ba29fcb8e96212d232a29824eea1a69ee6aae [file] [log] [blame]
/*
* Copyright (C) 2010 The Android Open Source Project
* Copyright (C) 2023 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.dialer.interactions;
import android.Manifest.permission;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.provider.ContactsContract.CommonDataKinds.Phone;
import android.provider.ContactsContract.CommonDataKinds.SipAddress;
import android.provider.ContactsContract.Contacts;
import android.provider.ContactsContract.Data;
import android.provider.ContactsContract.RawContacts;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.CheckBox;
import android.widget.ListAdapter;
import android.widget.TextView;
import androidx.annotation.IntDef;
import androidx.core.app.ActivityCompat;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import com.android.contacts.common.Collapser;
import com.android.contacts.common.Collapser.Collapsible;
import com.android.contacts.common.MoreContactUtils;
import com.android.contacts.common.util.ContactDisplayUtils;
import com.android.dialer.R;
import com.android.dialer.callintent.CallIntentBuilder;
import com.android.dialer.callintent.CallIntentParser;
import com.android.dialer.callintent.CallSpecificAppData;
import com.android.dialer.common.Assert;
import com.android.dialer.common.LogUtil;
import com.android.dialer.precall.PreCall;
import com.android.dialer.util.DialerUtils;
import com.android.dialer.util.PermissionsUtil;
import com.android.dialer.util.TransactionSafeActivity;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* Initiates phone calls or a text message. If there are multiple candidates, this class shows a
* dialog to pick one. Creating one of these interactions should be done through the static factory
* methods.
*
* <p>Note that this class initiates not only usual *phone* calls but also *SIP* calls.
*
* <p>TODO: clean up code and documents since it is quite confusing to use "phone numbers" or "phone
* calls" here while they can be SIP addresses or SIP calls (See also issue 5039627).
*/
public class PhoneNumberInteraction implements Loader.OnLoadCompleteListener<Cursor> {
static final String TAG = PhoneNumberInteraction.class.getSimpleName();
/** The identifier for a permissions request if one is generated. */
public static final int REQUEST_READ_CONTACTS = 1;
public static final int REQUEST_CALL_PHONE = 2;
private static final String[] PHONE_NUMBER_PROJECTION =
new String[] {
Phone._ID,
Phone.NUMBER,
Phone.IS_SUPER_PRIMARY,
RawContacts.ACCOUNT_TYPE,
RawContacts.DATA_SET,
Phone.TYPE,
Phone.LABEL,
Phone.MIMETYPE,
Phone.CONTACT_ID,
};
private static final String PHONE_NUMBER_SELECTION =
Data.MIMETYPE
+ " IN ('"
+ Phone.CONTENT_ITEM_TYPE
+ "', "
+ "'"
+ SipAddress.CONTENT_ITEM_TYPE
+ "') AND "
+ Data.DATA1
+ " NOT NULL";
private static final int UNKNOWN_CONTACT_ID = -1;
private final Context context;
private final int interactionType;
private final CallSpecificAppData callSpecificAppData;
private long contactId = UNKNOWN_CONTACT_ID;
private CursorLoader loader;
private final boolean isVideoCall;
/** Error codes for interactions. */
@Retention(RetentionPolicy.SOURCE)
@IntDef(
value = {
InteractionErrorCode.CONTACT_NOT_FOUND,
InteractionErrorCode.CONTACT_HAS_NO_NUMBER,
InteractionErrorCode.USER_LEAVING_ACTIVITY,
InteractionErrorCode.OTHER_ERROR
}
)
public @interface InteractionErrorCode {
int CONTACT_NOT_FOUND = 1;
int CONTACT_HAS_NO_NUMBER = 2;
int OTHER_ERROR = 3;
int USER_LEAVING_ACTIVITY = 4;
}
/**
* Activities which use this class must implement this. They will be notified if there was an
* error performing the interaction. For example, this callback will be invoked on the activity if
* the contact URI provided points to a deleted contact, or to a contact without a phone number.
*/
public interface InteractionErrorListener {
void interactionError(@InteractionErrorCode int interactionErrorCode);
}
/**
* Activities which use this class must implement this. They will be notified if the phone number
* disambiguation dialog is dismissed.
*/
public interface DisambigDialogDismissedListener {
void onDisambigDialogDismissed();
}
private PhoneNumberInteraction(
Context context,
int interactionType,
boolean isVideoCall,
CallSpecificAppData callSpecificAppData) {
this.context = context;
this.interactionType = interactionType;
this.callSpecificAppData = callSpecificAppData;
this.isVideoCall = isVideoCall;
Assert.checkArgument(context instanceof InteractionErrorListener);
Assert.checkArgument(context instanceof DisambigDialogDismissedListener);
Assert.checkArgument(context instanceof ActivityCompat.OnRequestPermissionsResultCallback);
}
private static void performAction(
Context context,
String phoneNumber,
int interactionType,
boolean isVideoCall,
CallSpecificAppData callSpecificAppData) {
Intent intent;
switch (interactionType) {
case ContactDisplayUtils.INTERACTION_SMS:
intent = new Intent(Intent.ACTION_SENDTO, Uri.fromParts("sms", phoneNumber, null));
break;
default:
intent =
PreCall.getIntent(
context,
new CallIntentBuilder(phoneNumber, callSpecificAppData)
.setIsVideoCall(isVideoCall)
.setAllowAssistedDial(callSpecificAppData.getAllowAssistedDialing()));
break;
}
DialerUtils.startActivityWithErrorToast(context, intent);
}
/**
* @param activity that is calling this interaction. This must be of type {@link
* TransactionSafeActivity} because we need to check on the activity state after the phone
* numbers have been queried for. The activity must implement {@link InteractionErrorListener}
* and {@link DisambigDialogDismissedListener}.
* @param isVideoCall {@code true} if the call is a video call, {@code false} otherwise.
*/
public static void startInteractionForPhoneCall(
TransactionSafeActivity activity,
Uri uri,
boolean isVideoCall,
CallSpecificAppData callSpecificAppData) {
new PhoneNumberInteraction(
activity, ContactDisplayUtils.INTERACTION_CALL, isVideoCall, callSpecificAppData)
.startInteraction(uri);
}
private void performAction(String phoneNumber) {
PhoneNumberInteraction.performAction(
context, phoneNumber, interactionType, isVideoCall, callSpecificAppData);
}
/**
* Initiates the interaction to result in either a phone call or sms message for a contact.
*
* @param uri Contact Uri
*/
private void startInteraction(Uri uri) {
// It's possible for a shortcut to have been created, and then permissions revoked. To avoid a
// crash when the user tries to use such a shortcut, check for this condition and ask the user
// for the permission.
if (!PermissionsUtil.hasPhonePermissions(context)) {
LogUtil.i("PhoneNumberInteraction.startInteraction", "Need phone permission: CALL_PHONE");
ActivityCompat.requestPermissions(
(Activity) context, new String[] {permission.CALL_PHONE}, REQUEST_CALL_PHONE);
return;
}
String[] deniedContactsPermissions =
PermissionsUtil.getPermissionsCurrentlyDenied(
context, PermissionsUtil.allContactsGroupPermissionsUsedInDialer);
if (deniedContactsPermissions.length > 0) {
LogUtil.i(
"PhoneNumberInteraction.startInteraction",
"Need contact permissions: " + Arrays.toString(deniedContactsPermissions));
ActivityCompat.requestPermissions(
(Activity) context, deniedContactsPermissions, REQUEST_READ_CONTACTS);
return;
}
if (loader != null) {
loader.reset();
}
final Uri queryUri;
final String inputUriAsString = uri.toString();
if (inputUriAsString.startsWith(Contacts.CONTENT_URI.toString())) {
if (!inputUriAsString.endsWith(Contacts.Data.CONTENT_DIRECTORY)) {
queryUri = Uri.withAppendedPath(uri, Contacts.Data.CONTENT_DIRECTORY);
} else {
queryUri = uri;
}
} else if (inputUriAsString.startsWith(Data.CONTENT_URI.toString())) {
queryUri = uri;
} else {
throw new UnsupportedOperationException(
"Input Uri must be contact Uri or data Uri (input: \"" + uri + "\")");
}
loader =
new CursorLoader(
context, queryUri, PHONE_NUMBER_PROJECTION, PHONE_NUMBER_SELECTION, null, null);
loader.registerListener(0, this);
loader.startLoading();
}
@Override
public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) {
if (cursor == null) {
LogUtil.i("PhoneNumberInteraction.onLoadComplete", "null cursor");
interactionError(InteractionErrorCode.OTHER_ERROR);
return;
}
try {
ArrayList<PhoneItem> phoneList = new ArrayList<>();
String primaryPhone = null;
if (!isSafeToCommitTransactions()) {
LogUtil.i("PhoneNumberInteraction.onLoadComplete", "not safe to commit transaction");
interactionError(InteractionErrorCode.USER_LEAVING_ACTIVITY);
return;
}
if (cursor.moveToFirst()) {
int contactIdColumn = cursor.getColumnIndexOrThrow(Phone.CONTACT_ID);
int isSuperPrimaryColumn = cursor.getColumnIndexOrThrow(Phone.IS_SUPER_PRIMARY);
int phoneNumberColumn = cursor.getColumnIndexOrThrow(Phone.NUMBER);
int phoneIdColumn = cursor.getColumnIndexOrThrow(Phone._ID);
int accountTypeColumn = cursor.getColumnIndexOrThrow(RawContacts.ACCOUNT_TYPE);
int dataSetColumn = cursor.getColumnIndexOrThrow(RawContacts.DATA_SET);
int phoneTypeColumn = cursor.getColumnIndexOrThrow(Phone.TYPE);
int phoneLabelColumn = cursor.getColumnIndexOrThrow(Phone.LABEL);
int phoneMimeTpeColumn = cursor.getColumnIndexOrThrow(Phone.MIMETYPE);
do {
if (contactId == UNKNOWN_CONTACT_ID) {
contactId = cursor.getLong(contactIdColumn);
}
if (cursor.getInt(isSuperPrimaryColumn) != 0) {
// Found super primary, call it.
primaryPhone = cursor.getString(phoneNumberColumn);
}
PhoneItem item = new PhoneItem();
item.id = cursor.getLong(phoneIdColumn);
item.phoneNumber = cursor.getString(phoneNumberColumn);
item.accountType = cursor.getString(accountTypeColumn);
item.dataSet = cursor.getString(dataSetColumn);
item.type = cursor.getInt(phoneTypeColumn);
item.label = cursor.getString(phoneLabelColumn);
item.mimeType = cursor.getString(phoneMimeTpeColumn);
phoneList.add(item);
} while (cursor.moveToNext());
} else {
interactionError(InteractionErrorCode.CONTACT_NOT_FOUND);
return;
}
if (primaryPhone != null) {
performAction(primaryPhone);
return;
}
Collapser.collapseList(phoneList, context);
if (phoneList.size() == 0) {
interactionError(InteractionErrorCode.CONTACT_HAS_NO_NUMBER);
} else if (phoneList.size() == 1) {
PhoneItem item = phoneList.get(0);
performAction(item.phoneNumber);
} else {
// There are multiple candidates. Let the user choose one.
showDisambiguationDialog(phoneList);
}
} finally {
cursor.close();
}
}
private void interactionError(@InteractionErrorCode int interactionErrorCode) {
// mContext is really the activity -- see ctor docs.
((InteractionErrorListener) context).interactionError(interactionErrorCode);
}
private boolean isSafeToCommitTransactions() {
return !(context instanceof TransactionSafeActivity)
|| ((TransactionSafeActivity) context).isSafeToCommitTransactions();
}
private void showDisambiguationDialog(ArrayList<PhoneItem> phoneList) {
// TODO(a bug): don't leak the activity
final FragmentActivity activity = (FragmentActivity) context;
if (activity.isFinishing()) {
LogUtil.i("PhoneNumberInteraction.showDisambiguationDialog", "activity finishing");
return;
}
if (activity.isDestroyed()) {
// Check whether the activity is still running
LogUtil.i("PhoneNumberInteraction.showDisambiguationDialog", "activity destroyed");
return;
}
try {
PhoneDisambiguationDialogFragment.show(
activity.getSupportFragmentManager(),
phoneList,
interactionType,
isVideoCall,
callSpecificAppData);
} catch (IllegalStateException e) {
// ignore to be safe. Shouldn't happen because we checked the
// activity wasn't destroyed, but to be safe.
LogUtil.e("PhoneNumberInteraction.showDisambiguationDialog", "caught exception", e);
}
}
/** A model object for capturing a phone number for a given contact. */
private static class PhoneItem implements Parcelable, Collapsible<PhoneItem> {
public static final Parcelable.Creator<PhoneItem> CREATOR =
new Parcelable.Creator<PhoneItem>() {
@Override
public PhoneItem createFromParcel(Parcel in) {
return new PhoneItem(in);
}
@Override
public PhoneItem[] newArray(int size) {
return new PhoneItem[size];
}
};
long id;
String phoneNumber;
String accountType;
String dataSet;
long type;
String label;
/** {@link Phone#CONTENT_ITEM_TYPE} or {@link SipAddress#CONTENT_ITEM_TYPE}. */
String mimeType;
private PhoneItem() {}
private PhoneItem(Parcel in) {
this.id = in.readLong();
this.phoneNumber = in.readString();
this.accountType = in.readString();
this.dataSet = in.readString();
this.type = in.readLong();
this.label = in.readString();
this.mimeType = in.readString();
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeLong(id);
dest.writeString(phoneNumber);
dest.writeString(accountType);
dest.writeString(dataSet);
dest.writeLong(type);
dest.writeString(label);
dest.writeString(mimeType);
}
@Override
public int describeContents() {
return 0;
}
@Override
public void collapseWith(PhoneItem phoneItem) {
// Just keep the number and id we already have.
}
@Override
public boolean shouldCollapseWith(PhoneItem phoneItem, Context context) {
return MoreContactUtils.shouldCollapse(
Phone.CONTENT_ITEM_TYPE, phoneNumber, Phone.CONTENT_ITEM_TYPE, phoneItem.phoneNumber);
}
@Override
public String toString() {
return phoneNumber;
}
}
/** A list adapter that populates the list of contact's phone numbers. */
private static class PhoneItemAdapter extends ArrayAdapter<PhoneItem> {
private final int interactionType;
PhoneItemAdapter(Context context, List<PhoneItem> list, int interactionType) {
super(context, R.layout.phone_disambig_item, android.R.id.text2, list);
this.interactionType = interactionType;
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
final View view = super.getView(position, convertView, parent);
final PhoneItem item = getItem(position);
Assert.isNotNull(item, "Null item at position: %d", position);
final TextView typeView = (TextView) view.findViewById(android.R.id.text1);
CharSequence value =
ContactDisplayUtils.getLabelForCallOrSms(
(int) item.type, item.label, interactionType, getContext());
typeView.setText(value);
return view;
}
}
/**
* {@link DialogFragment} used for displaying a dialog with a list of phone numbers of which one
* will be chosen to make a call or initiate an sms message.
*
* <p>It is recommended to use {@link #startInteractionForPhoneCall(TransactionSafeActivity, Uri,
* boolean, CallSpecificAppData)} instead of directly using this class, as those methods handle
* one or multiple data cases appropriately.
*
* <p>This fragment may only be attached to activities which implement {@link
* DisambigDialogDismissedListener}.
*/
@SuppressWarnings("WeakerAccess") // Made public to let the system reach this class
public static class PhoneDisambiguationDialogFragment extends DialogFragment
implements DialogInterface.OnClickListener, DialogInterface.OnDismissListener {
private static final String ARG_PHONE_LIST = "phoneList";
private static final String ARG_INTERACTION_TYPE = "interactionType";
private static final String ARG_IS_VIDEO_CALL = "is_video_call";
private int interactionType;
private ListAdapter phonesAdapter;
private List<PhoneItem> phoneList;
private CallSpecificAppData callSpecificAppData;
private boolean isVideoCall;
public PhoneDisambiguationDialogFragment() {
super();
}
public static void show(
FragmentManager fragmentManager,
ArrayList<PhoneItem> phoneList,
int interactionType,
boolean isVideoCall,
CallSpecificAppData callSpecificAppData) {
PhoneDisambiguationDialogFragment fragment = new PhoneDisambiguationDialogFragment();
Bundle bundle = new Bundle();
bundle.putParcelableArrayList(ARG_PHONE_LIST, phoneList);
bundle.putInt(ARG_INTERACTION_TYPE, interactionType);
bundle.putBoolean(ARG_IS_VIDEO_CALL, isVideoCall);
CallIntentParser.putCallSpecificAppData(bundle, callSpecificAppData);
fragment.setArguments(bundle);
fragment.show(fragmentManager, TAG);
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
final Activity activity = getActivity();
Assert.checkState(activity instanceof DisambigDialogDismissedListener);
phoneList = getArguments().getParcelableArrayList(ARG_PHONE_LIST);
interactionType = getArguments().getInt(ARG_INTERACTION_TYPE);
isVideoCall = getArguments().getBoolean(ARG_IS_VIDEO_CALL);
callSpecificAppData = CallIntentParser.getCallSpecificAppData(getArguments());
phonesAdapter = new PhoneItemAdapter(activity, phoneList, interactionType);
final LayoutInflater inflater = activity.getLayoutInflater();
@SuppressLint("InflateParams") // Allowed since dialog view is not available yet
final View setPrimaryView = inflater.inflate(R.layout.set_primary_checkbox, null);
return new AlertDialog.Builder(activity)
.setAdapter(phonesAdapter, this)
.setTitle(
interactionType == ContactDisplayUtils.INTERACTION_SMS
? R.string.sms_disambig_title
: R.string.call_disambig_title)
.setView(setPrimaryView)
.create();
}
@Override
public void onClick(DialogInterface dialog, int which) {
final Activity activity = getActivity();
if (activity == null) {
return;
}
final AlertDialog alertDialog = (AlertDialog) dialog;
if (phoneList.size() > which && which >= 0) {
final PhoneItem phoneItem = phoneList.get(which);
final CheckBox checkBox = (CheckBox) alertDialog.findViewById(R.id.setPrimary);
if (checkBox.isChecked()) {
// Request to mark the data as primary in the background.
final Intent serviceIntent =
ContactUpdateService.createSetSuperPrimaryIntent(activity, phoneItem.id);
activity.startService(serviceIntent);
}
PhoneNumberInteraction.performAction(
activity, phoneItem.phoneNumber, interactionType, isVideoCall, callSpecificAppData);
} else {
dialog.dismiss();
}
}
@Override
public void onDismiss(DialogInterface dialogInterface) {
super.onDismiss(dialogInterface);
Activity activity = getActivity();
if (activity != null) {
((DisambigDialogDismissedListener) activity).onDisambigDialogDismissed();
}
}
}
}