Batch join contacts

* Add new action to ContactSaveService to support joining more than
 two contacts toghether.
* Add new dialog fragment for the join

Bug: 19549465
Change-Id: Ib0b1d5e7652e429f8e78d81dd3d98d03b3129e1e
diff --git a/src/com/android/contacts/ContactSaveService.java b/src/com/android/contacts/ContactSaveService.java
index 1668521..cf36edf 100644
--- a/src/com/android/contacts/ContactSaveService.java
+++ b/src/com/android/contacts/ContactSaveService.java
@@ -109,9 +109,9 @@
     public static final String EXTRA_DATA_ID = "dataId";
 
     public static final String ACTION_JOIN_CONTACTS = "joinContacts";
+    public static final String ACTION_JOIN_SEVERAL_CONTACTS = "joinSeveralContacts";
     public static final String EXTRA_CONTACT_ID1 = "contactId1";
     public static final String EXTRA_CONTACT_ID2 = "contactId2";
-    public static final String EXTRA_CONTACT_WRITABLE = "contactWritable";
 
     public static final String ACTION_SET_SEND_TO_VOICEMAIL = "sendToVoicemail";
     public static final String EXTRA_SEND_TO_VOICEMAIL_FLAG = "sendToVoicemailFlag";
@@ -211,6 +211,8 @@
             deleteContact(intent);
         } else if (ACTION_JOIN_CONTACTS.equals(action)) {
             joinContacts(intent);
+        } else if (ACTION_JOIN_SEVERAL_CONTACTS.equals(action)) {
+            joinSeveralContacts(intent);
         } else if (ACTION_SET_SEND_TO_VOICEMAIL.equals(action)) {
             setSendToVoicemail(intent);
         } else if (ACTION_SET_RINGTONE.equals(action)) {
@@ -985,15 +987,14 @@
 
     /**
      * Creates an intent that can be sent to this service to join two contacts.
+     * The resulting contact uses the name from {@param contactId1} if possible.
      */
     public static Intent createJoinContactsIntent(Context context, long contactId1,
-            long contactId2, boolean contactWritable,
-            Class<? extends Activity> callbackActivity, String callbackAction) {
+            long contactId2, Class<? extends Activity> callbackActivity, String callbackAction) {
         Intent serviceIntent = new Intent(context, ContactSaveService.class);
         serviceIntent.setAction(ContactSaveService.ACTION_JOIN_CONTACTS);
         serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_ID1, contactId1);
         serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_ID2, contactId2);
-        serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_WRITABLE, contactWritable);
 
         // Callback intent will be invoked by the service once the contacts are joined.
         Intent callbackIntent = new Intent(context, callbackActivity);
@@ -1003,6 +1004,17 @@
         return serviceIntent;
     }
 
+    /**
+     * Creates an intent to join all raw contacts inside {@param contactIds}'s contacts.
+     * No special attention is paid to where the resulting contact's name is taken from.
+     */
+    public static Intent createJoinSeveralContactsIntent(Context context, long[] contactIds) {
+        Intent serviceIntent = new Intent(context, ContactSaveService.class);
+        serviceIntent.setAction(ContactSaveService.ACTION_JOIN_SEVERAL_CONTACTS);
+        serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_IDS, contactIds);
+        return serviceIntent;
+    }
+
 
     private interface JoinContactQuery {
         String[] PROJECTION = {
@@ -1011,8 +1023,6 @@
                 RawContacts.DISPLAY_NAME_SOURCE,
         };
 
-        String SELECTION = RawContacts.CONTACT_ID + "=? OR " + RawContacts.CONTACT_ID + "=?";
-
         int _ID = 0;
         int CONTACT_ID = 1;
         int DISPLAY_NAME_SOURCE = 2;
@@ -1034,22 +1044,48 @@
         int IS_SUPER_PRIMARY = 2;
     }
 
