diff options
22 files changed, 1925 insertions, 25 deletions
diff --git a/proguard.flags b/proguard.flags index 80ffe0ac3..e50e64095 100644 --- a/proguard.flags +++ b/proguard.flags @@ -18,6 +18,8 @@ -keep class com.android.contacts.common.ContactPhotoManager { *; } -keep class com.android.contacts.common.ContactsUtils { *; } -keep class com.android.contacts.common.database.NoNullCursorAsyncQueryHandler { *; } +-keep class com.android.contacts.common.database.SimContactDao { *; } +-keep class com.android.contacts.common.database.SimContactDao$* { *; } -keep class com.android.contacts.common.format.FormatUtils { *; } -keep class com.android.contacts.common.format.TextHighlighter { *; } -keep class com.android.contacts.common.list.ContactListItemView { *; } diff --git a/res/anim/slide_and_fade_out.xml b/res/anim/slide_and_fade_out.xml new file mode 100644 index 000000000..7bb65d7dc --- /dev/null +++ b/res/anim/slide_and_fade_out.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2016 The Android Open Source 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. +--> +<set xmlns:android="http://schemas.android.com/apk/res/android" + android:interpolator="@android:interpolator/linear_out_slow_in"> + <alpha + android:duration="@integer/lists_on_load_animation_duration" + android:fromAlpha="1.0" + android:toAlpha="0"/> + <translate + android:duration="@integer/lists_on_load_animation_duration" + android:fromYDelta="0%" + android:toYDelta="5%"/> +</set> diff --git a/res/layout/fragment_sim_import.xml b/res/layout/fragment_sim_import.xml new file mode 100644 index 000000000..9e2f49376 --- /dev/null +++ b/res/layout/fragment_sim_import.xml @@ -0,0 +1,80 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2016 The Android Open Source 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. +--> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <android.support.v7.widget.Toolbar + android:id="@+id/toolbar" + android:layout_width="match_parent" + android:layout_height="?attr/actionBarSize" + android:background="?attr/colorPrimary" + android:elevation="4dp" + android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar" + app:navigationIcon="@drawable/ic_close_dk" + app:title="@string/sim_import_title_none_selected"> + + <Button + android:id="@+id/import_button" + style="@style/Widget.AppCompat.Button.Borderless" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="right|center_vertical" + android:text="@string/sim_import_button_text" + /> + </android.support.v7.widget.Toolbar> + + <FrameLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="16dp" + android:background="?android:colorBackground" + android:elevation="4dp"> + + <include layout="@layout/editor_account_header"/> + </FrameLayout> + + <FrameLayout + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <ListView + android:id="@+id/list" + android:layout_width="match_parent" + android:layout_height="match_parent"/> + + <android.support.v4.widget.ContentLoadingProgressBar + android:id="@+id/loading_progress" + style="@style/Widget.AppCompat.ProgressBar" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:indeterminate="true"/> + + <TextView + android:id="@+id/empty_message" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:text="@string/sim_import_empty_message" + android:textAppearance="?android:textAppearanceMedium" + android:visibility="gone"/> + + </FrameLayout> + +</LinearLayout>
\ No newline at end of file diff --git a/res/values/strings.xml b/res/values/strings.xml index 454777e02..5ec6f1c2b 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -1787,4 +1787,26 @@ <!-- Toast shown when a dynamic shortcut is tapped after being disabled because the contact was removed --> <string name="dynamic_shortcut_contact_removed_message">Contact was removed</string> + <!-- Text for button shown in toolbar to start import of SIM contacts --> + <string name="sim_import_button_text">Import</string> + + <!-- Toolbar title shown when importing SIM contacts and none are selected --> + <string name="sim_import_title_none_selected">Select contacts</string> + + <!-- Toolbar title shown when importing SIM contacts and some are selected --> + <string name="sim_import_title_some_selected_fmt"><xliff:g id="count">%d</xliff:g> Selected</string> + + <!-- Message shown when the SIM import screen is displayed but there are no contacts on the + SIM card --> + <string name="sim_import_empty_message">No contacts on your SIM card</string> + + <!-- Toast shown on settings screen when importing from SIM completes successfully --> + <plurals name="sim_import_success_toast_fmt"> + <item quantity="one">1 SIM contact imported</item> + <item quantity="other"><xliff:g id="count">%d</xliff:g> SIM contacts imported</item> + </plurals> + + <!-- Toast shown on settings screen when importing from SIM completes with an error --> + <string name="sim_import_failed_toast">Failed to import SIM contacts</string> + </resources> diff --git a/res/values/styles.xml b/res/values/styles.xml index d51392abf..fc52bc57a 100644 --- a/res/values/styles.xml +++ b/res/values/styles.xml @@ -253,7 +253,7 @@ <style name="ContactPickerTheme" parent="PeopleActivityTheme" > </style> - <style name="ContactsPreferencesTheme" parent="@style/PeopleTheme"> + <style name="ContactsPreferencesTheme" parent="@style/PeopleThemeAppCompat"> <item name="android:listViewStyle">@style/ListViewStyle</item> </style> @@ -508,4 +508,18 @@ background and text color. See also android:style/Widget.Holo.TextView.ListSepar <item name="android:windowIsFloating">true</item> <item name="android:backgroundDimEnabled">false</item> </style> + + <style name="FullScreenDialogAnimationStyle"> + <item name="android:windowEnterAnimation">@anim/slide_and_fade_in</item> + <item name="android:windowExitAnimation">@anim/slide_and_fade_out</item> + </style> + + <style name="PeopleThemeAppCompat.FullScreenDialog"> + <item name="android:windowNoTitle">true</item> + <item name="android:windowActionBar">false</item> + <item name="windowActionBar">false</item> + <item name="windowNoTitle">true</item> + <item name="android:listSelector">?android:attr/listChoiceBackgroundIndicator</item> + <item name="android:windowAnimationStyle">@style/FullScreenDialogAnimationStyle</item> + </style> </resources> diff --git a/src/com/android/contacts/ContactSaveService.java b/src/com/android/contacts/ContactSaveService.java index d4da58821..1ed36b54f 100755 --- a/src/com/android/contacts/ContactSaveService.java +++ b/src/com/android/contacts/ContactSaveService.java @@ -16,7 +16,6 @@ package com.android.contacts; -import static android.Manifest.permission.WRITE_CONTACTS; import android.app.Activity; import android.app.IntentService; import android.content.ContentProviderOperation; @@ -54,16 +53,17 @@ import android.widget.Toast; import com.android.contacts.activities.ContactEditorActivity; import com.android.contacts.common.compat.CompatUtils; import com.android.contacts.common.database.ContactUpdateUtils; +import com.android.contacts.common.database.SimContactDao; import com.android.contacts.common.model.AccountTypeManager; import com.android.contacts.common.model.CPOWrapper; import com.android.contacts.common.model.RawContactDelta; import com.android.contacts.common.model.RawContactDeltaList; import com.android.contacts.common.model.RawContactModifier; +import com.android.contacts.common.model.SimContact; import com.android.contacts.common.model.account.AccountWithDataSet; import com.android.contacts.common.util.PermissionsUtil; import com.android.contacts.compat.PinnedPositionsCompat; import com.android.contacts.util.ContactPhotoUtils; - import com.google.common.collect.Lists; import com.google.common.collect.Sets; @@ -72,6 +72,8 @@ import java.util.HashSet; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; +import static android.Manifest.permission.WRITE_CONTACTS; + /** * A service responsible for saving changes to the content provider. */ @@ -86,6 +88,7 @@ public class ContactSaveService extends IntentService { public static final String EXTRA_ACCOUNT_NAME = "accountName"; public static final String EXTRA_ACCOUNT_TYPE = "accountType"; public static final String EXTRA_DATA_SET = "dataSet"; + public static final String EXTRA_ACCOUNT = "account"; public static final String EXTRA_CONTENT_VALUES = "contentValues"; public static final String EXTRA_CALLBACK_INTENT = "callbackIntent"; public static final String EXTRA_RESULT_RECEIVER = "resultReceiver"; @@ -136,12 +139,23 @@ public class ContactSaveService extends IntentService { public static final String EXTRA_UNDO_ACTION = "undoAction"; public static final String EXTRA_UNDO_DATA = "undoData"; - public static final String BROADCAST_ACTION_GROUP_DELETED = "groupDeleted"; + public static final String ACTION_IMPORT_FROM_SIM = "importFromSim"; + public static final String EXTRA_SIM_CONTACTS = "simContacts"; + + public static final String BROADCAST_GROUP_DELETED = "groupDeleted"; + public static final String BROADCAST_SIM_IMPORT_COMPLETE = "simImportComplete"; + + public static final String EXTRA_RESULT_CODE = "resultCode"; + public static final String EXTRA_RESULT_COUNT = "count"; + public static final String EXTRA_OPERATION_REQUESTED_AT_TIME = "requestedTime"; public static final int CP2_ERROR = 0; public static final int CONTACTS_LINKED = 1; public static final int CONTACTS_SPLIT = 2; public static final int BAD_ARGUMENTS = 3; + public static final int RESULT_UNKNOWN = 0; + public static final int RESULT_SUCCESS = 1; + public static final int RESULT_FAILURE = 2; private static final HashSet<String> ALLOWED_DATA_COLUMNS = Sets.newHashSet( Data.MIMETYPE, @@ -176,6 +190,7 @@ public class ContactSaveService extends IntentService { private Handler mMainHandler; private GroupsDao mGroupsDao; + private SimContactDao mSimContactDao; public ContactSaveService() { super(TAG); @@ -187,6 +202,7 @@ public class ContactSaveService extends IntentService { public void onCreate() { super.onCreate(); mGroupsDao = new GroupsDaoImpl(this); + mSimContactDao = new SimContactDao(this); } public static void registerListener(Listener listener) { @@ -305,6 +321,8 @@ public class ContactSaveService extends IntentService { setRingtone(intent); } else if (ACTION_UNDO.equals(action)) { undo(intent); + } else if (ACTION_IMPORT_FROM_SIM.equals(action)) { + importFromSim(intent); } } @@ -812,7 +830,7 @@ public class ContactSaveService extends IntentService { } final Uri groupUri = ContentUris.withAppendedId(Groups.CONTENT_URI, groupId); - final Intent callbackIntent = new Intent(BROADCAST_ACTION_GROUP_DELETED); + final Intent callbackIntent = new Intent(BROADCAST_GROUP_DELETED); final Bundle undoData = mGroupsDao.captureDeletionUndoData(groupUri); callbackIntent.putExtra(EXTRA_UNDO_ACTION, ACTION_DELETE_GROUP); callbackIntent.putExtra(EXTRA_UNDO_DATA, undoData); @@ -1628,6 +1646,36 @@ public class ContactSaveService extends IntentService { operations.add(builder.build()); } + public static Intent createImportFromSimIntent(Context context, + ArrayList<SimContact> contacts, AccountWithDataSet targetAccount) { + return new Intent(context, ContactSaveService.class) + .setAction(ACTION_IMPORT_FROM_SIM) + .putExtra(EXTRA_SIM_CONTACTS, contacts) + .putExtra(EXTRA_ACCOUNT, targetAccount); + } + + private void importFromSim(Intent intent) { + final Intent result = new Intent(BROADCAST_SIM_IMPORT_COMPLETE) + .putExtra(EXTRA_OPERATION_REQUESTED_AT_TIME, System.currentTimeMillis()); + try { + final AccountWithDataSet targetAccount = intent.getParcelableExtra(EXTRA_ACCOUNT); + final ArrayList<SimContact> contacts = + intent.getParcelableArrayListExtra(EXTRA_SIM_CONTACTS); + mSimContactDao.importContacts(contacts, targetAccount); + // notify success + LocalBroadcastManager.getInstance(this).sendBroadcast(result + .putExtra(EXTRA_RESULT_COUNT, contacts.size()) + .putExtra(EXTRA_RESULT_CODE, RESULT_SUCCESS)); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "importFromSim completed successfully"); + } + } catch (RemoteException|OperationApplicationException e) { + Log.e(TAG, "Failed to import contacts from SIM card", e); + LocalBroadcastManager.getInstance(this).sendBroadcast(result + .putExtra(EXTRA_RESULT_CODE, RESULT_FAILURE)); + } + } + /** * Shows a toast on the UI thread. */ diff --git a/src/com/android/contacts/SimImportFragment.java b/src/com/android/contacts/SimImportFragment.java new file mode 100644 index 000000000..a9dcf9774 --- /dev/null +++ b/src/com/android/contacts/SimImportFragment.java @@ -0,0 +1,323 @@ +/* + * Copyright (C) 2016 The Android Open Source 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.contacts; + +import android.app.DialogFragment; +import android.app.LoaderManager; +import android.content.AsyncTaskLoader; +import android.content.Context; +import android.content.CursorLoader; +import android.content.Loader; +import android.database.Cursor; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v4.widget.ContentLoadingProgressBar; +import android.support.v7.widget.Toolbar; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ListView; + +import com.android.contacts.common.ContactPhotoManager; +import com.android.contacts.common.compat.CompatUtils; +import com.android.contacts.common.database.SimContactDao; +import com.android.contacts.common.list.ContactListAdapter; +import com.android.contacts.common.list.ContactListItemView; +import com.android.contacts.common.list.MultiSelectEntryContactListAdapter; +import com.android.contacts.common.model.AccountTypeManager; +import com.android.contacts.common.model.SimContact; +import com.android.contacts.common.model.account.AccountWithDataSet; +import com.android.contacts.common.preference.ContactsPreferences; +import com.android.contacts.editor.AccountHeaderPresenter; + +import java.util.ArrayList; +import java.util.Set; +import java.util.TreeSet; + +/** + * Dialog that presents a list of contacts from a SIM card that can be imported into a selected + * account + */ +public class SimImportFragment extends DialogFragment + implements LoaderManager.LoaderCallbacks<ArrayList<SimContact>>, + MultiSelectEntryContactListAdapter.SelectedContactsListener { + + private static final String KEY_ACCOUNT = "account"; + private static final String ARG_SUBSCRIPTION_ID = "subscriptionId"; + public static final int NO_SUBSCRIPTION_ID = -1; + + private ContactsPreferences mPreferences; + private AccountTypeManager mAccountTypeManager; + private SimContactAdapter mAdapter; + private AccountHeaderPresenter mAccountHeaderPresenter; + private ContentLoadingProgressBar mLoadingIndicator; + private Toolbar mToolbar; + private ListView mListView; + private View mImportButton; + + private int mSubscriptionId; + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setStyle(STYLE_NO_TITLE, R.style.PeopleThemeAppCompat_FullScreenDialog); + mPreferences = new ContactsPreferences(getContext()); + mAccountTypeManager = AccountTypeManager.getInstance(getActivity()); + mAdapter = new SimContactAdapter(getActivity()); + + mAdapter.setPhotoLoader(ContactPhotoManager.getInstance(getActivity())); + mAdapter.setDisplayCheckBoxes(true); + mAdapter.setHasHeader(0, false); + + final Bundle args = getArguments(); + mSubscriptionId = args == null ? NO_SUBSCRIPTION_ID : args.getInt(ARG_SUBSCRIPTION_ID, + NO_SUBSCRIPTION_ID); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + getLoaderManager().initLoader(0, null, this); + } + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + final View view = inflater.inflate(R.layout.fragment_sim_import, container, false); + + mAccountHeaderPresenter = new AccountHeaderPresenter( + view.findViewById(R.id.account_header_container)); + if (savedInstanceState != null) { + AccountWithDataSet account = savedInstanceState.getParcelable(KEY_ACCOUNT); + mAccountHeaderPresenter.setCurrentAccount(account); + } else { + final AccountWithDataSet currentDefaultAccount = AccountWithDataSet + .getDefaultOrBestFallback(mPreferences, mAccountTypeManager); + mAccountHeaderPresenter.setCurrentAccount(currentDefaultAccount); + } + + mListView = (ListView) view.findViewById(R.id.list); + mListView.setAdapter(mAdapter); + mListView.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + mAdapter.toggleSelectionOfContactId(id); + } + }); + mImportButton = view.findViewById(R.id.import_button); + mImportButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + importCurrentSelections(); + // Do we wait for import to finish? + dismiss(); + } + }); + mImportButton.setEnabled(mAdapter.getSelectedContactIds().size() > 0); + + mToolbar = (Toolbar) view.findViewById(R.id.toolbar); + mToolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + dismiss(); + } + }); + + mLoadingIndicator = (ContentLoadingProgressBar) view.findViewById(R.id.loading_progress); + mAdapter.setSelectedContactsListener(this); + + return view; + } + + @Override + public void onStart() { + super.onStart(); + if (mAdapter.isEmpty() && getLoaderManager().getLoader(0).isStarted()) { + mLoadingIndicator.show(); + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putParcelable(KEY_ACCOUNT, mAccountHeaderPresenter.getCurrentAccount()); + } + + @Override + public SimContactLoader onCreateLoader(int id, Bundle args) { + return new SimContactLoader(getContext(), mSubscriptionId); + } + + @Override + public void onLoadFinished(Loader<ArrayList<SimContact>> loader, + ArrayList<SimContact> data) { + mListView.setEmptyView(getView().findViewById(R.id.empty_message)); + mAdapter.setContacts(data); + // we default to selecting all contacts. + mAdapter.selectAll(); + mLoadingIndicator.hide(); + } + + @Override + public void onLoaderReset(Loader<ArrayList<SimContact>> loader) { + } + + private void importCurrentSelections() { + ContactSaveService.startService(getContext(), ContactSaveService + .createImportFromSimIntent(getContext(), mAdapter.getSelectedContacts(), + mAccountHeaderPresenter.getCurrentAccount())); + } + + @Override + public void onSelectedContactsChanged() { + updateSelectedCount(); + } + + @Override + public void onSelectedContactsChangedViaCheckBox() { + updateSelectedCount(); + } + + private void updateSelectedCount() { + final int selectedCount = mAdapter.getSelectedContactIds().size(); + if (selectedCount == 0) { + mToolbar.setTitle(R.string.sim_import_title_none_selected); + } else { + mToolbar.setTitle(getString(R.string.sim_import_title_some_selected_fmt, + selectedCount)); + } + if (mImportButton != null) { + mImportButton.setEnabled(selectedCount > 0); + } + } + + public Context getContext() { + if (CompatUtils.isMarshmallowCompatible()) { + return super.getContext(); + } + return getActivity(); + } + + /** + * Creates a fragment that will display contacts stored on the default SIM card + */ + public static SimImportFragment newInstance() { + return new SimImportFragment(); + } + + /** + * Creates a fragment that will display the contacts stored on the SIM card that has the + * provided subscriptionId + */ + public static SimImportFragment newInstance(int subscriptionId) { + final SimImportFragment fragment = new SimImportFragment(); + final Bundle args = new Bundle(); + args.putInt(ARG_SUBSCRIPTION_ID, subscriptionId); + fragment.setArguments(args); + return fragment; + } + + private static class SimContactAdapter extends ContactListAdapter { + private ArrayList<SimContact> mContacts; + + public SimContactAdapter(Context context) { + super(context); + } + + @Override + public void configureLoader(CursorLoader loader, long directoryId) { + } + + @Override + protected void bindView(View itemView, int partition, Cursor cursor, int position) { + super.bindView(itemView, partition, cursor, position); + ContactListItemView contactView = (ContactListItemView) itemView; + bindNameAndViewId(contactView, cursor); + bindPhoto(contactView, partition, cursor); + } + + public void setContacts(ArrayList<SimContact> contacts) { + mContacts = contacts; + changeCursor(SimContact.convertToContactsCursor(mContacts, + ContactQuery.CONTACT_PROJECTION_PRIMARY)); + } + + public ArrayList<SimContact> getSelectedContacts() { + if (mContacts == null) return null; + + final Set<Long> selectedIds = getSelectedContactIds(); + final ArrayList<SimContact> selected = new ArrayList<>(); + for (SimContact contact : mContacts) { + if (selectedIds.contains(contact.getId())) { + selected.add(contact); + } + } + return selected; + } + + public void selectAll() { + if (mContacts == null) return; + + final TreeSet<Long> selected = new TreeSet<>(); + for (SimContact contact : mContacts) { + selected.add(contact.getId()); + } + setSelectedContactIds(selected); + } + } + + public static class SimContactLoader extends AsyncTaskLoader<ArrayList<SimContact>> { + private SimContactDao mDao; + private final int mSubscriptionId; + private ArrayList<SimContact> mData; + + public SimContactLoader(Context context, int subscriptionId) { + super(context); + mDao = new SimContactDao(context); + mSubscriptionId = subscriptionId; + } + + @Override + protected void onStartLoading() { + if (mData != null) { + deliverResult(mData); + } else { + forceLoad(); + } + } + + @Override + public void deliverResult(ArrayList<SimContact> data) { + mData = data; + super.deliverResult(data); + } + + @Override + public ArrayList<SimContact> loadInBackground() { + if (mSubscriptionId != NO_SUBSCRIPTION_ID) { + return mDao.loadSimContacts(mSubscriptionId); + } else { + return mDao.loadSimContacts(); + } + } + + @Override + protected void onReset() { + mData = null; + } + } +} diff --git a/src/com/android/contacts/activities/PeopleActivity.java b/src/com/android/contacts/activities/PeopleActivity.java index b2a0a07b1..935c66fb7 100644 --- a/src/com/android/contacts/activities/PeopleActivity.java +++ b/src/com/android/contacts/activities/PeopleActivity.java @@ -20,15 +20,13 @@ import android.accounts.Account; import android.app.Fragment; import android.app.FragmentManager; import android.app.FragmentTransaction; -import android.content.ContentResolver; import android.content.BroadcastReceiver; +import android.content.ContentResolver; import android.content.ContentUris; import android.content.Context; import android.content.Intent; -import android.content.SyncStatusObserver; import android.content.IntentFilter; -import android.graphics.Color; -import android.graphics.drawable.ColorDrawable; +import android.content.SyncStatusObserver; import android.net.Uri; import android.os.Bundle; import android.os.Handler; @@ -40,7 +38,6 @@ import android.support.v4.content.ContextCompat; import android.support.v4.content.LocalBroadcastManager; import android.support.v4.view.GravityCompat; import android.support.v4.widget.SwipeRefreshLayout; -import android.text.TextUtils; import android.util.Log; import android.view.KeyCharacterMap; import android.view.KeyEvent; @@ -65,8 +62,6 @@ import com.android.contacts.common.list.ProviderStatusWatcher.ProviderStatusList import com.android.contacts.common.logging.Logger; import com.android.contacts.common.logging.ScreenEvent.ScreenType; import com.android.contacts.common.model.AccountTypeManager; -import com.android.contacts.common.model.account.AccountDisplayInfo; -import com.android.contacts.common.model.account.AccountDisplayInfoFactory; import com.android.contacts.common.model.account.AccountWithDataSet; import com.android.contacts.common.util.AccountFilterUtil; import com.android.contacts.common.util.Constants; @@ -469,7 +464,7 @@ public class PeopleActivity extends ContactsDrawerActivity { mSaveServiceListener = new SaveServiceListener(); LocalBroadcastManager.getInstance(this).registerReceiver(mSaveServiceListener, - new IntentFilter(ContactSaveService.BROADCAST_ACTION_GROUP_DELETED)); + new IntentFilter(ContactSaveService.BROADCAST_GROUP_DELETED)); } @Override @@ -731,7 +726,7 @@ public class PeopleActivity extends ContactsDrawerActivity { @Override public void onReceive(Context context, Intent intent) { switch (intent.getAction()) { - case ContactSaveService.BROADCAST_ACTION_GROUP_DELETED: + case ContactSaveService.BROADCAST_GROUP_DELETED: onGroupDeleted(intent); break; } diff --git a/src/com/android/contacts/common/database/SimContactDao.java b/src/com/android/contacts/common/database/SimContactDao.java new file mode 100644 index 000000000..f9fb4e817 --- /dev/null +++ b/src/com/android/contacts/common/database/SimContactDao.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2016 The Android Open Source 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.contacts.common.database; + +import android.content.ContentProviderOperation; +import android.content.ContentProviderResult; +import android.content.ContentResolver; +import android.content.Context; +import android.content.OperationApplicationException; +import android.database.Cursor; +import android.net.Uri; +import android.os.RemoteException; +import android.provider.BaseColumns; +import android.provider.ContactsContract; +import android.support.annotation.VisibleForTesting; + +import com.android.contacts.common.model.SimContact; +import com.android.contacts.common.model.account.AccountWithDataSet; + +import java.util.ArrayList; +import java.util.List; + +/** + * Provides data access methods for loading contacts from a SIM card and and migrating these + * SIM contacts to a CP2 account. + */ +public class SimContactDao { + @VisibleForTesting + public static final Uri ICC_CONTENT_URI = Uri.parse("content://icc/adn"); + + public static String _ID = BaseColumns._ID; + public static String NAME = "name"; + public static String NUMBER = "number"; + public static String EMAILS = "emails"; + + private ContentResolver mResolver; + + public SimContactDao(Context context) { + this(context.getContentResolver()); + } + + private SimContactDao(ContentResolver resolver) { + mResolver = resolver; + } + + public ArrayList<SimContact> loadSimContacts(int subscriptionId) { + return loadFrom(ICC_CONTENT_URI.buildUpon() + .appendPath("subId") + .appendPath(String.valueOf(subscriptionId)) + .build()); + } + + public ArrayList<SimContact> loadSimContacts() { + return loadFrom(ICC_CONTENT_URI); + } + + private ArrayList<SimContact> loadFrom(Uri uri) { + final Cursor cursor = mResolver.query(uri, null, null, null, null); + + try { + return loadFromCursor(cursor); + } finally { + cursor.close(); + } + } + + private ArrayList<SimContact> loadFromCursor(Cursor cursor) { + final int colId = cursor.getColumnIndex(_ID); + final int colName = cursor.getColumnIndex(NAME); + final int colNumber = cursor.getColumnIndex(NUMBER); + final int colEmails = cursor.getColumnIndex(EMAILS); + + final ArrayList<SimContact> result = new ArrayList<>(); + + while (cursor.moveToNext()) { + final long id = cursor.getLong(colId); + final String name = cursor.getString(colName); + final String number = cursor.getString(colNumber); + final String emails = cursor.getString(colEmails); + + final SimContact contact = new SimContact(id, name, number, parseEmails(emails)); + result.add(contact); + } + return result; + } + + public ContentProviderResult[] importContacts(List<SimContact> contacts, + AccountWithDataSet targetAccount) + throws RemoteException, OperationApplicationException { + final ArrayList<ContentProviderOperation> ops = createImportOperations(contacts, targetAccount); + return mResolver.applyBatch(ContactsContract.AUTHORITY, ops); + } + + private ArrayList<ContentProviderOperation> createImportOperations(List<SimContact> contacts, + AccountWithDataSet targetAccount) { + final ArrayList<ContentProviderOperation> ops = new ArrayList<>(); + for (SimContact contact : contacts) { + contact.appendCreateContactOperations(ops, targetAccount); + } + return ops; + } + + private String[] parseEmails(String emails) { + return emails != null ? emails.split(",") : null; + } +} diff --git a/src/com/android/contacts/common/interactions/ImportDialogFragment.java b/src/com/android/contacts/common/interactions/ImportDialogFragment.java index 0c0ce73e5..991a9c2fd 100644 --- a/src/com/android/contacts/common/interactions/ImportDialogFragment.java +++ b/src/com/android/contacts/common/interactions/ImportDialogFragment.java @@ -37,6 +37,7 @@ import android.view.ViewGroup; import android.widget.ArrayAdapter; import android.widget.TextView; +import com.android.contacts.SimImportFragment; import com.android.contacts.common.R; import com.android.contacts.common.compat.CompatUtils; import com.android.contacts.common.compat.PhoneNumberUtilsCompat; @@ -151,7 +152,9 @@ public class ImportDialogFragment extends DialogFragment public void onClick(DialogInterface dialog, int which) { boolean dismissDialog; final int resId = adapter.getItem(which).mChoiceResourceId; - if (resId == R.string.import_from_sim || resId == R.string.import_from_vcf_file) { + if (resId == R.string.import_from_sim) { + dismissDialog = handleSimImportRequest(adapter.getItem(which).mSubscriptionId); + } else if (resId == R.string.import_from_vcf_file) { dismissDialog = handleImportRequest(resId, adapter.getItem(which).mSubscriptionId); } else { @@ -172,8 +175,13 @@ public class ImportDialogFragment extends DialogFragment .create(); } + private boolean handleSimImportRequest(int subscriptionId) { + SimImportFragment.newInstance(subscriptionId).show(getFragmentManager(), "SimImport"); + return true; + } + /** - * Handle "import from SIM" and "import from SD". + * Handle "import from SD". * * @return {@code true} if the dialog show be closed. {@code false} otherwise. */ diff --git a/src/com/android/contacts/common/list/ContactListAdapter.java b/src/com/android/contacts/common/list/ContactListAdapter.java index 677aa46fe..6294f4788 100644 --- a/src/com/android/contacts/common/list/ContactListAdapter.java +++ b/src/com/android/contacts/common/list/ContactListAdapter.java @@ -41,7 +41,7 @@ import java.util.Set; public abstract class ContactListAdapter extends MultiSelectEntryContactListAdapter { public static class ContactQuery { - private static final String[] CONTACT_PROJECTION_PRIMARY = new String[] { + public static final String[] CONTACT_PROJECTION_PRIMARY = new String[] { Contacts._ID, // 0 Contacts.DISPLAY_NAME_PRIMARY, // 1 Contacts.CONTACT_PRESENCE, // 2 diff --git a/src/com/android/contacts/common/model/SimContact.java b/src/com/android/contacts/common/model/SimContact.java new file mode 100644 index 000000000..a13ef67da --- /dev/null +++ b/src/com/android/contacts/common/model/SimContact.java @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2016 The Android Open Source 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.contacts.common.model; + +import android.content.ContentProviderOperation; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Email; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.StructuredName; + +import com.android.contacts.common.model.account.AccountWithDataSet; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +/** + * Holds data for contacts loaded from the SIM card. + */ +public class SimContact implements Parcelable { + private final long mId; + private final String mName; + private final String mPhone; + private final String[] mEmails; + + public SimContact(long id, String name, String phone, String[] emails) { + this.mId = id; + this.mName = name; + this.mPhone = phone; + this.mEmails = emails; + } + + public long getId() { + return mId; + } + + public String getName() { + return mName; + } + + public String getPhone() { + return mPhone; + } + + public String[] getEmails() { + return mEmails; + } + + public void appendCreateContactOperations(List<ContentProviderOperation> ops, + AccountWithDataSet targetAccount) { + // nothing to save. + if (mName == null && mPhone == null && mEmails == null) return; + + final int rawContactOpIndex = ops.size(); + ops.add(ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI) + .withYieldAllowed(true) + .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, targetAccount.name) + .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, targetAccount.type) + .withValue(ContactsContract.RawContacts.DATA_SET, targetAccount.dataSet) + .build()); + if (mName != null) { + ops.add(createInsertOp(rawContactOpIndex, StructuredName.CONTENT_ITEM_TYPE, + StructuredName.DISPLAY_NAME, mName)); + } + if (mPhone != null) { + ops.add(createInsertOp(rawContactOpIndex, Phone.CONTENT_ITEM_TYPE, + Phone.NUMBER, mPhone)); + } + if (mEmails != null) { + for (String email : mEmails) { + ops.add(createInsertOp(rawContactOpIndex, Email.CONTENT_ITEM_TYPE, + Email.ADDRESS, email)); + } + } + } + + private ContentProviderOperation createInsertOp(int rawContactOpIndex, String mimeType, + String column, String value) { + return ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI) + .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, rawContactOpIndex) + .withValue(ContactsContract.Data.MIMETYPE, mimeType) + .withValue(column, value) + .build(); + } + + public void appendAsContactRow(MatrixCursor cursor) { + cursor.newRow().add(ContactsContract.Contacts._ID, mId) + .add(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY, mName) + .add(ContactsContract.Contacts.LOOKUP_KEY, getLookupKey()); + } + + /** + * Generate a "fake" lookup key. This is needed because + * {@link com.android.contacts.common.ContactPhotoManager} will only generate a letter avatar + * if the contact has a lookup key. + */ + private String getLookupKey() { + if (mName != null) { + return "sim-n-" + Uri.encode(mName); + } else if (mPhone != null) { + return "sim-p-" + Uri.encode(mPhone); + } else { + return null; + } + } + + @Override + public String toString() { + return "SimContact{" + + "mId=" + mId + + ", mName='" + mName + '\'' + + ", mPhone='" + mPhone + '\'' + + ", mEmails=" + Arrays.toString(mEmails) + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + final SimContact that = (SimContact) o; + + if (mId != that.mId) return false; + if (mName != null ? !mName.equals(that.mName) : that.mName != null) return false; + if (mPhone != null ? !mPhone.equals(that.mPhone) : that.mPhone != null) return false; + return Arrays.equals(mEmails, that.mEmails); + } + + @Override + public int hashCode() { + int result = (int) (mId ^ (mId >>> 32)); + result = 31 * result + (mName != null ? mName.hashCode() : 0); + result = 31 * result + (mPhone != null ? mPhone.hashCode() : 0); + result = 31 * result + Arrays.hashCode(mEmails); + return result; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(mId); + dest.writeString(mName); + dest.writeString(mPhone); + dest.writeStringArray(mEmails); + } + + /** + * Convert a collection of SIM contacts to a Cursor matching a query from + * {@link android.provider.ContactsContract.Contacts#CONTENT_URI} with the provided projection. + * + * This allows a collection of SIM contacts to be displayed using the existing adapters for + * contacts. + */ + public static final MatrixCursor convertToContactsCursor(Collection<SimContact> contacts, + String[] projection) { + final MatrixCursor result = new MatrixCursor(projection); + for (SimContact contact : contacts) { + contact.appendAsContactRow(result); + } + return result; + } + + public static final Creator<SimContact> CREATOR = new Creator<SimContact>() { + @Override + public SimContact createFromParcel(Parcel source) { + long id = source.readLong(); + String name = source.readString(); + String phone = source.readString(); + String[] emails = source.createStringArray(); + return new SimContact(id, name, phone, emails); + } + + @Override + public SimContact[] newArray(int size) { + return new SimContact[size]; + } + }; +} diff --git a/src/com/android/contacts/common/model/account/AccountWithDataSet.java b/src/com/android/contacts/common/model/account/AccountWithDataSet.java index 187f71c0c..37f6652d1 100644 --- a/src/com/android/contacts/common/model/account/AccountWithDataSet.java +++ b/src/com/android/contacts/common/model/account/AccountWithDataSet.java @@ -20,13 +20,15 @@ import android.accounts.Account; import android.content.Context; import android.database.Cursor; import android.net.Uri; -import android.os.Parcelable; import android.os.Parcel; +import android.os.Parcelable; import android.provider.BaseColumns; import android.provider.ContactsContract; import android.provider.ContactsContract.RawContacts; import android.text.TextUtils; +import com.android.contacts.common.model.AccountTypeManager; +import com.android.contacts.common.preference.ContactsPreferences; import com.google.common.base.Objects; import com.google.common.collect.Lists; @@ -237,4 +239,25 @@ public class AccountWithDataSet implements Parcelable { return ret; } + + public static AccountWithDataSet getDefaultOrBestFallback(ContactsPreferences preferences, + AccountTypeManager accountTypeManager) { + if (preferences.isDefaultAccountSet()) { + return preferences.getDefaultAccount(); + } + List<AccountWithDataSet> accounts = accountTypeManager.getAccounts(/* writableOnly */ true); + + if (accounts.isEmpty()) { + return AccountWithDataSet.getNullAccount(); + } + + // Return the first google account + for (AccountWithDataSet account : accounts) { + if (GoogleAccountType.ACCOUNT_TYPE.equals(account) && account.dataSet == null) { + return account; + } + } + // Arbitrarily return the first writable account + return accounts.get(0); + } } diff --git a/src/com/android/contacts/common/preference/ContactsPreferenceActivity.java b/src/com/android/contacts/common/preference/ContactsPreferenceActivity.java index f544c7bdf..030575da1 100644 --- a/src/com/android/contacts/common/preference/ContactsPreferenceActivity.java +++ b/src/com/android/contacts/common/preference/ContactsPreferenceActivity.java @@ -16,14 +16,22 @@ package com.android.contacts.common.preference; -import android.app.ActionBar; +import android.content.res.Configuration; import android.database.Cursor; import android.os.Bundle; import android.preference.PreferenceActivity; import android.provider.ContactsContract.ProviderStatus; -import android.provider.ContactsContract.QuickContact; +import android.support.annotation.LayoutRes; +import android.support.annotation.NonNull; +import android.support.annotation.StringRes; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatDelegate; +import android.support.v7.widget.Toolbar; import android.text.TextUtils; +import android.view.MenuInflater; import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; import com.android.contacts.common.R; import com.android.contacts.common.list.ProviderStatusWatcher; @@ -44,13 +52,19 @@ public final class ContactsPreferenceActivity extends PreferenceActivity impleme private ProviderStatusWatcher mProviderStatusWatcher; + private AppCompatDelegate mCompatDelegate; + public static final String EXTRA_NEW_LOCAL_PROFILE = "newLocalProfile"; @Override protected void onCreate(Bundle savedInstanceState) { + mCompatDelegate = AppCompatDelegate.create(this, null); + super.onCreate(savedInstanceState); + mCompatDelegate.onCreate(savedInstanceState); + - final ActionBar actionBar = getActionBar(); + final ActionBar actionBar = mCompatDelegate.getSupportActionBar(); if (actionBar != null) { actionBar.setDisplayOptions(ActionBar.DISPLAY_HOME_AS_UP, ActionBar.DISPLAY_HOME_AS_UP); } @@ -80,6 +94,71 @@ public final class ContactsPreferenceActivity extends PreferenceActivity impleme } } + @Override + protected void onPostCreate(Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + mCompatDelegate.onPostCreate(savedInstanceState); + } + + public void setSupportActionBar(Toolbar toolbar) { + mCompatDelegate.setSupportActionBar(toolbar); + } + + @NonNull + @Override + public MenuInflater getMenuInflater() { + return mCompatDelegate.getMenuInflater(); + } + + @Override + public void setContentView(@LayoutRes int layoutRes) { + mCompatDelegate.setContentView(layoutRes); + } + + @Override + public void setContentView(View view) { + mCompatDelegate.setContentView(view); + } + + @Override + public void setContentView(View view, ViewGroup.LayoutParams params) { + mCompatDelegate.setContentView(view, params); + } + + @Override + public void addContentView(View view, ViewGroup.LayoutParams params) { + mCompatDelegate.addContentView(view, params); + } + + @Override + protected void onPostResume() { + super.onPostResume(); + mCompatDelegate.onPostResume(); + } + + @Override + protected void onTitleChanged(CharSequence title, int color) { + super.onTitleChanged(title, color); + mCompatDelegate.setTitle(title); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + mCompatDelegate.onConfigurationChanged(newConfig); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + mCompatDelegate.onDestroy(); + } + + @Override + public void invalidateOptionsMenu() { + mCompatDelegate.invalidateOptionsMenu(); + } + protected void showAboutFragment() { getFragmentManager().beginTransaction() .replace(android.R.id.content, AboutPreferenceFragment.newInstance(), TAG_ABOUT) @@ -107,8 +186,8 @@ public final class ContactsPreferenceActivity extends PreferenceActivity impleme } } - private void setActivityTitle(int res) { - final ActionBar actionBar = getActionBar(); + private void setActivityTitle(@StringRes int res) { + final ActionBar actionBar = mCompatDelegate.getSupportActionBar(); if (actionBar != null) { actionBar.setTitle(res); } diff --git a/src/com/android/contacts/common/preference/ContactsPreferences.java b/src/com/android/contacts/common/preference/ContactsPreferences.java index f4187ebd6..1ae2b9de0 100644 --- a/src/com/android/contacts/common/preference/ContactsPreferences.java +++ b/src/com/android/contacts/common/preference/ContactsPreferences.java @@ -206,6 +206,10 @@ public class ContactsPreferences implements OnSharedPreferenceChangeListener { mPreferences.edit().putString(mDefaultAccountKey, accountWithDataSet.stringify()).commit(); } + public boolean isDefaultAccountSet() { + return mDefaultAccount != null || mPreferences.contains(mDefaultAccountKey); + } + /** * @return false if there is only one writable account or no requirement to return true is met. * true if the contact editor should show the "accounts changed" notification, that is: diff --git a/src/com/android/contacts/common/preference/DisplayOptionsPreferenceFragment.java b/src/com/android/contacts/common/preference/DisplayOptionsPreferenceFragment.java index 89208a8f3..b3b28322b 100644 --- a/src/com/android/contacts/common/preference/DisplayOptionsPreferenceFragment.java +++ b/src/com/android/contacts/common/preference/DisplayOptionsPreferenceFragment.java @@ -18,10 +18,12 @@ package com.android.contacts.common.preference; import android.app.Activity; import android.app.LoaderManager; +import android.content.BroadcastReceiver; import android.content.ContentUris; import android.content.Context; import android.content.CursorLoader; import android.content.Intent; +import android.content.IntentFilter; import android.content.Loader; import android.content.res.Resources; import android.database.Cursor; @@ -32,15 +34,22 @@ import android.preference.PreferenceFragment; import android.provider.BlockedNumberContract; import android.provider.ContactsContract.Contacts; import android.provider.ContactsContract.Profile; +import android.support.design.widget.Snackbar; +import android.support.v4.content.LocalBroadcastManager; import android.telecom.TelecomManager; import android.telephony.TelephonyManager; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import com.android.contacts.ContactSaveService; +import com.android.contacts.R; import com.android.contacts.common.ContactsUtils; -import com.android.contacts.common.R; import com.android.contacts.common.compat.TelecomManagerUtil; import com.android.contacts.common.compat.TelephonyManagerCompat; -import com.android.contacts.common.interactions.ImportDialogFragment; import com.android.contacts.common.interactions.ExportDialogFragment; +import com.android.contacts.common.interactions.ImportDialogFragment; import com.android.contacts.common.list.ContactListFilter; import com.android.contacts.common.list.ContactListFilterController; import com.android.contacts.common.logging.ScreenEvent.ScreenType; @@ -122,6 +131,9 @@ public class DisplayOptionsPreferenceFragment extends PreferenceFragment private ProfileListener mListener; + private ViewGroup mRootView; + private SaveServiceResultListener mSaveServiceListener; + private final LoaderManager.LoaderCallbacks<Cursor> mProfileLoaderListener = new LoaderManager.LoaderCallbacks<Cursor>() { @@ -165,6 +177,25 @@ public class DisplayOptionsPreferenceFragment extends PreferenceFragment } @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + // Wrap the preference view in a FrameLayout so we can show a snackbar + mRootView = new FrameLayout(getActivity()); + final View list = super.onCreateView(inflater, mRootView, savedInstanceState); + mRootView.addView(list); + return mRootView; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + mSaveServiceListener = new SaveServiceResultListener(); + LocalBroadcastManager.getInstance(getActivity()).registerReceiver( + mSaveServiceListener, + new IntentFilter(ContactSaveService.BROADCAST_SIM_IMPORT_COMPLETE)); + } + + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -212,6 +243,13 @@ public class DisplayOptionsPreferenceFragment extends PreferenceFragment getLoaderManager().restartLoader(LOADER_PROFILE, null, mProfileLoaderListener); } + @Override + public void onDestroyView() { + super.onDestroyView(); + LocalBroadcastManager.getInstance(getActivity()).unregisterReceiver(mSaveServiceListener); + mRootView = null; + } + public void updateMyInfoPreference(boolean hasProfile, String displayName, long contactId) { final CharSequence summary = hasProfile ? displayName : getString(R.string.set_up_profile); mMyInfoPreference.setSummary(summary); @@ -361,5 +399,30 @@ public class DisplayOptionsPreferenceFragment extends PreferenceFragment } } } + + private class SaveServiceResultListener extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + final long now = System.currentTimeMillis(); + final long opStart = intent.getLongExtra( + ContactSaveService.EXTRA_OPERATION_REQUESTED_AT_TIME, now); + + // If it's been over 30 seconds the user is likely in a different context so suppress + // the toast message. + if (now - opStart > 30*1000) return; + + final int code = intent.getIntExtra(ContactSaveService.EXTRA_RESULT_CODE, + ContactSaveService.RESULT_UNKNOWN); + final int count = intent.getIntExtra(ContactSaveService.EXTRA_RESULT_COUNT, -1); + if (code == ContactSaveService.RESULT_SUCCESS && count > 0) { + Snackbar.make(mRootView, getResources().getQuantityString( + R.plurals.sim_import_success_toast_fmt, count, count), + Snackbar.LENGTH_LONG).show(); + } else if (code == ContactSaveService.RESULT_FAILURE) { + Snackbar.make(mRootView, R.string.sim_import_failed_toast, + Snackbar.LENGTH_LONG).show(); + } + } + } } diff --git a/src/com/android/contacts/editor/AccountHeaderPresenter.java b/src/com/android/contacts/editor/AccountHeaderPresenter.java new file mode 100644 index 000000000..de2b0143b --- /dev/null +++ b/src/com/android/contacts/editor/AccountHeaderPresenter.java @@ -0,0 +1,185 @@ +/* + * Copyright (C) 2016 The Android Open Source 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.contacts.editor; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.annotation.StringRes; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ImageView; +import android.widget.ListPopupWindow; +import android.widget.TextView; + +import com.android.contacts.R; +import com.android.contacts.common.model.AccountTypeManager; +import com.android.contacts.common.model.account.AccountDisplayInfo; +import com.android.contacts.common.model.account.AccountDisplayInfoFactory; +import com.android.contacts.common.model.account.AccountWithDataSet; +import com.android.contacts.common.util.AccountsListAdapter; +import com.android.contacts.util.UiClosables; + +import java.util.List; + +/** + * Controls the display of an account selector or header. + * + * TODO: This was mostly copied from {@link RawContactEditorView}. The code in that class + * should probably be modified to use this instead of leaving it duplicated. + */ +public class AccountHeaderPresenter { + + public interface Observer { + void onChange(AccountHeaderPresenter sender); + + public static final Observer NONE = new Observer() { + @Override + public void onChange(AccountHeaderPresenter sender) { + } + }; + } + + private final Context mContext; + private AccountDisplayInfoFactory mAccountDisplayInfoFactory; + + private AccountWithDataSet mCurrentAccount; + + // Account header + private final View mAccountHeaderContainer; + private TextView mAccountHeaderType; + private TextView mAccountHeaderName; + private ImageView mAccountHeaderIcon; + private ImageView mAccountHeaderExpanderIcon; + + // This would be different if the account was readonly + @StringRes + private int mSelectorTitle = R.string.editor_account_selector_title; + + private Observer mObserver = Observer.NONE; + + public AccountHeaderPresenter(View container) { + mContext = container.getContext(); + mAccountHeaderContainer = container; + mAccountHeaderType = (TextView) container.findViewById(R.id.account_type); + mAccountHeaderName = (TextView) container.findViewById(R.id.account_name); + mAccountHeaderIcon = (ImageView) container.findViewById(R.id.account_type_icon); + mAccountHeaderExpanderIcon = (ImageView) container.findViewById(R.id.account_expander_icon); + + mAccountDisplayInfoFactory = AccountDisplayInfoFactory.forWritableAccounts(mContext); + } + + public void setObserver(Observer observer) { + mObserver = observer; + } + + public void setCurrentAccount(@NonNull AccountWithDataSet account) { + if (mCurrentAccount != null && mCurrentAccount.equals(account)) { + return; + } + mCurrentAccount = account; + if (mObserver != null) { + mObserver.onChange(this); + } + updateDisplayedAccount(); + } + + public AccountWithDataSet getCurrentAccount() { + return mCurrentAccount; + } + + private void updateDisplayedAccount() { + mAccountHeaderContainer.setVisibility(View.GONE); + if (mCurrentAccount == null) return; + + final AccountDisplayInfo account = + mAccountDisplayInfoFactory.getAccountDisplayInfo(mCurrentAccount); + + final String accountLabel = getAccountLabel(account); + + // Either the account header or selector should be shown, not both. + final List<AccountWithDataSet> accounts = + AccountTypeManager.getInstance(mContext).getAccounts(true); + + if (accounts.size() > 1) { + addAccountSelector(accountLabel); + } else { + addAccountHeader(accountLabel); + } + } + + private void addAccountHeader(String accountLabel) { + mAccountHeaderContainer.setVisibility(View.VISIBLE); + + // Set the account name + mAccountHeaderName.setVisibility(View.VISIBLE); + mAccountHeaderName.setText(accountLabel); + + // Set the account type + final String selectorTitle = mContext.getResources().getString(mSelectorTitle); + mAccountHeaderType.setText(selectorTitle); + + // Set the icon + final AccountDisplayInfo displayInfo = mAccountDisplayInfoFactory + .getAccountDisplayInfo(mCurrentAccount); + mAccountHeaderIcon.setImageDrawable(displayInfo.getIcon()); + + // Set the content description + mAccountHeaderContainer.setContentDescription( + EditorUiUtils.getAccountInfoContentDescription(accountLabel, + selectorTitle)); + } + + private void addAccountSelector(CharSequence nameLabel) { + final View.OnClickListener onClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + final ListPopupWindow popup = new ListPopupWindow(mContext, null); + final AccountsListAdapter adapter = + new AccountsListAdapter(mContext, + AccountsListAdapter.AccountListFilter.ACCOUNTS_CONTACT_WRITABLE, + mCurrentAccount); + popup.setWidth(mAccountHeaderContainer.getWidth()); + popup.setAnchorView(mAccountHeaderContainer); + popup.setAdapter(adapter); + popup.setModal(true); + popup.setInputMethodMode(ListPopupWindow.INPUT_METHOD_NOT_NEEDED); + popup.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, + long id) { + UiClosables.closeQuietly(popup); + final AccountWithDataSet newAccount = adapter.getItem(position); + setCurrentAccount(newAccount); + } + }); + popup.show(); + } + }; + setUpAccountSelector(nameLabel.toString(), onClickListener); + } + + private void setUpAccountSelector(String nameLabel, View.OnClickListener listener) { + addAccountHeader(nameLabel); + // Add handlers for choosing another account to save to. + mAccountHeaderExpanderIcon.setVisibility(View.VISIBLE); + mAccountHeaderContainer.setOnClickListener(listener); + } + + private String getAccountLabel(AccountDisplayInfo account) { + // TODO: if used from editor this would need to be different if editing the user's profile. + return account.getNameLabel().toString(); + } +} diff --git a/tests/src/com/android/contacts/NoPermissionsLaunchSmokeTest.java b/tests/src/com/android/contacts/NoPermissionsLaunchSmokeTest.java index 8364b7b07..c9ea3b6aa 100644 --- a/tests/src/com/android/contacts/NoPermissionsLaunchSmokeTest.java +++ b/tests/src/com/android/contacts/NoPermissionsLaunchSmokeTest.java @@ -6,6 +6,7 @@ import android.content.Intent; import android.content.pm.PackageManager; import android.support.test.InstrumentationRegistry; import android.support.test.filters.MediumTest; +import android.support.test.filters.Suppress; import android.support.test.runner.AndroidJUnit4; import android.support.test.uiautomator.By; import android.support.test.uiautomator.UiDevice; @@ -36,6 +37,8 @@ import static org.junit.Assume.assumeTrue; * -e class com.android.contacts.NoPermissionsLaunchSmokeTest */ @MediumTest +// suppressed because failed assumptions are reported as test failures by the build server +@Suppress @RunWith(AndroidJUnit4.class) public class NoPermissionsLaunchSmokeTest { private static final long TIMEOUT = 5000; diff --git a/tests/src/com/android/contacts/common/database/SimContactDaoTests.java b/tests/src/com/android/contacts/common/database/SimContactDaoTests.java new file mode 100644 index 000000000..5b25eb2b5 --- /dev/null +++ b/tests/src/com/android/contacts/common/database/SimContactDaoTests.java @@ -0,0 +1,359 @@ +/* + * Copyright (C) 2016 The Android Open Source 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.contacts.common.database; + +import android.content.ContentProviderOperation; +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.database.CursorWrapper; +import android.database.DatabaseUtils; +import android.provider.ContactsContract; +import android.support.annotation.RequiresApi; +import android.support.test.InstrumentationRegistry; +import android.support.test.filters.LargeTest; +import android.support.test.filters.SdkSuppress; +import android.support.test.runner.AndroidJUnit4; + +import com.android.contacts.common.model.SimContact; +import com.android.contacts.common.model.account.AccountWithDataSet; +import com.android.contacts.tests.AccountsTestHelper; +import com.android.contacts.tests.SimContactsTestHelper; + +import org.hamcrest.BaseMatcher; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; + +import java.util.ArrayList; +import java.util.Arrays; + +import static android.os.Build.VERSION_CODES; +import static org.hamcrest.Matchers.allOf; +import static org.junit.Assert.assertThat; + +import android.support.test.filters.Suppress; + +@RunWith(Enclosed.class) +public class SimContactDaoTests { + + // Lollipop MR1 required for AccountManager.removeAccountExplicitly + @RequiresApi(api = VERSION_CODES.LOLLIPOP_MR1) + @SdkSuppress(minSdkVersion = VERSION_CODES.LOLLIPOP_MR1) + @LargeTest + @RunWith(AndroidJUnit4.class) + public static class ImportIntegrationTest { + private AccountWithDataSet mAccount; + private AccountsTestHelper mAccountsHelper; + private ContentResolver mResolver; + + @Before + public void setUp() throws Exception { + mAccountsHelper = new AccountsTestHelper(); + mAccount = mAccountsHelper.addTestAccount(); + mResolver = getContext().getContentResolver(); + } + + @After + public void tearDown() throws Exception { + mAccountsHelper.cleanup(); + } + + @Test + public void importFromSim() throws Exception { + final SimContactDao sut = new SimContactDao(getContext()); + + sut.importContacts(Arrays.asList( + new SimContact(1, "Test One", "15095550101", null), + new SimContact(2, "Test Two", "15095550102", null), + new SimContact(3, "Test Three", "15095550103", new String[] { + "user@example.com", "user2@example.com" + }) + ), mAccount); + + Cursor cursor = queryContactWithName("Test One"); + assertThat(cursor, hasCount(2)); + assertThat(cursor, hasName("Test One")); + assertThat(cursor, hasPhone("15095550101")); + cursor.close(); + + cursor = queryContactWithName("Test Two"); + assertThat(cursor, hasCount(2)); + assertThat(cursor, hasName("Test Two")); + assertThat(cursor, hasPhone("15095550102")); + cursor.close(); + + cursor = queryContactWithName("Test Three"); + assertThat(cursor, hasCount(4)); + assertThat(cursor, hasName("Test Three")); + assertThat(cursor, hasPhone("15095550103")); + assertThat(cursor, allOf(hasEmail("user@example.com"), hasEmail("user2@example.com"))); + cursor.close(); + } + + @Test + public void importContactWhichOnlyHasName() throws Exception { + final SimContactDao sut = new SimContactDao(getContext()); + + sut.importContacts(Arrays.asList( + new SimContact(1, "Test importJustName", null, null) + ), mAccount); + + Cursor cursor = queryAllDataInAccount(); + + assertThat(cursor, hasCount(1)); + assertThat(cursor, hasName("Test importJustName")); + cursor.close(); + } + + @Test + public void importContactWhichOnlyHasPhone() throws Exception { + final SimContactDao sut = new SimContactDao(getContext()); + + sut.importContacts(Arrays.asList( + new SimContact(1, null, "15095550111", null) + ), mAccount); + + Cursor cursor = queryAllDataInAccount(); + + assertThat(cursor, hasCount(1)); + assertThat(cursor, hasPhone("15095550111")); + cursor.close(); + } + + @Test + public void ignoresEmptyContacts() throws Exception { + final SimContactDao sut = new SimContactDao(getContext()); + + // This probably isn't possible but we'll test it to demonstrate expected behavior and + // just in case it does occur + sut.importContacts(Arrays.asList( + new SimContact(1, null, null, null), + new SimContact(2, null, null, null), + new SimContact(3, null, null, null), + new SimContact(4, "Not null", null, null) + ), mAccount); + + final Cursor contactsCursor = queryAllRawContactsInAccount(); + assertThat(contactsCursor, hasCount(1)); + contactsCursor.close(); + + final Cursor dataCursor = queryAllDataInAccount(); + assertThat(dataCursor, hasCount(1)); + + dataCursor.close(); + } + + private Cursor queryAllRawContactsInAccount() { + return new StringableCursor(mResolver.query(ContactsContract.RawContacts.CONTENT_URI, null, + ContactsContract.RawContacts.ACCOUNT_NAME + "=? AND " + + ContactsContract.RawContacts.ACCOUNT_TYPE+ "=?", + new String[] { + mAccount.name, + mAccount.type + }, null)); + } + + private Cursor queryAllDataInAccount() { + return new StringableCursor(mResolver.query(ContactsContract.Data.CONTENT_URI, null, + ContactsContract.RawContacts.ACCOUNT_NAME + "=? AND " + + ContactsContract.RawContacts.ACCOUNT_TYPE+ "=?", + new String[] { + mAccount.name, + mAccount.type + }, null)); + } + + private Cursor queryContactWithName(String name) { + return new StringableCursor(mResolver.query(ContactsContract.Data.CONTENT_URI, null, + ContactsContract.RawContacts.ACCOUNT_NAME + "=? AND " + + ContactsContract.RawContacts.ACCOUNT_TYPE+ "=? AND " + + ContactsContract.Data.DISPLAY_NAME + "=?", + new String[] { + mAccount.name, + mAccount.type, + name + }, null)); + } + } + + @LargeTest + // suppressed because failed assumptions are reported as test failures by the build server + @Suppress + @RunWith(AndroidJUnit4.class) + public static class ReadIntegrationTest { + private SimContactsTestHelper mSimTestHelper; + private ArrayList<ContentProviderOperation> mSimSnapshot; + + @Before + public void setUp() throws Exception { + mSimTestHelper = new SimContactsTestHelper(); + + mSimTestHelper.assumeSimWritable(); + if (!mSimTestHelper.isSimWritable()) return; + + mSimSnapshot = mSimTestHelper.captureRestoreSnapshot(); + mSimTestHelper.deleteAllSimContacts(); + } + + @After + public void tearDown() throws Exception { + mSimTestHelper.restore(mSimSnapshot); + } + + @Test + public void readFromSim() { + mSimTestHelper.addSimContact("Test Simone", "15095550101"); + mSimTestHelper.addSimContact("Test Simtwo", "15095550102"); + mSimTestHelper.addSimContact("Test Simthree", "15095550103"); + + final SimContactDao sut = new SimContactDao(getContext()); + final ArrayList<SimContact> contacts = sut.loadSimContacts(); + + assertThat(contacts.get(0), isSimContactWithNameAndPhone("Test Simone", "15095550101")); + assertThat(contacts.get(1), isSimContactWithNameAndPhone("Test Simtwo", "15095550102")); + assertThat(contacts.get(2), isSimContactWithNameAndPhone("Test Simthree", "15095550103")); + } + } + + private static Matcher<SimContact> isSimContactWithNameAndPhone(final String name, + final String phone) { + return new BaseMatcher<SimContact>() { + @Override + public boolean matches(Object o) { + if (!(o instanceof SimContact)) return false; + + SimContact other = (SimContact) o; + + return name.equals(other.getName()) + && phone.equals(other.getPhone()); + } + + @Override + public void describeTo(Description description) { + description.appendText("SimContact with name=" + name + " and phone=" + + phone); + } + }; + } + + private static Matcher<Cursor> hasCount(final int count) { + return new BaseMatcher<Cursor>() { + @Override + public boolean matches(Object o) { + if (!(o instanceof Cursor)) return false; + return ((Cursor)o).getCount() == count; + } + + @Override + public void describeTo(Description description) { + description.appendText("Cursor with " + count + " rows"); + } + }; + } + + private static Matcher<Cursor> hasMimeType(String type) { + return hasValueForColumn(ContactsContract.Data.MIMETYPE, type); + } + + private static Matcher<Cursor> hasValueForColumn(final String column, final String value) { + return new BaseMatcher<Cursor>() { + + @Override + public boolean matches(Object o) { + if (!(o instanceof Cursor)) return false; + final Cursor cursor = (Cursor)o; + + final int index = cursor.getColumnIndexOrThrow(column); + return value.equals(cursor.getString(index)); + } + + @Override + public void describeTo(Description description) { + description.appendText("Cursor with " + column + "=" + value); + } + }; + } + + private static Matcher<Cursor> hasRowMatching(final Matcher<Cursor> rowMatcher) { + return new BaseMatcher<Cursor>() { + @Override + public boolean matches(Object o) { + if (!(o instanceof Cursor)) return false; + final Cursor cursor = (Cursor)o; + + cursor.moveToPosition(-1); + while (cursor.moveToNext()) { + if (rowMatcher.matches(cursor)) return true; + } + + return false; + } + + @Override + public void describeTo(Description description) { + description.appendText("Cursor with row matching "); + rowMatcher.describeTo(description); + } + }; + } + + private static Matcher<Cursor> hasName(final String name) { + return hasRowMatching(allOf( + hasMimeType(ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE), + hasValueForColumn( + ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, name))); + } + + private static Matcher<Cursor> hasPhone(final String phone) { + return hasRowMatching(allOf( + hasMimeType(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE), + hasValueForColumn( + ContactsContract.CommonDataKinds.Phone.NUMBER, phone))); + } + + private static Matcher<Cursor> hasEmail(final String email) { + return hasRowMatching(allOf( + hasMimeType(ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE), + hasValueForColumn( + ContactsContract.CommonDataKinds.Email.ADDRESS, email))); + } + + static class StringableCursor extends CursorWrapper { + public StringableCursor(Cursor cursor) { + super(cursor); + } + + @Override + public String toString() { + final Cursor wrapped = getWrappedCursor(); + + if (wrapped.getCount() == 0) { + return "Empty Cursor"; + } + + return DatabaseUtils.dumpCursorToString(wrapped); + } + } + + static Context getContext() { + return InstrumentationRegistry.getTargetContext(); + } +} diff --git a/tests/src/com/android/contacts/common/model/SimContactTests.java b/tests/src/com/android/contacts/common/model/SimContactTests.java new file mode 100644 index 000000000..de9ab5a1f --- /dev/null +++ b/tests/src/com/android/contacts/common/model/SimContactTests.java @@ -0,0 +1,43 @@ +package com.android.contacts.common.model; + +import android.os.Parcel; +import android.support.test.filters.SmallTest; +import android.support.test.runner.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertThat; + +/** + * Created by mhagerott on 10/6/16. + */ + +@SmallTest +@RunWith(AndroidJUnit4.class) +public class SimContactTests { + @Test + public void parcelRoundtrip() { + assertParcelsCorrectly(new SimContact(1, "name1", "phone1", + new String[] { "email1a", "email1b" })); + assertParcelsCorrectly(new SimContact(2, "name2", "phone2", null)); + assertParcelsCorrectly(new SimContact(3, "name3", null, + new String[] { "email3" })); + assertParcelsCorrectly(new SimContact(4, null, "phone4", + new String[] { "email4" })); + assertParcelsCorrectly(new SimContact(5, null, null, null)); + assertParcelsCorrectly(new SimContact(6, "name6", "phone6", + new String[0])); + } + + private void assertParcelsCorrectly(SimContact contact) { + final Parcel parcel = Parcel.obtain(); + parcel.writeParcelable(contact, 0); + parcel.setDataPosition(0); + final SimContact unparceled = parcel.readParcelable( + SimContact.class.getClassLoader()); + assertThat(unparceled, equalTo(contact)); + parcel.recycle(); + } +} diff --git a/tests/src/com/android/contacts/tests/AccountsTestHelper.java b/tests/src/com/android/contacts/tests/AccountsTestHelper.java new file mode 100644 index 000000000..be826f7ec --- /dev/null +++ b/tests/src/com/android/contacts/tests/AccountsTestHelper.java @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2016 The Android Open Source 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.contacts.tests; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.content.ContentResolver; +import android.content.Context; +import android.os.Build; +import android.provider.ContactsContract.RawContacts; +import android.support.annotation.NonNull; +import android.support.annotation.RequiresApi; +import android.support.test.InstrumentationRegistry; + +import com.android.contacts.common.model.account.AccountWithDataSet; + +import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertTrue; + +@SuppressWarnings("MissingPermission") +public class AccountsTestHelper { + public static final String TEST_ACCOUNT_TYPE = "com.android.contacts.tests.testauth.basic"; + + private final Context mContext; + private final AccountManager mAccountManager; + private final ContentResolver mResolver; + + private Account mTestAccount; + + public AccountsTestHelper() { + // Use context instead of target context because the test package has the permissions needed + // to add and remove accounts. + this(InstrumentationRegistry.getContext()); + } + + public AccountsTestHelper(Context context) { + mContext = context; + mAccountManager = AccountManager.get(mContext); + mResolver = mContext.getContentResolver(); + } + + public AccountWithDataSet addTestAccount() { + return addTestAccount(generateAccountName()); + } + + public String generateAccountName(String prefix) { + return prefix + "_t" + System.nanoTime(); + } + + public String generateAccountName() { + return generateAccountName("test"); + } + + public AccountWithDataSet addTestAccount(@NonNull String name) { + // remember the most recent one. If the caller wants to add multiple accounts they will + // have to keep track of them themselves. + mTestAccount = new Account(name, TEST_ACCOUNT_TYPE); + assertTrue(mAccountManager.addAccountExplicitly(mTestAccount, null, null)); + return convertTestAccount(); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP_MR1) + public void removeTestAccount(AccountWithDataSet account) { + final Account remove = account.getAccountOrNull(); + mAccountManager.removeAccountExplicitly(remove); + } + + public void removeContactsForAccount() { + // Not sure if this is necessary or if contacts are automatically cleaned up when the + // account is removed. + mResolver.delete(RawContacts.CONTENT_URI, + RawContacts.ACCOUNT_NAME + "=? AND " + RawContacts.ACCOUNT_TYPE + "=?", + new String[] { mTestAccount.name, mTestAccount.type }); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP_MR1) + public void cleanup() { + assertNotNull(mTestAccount); + + // Note that we don't need to cleanup up the contact data associated with the account. + // CP2 will eventually do that automatically so as long as we're using unique account + // names we should be safe. Note that cleanup is not done synchronously when the account + // is removed so if multiple tests are using the same account name then the data should + // be manually deleted after each test run. + + mAccountManager.removeAccountExplicitly(mTestAccount); + mTestAccount = null; + } + + private AccountWithDataSet convertTestAccount() { + return new AccountWithDataSet(mTestAccount.name, mTestAccount.type, null); + } +} diff --git a/tests/src/com/android/contacts/tests/SimContactsTestHelper.java b/tests/src/com/android/contacts/tests/SimContactsTestHelper.java new file mode 100644 index 000000000..45ac8d96f --- /dev/null +++ b/tests/src/com/android/contacts/tests/SimContactsTestHelper.java @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2016 The Android Open Source 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.contacts.tests; + +import android.content.ContentProvider; +import android.content.ContentProviderOperation; +import android.content.ContentProviderResult; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.OperationApplicationException; +import android.database.Cursor; +import android.net.Uri; +import android.os.RemoteException; +import android.support.annotation.NonNull; +import android.support.test.InstrumentationRegistry; +import android.telephony.TelephonyManager; + +import com.android.contacts.common.model.SimContact; +import com.android.contacts.common.database.SimContactDao; +import com.android.contacts.common.test.mocks.MockContentProvider; + +import java.util.ArrayList; +import java.util.List; + +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assume.assumeThat; +import static org.junit.Assume.assumeTrue; + +public class SimContactsTestHelper { + + private final Context mContext; + private final TelephonyManager mTelephonyManager; + private final ContentResolver mResolver; + private final SimContactDao mSimDao; + + public SimContactsTestHelper() { + this(InstrumentationRegistry.getTargetContext()); + } + + public SimContactsTestHelper(Context context) { + mContext = context; + mResolver = context.getContentResolver(); + mTelephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + mSimDao = new SimContactDao(context); + } + + public int getSimContactCount() { + Cursor cursor = mContext.getContentResolver().query(SimContactDao.ICC_CONTENT_URI, + null, null, null, null); + try { + return cursor.getCount(); + } finally { + cursor.close(); + } + } + + public ContentValues iccRow(long id, String name, String number, String emails) { + ContentValues values = new ContentValues(); + values.put(SimContactDao._ID, id); + values.put(SimContactDao.NAME, name); + values.put(SimContactDao.NUMBER, number); + values.put(SimContactDao.EMAILS, emails); + return values; + } + + public ContentProvider iccProviderExpectingNoQueries() { + return new MockContentProvider(); + } + + public ContentProvider emptyIccProvider() { + final MockContentProvider provider = new MockContentProvider(); + provider.expectQuery(SimContactDao.ICC_CONTENT_URI) + .withDefaultProjection( + SimContactDao._ID, SimContactDao.NAME, + SimContactDao.NUMBER, SimContactDao.EMAILS) + .withAnyProjection() + .withAnySelection() + .withAnySortOrder() + .returnEmptyCursor(); + return provider; + } + + public Uri addSimContact(String name, String number) { + ContentValues values = new ContentValues(); + // Oddly even though it's called name when querying we have to use "tag" for it to work + // when inserting. + if (name != null) { + values.put("tag", name); + } + if (number != null) { + values.put(SimContactDao.NUMBER, number); + } + return mResolver.insert(SimContactDao.ICC_CONTENT_URI, values); + } + + public ContentProviderResult[] deleteAllSimContacts() + throws RemoteException, OperationApplicationException { + SimContactDao dao = new SimContactDao(mContext); + List<SimContact> contacts = dao.loadSimContacts(); + ArrayList<ContentProviderOperation> ops = new ArrayList<>(); + for (SimContact contact : contacts) { + ops.add(ContentProviderOperation + .newDelete(SimContactDao.ICC_CONTENT_URI) + .withSelection(getWriteSelection(contact), null) + .build()); + } + return mResolver.applyBatch(SimContactDao.ICC_CONTENT_URI.getAuthority(), ops); + } + + public ContentProviderResult[] restore(ArrayList<ContentProviderOperation> restoreOps) + throws RemoteException, OperationApplicationException { + if (restoreOps == null) return null; + + // Remove SIM contacts because we assume that caller wants the data to be in the exact + // state as when the restore ops were captured. + deleteAllSimContacts(); + return mResolver.applyBatch(SimContactDao.ICC_CONTENT_URI.getAuthority(), restoreOps); + } + + public ArrayList<ContentProviderOperation> captureRestoreSnapshot() { + ArrayList<SimContact> contacts = mSimDao.loadSimContacts(); + + ArrayList<ContentProviderOperation> ops = new ArrayList<>(); + for (SimContact contact : contacts) { + final String[] emails = contact.getEmails(); + if (emails != null && emails.length > 0) { + throw new IllegalStateException("Cannot restore emails." + + " Please manually remove SIM contacts with emails."); + } + ops.add(ContentProviderOperation + .newInsert(SimContactDao.ICC_CONTENT_URI) + .withValue("tag", contact.getName()) + .withValue("number", contact.getPhone()) + .build()); + } + return ops; + } + + public String getWriteSelection(SimContact simContact) { + return "tag='" + simContact.getName() + "' AND " + SimContactDao.NUMBER + "='" + + simContact.getPhone() + "'"; + } + + public int deleteSimContact(@NonNull String name, @NonNull String number) { + // IccProvider doesn't use the selection args. + final String selection = "tag='" + name + "' AND " + + SimContactDao.NUMBER + "='" + number + "'"; + return mResolver.delete(SimContactDao.ICC_CONTENT_URI, selection, null); + } + + public boolean isSimReady() { + return mTelephonyManager.getSimState() == TelephonyManager.SIM_STATE_READY; + } + + public boolean doesSimHaveContacts() { + return isSimReady() && getSimContactCount() > 0; + } + + public boolean isSimWritable() { + if (!isSimReady()) return false; + final String name = "writabeProbe" + System.nanoTime(); + final Uri uri = addSimContact(name, "15095550101"); + return uri != null && deleteSimContact(name, "15095550101") == 1; + } + + public void assumeSimReady() { + assumeTrue(isSimReady()); + } + + public void assumeHasSimContacts() { + assumeTrue(doesSimHaveContacts()); + } + + public void assumeSimCardAbsent() { + assumeThat(mTelephonyManager.getSimState(), equalTo(TelephonyManager.SIM_STATE_ABSENT)); + } + + // The emulator reports SIM_STATE_READY but writes are ignored. This verifies that the + // device will actually persist writes to the SIM card. + public void assumeSimWritable() { + assumeSimReady(); + assumeTrue(isSimWritable()); + } +} |