Merge "Show snackbar with "undo" action when deleting group." 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 244f5b1..fbedf58 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -348,7 +348,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] -->
@@ -967,6 +967,10 @@
     <!-- The body text for hamburger promo [CHAR LIMIT=200]-->
     <string name="hamburger_feature_highlight_body">Clean up duplicates &amp; 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/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/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();
+    }
+}