-    private void joinContacts(Intent intent) {
-        long contactId1 = intent.getLongExtra(EXTRA_CONTACT_ID1, -1);
-        long contactId2 = intent.getLongExtra(EXTRA_CONTACT_ID2, -1);
+    private void joinSeveralContacts(Intent intent) {
+        final long[] contactIds = intent.getLongArrayExtra(EXTRA_CONTACT_IDS);
 
-        if (contactId1 == -1 || contactId2 == -1) {
-            Log.e(TAG, "Invalid arguments for joinContacts request");
+        // Load raw contact IDs for all contacts involved.
+        long rawContactIds[] = getRawContactIdsForAggregation(contactIds);
+        if (rawContactIds == null) {
+            Log.e(TAG, "Invalid arguments for joinSeveralContacts request");
             return;
         }
 
+        // For each pair of raw contacts, insert an aggregation exception
         final ContentResolver resolver = getContentResolver();
+        final ArrayList<ContentProviderOperation> operations
+                = new ArrayList<ContentProviderOperation>();
+        for (int i = 0; i < rawContactIds.length; i++) {
+            for (int j = 0; j < rawContactIds.length; j++) {
+                if (i != j) {
+                    buildJoinContactDiff(operations, rawContactIds[i], rawContactIds[j]);
+                }
+            }
+        }
+
+        // Apply all aggregation exceptions as one batch
+        try {
+            resolver.applyBatch(ContactsContract.AUTHORITY, operations);
+            showToast(R.string.contactsJoinedMessage);
+        } catch (RemoteException | OperationApplicationException e) {
+            Log.e(TAG, "Failed to apply aggregation exception batch", e);
+            showToast(R.string.contactSavedErrorToast);
+        }
+    }
+
+
+    private void joinContacts(Intent intent) {
+        long contactId1 = intent.getLongExtra(EXTRA_CONTACT_ID1, -1);
+        long contactId2 = intent.getLongExtra(EXTRA_CONTACT_ID2, -1);
 
         // Load raw contact IDs for all raw contacts involved - currently edited and selected
         // in the join UIs.
         long rawContactIds[] = getRawContactIdsForAggregation(contactId1, contactId2);
         if (rawContactIds == null) {
-            // Error.
+            Log.e(TAG, "Invalid arguments for joinContacts request");
             return;
         }
 
@@ -1064,6 +1100,8 @@
             }
         }
 
+        final ContentResolver resolver = getContentResolver();
+
         // Use the name for contactId1 as the name for the newly aggregated contact.
         final Uri contactId1Uri = ContentUris.withAppendedId(
                 Contacts.CONTENT_URI, contactId1);
@@ -1101,10 +1139,7 @@
             resolver.applyBatch(ContactsContract.AUTHORITY, operations);
             showToast(R.string.contactsJoinedMessage);
             success = true;
-        } catch (RemoteException e) {
-            Log.e(TAG, "Failed to apply aggregation exception batch", e);
-            showToast(R.string.contactSavedErrorToast);
-        } catch (OperationApplicationException e) {
+        } catch (RemoteException | OperationApplicationException e) {
             Log.e(TAG, "Failed to apply aggregation exception batch", e);
             showToast(R.string.contactSavedErrorToast);
         }
@@ -1118,13 +1153,32 @@
         deliverCallback(callbackIntent);
     }
 
