Merge "Intents should say "Contacts"" into ub-contactsdialer-g-dev
diff --git a/res/layout/people_activity.xml b/res/layout/people_activity.xml
index d8b900d..9fd1a69 100644
--- a/res/layout/people_activity.xml
+++ b/res/layout/people_activity.xml
@@ -14,35 +14,43 @@
limitations under the License.
-->
-<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
- android:id="@+id/list_container"
+<android.support.design.widget.CoordinatorLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/root"
android:layout_width="match_parent"
- android:layout_height="match_parent">
+ android:layout_height="match_parent" >
- <!--
- ViewPager for swiping between tabs. We put fragments at runtime.
+ <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/list_container"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
- (Adding them directly as the children of this view is not recommended. ViewPager should
- be treated like a ListView, which doesn't expect children to be added from the layout.)
- -->
- <android.support.v4.view.ViewPager
- android:id="@+id/tab_pager"
- android:layout_height="match_parent"
- android:layout_width="match_parent"
- android:layout_below="@id/toolbar_parent"
- />
+ <!--
+ ViewPager for swiping between tabs. We put fragments at runtime.
- <FrameLayout
- android:id="@+id/contacts_unavailable_view"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:layout_below="@id/toolbar_parent"
- android:visibility="gone">
- <FrameLayout
- android:id="@+id/contacts_unavailable_container"
+ (Adding them directly as the children of this view is not recommended. ViewPager should
+ be treated like a ListView, which doesn't expect children to be added from the layout.)
+ -->
+ <android.support.v4.view.ViewPager
+ android:id="@+id/tab_pager"
android:layout_height="match_parent"
- android:layout_width="match_parent" />
- </FrameLayout>
+ android:layout_width="match_parent"
+ android:layout_below="@id/toolbar_parent"
+ />
+
+ <FrameLayout
+ android:id="@+id/contacts_unavailable_view"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_below="@id/toolbar_parent"
+ android:visibility="gone">
+ <FrameLayout
+ android:id="@+id/contacts_unavailable_container"
+ android:layout_height="match_parent"
+ android:layout_width="match_parent" />
+ </FrameLayout>
+
+ </RelativeLayout>
<include layout="@layout/floating_action_button" />
-</RelativeLayout>
+</android.support.design.widget.CoordinatorLayout>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 6218d82..12c83f0 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -320,7 +320,7 @@
<!-- Toast displayed when a label is saved [CHAR LIMIT=30] -->
<string name="groupSavedToast">Label saved</string>
- <!-- Toast displayed when a label name is deleted. [CHAR LIMIT=50] -->
+ <!-- Toast or snackbar displayed when a label name is deleted. [CHAR LIMIT=50] -->
<string name="groupDeletedToast">Label deleted</string>
<!-- Toast displayed when a new label name is created. [CHAR LIMIT=50] -->
@@ -647,6 +647,9 @@
and return to the editor [CHAR LIMIT=30] -->
<string name="cancel_confirmation_dialog_keep_editing_button">Cancel</string>
+ <!-- Contents of the alert dialog when the user hits the Cancel button in the customize screen [CHAR LIMIT=128] -->
+ <string name="leave_customize_confirmation_dialog_message">Discard customizations?</string>
+
<!-- Description of a call log entry, made of a call type and a date -->
<string name="call_type_and_date">
<xliff:g id="call_type" example="Friends">%1$s</xliff:g> <xliff:g id="call_short_date" example="Friends">%2$s</xliff:g>
@@ -936,6 +939,10 @@
<!-- The body text for hamburger promo [CHAR LIMIT=200]-->
<string name="hamburger_feature_highlight_body">Clean up duplicates & group contacts by label</string>
+ <!-- The label for the action shown in a snackbar after an operation that modifies some data is performed.
+ The user can click on the action to rollback the modification-->
+ <string name="undo">Undo</string>
+
<!-- Toast shown when text is copied to the clipboard [CHAR LIMIT=64] -->
<string name="toast_text_copied">Text copied</string>
<!-- Option displayed in context menu to copy long pressed item to clipboard [CHAR LIMIT=64] -->
diff --git a/res/values/styles.xml b/res/values/styles.xml
index 9ff882d..33fa11e 100644
--- a/res/values/styles.xml
+++ b/res/values/styles.xml
@@ -486,11 +486,6 @@
<item name="android:overScrollMode">always</item>
</style>
- <style name="ContactListFilterTheme" parent="@android:Theme.Holo.Light">
- <item name="android:listViewStyle">@style/ListViewStyle</item>
- <item name="android:actionButtonStyle">@style/FilterActionButtonStyle</item>
- </style>
-
<!-- Adding padding to action button doesn't move it to left, we increase the button width to
make margin between the button and screen edge 16dp -->
<style name="FilterActionButtonStyle" parent="@android:Widget.ActionButton">
diff --git a/src/com/android/contacts/ContactSaveService.java b/src/com/android/contacts/ContactSaveService.java
index bfb684a..33da756 100755
--- a/src/com/android/contacts/ContactSaveService.java
+++ b/src/com/android/contacts/ContactSaveService.java
@@ -29,6 +29,7 @@
import android.content.Intent;
import android.content.OperationApplicationException;
import android.database.Cursor;
+import android.database.DatabaseUtils;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
@@ -45,8 +46,8 @@
import android.provider.ContactsContract.Profile;
import android.provider.ContactsContract.RawContacts;
import android.provider.ContactsContract.RawContactsEntity;
+import android.support.v4.content.LocalBroadcastManager;
import android.support.v4.os.ResultReceiver;
-import android.text.TextUtils;
import android.util.Log;
import android.widget.Toast;
@@ -59,6 +60,7 @@
import com.android.contacts.common.model.RawContactDeltaList;
import com.android.contacts.common.model.RawContactModifier;
import com.android.contacts.common.model.account.AccountWithDataSet;
+import com.android.contacts.common.testing.NeededForTesting;
import com.android.contacts.common.util.PermissionsUtil;
import com.android.contacts.compat.PinnedPositionsCompat;
import com.android.contacts.util.ContactPhotoUtils;
@@ -131,6 +133,12 @@
public static final String ACTION_SET_RINGTONE = "setRingtone";
public static final String EXTRA_CUSTOM_RINGTONE = "customRingtone";
+ public static final String ACTION_UNDO = "undo";
+ 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 int CP2_ERROR = 0;
public static final int CONTACTS_LINKED = 1;
public static final int CONTACTS_SPLIT = 2;
@@ -168,6 +176,7 @@
new CopyOnWriteArrayList<Listener>();
private Handler mMainHandler;
+ private GroupsDao mGroupsDao;
public ContactSaveService() {
super(TAG);
@@ -175,6 +184,12 @@
mMainHandler = new Handler(Looper.getMainLooper());
}
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ mGroupsDao = new GroupsDaoImpl(this);
+ }
+
public static void registerListener(Listener listener) {
if (!(listener instanceof Activity)) {
throw new ClassCastException("Only activities can be registered to"
@@ -183,6 +198,10 @@
sListeners.add(0, listener);
}
+ public static boolean canUndo(Intent resultIntent) {
+ return resultIntent.hasExtra(EXTRA_UNDO_DATA);
+ }
+
public static void unregisterListener(Listener listener) {
sListeners.remove(listener);
}
@@ -285,6 +304,8 @@
setSendToVoicemail(intent);
} else if (ACTION_SET_RINGTONE.equals(action)) {
setRingtone(intent);
+ } else if (ACTION_UNDO.equals(action)) {
+ undo(intent);
}
}
@@ -706,16 +727,10 @@
String label = intent.getStringExtra(EXTRA_GROUP_LABEL);
final long[] rawContactsToAdd = intent.getLongArrayExtra(EXTRA_RAW_CONTACTS_TO_ADD);
- ContentValues values = new ContentValues();
- values.put(Groups.ACCOUNT_TYPE, accountType);
- values.put(Groups.ACCOUNT_NAME, accountName);
- values.put(Groups.DATA_SET, dataSet);
- values.put(Groups.TITLE, label);
-
- final ContentResolver resolver = getContentResolver();
-
// Create the new group
- final Uri groupUri = resolver.insert(Groups.CONTENT_URI, values);
+ final Uri groupUri = mGroupsDao.create(label,
+ new AccountWithDataSet(accountName, accountType, dataSet));
+ final ContentResolver resolver = getContentResolver();
// If there's no URI, then the insertion failed. Abort early because group members can't be
// added if the group doesn't exist
@@ -727,6 +742,7 @@
// Add new group members
addMembersToGroup(resolver, rawContactsToAdd, ContentUris.parseId(groupUri));
+ ContentValues values = new ContentValues();
// TODO: Move this into the contact editor where it belongs. This needs to be integrated
// with the way other intent extras that are passed to the {@link ContactEditorActivity}.
values.clear();
@@ -780,19 +796,11 @@
/**
* Creates an intent that can be sent to this service to delete a group.
*/
- public static Intent createGroupDeletionIntent(Context context, long groupId,
- Class<? extends Activity> callbackActivity, String callbackAction) {
- Intent serviceIntent = new Intent(context, ContactSaveService.class);
+ public static Intent createGroupDeletionIntent(Context context, long groupId) {
+ final Intent serviceIntent = new Intent(context, ContactSaveService.class);
serviceIntent.setAction(ContactSaveService.ACTION_DELETE_GROUP);
serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_ID, groupId);
- // Callback intent will be invoked by the service once the group is updated
- if (callbackActivity != null && !TextUtils.isEmpty(callbackAction)) {
- final Intent callbackIntent = new Intent(context, callbackActivity);
- callbackIntent.setAction(callbackAction);
- serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
- }
-
return serviceIntent;
}
@@ -802,18 +810,33 @@
Log.e(TAG, "Invalid arguments for deleteGroup request");
return;
}
+ final Uri groupUri = ContentUris.withAppendedId(Groups.CONTENT_URI, groupId);
- getContentResolver().delete(
- ContentUris.withAppendedId(Groups.CONTENT_URI, groupId), null, null);
+ final Intent callbackIntent = new Intent(BROADCAST_ACTION_GROUP_DELETED);
+ final Bundle undoData = mGroupsDao.captureDeletionUndoData(groupUri);
+ callbackIntent.putExtra(EXTRA_UNDO_ACTION, ACTION_DELETE_GROUP);
+ callbackIntent.putExtra(EXTRA_UNDO_DATA, undoData);
- final Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
- if (callbackIntent != null) {
- final Uri groupUri = ContentUris.withAppendedId(Groups.CONTENT_URI, groupId);
- callbackIntent.setData(groupUri);
- deliverCallback(callbackIntent);
+ mGroupsDao.delete(groupUri);
+
+ LocalBroadcastManager.getInstance(this).sendBroadcast(callbackIntent);
+ }
+
+ public static Intent createUndoIntent(Context context, Intent resultIntent) {
+ final Intent serviceIntent = new Intent(context, ContactSaveService.class);
+ serviceIntent.setAction(ContactSaveService.ACTION_UNDO);
+ serviceIntent.putExtras(resultIntent);
+ return serviceIntent;
+ }
+
+ private void undo(Intent intent) {
+ final String actionToUndo = intent.getStringExtra(EXTRA_UNDO_ACTION);
+ if (ACTION_DELETE_GROUP.equals(actionToUndo)) {
+ mGroupsDao.undoDeletion(intent.getBundleExtra(EXTRA_UNDO_DATA));
}
}
+
/**
* Creates an intent that can be sent to this service to rename a group as
* well as add and remove members from the group.
@@ -1620,4 +1643,109 @@
}
}
}
+
+ public interface GroupsDao {
+ Uri create(String title, AccountWithDataSet account);
+ int delete(Uri groupUri);
+ Bundle captureDeletionUndoData(Uri groupUri);
+ Uri undoDeletion(Bundle undoData);
+ }
+
+ @NeededForTesting
+ public static class GroupsDaoImpl implements GroupsDao {
+ @NeededForTesting
+ public static final String KEY_GROUP_DATA = "groupData";
+ @NeededForTesting
+ public static final String KEY_GROUP_MEMBERS = "groupMemberIds";
+
+ private static final String TAG = "GroupsDao";
+ private final Context context;
+ private final ContentResolver contentResolver;
+
+ public GroupsDaoImpl(Context context) {
+ this(context, context.getContentResolver());
+ }
+
+ public GroupsDaoImpl(Context context, ContentResolver contentResolver) {
+ this.context = context;
+ this.contentResolver = contentResolver;
+ }
+
+ public Bundle captureDeletionUndoData(Uri groupUri) {
+ final long groupId = ContentUris.parseId(groupUri);
+ final Bundle result = new Bundle();
+
+ final Cursor cursor = contentResolver.query(groupUri,
+ new String[]{
+ Groups.TITLE, Groups.NOTES, Groups.GROUP_VISIBLE,
+ Groups.ACCOUNT_TYPE, Groups.ACCOUNT_NAME, Groups.DATA_SET,
+ Groups.SHOULD_SYNC
+ },
+ Groups.DELETED + "=?", new String[] { "0" }, null);
+ try {
+ if (cursor.moveToFirst()) {
+ final ContentValues groupValues = new ContentValues();
+ DatabaseUtils.cursorRowToContentValues(cursor, groupValues);
+ result.putParcelable(KEY_GROUP_DATA, groupValues);
+ } else {
+ // Group doesn't exist.
+ return result;
+ }
+ } finally {
+ cursor.close();
+ }
+
+ final Cursor membersCursor = contentResolver.query(
+ Data.CONTENT_URI, new String[] { Data.RAW_CONTACT_ID },
+ Data.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?",
+ new String[] { GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(groupId) }, null);
+ final long[] memberIds = new long[membersCursor.getCount()];
+ int i = 0;
+ while (membersCursor.moveToNext()) {
+ memberIds[i++] = membersCursor.getLong(0);
+ }
+ result.putLongArray(KEY_GROUP_MEMBERS, memberIds);
+ return result;
+ }
+
+ public Uri undoDeletion(Bundle deletedGroupData) {
+ final ContentValues groupData = deletedGroupData.getParcelable(KEY_GROUP_DATA);
+ if (groupData == null) {
+ return null;
+ }
+ final Uri groupUri = contentResolver.insert(Groups.CONTENT_URI, groupData);
+ final long groupId = ContentUris.parseId(groupUri);
+
+ final long[] memberIds = deletedGroupData.getLongArray(KEY_GROUP_MEMBERS);
+ if (memberIds == null) {
+ return groupUri;
+ }
+ final ContentValues[] memberInsertions = new ContentValues[memberIds.length];
+ for (int i = 0; i < memberIds.length; i++) {
+ memberInsertions[i] = new ContentValues();
+ memberInsertions[i].put(Data.RAW_CONTACT_ID, memberIds[i]);
+ memberInsertions[i].put(Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE);
+ memberInsertions[i].put(GroupMembership.GROUP_ROW_ID, groupId);
+ }
+ final int inserted = contentResolver.bulkInsert(Data.CONTENT_URI, memberInsertions);
+ if (inserted != memberIds.length) {
+ Log.e(TAG, "Could not recover some members for group deletion undo");
+ }
+
+ return groupUri;
+ }
+
+ public Uri create(String title, AccountWithDataSet account) {
+ final ContentValues values = new ContentValues();
+ values.put(Groups.TITLE, title);
+ values.put(Groups.ACCOUNT_NAME, account.name);
+ values.put(Groups.ACCOUNT_TYPE, account.type);
+ values.put(Groups.DATA_SET, account.dataSet);
+ return contentResolver.insert(Groups.CONTENT_URI, values);
+ }
+
+ public int delete(Uri groupUri) {
+ return contentResolver.delete(groupUri, null, null);
+ }
+ }
}
diff --git a/src/com/android/contacts/activities/GroupMembersActivity.java b/src/com/android/contacts/activities/GroupMembersActivity.java
index 68f2f44..a39981d 100644
--- a/src/com/android/contacts/activities/GroupMembersActivity.java
+++ b/src/com/android/contacts/activities/GroupMembersActivity.java
@@ -33,6 +33,7 @@
import com.android.contacts.ContactSaveService;
import com.android.contacts.ContactsDrawerActivity;
import com.android.contacts.R;
+import com.android.contacts.common.GroupMetaData;
import com.android.contacts.common.logging.ListEvent;
import com.android.contacts.common.logging.Logger;
import com.android.contacts.common.logging.ScreenEvent.ScreenType;
@@ -416,13 +417,13 @@
private void deleteGroup() {
if (mMembersFragment.getMemberCount() == 0) {
- final Intent intent = ContactSaveService.createGroupDeletionIntent(
- this, mGroupMetadata.groupId,
- GroupMembersActivity.class, ACTION_DELETE_GROUP);
+ final Intent intent = ContactSaveService.createGroupDeletionIntent(this,
+ mGroupMetadata.groupId);
startService(intent);
+ finish();
} else {
GroupDeletionDialogFragment.show(getFragmentManager(), mGroupMetadata.groupId,
- mGroupMetadata.groupName, /* endActivity */ false, ACTION_DELETE_GROUP);
+ mGroupMetadata.groupName);
}
}
diff --git a/src/com/android/contacts/activities/PeopleActivity.java b/src/com/android/contacts/activities/PeopleActivity.java
index 8a0ae1b..4e7f300 100644
--- a/src/com/android/contacts/activities/PeopleActivity.java
+++ b/src/com/android/contacts/activities/PeopleActivity.java
@@ -21,8 +21,11 @@
import android.app.FragmentManager;
import android.app.FragmentTransaction;
import android.content.ActivityNotFoundException;
+import android.content.BroadcastReceiver;
import android.content.ContentUris;
+import android.content.Context;
import android.content.Intent;
+import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.Rect;
@@ -33,12 +36,17 @@
import android.provider.ContactsContract.Intents;
import android.provider.ContactsContract.ProviderStatus;
import android.provider.ContactsContract.QuickContact;
+import android.support.design.widget.CoordinatorLayout;
+import android.support.design.widget.FloatingActionButton;
+import android.support.design.widget.Snackbar;
import android.support.v13.app.FragmentPagerAdapter;
+import android.support.v4.content.LocalBroadcastManager;
import android.support.v4.view.GravityCompat;
import android.support.v4.view.PagerAdapter;
import android.support.v4.view.ViewPager;
import android.text.TextUtils;
import android.util.Log;
+import android.view.Gravity;
import android.view.KeyCharacterMap;
import android.view.KeyEvent;
import android.view.Menu;
@@ -82,13 +90,13 @@
import com.android.contacts.list.ContactsRequest;
import com.android.contacts.list.ContactsUnavailableFragment;
import com.android.contacts.list.DefaultContactBrowseListFragment;
-import com.android.contacts.list.DefaultContactBrowseListFragment.FeatureHighlightCallback;
import com.android.contacts.list.MultiSelectContactsListFragment.OnCheckBoxListActionListener;
import com.android.contacts.list.OnContactBrowserActionListener;
import com.android.contacts.list.OnContactsUnavailableActionListener;
import com.android.contacts.quickcontact.QuickContactActivity;
import com.android.contacts.util.DialogManager;
import com.android.contacts.util.SharedPreferenceUtil;
+import com.android.contacts.widget.FloatingActionButtonBehavior;
import com.google.android.libraries.material.featurehighlight.FeatureHighlight;
import java.util.List;
@@ -129,8 +137,12 @@
private ProviderStatusWatcher mProviderStatusWatcher;
private Integer mProviderStatus;
+ private BroadcastReceiver mSaveServiceListener;
+
private boolean mOptionsMenuContactsAvailable;
+ private CoordinatorLayout mLayoutRoot;
+
/**
* Showing a list of Contacts. Also used for showing search results in search mode.
*/
@@ -384,6 +396,17 @@
initializeFabVisibility();
invalidateOptionsMenuIfNeeded();
+
+ mLayoutRoot = (CoordinatorLayout) findViewById(R.id.root);
+
+ // Setup the FAB to animate upwards when a snackbar is shown in this activity.
+ // Normally the layout_behavior attribute could be used for this but for some reason it
+ // throws a ClassNotFoundException so the layout parameters are set programmatically.
+ final CoordinatorLayout.LayoutParams fabParams = new CoordinatorLayout.LayoutParams(
+ (ViewGroup.MarginLayoutParams) mFloatingActionButtonContainer.getLayoutParams());
+ fabParams.setBehavior(new FloatingActionButtonBehavior());
+ fabParams.gravity = Gravity.BOTTOM | Gravity.END;
+ mFloatingActionButtonContainer.setLayoutParams(fabParams);
}
@Override
@@ -414,7 +437,11 @@
protected void onPause() {
mOptionsMenuContactsAvailable = false;
mProviderStatusWatcher.stop();
+
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(mSaveServiceListener);
+
super.onPause();
+
}
@Override
@@ -435,6 +462,10 @@
// the actual contents match the tab.
updateFragmentsVisibility();
maybeShowHamburgerFeatureHighlight();
+
+ mSaveServiceListener = new SaveServiceListener();
+ LocalBroadcastManager.getInstance(this).registerReceiver(mSaveServiceListener,
+ new IntentFilter(ContactSaveService.BROADCAST_ACTION_GROUP_DELETED));
}
@Override
@@ -1506,4 +1537,30 @@
public void onLoadFinishedCallback() {
maybeShowHamburgerFeatureHighlight();
}
+
+ private void onGroupDeleted(Intent intent) {
+ if (!ContactSaveService.canUndo(intent)) {
+ return;
+ }
+ Snackbar.make(mLayoutRoot, getString(R.string.groupDeletedToast), Snackbar.LENGTH_LONG)
+ .setAction(R.string.undo, new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ ContactSaveService.startService(PeopleActivity.this,
+ ContactSaveService.createUndoIntent(PeopleActivity.this, intent));
+ }
+ }).show();
+ }
+
+
+ private class SaveServiceListener extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ switch (intent.getAction()) {
+ case ContactSaveService.BROADCAST_ACTION_GROUP_DELETED:
+ onGroupDeleted(intent);
+ break;
+ }
+ }
+ }
}
diff --git a/src/com/android/contacts/common/list/AccountFilterActivity.java b/src/com/android/contacts/common/list/AccountFilterActivity.java
index bed6977..3908d18 100644
--- a/src/com/android/contacts/common/list/AccountFilterActivity.java
+++ b/src/com/android/contacts/common/list/AccountFilterActivity.java
@@ -90,8 +90,9 @@
if (filter.filterType == ContactListFilter.FILTER_TYPE_CUSTOM) {
mCustomFilterView = listFilterView;
mIsCustomFilterViewSelected = listFilterView.isChecked();
- final Intent intent = new Intent(this,
- CustomContactListFilterActivity.class);
+ final Intent intent = new Intent(this, CustomContactListFilterActivity.class)
+ .putExtra(CustomContactListFilterActivity.EXTRA_CURRENT_LIST_FILTER_TYPE,
+ mCurrentFilterType);
listFilterView.setActivated(true);
// Switching activity has the highest priority. So when we open another activity, the
// announcement that indicates an account is checked will be interrupted. This is the
diff --git a/src/com/android/contacts/common/list/CustomContactListFilterActivity.java b/src/com/android/contacts/common/list/CustomContactListFilterActivity.java
index 04337b8..74e8f84 100644
--- a/src/com/android/contacts/common/list/CustomContactListFilterActivity.java
+++ b/src/com/android/contacts/common/list/CustomContactListFilterActivity.java
@@ -19,6 +19,8 @@
import android.app.ActionBar;
import android.app.Activity;
import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
import android.app.LoaderManager.LoaderCallbacks;
import android.app.ProgressDialog;
import android.content.AsyncTaskLoader;
@@ -30,14 +32,12 @@
import android.content.Intent;
import android.content.Loader;
import android.content.OperationApplicationException;
-import android.content.SharedPreferences;
import android.database.Cursor;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.net.Uri;
import android.os.Bundle;
import android.os.RemoteException;
-import android.preference.PreferenceManager;
import android.provider.ContactsContract;
import android.provider.ContactsContract.Groups;
import android.provider.ContactsContract.Settings;
@@ -81,13 +81,13 @@
LoaderCallbacks<CustomContactListFilterActivity.AccountSet> {
private static final String TAG = "CustomContactListFilterActivity";
+ public static final String EXTRA_CURRENT_LIST_FILTER_TYPE = "currentListFilterType";
+
private static final int ACCOUNT_SET_LOADER_ID = 1;
private ExpandableListView mList;
private DisplayAdapter mAdapter;
- private SharedPreferences mPrefs;
-
@Override
protected void onCreate(Bundle icicle) {
super.onCreate(icicle);
@@ -111,7 +111,6 @@
}
});
- mPrefs = PreferenceManager.getDefaultSharedPreferences(this);
mAdapter = new DisplayAdapter(this);
mList.setOnCreateContextMenuListener(this);
@@ -837,6 +836,20 @@
}
}
+ private boolean hasUnsavedChanges() {
+ if (mAdapter == null || mAdapter.mAccounts == null) {
+ return false;
+ }
+ if (getCurrentListFilterType() != ContactListFilter.FILTER_TYPE_CUSTOM) {
+ return true;
+ }
+ final ArrayList<ContentProviderOperation> diff = mAdapter.mAccounts.buildDiff();
+ if (diff.isEmpty()) {
+ return false;
+ }
+ return true;
+ }
+
@SuppressWarnings("unchecked")
private void doSaveAction() {
if (mAdapter == null || mAdapter.mAccounts == null) {
@@ -933,9 +946,7 @@
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case android.R.id.home:
- // Pretend cancel.
- setResult(Activity.RESULT_CANCELED);
- finish();
+ confirmFinish();
return true;
case R.id.menu_save:
this.doSaveAction();
@@ -945,4 +956,47 @@
}
return super.onOptionsItemSelected(item);
}
+
+ @Override
+ public void onBackPressed() {
+ confirmFinish();
+ }
+
+ private void confirmFinish() {
+ // Prompt the user whether they want to discard there customizations unless
+ // nothing will be changed.
+ if (hasUnsavedChanges()) {
+ new ConfirmNavigationDialogFragment().show(getFragmentManager(),
+ "ConfirmNavigationDialog");
+ } else {
+ setResult(RESULT_CANCELED);
+ finish();
+ }
+ }
+
+ private int getCurrentListFilterType() {
+ return getIntent().getIntExtra(EXTRA_CURRENT_LIST_FILTER_TYPE,
+ ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS);
+ }
+
+ public static class ConfirmNavigationDialogFragment
+ extends DialogFragment implements DialogInterface.OnClickListener {
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ return new AlertDialog.Builder(getActivity(), getTheme())
+ .setMessage(R.string.leave_customize_confirmation_dialog_message)
+ .setNegativeButton(android.R.string.no, null)
+ .setPositiveButton(android.R.string.yes, this)
+ .create();
+ }
+
+ @Override
+ public void onClick(DialogInterface dialogInterface, int i) {
+ if (i == DialogInterface.BUTTON_POSITIVE) {
+ getActivity().setResult(RESULT_CANCELED);
+ getActivity().finish();
+ }
+ }
+ }
}
diff --git a/src/com/android/contacts/group/GroupNameEditDialogFragment.java b/src/com/android/contacts/group/GroupNameEditDialogFragment.java
index b2bfd0b..36a4710 100644
--- a/src/com/android/contacts/group/GroupNameEditDialogFragment.java
+++ b/src/com/android/contacts/group/GroupNameEditDialogFragment.java
@@ -25,9 +25,8 @@
import android.content.Intent;
import android.content.Loader;
import android.database.Cursor;
-import android.net.Uri;
import android.os.Bundle;
-import android.provider.ContactsContract;
+import android.provider.ContactsContract.Groups;
import android.support.design.widget.TextInputLayout;
import android.support.v7.app.AlertDialog;
import android.text.Editable;
@@ -273,16 +272,27 @@
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
// Only a single loader so id is ignored.
- return new CursorLoader(getActivity(), GroupNameQuery.URI,
- GroupNameQuery.PROJECTION, GroupNameQuery.getSelection(mAccount),
- GroupNameQuery.getSelectionArgs(mAccount), null);
+ return new CursorLoader(getActivity(), Groups.CONTENT_SUMMARY_URI,
+ new String[] { Groups.TITLE, Groups.SYSTEM_ID, Groups.ACCOUNT_TYPE,
+ Groups.SUMMARY_COUNT, Groups.GROUP_IS_READ_ONLY},
+ getSelection(), getSelectionArgs(), null);
}
@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
mExistingGroups = new HashSet<>();
+ final GroupUtil.GroupsProjection projection = new GroupUtil.GroupsProjection(data);
while (data.moveToNext()) {
- mExistingGroups.add(data.getString(GroupNameQuery.TITLE));
+ final String title = projection.getTitle(data);
+ // Empty system groups aren't shown in the nav drawer so it would be confusing to tell
+ // the user that they already exist. Instead we allow them to create a duplicate
+ // group in this case. This is how the web handles this case as well (it creates a
+ // new non-system group if a new group with a title that matches a system group is
+ // create).
+ if (projection.isEmptyFFCGroup(data)) {
+ continue;
+ }
+ mExistingGroups.add(title);
}
}
@@ -290,38 +300,6 @@
public void onLoaderReset(Loader<Cursor> loader) {
}
- /**
- * Defines the structure of the query performed by the CursorLoader created by
- * GroupNameEditDialogFragment
- */
- private static class GroupNameQuery {
-
- public static final int TITLE = 0;
- public static final Uri URI = ContactsContract.Groups.CONTENT_URI;
- public static final String[] PROJECTION = new String[] { ContactsContract.Groups.TITLE };
-
- public static String getSelection(AccountWithDataSet account) {
- final StringBuilder builder = new StringBuilder();
- builder.append(ContactsContract.Groups.ACCOUNT_NAME).append("=? AND ")
- .append(ContactsContract.Groups.ACCOUNT_TYPE).append("=?");
- if (account.dataSet != null) {
- builder.append(" AND ").append(ContactsContract.Groups.DATA_SET).append("=?");
- }
- return builder.toString();
- }
-
- public static String[] getSelectionArgs(AccountWithDataSet account) {
- final int len = account.dataSet == null ? 2 : 3;
- final String[] args = new String[len];
- args[0] = account.name;
- args[1] = account.type;
- if (account.dataSet != null) {
- args[2] = account.dataSet;
- }
- return args;
- }
- }
-
private void showInputMethod(View view) {
final InputMethodManager imm = (InputMethodManager) getActivity().getSystemService(
Context.INPUT_METHOD_SERVICE);
@@ -352,4 +330,27 @@
return mGroupNameEditText == null || mGroupNameEditText.getText() == null
? null : mGroupNameEditText.getText().toString();
}
+
+ private String getSelection() {
+ final StringBuilder builder = new StringBuilder();
+ builder.append(Groups.ACCOUNT_NAME).append("=? AND ")
+ .append(Groups.ACCOUNT_TYPE).append("=? AND ")
+ .append(Groups.DELETED).append("=?");
+ if (mAccount.dataSet != null) {
+ builder.append(" AND ").append(Groups.DATA_SET).append("=?");
+ }
+ return builder.toString();
+ }
+
+ private String[] getSelectionArgs() {
+ final int len = mAccount.dataSet == null ? 3 : 4;
+ final String[] args = new String[len];
+ args[0] = mAccount.name;
+ args[1] = mAccount.type;
+ args[2] = "0"; // Not deleted
+ if (mAccount.dataSet != null) {
+ args[3] = mAccount.dataSet;
+ }
+ return args;
+ }
}
diff --git a/src/com/android/contacts/group/GroupUtil.java b/src/com/android/contacts/group/GroupUtil.java
index fd8c03d..eae0217 100644
--- a/src/com/android/contacts/group/GroupUtil.java
+++ b/src/com/android/contacts/group/GroupUtil.java
@@ -219,4 +219,77 @@
}
return array;
}
+
+ /**
+ * Stores column ordering for the projection of a query of ContactsContract.Groups
+ */
+ public static final class GroupsProjection {
+ public final int groupId;
+ public final int title;
+ public final int summaryCount;
+ public final int systemId;
+ public final int accountName;
+ public final int accountType;
+ public final int dataSet;
+ public final int autoAdd;
+ public final int favorites;
+ public final int isReadOnly;
+ public final int deleted;
+
+ public GroupsProjection(Cursor cursor) {
+ groupId = cursor.getColumnIndex(Groups._ID);
+ title = cursor.getColumnIndex(Groups.TITLE);
+ summaryCount = cursor.getColumnIndex(Groups.SUMMARY_COUNT);
+ systemId = cursor.getColumnIndex(Groups.SYSTEM_ID);
+ accountName = cursor.getColumnIndex(Groups.ACCOUNT_NAME);
+ accountType = cursor.getColumnIndex(Groups.ACCOUNT_TYPE);
+ dataSet = cursor.getColumnIndex(Groups.DATA_SET);
+ autoAdd = cursor.getColumnIndex(Groups.AUTO_ADD);
+ favorites = cursor.getColumnIndex(Groups.FAVORITES);
+ isReadOnly = cursor.getColumnIndex(Groups.GROUP_IS_READ_ONLY);
+ deleted = cursor.getColumnIndex(Groups.DELETED);
+ }
+
+ public GroupsProjection(String[] projection) {
+ List<String> list = Arrays.asList(projection);
+ groupId = list.indexOf(Groups._ID);
+ title = list.indexOf(Groups.TITLE);
+ summaryCount = list.indexOf(Groups.SUMMARY_COUNT);
+ systemId = list.indexOf(Groups.SYSTEM_ID);
+ accountName = list.indexOf(Groups.ACCOUNT_NAME);
+ accountType = list.indexOf(Groups.ACCOUNT_TYPE);
+ dataSet = list.indexOf(Groups.DATA_SET);
+ autoAdd = list.indexOf(Groups.AUTO_ADD);
+ favorites = list.indexOf(Groups.FAVORITES);
+ isReadOnly = list.indexOf(Groups.GROUP_IS_READ_ONLY);
+ deleted = list.indexOf(Groups.DELETED);
+ }
+
+ public String getTitle(Cursor cursor) {
+ return cursor.getString(title);
+ }
+
+ public long getId(Cursor cursor) {
+ return cursor.getLong(groupId);
+ }
+
+ public String getSystemId(Cursor cursor) {
+ return cursor.getString(systemId);
+ }
+
+ public int getSummaryCount(Cursor cursor) {
+ return cursor.getInt(summaryCount);
+ }
+
+ public boolean isEmptyFFCGroup(Cursor cursor) {
+ if (accountType == -1 || isReadOnly == -1 ||
+ systemId == -1 || summaryCount == -1) {
+ throw new IllegalArgumentException("Projection is missing required columns");
+ }
+ return GoogleAccountType.ACCOUNT_TYPE.equals(cursor.getString(accountType))
+ && cursor.getInt(isReadOnly) != 0
+ && isSystemIdFFC(cursor.getString(systemId))
+ && cursor.getInt(summaryCount) <= 0;
+ }
+ }
}
\ No newline at end of file
diff --git a/src/com/android/contacts/interactions/GroupDeletionDialogFragment.java b/src/com/android/contacts/interactions/GroupDeletionDialogFragment.java
index 752a89a..e247536 100644
--- a/src/com/android/contacts/interactions/GroupDeletionDialogFragment.java
+++ b/src/com/android/contacts/interactions/GroupDeletionDialogFragment.java
@@ -32,22 +32,12 @@
private static final String ARG_GROUP_ID = "groupId";
private static final String ARG_LABEL = "label";
- private static final String ARG_SHOULD_END_ACTIVITY = "endActivity";
- private static final String ARG_CALLBACK_ACTION = "callbackAction";
- public static void show(FragmentManager fragmentManager, long groupId, String label,
- boolean endActivity) {
- show(fragmentManager, groupId, label, endActivity, /* callbackAction */ null);
- }
-
- public static void show(FragmentManager fragmentManager, long groupId, String label,
- boolean endActivity, String callbackAction) {
+ public static void show(FragmentManager fragmentManager, long groupId, String label) {
GroupDeletionDialogFragment dialog = new GroupDeletionDialogFragment();
Bundle args = new Bundle();
args.putLong(ARG_GROUP_ID, groupId);
args.putString(ARG_LABEL, label);
- args.putBoolean(ARG_SHOULD_END_ACTIVITY, endActivity);
- args.putString(ARG_CALLBACK_ACTION, callbackAction);
dialog.setArguments(args);
dialog.show(fragmentManager, "deleteGroup");
}
@@ -73,18 +63,9 @@
}
protected void deleteGroup() {
- Bundle arguments = getArguments();
- long groupId = arguments.getLong(ARG_GROUP_ID);
- final String callbackAction = arguments.getString(ARG_CALLBACK_ACTION);
-
+ final long groupId = getArguments().getLong(ARG_GROUP_ID);
getActivity().startService(ContactSaveService.createGroupDeletionIntent(
- getActivity(), groupId, getActivity().getClass(), callbackAction));
- if (shouldEndActivity()) {
- getActivity().finish();
- }
- }
-
- private boolean shouldEndActivity() {
- return getArguments().getBoolean(ARG_SHOULD_END_ACTIVITY);
+ getActivity(), groupId));
+ getActivity().finish();
}
}
diff --git a/src/com/android/contacts/widget/FloatingActionButtonBehavior.java b/src/com/android/contacts/widget/FloatingActionButtonBehavior.java
new file mode 100644
index 0000000..44ca81e
--- /dev/null
+++ b/src/com/android/contacts/widget/FloatingActionButtonBehavior.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2015 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.widget;
+
+import android.content.Context;
+import android.support.design.widget.CoordinatorLayout;
+import android.support.design.widget.Snackbar.SnackbarLayout;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.FrameLayout;
+
+/**
+ * Implements custom behavior for the movement of the FAB in response to the Snackbar.
+ * Because we are not using the design framework FloatingActionButton widget, we need to manually
+ * implement the Material Design behavior of having the FAB translate upward and downward with
+ * the appearance and disappearance of a Snackbar.
+ */
+public class FloatingActionButtonBehavior extends CoordinatorLayout.Behavior<FrameLayout> {
+
+ public FloatingActionButtonBehavior() {
+ }
+
+ public FloatingActionButtonBehavior(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public boolean layoutDependsOn(CoordinatorLayout parent, FrameLayout child, View dependency) {
+ return dependency instanceof SnackbarLayout;
+ }
+
+ @Override
+ public boolean onDependentViewChanged(CoordinatorLayout parent, FrameLayout child,
+ View dependency) {
+ float translationY = Math.min(0, dependency.getTranslationY() - dependency.getHeight());
+ child.setTranslationY(translationY);
+ return true;
+ }
+}
diff --git a/tests/src/com/android/contacts/GroupsDaoIntegrationTests.java b/tests/src/com/android/contacts/GroupsDaoIntegrationTests.java
new file mode 100644
index 0000000..9122dd5
--- /dev/null
+++ b/tests/src/com/android/contacts/GroupsDaoIntegrationTests.java
@@ -0,0 +1,282 @@
+/*
+ * 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.accounts.Account;
+import android.accounts.AccountManager;
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
+import android.provider.ContactsContract.Data;
+import android.test.InstrumentationTestCase;
+
+import com.android.contacts.common.model.account.AccountWithDataSet;
+
+/**
+ * Tests of GroupsDaoImpl that perform DB operations directly against CP2
+ */
+public class GroupsDaoIntegrationTests extends InstrumentationTestCase {
+
+ private Account mAccount;
+ private ContentResolver cr;
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+
+ mAccount = new Account(getClass().getSimpleName() + "_t" +
+ System.currentTimeMillis(), "com.android.contacts.tests.authtest.basic");
+ AccountManager accountManager = (AccountManager) getContext()
+ .getSystemService(Context.ACCOUNT_SERVICE);
+ accountManager.addAccountExplicitly(mAccount, null, null);
+ cr = getContext().getContentResolver();
+ }
+
+ @Override
+ protected void tearDown() throws Exception {
+ super.tearDown();
+
+ // Cleanup anything leftover by the tests.
+ // the ACCOUNT_NAME should be unique because it contains a timestamp
+ final Uri groupsUri = ContactsContract.Groups.CONTENT_URI.buildUpon()
+ .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build();
+ final Uri rawContactsUri = ContactsContract.RawContacts.CONTENT_URI.buildUpon()
+ .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build();
+ getContext().getContentResolver().delete(groupsUri,
+ ContactsContract.Groups.ACCOUNT_NAME + "=?", new String[] { mAccount.name });
+ getContext().getContentResolver().delete(rawContactsUri,
+ ContactsContract.RawContacts.ACCOUNT_NAME + "=?", new String[] { mAccount.name });
+
+ if (mAccount != null) {
+ AccountManager accountManager = (AccountManager) getContext()
+ .getSystemService(Context.ACCOUNT_SERVICE);
+ accountManager.removeAccountExplicitly(mAccount);
+ mAccount = null;
+ }
+ }
+
+ public void test_createGroup_createsGroupWithCorrectTitle() throws Exception {
+ ContactSaveService.GroupsDaoImpl sut = createDao();
+ Uri uri = sut.create("Test Create Group", getTestAccount());
+
+ assertNotNull(uri);
+ assertGroupHasTitle(uri, "Test Create Group");
+ }
+
+ public void test_deleteEmptyGroup_marksRowDeleted() throws Exception {
+ ContactSaveService.GroupsDaoImpl sut = createDao();
+ Uri uri = sut.create("Test Delete Group", getTestAccount());
+
+ assertEquals(1, sut.delete(uri));
+
+ Cursor cursor = cr.query(uri, null, null, null, null, null);
+ try {
+ cursor.moveToFirst();
+ assertEquals(1, cursor.getInt(cursor.getColumnIndexOrThrow(ContactsContract.Groups.DELETED)));
+ } finally {
+ cursor.close();
+ }
+ }
+
+ public void test_undoDeleteEmptyGroup_createsGroupWithMatchingTitle() throws Exception {
+ ContactSaveService.GroupsDaoImpl sut = createDao();
+ Uri uri = sut.create("Test Undo Delete Empty Group", getTestAccount());
+
+ Bundle undoData = sut.captureDeletionUndoData(uri);
+
+ assertEquals(1, sut.delete(uri));
+
+ Uri groupUri = sut.undoDeletion(undoData);
+
+ assertGroupHasTitle(groupUri, "Test Undo Delete Empty Group");
+ }
+
+ public void test_deleteNonEmptyGroup_removesGroupAndMembers() throws Exception {
+ final ContactSaveService.GroupsDaoImpl sut = createDao();
+ final Uri groupUri = sut.create("Test delete non-empty group", getTestAccount());
+
+ final long groupId = ContentUris.parseId(groupUri);
+ addMemberToGroup(ContentUris.parseId(createRawContact()), groupId);
+ addMemberToGroup(ContentUris.parseId(createRawContact()), groupId);
+
+ assertEquals(1, sut.delete(groupUri));
+
+ final Cursor cursor = cr.query(Data.CONTENT_URI, null,
+ Data.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?",
+ new String[] { GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(groupId) },
+ null, null);
+
+ try {
+ cursor.moveToFirst();
+ // This is more of a characterization test since our code isn't manually deleting
+ // the membership rows just the group but this still helps document the expected
+ // behavior.
+ assertEquals(0, cursor.getCount());
+ } finally {
+ cursor.close();
+ }
+ }
+
+ public void test_undoDeleteNonEmptyGroup_restoresGroupAndMembers() throws Exception {
+ final ContactSaveService.GroupsDaoImpl sut = createDao();
+ final Uri groupUri = sut.create("Test undo delete non-empty group", getTestAccount());
+
+ final long groupId = ContentUris.parseId(groupUri);
+ addMemberToGroup(ContentUris.parseId(createRawContact()), groupId);
+ addMemberToGroup(ContentUris.parseId(createRawContact()), groupId);
+
+ Bundle undoData = sut.captureDeletionUndoData(groupUri);
+
+ sut.delete(groupUri);
+
+ final Uri recreatedGroup = sut.undoDeletion(undoData);
+
+ final long newGroupId = ContentUris.parseId(recreatedGroup);
+
+ final Cursor cursor = cr.query(Data.CONTENT_URI, null,
+ Data.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?",
+ new String[] { GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(newGroupId) },
+ null, null);
+
+ try {
+ assertEquals(2, cursor.getCount());
+ } finally {
+ cursor.close();
+ }
+ }
+
+ public void test_captureUndoDataForDeletedGroup_returnsEmptyBundle() {
+ final ContactSaveService.GroupsDaoImpl sut = createDao();
+
+ Uri uri = sut.create("a deleted group", getTestAccount());
+ sut.delete(uri);
+
+ Bundle undoData = sut.captureDeletionUndoData(uri);
+
+ assertTrue(undoData.isEmpty());
+ }
+
+ public void test_captureUndoDataForNonExistentGroup_returnsEmptyBundle() {
+ final ContactSaveService.GroupsDaoImpl sut = createDao();
+
+ // This test could potentially be flaky if this ID exists for some reason. 10 is subtracted
+ // to reduce the likelihood of this happening; some other test may use Integer.MAX_VALUE
+ // or nearby values to cover some special case or boundary condition.
+ final long nonExistentId = Integer.MAX_VALUE - 10;
+
+ Bundle undoData = sut.captureDeletionUndoData(ContentUris
+ .withAppendedId(ContactsContract.Groups.CONTENT_URI, nonExistentId));
+
+ assertTrue(undoData.isEmpty());
+ }
+
+ public void test_undoWithEmptyBundle_doesNothing() {
+ final ContactSaveService.GroupsDaoImpl sut = createDao();
+
+ Cursor cursor = queryGroupsForTestAccount();
+ try {
+ assertEquals(0, cursor.getCount());
+ } finally {
+ cursor.close();
+ }
+
+ sut.undoDeletion(new Bundle());
+
+ cursor = queryGroupsForTestAccount();
+ try {
+ assertEquals(0, cursor.getCount());
+ } finally {
+ cursor.close();
+ }
+ }
+
+ public void test_undoDeleteEmptyGroupWithMissingMembersKey_shouldRecreateGroup() {
+ final ContactSaveService.GroupsDaoImpl sut = createDao();
+ final Uri groupUri = sut.create("Test undo delete null memberIds", getTestAccount());
+
+ Bundle undoData = sut.captureDeletionUndoData(groupUri);
+ undoData.remove(ContactSaveService.GroupsDaoImpl.KEY_GROUP_MEMBERS);
+
+ sut.undoDeletion(undoData);
+
+ assertGroupWithTitleExists("Test undo delete null memberIds");
+ }
+
+ private void assertGroupHasTitle(Uri groupUri, String title) {
+ final Cursor cursor = cr.query(groupUri, new String[] { ContactsContract.Groups.TITLE },
+ ContactsContract.Groups.DELETED + "=?",
+ new String[] { "0" }, null, null);
+ try {
+ assertTrue("Group does not have title \"" + title + "\"",
+ cursor.getCount() == 1 && cursor.moveToFirst() &&
+ title.equals(cursor.getString(0)));
+ } finally {
+ cursor.close();
+ }
+ }
+
+ private void assertGroupWithTitleExists(String title) {
+ final Cursor cursor = cr.query(ContactsContract.Groups.CONTENT_URI, null,
+ ContactsContract.Groups.TITLE + "=? AND " + ContactsContract.Groups.DELETED + "=?",
+ new String[] { title, "0" }, null, null);
+
+ try {
+ assertEquals(2, cursor.getCount());
+ } finally {
+ cursor.close();
+ }
+ }
+
+ private Cursor queryGroupsForTestAccount() {
+ return cr.query(ContactsContract.Groups.CONTENT_URI, null,
+ ContactsContract.Groups.ACCOUNT_NAME + "=?", new String[] { mAccount.name }, null);
+ }
+
+ public ContactSaveService.GroupsDaoImpl createDao() {
+ return new ContactSaveService.GroupsDaoImpl(getContext());
+ }
+
+ private Uri createRawContact() {
+ ContentValues values = new ContentValues();
+ values.put(ContactsContract.RawContacts.ACCOUNT_NAME, mAccount.name);
+ values.put(ContactsContract.RawContacts.ACCOUNT_TYPE, mAccount.type);
+ return cr.insert(ContactsContract.RawContacts.CONTENT_URI, values);
+ }
+
+ private Uri addMemberToGroup(long rawContactId, long groupId) {
+ ContentValues values = new ContentValues();
+ values.put(Data.RAW_CONTACT_ID, rawContactId);
+ values.put(Data.MIMETYPE,
+ GroupMembership.CONTENT_ITEM_TYPE);
+ values.put(GroupMembership.GROUP_ROW_ID, groupId);
+ return cr.insert(Data.CONTENT_URI, values);
+ }
+
+ private AccountWithDataSet getTestAccount() {
+ return new AccountWithDataSet(mAccount.name, mAccount.type, null);
+ }
+
+ private Context getContext() {
+ return getInstrumentation().getTargetContext();
+ }
+}