-    private long[] getRawContactIdsForAggregation(long contactId1, long contactId2) {
+    private long[] getRawContactIdsForAggregation(long[] contactIds) {
+        if (contactIds == null) {
+            return null;
+        }
+
         final ContentResolver resolver = getContentResolver();
         long rawContactIds[];
+
+        final StringBuilder queryBuilder = new StringBuilder();
+        final String stringContactIds[] = new String[contactIds.length];
+        for (int i = 0; i < contactIds.length; i++) {
+            queryBuilder.append(RawContacts.CONTACT_ID + "=?");
+            stringContactIds[i] = String.valueOf(contactIds[i]);
+            if (contactIds[i] == -1) {
+                return null;
+            }
+            if (i == contactIds.length -1) {
+                break;
+            }
+            queryBuilder.append(" OR ");
+        }
+
         final Cursor c = resolver.query(RawContacts.CONTENT_URI,
                 JoinContactQuery.PROJECTION,
-                JoinContactQuery.SELECTION,
-                new String[]{String.valueOf(contactId1), String.valueOf(contactId2)}, null);
+                queryBuilder.toString(),
+                stringContactIds, null);
         if (c == null) {
             Log.e(TAG, "Unable to open Contacts DB cursor");
             showToast(R.string.contactSavedErrorToast);
@@ -1132,7 +1186,7 @@
         }
         try {
             if (c.getCount() < 2) {
-                Log.e(TAG, "Not enough raw contacts to aggregate toghether.");
+                Log.e(TAG, "Not enough raw contacts to aggregate together.");
                 return null;
             }
             rawContactIds = new long[c.getCount()];
@@ -1147,6 +1201,10 @@
         return rawContactIds;
     }
 
+    private long[] getRawContactIdsForAggregation(long contactId1, long contactId2) {
+        return getRawContactIdsForAggregation(new long[] {contactId1, contactId2});
+    }
+
     /**
      * Construct a {@link AggregationExceptions#TYPE_KEEP_TOGETHER} ContentProviderOperation.
      */
diff --git a/src/com/android/contacts/activities/PeopleActivity.java b/src/com/android/contacts/activities/PeopleActivity.java
index 577bf57..d660f4f 100644
--- a/src/com/android/contacts/activities/PeopleActivity.java
+++ b/src/com/android/contacts/activities/PeopleActivity.java
@@ -64,6 +64,8 @@
 import com.android.contacts.common.list.ContactTileAdapter.DisplayType;
 import com.android.contacts.interactions.ContactMultiDeletionInteraction;
 import com.android.contacts.interactions.ContactMultiDeletionInteraction.MultiContactDeleteListener;
+import com.android.contacts.interactions.JoinContactsDialogFragment;
+import com.android.contacts.interactions.JoinContactsDialogFragment.JoinContactsListener;
 import com.android.contacts.list.MultiSelectContactsListFragment;
 import com.android.contacts.list.MultiSelectContactsListFragment.OnCheckBoxListActionListener;
 import com.android.contacts.list.ContactTileListFragment;
@@ -100,7 +102,8 @@
         DialogManager.DialogShowingViewActivity,
         ContactListFilterController.ContactListFilterListener,
         ProviderStatusListener,
-        MultiContactDeleteListener {
+        MultiContactDeleteListener,
+        JoinContactsListener {
 
     private static final String TAG = "PeopleActivity";
 
@@ -1065,6 +1068,8 @@
                 && mAllFragment.getSelectedContactIds().size() != 0;
         makeMenuItemVisible(menu, R.id.menu_share, showSelectedContactOptions);
         makeMenuItemVisible(menu, R.id.menu_delete, showSelectedContactOptions);
+        makeMenuItemVisible(menu, R.id.menu_join, showSelectedContactOptions);
+        makeMenuItemEnabled(menu, R.id.menu_join, mAllFragment.getSelectedContactIds().size() > 1);
 
         // Debug options need to be visible even in search mode.
         makeMenuItemVisible(menu, R.id.export_database, mEnableDebugMenuOptions);
@@ -1081,12 +1086,19 @@
     }
 
     private void makeMenuItemVisible(Menu menu, int itemId, boolean visible) {
-        MenuItem item =menu.findItem(itemId);
+        final MenuItem item = menu.findItem(itemId);
         if (item != null) {
             item.setVisible(visible);
         }
     }
 
+    private void makeMenuItemEnabled(Menu menu, int itemId, boolean visible) {
+        final MenuItem item = menu.findItem(itemId);
+        if (item != null) {
+            item.setEnabled(visible);
+        }
+    }
+
     @Override
     public boolean onOptionsItemSelected(MenuItem item) {
         if (mDisableOptionItemSelected) {
@@ -1130,6 +1142,9 @@
             case R.id.menu_share:
                 shareSelectedContacts();
                 return true;
+            case R.id.menu_join:
+                joinSelectedContacts();
+                return true;
             case R.id.menu_delete:
                 deleteSelectedContacts();
                 return true;
@@ -1197,6 +1212,15 @@
         ImplicitIntentsUtil.startActivityOutsideApp(this, intent);
     }
 
+    private void joinSelectedContacts() {
+        JoinContactsDialogFragment.start(this, mAllFragment.getSelectedContactIds());
+    }
+
+    @Override
+    public void onContactsJoined() {
+        mAllFragment.clearCheckBoxes();
+    }
+
     private void deleteSelectedContacts() {
         ContactMultiDeletionInteraction.start(PeopleActivity.this,
                 mAllFragment.getSelectedContactIds());
diff --git a/src/com/android/contacts/editor/CompactContactEditorFragment.java b/src/com/android/contacts/editor/CompactContactEditorFragment.java
index f25d01a..94e2263 100644
--- a/src/com/android/contacts/editor/CompactContactEditorFragment.java
+++ b/src/com/android/contacts/editor/CompactContactEditorFragment.java
@@ -304,8 +304,7 @@
     @Override
     protected void joinAggregate(final long contactId) {
         final Intent intent = ContactSaveService.createJoinContactsIntent(
-                mContext, mContactIdForJoin, contactId, mContactWritableForJoin,
-                CompactContactEditorActivity.class,
+                mContext, mContactIdForJoin, contactId, CompactContactEditorActivity.class,
                 CompactContactEditorActivity.ACTION_JOIN_COMPLETED);
         mContext.startService(intent);
     }
diff --git a/src/com/android/contacts/editor/ContactEditorBaseFragment.java b/src/com/android/contacts/editor/ContactEditorBaseFragment.java
index 57905d1..d8c20fb 100644
--- a/src/com/android/contacts/editor/ContactEditorBaseFragment.java
+++ b/src/com/android/contacts/editor/ContactEditorBaseFragment.java
@@ -139,7 +139,6 @@
 
     // Join Activity
     private static final String KEY_CONTACT_ID_FOR_JOIN = "contactidforjoin";
-    private static final String KEY_CONTACT_WRITABLE_FOR_JOIN = "contactwritableforjoin";
 
     protected static final int REQUEST_CODE_JOIN = 0;
     protected static final int REQUEST_CODE_ACCOUNTS_CHANGED = 1;
@@ -332,7 +331,6 @@
 
     // Join Activity
     protected long mContactIdForJoin;
-    protected boolean mContactWritableForJoin;
 
     //
     // Editor state for {@link ContactEditorView}.
@@ -465,7 +463,6 @@
 
             // Join Activity
             mContactIdForJoin = savedState.getLong(KEY_CONTACT_ID_FOR_JOIN);
-            mContactWritableForJoin = savedState.getBoolean(KEY_CONTACT_WRITABLE_FOR_JOIN);
         }
 
         // mState can still be null because it may not have have finished loading before
@@ -582,7 +579,6 @@
 
         // Join Activity
         outState.putLong(KEY_CONTACT_ID_FOR_JOIN, mContactIdForJoin);
-        outState.putBoolean(KEY_CONTACT_WRITABLE_FOR_JOIN, mContactWritableForJoin);
 
         super.onSaveInstanceState(outState);
     }
@@ -1337,7 +1333,6 @@
         }
 
         mContactIdForJoin = ContentUris.parseId(contactLookupUri);
-        mContactWritableForJoin = isContactWritable();
         final Intent intent = new Intent(UiIntentActions.PICK_JOIN_CONTACT_ACTION);
         intent.putExtra(UiIntentActions.TARGET_CONTACT_ID_EXTRA_KEY, mContactIdForJoin);
         startActivityForResult(intent, REQUEST_CODE_JOIN);
diff --git a/src/com/android/contacts/editor/ContactEditorFragment.java b/src/com/android/contacts/editor/ContactEditorFragment.java
index 96aaecd..9e5148d 100644
--- a/src/com/android/contacts/editor/ContactEditorFragment.java
+++ b/src/com/android/contacts/editor/ContactEditorFragment.java
@@ -509,8 +509,8 @@
     @Override
     protected void joinAggregate(final long contactId) {
         final Intent intent = ContactSaveService.createJoinContactsIntent(
-                mContext, mContactIdForJoin, contactId, mContactWritableForJoin,
-                ContactEditorActivity.class, ContactEditorActivity.ACTION_JOIN_COMPLETED);
+                mContext, mContactIdForJoin, contactId, ContactEditorActivity.class,
+                ContactEditorActivity.ACTION_JOIN_COMPLETED);
         mContext.startService(intent);
     }
 
diff --git a/src/com/android/contacts/interactions/JoinContactsDialogFragment.java b/src/com/android/contacts/interactions/JoinContactsDialogFragment.java
new file mode 100644
index 0000000..a9a1aa9
--- /dev/null
+++ b/src/com/android/contacts/interactions/JoinContactsDialogFragment.java
@@ -0,0 +1,107 @@
+/*
+ * 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.interactions;
+
+
+import com.android.contacts.ContactSaveService;
+import com.android.contacts.R;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.app.FragmentTransaction;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.os.Bundle;
+
+import java.util.TreeSet;
+
+/**
+ * An interaction invoked to join multiple contacts together.
+ */
+public class JoinContactsDialogFragment extends DialogFragment {
+
+    private static final String FRAGMENT_TAG = "joinDialog";
+    private static final String KEY_CONTACT_IDS = "contactIds";
+
+    public interface JoinContactsListener {
+        void onContactsJoined();
+    }
+
+    public static void start(Activity activity, TreeSet<Long> contactIds) {
+        final FragmentTransaction ft = activity.getFragmentManager().beginTransaction();
+        final JoinContactsDialogFragment newFragment
+                = JoinContactsDialogFragment.newInstance(contactIds);
+        newFragment.show(ft, FRAGMENT_TAG);
+    }
+
+    private static JoinContactsDialogFragment newInstance(TreeSet<Long> contactIds) {
+        final JoinContactsDialogFragment fragment = new JoinContactsDialogFragment();
+        Bundle arguments = new Bundle();
+        arguments.putSerializable(KEY_CONTACT_IDS, contactIds);
+        fragment.setArguments(arguments);
+        return fragment;
+    }
+
+    @Override
+    public Dialog onCreateDialog(Bundle savedInstanceState) {
+        final TreeSet<Long> contactIds =
+                (TreeSet<Long>) getArguments().getSerializable(KEY_CONTACT_IDS);
+        if (contactIds.size() <= 1) {
+            return new AlertDialog.Builder(getActivity())
+                    .setIconAttribute(android.R.attr.alertDialogIcon)
+                    .setMessage(R.string.batch_merge_single_contact_warning)
+                    .setPositiveButton(android.R.string.ok, null)
+                    .create();
+        }
+        return new AlertDialog.Builder(getActivity())
+                .setIconAttribute(android.R.attr.alertDialogIcon)
+                .setMessage(R.string.batch_merge_confirmation)
+                .setNegativeButton(android.R.string.cancel, null)
+                .setPositiveButton(android.R.string.ok,
+                        new DialogInterface.OnClickListener() {
+                            @Override
+                            public void onClick(DialogInterface dialog, int whichButton) {
+                                joinContacts(contactIds);
+                            }
+                        }
+                )
+                .create();
+    }
+
+    private void joinContacts(TreeSet<Long> contactIds) {
+        final Long[] contactIdsArray = contactIds.toArray(new Long[contactIds.size()]);
+        final long[] contactIdsArray2 = new long[contactIdsArray.length];
+        for (int i = 0; i < contactIds.size(); i++) {
+            contactIdsArray2[i] = contactIdsArray[i];
+        }
+
+        final Intent intent = ContactSaveService.createJoinSeveralContactsIntent(getActivity(),
+                contactIdsArray2);
+        getActivity().startService(intent);
+
+        notifyListener();
+    }
+
+    private void notifyListener() {
+        if (getActivity() instanceof JoinContactsListener) {
+            ((JoinContactsListener) getActivity()).onContactsJoined();
+        }
+    }
+
+}