1/*
2 * Copyright (C) 2010 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.contacts;
18
19import static android.Manifest.permission.WRITE_CONTACTS;
20import android.app.Activity;
21import android.app.IntentService;
22import android.content.ContentProviderOperation;
23import android.content.ContentProviderOperation.Builder;
24import android.content.ContentProviderResult;
25import android.content.ContentResolver;
26import android.content.ContentUris;
27import android.content.ContentValues;
28import android.content.Context;
29import android.content.Intent;
30import android.content.OperationApplicationException;
31import android.database.Cursor;
32import android.net.Uri;
33import android.os.Bundle;
34import android.os.Handler;
35import android.os.Looper;
36import android.os.Parcelable;
37import android.os.RemoteException;
38import android.provider.ContactsContract;
39import android.provider.ContactsContract.AggregationExceptions;
40import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
41import android.provider.ContactsContract.CommonDataKinds.StructuredName;
42import android.provider.ContactsContract.Contacts;
43import android.provider.ContactsContract.Data;
44import android.provider.ContactsContract.Groups;
45import android.provider.ContactsContract.PinnedPositions;
46import android.provider.ContactsContract.Profile;
47import android.provider.ContactsContract.RawContacts;
48import android.provider.ContactsContract.RawContactsEntity;
49import android.util.Log;
50import android.widget.Toast;
51
52import com.android.contacts.common.database.ContactUpdateUtils;
53import com.android.contacts.common.model.AccountTypeManager;
54import com.android.contacts.common.model.RawContactDelta;
55import com.android.contacts.common.model.RawContactDeltaList;
56import com.android.contacts.common.model.RawContactModifier;
57import com.android.contacts.common.model.account.AccountWithDataSet;
58import com.android.contacts.common.util.PermissionsUtil;
59import com.android.contacts.editor.ContactEditorFragment;
60import com.android.contacts.util.ContactPhotoUtils;
61
62import com.google.common.collect.Lists;
63import com.google.common.collect.Sets;
64
65import java.util.ArrayList;
66import java.util.HashSet;
67import java.util.List;
68import java.util.concurrent.CopyOnWriteArrayList;
69
70/**
71 * A service responsible for saving changes to the content provider.
72 */
73public class ContactSaveService extends IntentService {
74    private static final String TAG = "ContactSaveService";
75
76    /** Set to true in order to view logs on content provider operations */
77    private static final boolean DEBUG = false;
78
79    public static final String ACTION_NEW_RAW_CONTACT = "newRawContact";
80
81    public static final String EXTRA_ACCOUNT_NAME = "accountName";
82    public static final String EXTRA_ACCOUNT_TYPE = "accountType";
83    public static final String EXTRA_DATA_SET = "dataSet";
84    public static final String EXTRA_CONTENT_VALUES = "contentValues";
85    public static final String EXTRA_CALLBACK_INTENT = "callbackIntent";
86
87    public static final String ACTION_SAVE_CONTACT = "saveContact";
88    public static final String EXTRA_CONTACT_STATE = "state";
89    public static final String EXTRA_SAVE_MODE = "saveMode";
90    public static final String EXTRA_SAVE_IS_PROFILE = "saveIsProfile";
91    public static final String EXTRA_SAVE_SUCCEEDED = "saveSucceeded";
92    public static final String EXTRA_UPDATED_PHOTOS = "updatedPhotos";
93
94    public static final String ACTION_CREATE_GROUP = "createGroup";
95    public static final String ACTION_RENAME_GROUP = "renameGroup";
96    public static final String ACTION_DELETE_GROUP = "deleteGroup";
97    public static final String ACTION_UPDATE_GROUP = "updateGroup";
98    public static final String EXTRA_GROUP_ID = "groupId";
99    public static final String EXTRA_GROUP_LABEL = "groupLabel";
100    public static final String EXTRA_RAW_CONTACTS_TO_ADD = "rawContactsToAdd";
101    public static final String EXTRA_RAW_CONTACTS_TO_REMOVE = "rawContactsToRemove";
102
103    public static final String ACTION_SET_STARRED = "setStarred";
104    public static final String ACTION_DELETE_CONTACT = "delete";
105    public static final String ACTION_DELETE_MULTIPLE_CONTACTS = "deleteMultipleContacts";
106    public static final String EXTRA_CONTACT_URI = "contactUri";
107    public static final String EXTRA_CONTACT_IDS = "contactIds";
108    public static final String EXTRA_STARRED_FLAG = "starred";
109
110    public static final String ACTION_SET_SUPER_PRIMARY = "setSuperPrimary";
111    public static final String ACTION_CLEAR_PRIMARY = "clearPrimary";
112    public static final String EXTRA_DATA_ID = "dataId";
113
114    public static final String ACTION_JOIN_CONTACTS = "joinContacts";
115    public static final String ACTION_JOIN_SEVERAL_CONTACTS = "joinSeveralContacts";
116    public static final String EXTRA_CONTACT_ID1 = "contactId1";
117    public static final String EXTRA_CONTACT_ID2 = "contactId2";
118
119    public static final String ACTION_SET_SEND_TO_VOICEMAIL = "sendToVoicemail";
120    public static final String EXTRA_SEND_TO_VOICEMAIL_FLAG = "sendToVoicemailFlag";
121
122    public static final String ACTION_SET_RINGTONE = "setRingtone";
123    public static final String EXTRA_CUSTOM_RINGTONE = "customRingtone";
124
125    private static final HashSet<String> ALLOWED_DATA_COLUMNS = Sets.newHashSet(
126        Data.MIMETYPE,
127        Data.IS_PRIMARY,
128        Data.DATA1,
129        Data.DATA2,
130        Data.DATA3,
131        Data.DATA4,
132        Data.DATA5,
133        Data.DATA6,
134        Data.DATA7,
135        Data.DATA8,
136        Data.DATA9,
137        Data.DATA10,
138        Data.DATA11,
139        Data.DATA12,
140        Data.DATA13,
141        Data.DATA14,
142        Data.DATA15
143    );
144
145    private static final int PERSIST_TRIES = 3;
146
147    private static final int MAX_CONTACTS_PROVIDER_BATCH_SIZE = 499;
148
149    public interface Listener {
150        public void onServiceCompleted(Intent callbackIntent);
151    }
152
153    private static final CopyOnWriteArrayList<Listener> sListeners =
154            new CopyOnWriteArrayList<Listener>();
155
156    private Handler mMainHandler;
157
158    public ContactSaveService() {
159        super(TAG);
160        setIntentRedelivery(true);
161        mMainHandler = new Handler(Looper.getMainLooper());
162    }
163
164    public static void registerListener(Listener listener) {
165        if (!(listener instanceof Activity)) {
166            throw new ClassCastException("Only activities can be registered to"
167                    + " receive callback from " + ContactSaveService.class.getName());
168        }
169        sListeners.add(0, listener);
170    }
171
172    public static void unregisterListener(Listener listener) {
173        sListeners.remove(listener);
174    }
175
176    @Override
177    public Object getSystemService(String name) {
178        Object service = super.getSystemService(name);
179        if (service != null) {
180            return service;
181        }
182
183        return getApplicationContext().getSystemService(name);
184    }
185
186    @Override
187    protected void onHandleIntent(Intent intent) {
188        if (intent == null) {
189            Log.d(TAG, "onHandleIntent: could not handle null intent");
190            return;
191        }
192        if (!PermissionsUtil.hasPermission(this, WRITE_CONTACTS)) {
193            Log.w(TAG, "No WRITE_CONTACTS permission, unable to write to CP2");
194            // TODO: add more specific error string such as "Turn on Contacts
195            // permission to update your contacts"
196            showToast(R.string.contactSavedErrorToast);
197            return;
198        }
199
200        // Call an appropriate method. If we're sure it affects how incoming phone calls are
201        // handled, then notify the fact to in-call screen.
202        String action = intent.getAction();
203        if (ACTION_NEW_RAW_CONTACT.equals(action)) {
204            createRawContact(intent);
205        } else if (ACTION_SAVE_CONTACT.equals(action)) {
206            saveContact(intent);
207        } else if (ACTION_CREATE_GROUP.equals(action)) {
208            createGroup(intent);
209        } else if (ACTION_RENAME_GROUP.equals(action)) {
210            renameGroup(intent);
211        } else if (ACTION_DELETE_GROUP.equals(action)) {
212            deleteGroup(intent);
213        } else if (ACTION_UPDATE_GROUP.equals(action)) {
214            updateGroup(intent);
215        } else if (ACTION_SET_STARRED.equals(action)) {
216            setStarred(intent);
217        } else if (ACTION_SET_SUPER_PRIMARY.equals(action)) {
218            setSuperPrimary(intent);
219        } else if (ACTION_CLEAR_PRIMARY.equals(action)) {
220            clearPrimary(intent);
221        } else if (ACTION_DELETE_MULTIPLE_CONTACTS.equals(action)) {
222            deleteMultipleContacts(intent);
223        } else if (ACTION_DELETE_CONTACT.equals(action)) {
224            deleteContact(intent);
225        } else if (ACTION_JOIN_CONTACTS.equals(action)) {
226            joinContacts(intent);
227        } else if (ACTION_JOIN_SEVERAL_CONTACTS.equals(action)) {
228            joinSeveralContacts(intent);
229        } else if (ACTION_SET_SEND_TO_VOICEMAIL.equals(action)) {
230            setSendToVoicemail(intent);
231        } else if (ACTION_SET_RINGTONE.equals(action)) {
232            setRingtone(intent);
233        }
234    }
235
236    /**
237     * Creates an intent that can be sent to this service to create a new raw contact
238     * using data presented as a set of ContentValues.
239     */
240    public static Intent createNewRawContactIntent(Context context,
241            ArrayList<ContentValues> values, AccountWithDataSet account,
242            Class<? extends Activity> callbackActivity, String callbackAction) {
243        Intent serviceIntent = new Intent(
244                context, ContactSaveService.class);
245        serviceIntent.setAction(ContactSaveService.ACTION_NEW_RAW_CONTACT);
246        if (account != null) {
247            serviceIntent.putExtra(ContactSaveService.EXTRA_ACCOUNT_NAME, account.name);
248            serviceIntent.putExtra(ContactSaveService.EXTRA_ACCOUNT_TYPE, account.type);
249            serviceIntent.putExtra(ContactSaveService.EXTRA_DATA_SET, account.dataSet);
250        }
251        serviceIntent.putParcelableArrayListExtra(
252                ContactSaveService.EXTRA_CONTENT_VALUES, values);
253
254        // Callback intent will be invoked by the service once the new contact is
255        // created.  The service will put the URI of the new contact as "data" on
256        // the callback intent.
257        Intent callbackIntent = new Intent(context, callbackActivity);
258        callbackIntent.setAction(callbackAction);
259        serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
260        return serviceIntent;
261    }
262
263    private void createRawContact(Intent intent) {
264        String accountName = intent.getStringExtra(EXTRA_ACCOUNT_NAME);
265        String accountType = intent.getStringExtra(EXTRA_ACCOUNT_TYPE);
266        String dataSet = intent.getStringExtra(EXTRA_DATA_SET);
267        List<ContentValues> valueList = intent.getParcelableArrayListExtra(EXTRA_CONTENT_VALUES);
268        Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
269
270        ArrayList<ContentProviderOperation> operations = new ArrayList<ContentProviderOperation>();
271        operations.add(ContentProviderOperation.newInsert(RawContacts.CONTENT_URI)
272                .withValue(RawContacts.ACCOUNT_NAME, accountName)
273                .withValue(RawContacts.ACCOUNT_TYPE, accountType)
274                .withValue(RawContacts.DATA_SET, dataSet)
275                .build());
276
277        int size = valueList.size();
278        for (int i = 0; i < size; i++) {
279            ContentValues values = valueList.get(i);
280            values.keySet().retainAll(ALLOWED_DATA_COLUMNS);
281            operations.add(ContentProviderOperation.newInsert(Data.CONTENT_URI)
282                    .withValueBackReference(Data.RAW_CONTACT_ID, 0)
283                    .withValues(values)
284                    .build());
285        }
286
287        ContentResolver resolver = getContentResolver();
288        ContentProviderResult[] results;
289        try {
290            results = resolver.applyBatch(ContactsContract.AUTHORITY, operations);
291        } catch (Exception e) {
292            throw new RuntimeException("Failed to store new contact", e);
293        }
294
295        Uri rawContactUri = results[0].uri;
296        callbackIntent.setData(RawContacts.getContactLookupUri(resolver, rawContactUri));
297
298        deliverCallback(callbackIntent);
299    }
300
301    /**
302     * Creates an intent that can be sent to this service to create a new raw contact
303     * using data presented as a set of ContentValues.
304     * This variant is more convenient to use when there is only one photo that can
305     * possibly be updated, as in the Contact Details screen.
306     * @param rawContactId identifies a writable raw-contact whose photo is to be updated.
307     * @param updatedPhotoPath denotes a temporary file containing the contact's new photo.
308     */
309    public static Intent createSaveContactIntent(Context context, RawContactDeltaList state,
310            String saveModeExtraKey, int saveMode, boolean isProfile,
311            Class<? extends Activity> callbackActivity, String callbackAction, long rawContactId,
312            Uri updatedPhotoPath) {
313        Bundle bundle = new Bundle();
314        bundle.putParcelable(String.valueOf(rawContactId), updatedPhotoPath);
315        return createSaveContactIntent(context, state, saveModeExtraKey, saveMode, isProfile,
316                callbackActivity, callbackAction, bundle, /* backPressed =*/ false);
317    }
318
319    /**
320     * Creates an intent that can be sent to this service to create a new raw contact
321     * using data presented as a set of ContentValues.
322     * This variant is used when multiple contacts' photos may be updated, as in the
323     * Contact Editor.
324     * @param updatedPhotos maps each raw-contact's ID to the file-path of the new photo.
325     * @param backPressed whether the save was initiated as a result of a back button press
326     *         or because the framework stopped the editor Activity
327     */
328    public static Intent createSaveContactIntent(Context context, RawContactDeltaList state,
329            String saveModeExtraKey, int saveMode, boolean isProfile,
330            Class<? extends Activity> callbackActivity, String callbackAction,
331            Bundle updatedPhotos, boolean backPressed) {
332        Intent serviceIntent = new Intent(
333                context, ContactSaveService.class);
334        serviceIntent.setAction(ContactSaveService.ACTION_SAVE_CONTACT);
335        serviceIntent.putExtra(EXTRA_CONTACT_STATE, (Parcelable) state);
336        serviceIntent.putExtra(EXTRA_SAVE_IS_PROFILE, isProfile);
337        if (updatedPhotos != null) {
338            serviceIntent.putExtra(EXTRA_UPDATED_PHOTOS, (Parcelable) updatedPhotos);
339        }
340
341        if (callbackActivity != null) {
342            // Callback intent will be invoked by the service once the contact is
343            // saved.  The service will put the URI of the new contact as "data" on
344            // the callback intent.
345            Intent callbackIntent = new Intent(context, callbackActivity);
346            callbackIntent.putExtra(saveModeExtraKey, saveMode);
347            callbackIntent.setAction(callbackAction);
348            if (updatedPhotos != null) {
349                callbackIntent.putExtra(EXTRA_UPDATED_PHOTOS, (Parcelable) updatedPhotos);
350            }
351            callbackIntent.putExtra(ContactEditorFragment.INTENT_EXTRA_SAVE_BACK_PRESSED,
352                    backPressed);
353            serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
354        }
355        return serviceIntent;
356    }
357
358    private void saveContact(Intent intent) {
359        RawContactDeltaList state = intent.getParcelableExtra(EXTRA_CONTACT_STATE);
360        boolean isProfile = intent.getBooleanExtra(EXTRA_SAVE_IS_PROFILE, false);
361        Bundle updatedPhotos = intent.getParcelableExtra(EXTRA_UPDATED_PHOTOS);
362
363        if (state == null) {
364            Log.e(TAG, "Invalid arguments for saveContact request");
365            return;
366        }
367
368        // Trim any empty fields, and RawContacts, before persisting
369        final AccountTypeManager accountTypes = AccountTypeManager.getInstance(this);
370        RawContactModifier.trimEmpty(state, accountTypes);
371
372        Uri lookupUri = null;
373
374        final ContentResolver resolver = getContentResolver();
375        boolean succeeded = false;
376
377        // Keep track of the id of a newly raw-contact (if any... there can be at most one).
378        long insertedRawContactId = -1;
379
380        // Attempt to persist changes
381        int tries = 0;
382        while (tries++ < PERSIST_TRIES) {
383            try {
384                // Build operations and try applying
385                final ArrayList<ContentProviderOperation> diff = state.buildDiff();
386                if (DEBUG) {
387                    Log.v(TAG, "Content Provider Operations:");
388                    for (ContentProviderOperation operation : diff) {
389                        Log.v(TAG, operation.toString());
390                    }
391                }
392
393                ContentProviderResult[] results = null;
394                if (!diff.isEmpty()) {
395                    results = resolver.applyBatch(ContactsContract.AUTHORITY, diff);
396                    if (results == null) {
397                        Log.w(TAG, "Resolver.applyBatch failed in saveContacts");
398                        // Retry save
399                        continue;
400                    }
401                }
402
403                final long rawContactId = getRawContactId(state, diff, results);
404                if (rawContactId == -1) {
405                    throw new IllegalStateException("Could not determine RawContact ID after save");
406                }
407                // We don't have to check to see if the value is still -1.  If we reach here,
408                // the previous loop iteration didn't succeed, so any ID that we obtained is bogus.
409                insertedRawContactId = getInsertedRawContactId(diff, results);
410                if (isProfile) {
411                    // Since the profile supports local raw contacts, which may have been completely
412                    // removed if all information was removed, we need to do a special query to
413                    // get the lookup URI for the profile contact (if it still exists).
414                    Cursor c = resolver.query(Profile.CONTENT_URI,
415                            new String[] {Contacts._ID, Contacts.LOOKUP_KEY},
416                            null, null, null);
417                    if (c == null) {
418                        continue;
419                    }
420                    try {
421                        if (c.moveToFirst()) {
422                            final long contactId = c.getLong(0);
423                            final String lookupKey = c.getString(1);
424                            lookupUri = Contacts.getLookupUri(contactId, lookupKey);
425                        }
426                    } finally {
427                        c.close();
428                    }
429                } else {
430                    final Uri rawContactUri = ContentUris.withAppendedId(RawContacts.CONTENT_URI,
431                                    rawContactId);
432                    lookupUri = RawContacts.getContactLookupUri(resolver, rawContactUri);
433                }
434                if (lookupUri != null) {
435                    Log.v(TAG, "Saved contact. New URI: " + lookupUri);
436                }
437
438                // We can change this back to false later, if we fail to save the contact photo.
439                succeeded = true;
440                break;
441
442            } catch (RemoteException e) {
443                // Something went wrong, bail without success
444                Log.e(TAG, "Problem persisting user edits", e);
445                break;
446
447            } catch (IllegalArgumentException e) {
448                // This is thrown by applyBatch on malformed requests
449                Log.e(TAG, "Problem persisting user edits", e);
450                showToast(R.string.contactSavedErrorToast);
451                break;
452
453            } catch (OperationApplicationException e) {
454                // Version consistency failed, re-parent change and try again
455                Log.w(TAG, "Version consistency failed, re-parenting: " + e.toString());
456                final StringBuilder sb = new StringBuilder(RawContacts._ID + " IN(");
457                boolean first = true;
458                final int count = state.size();
459                for (int i = 0; i < count; i++) {
460                    Long rawContactId = state.getRawContactId(i);
461                    if (rawContactId != null && rawContactId != -1) {
462                        if (!first) {
463                            sb.append(',');
464                        }
465                        sb.append(rawContactId);
466                        first = false;
467                    }
468                }
469                sb.append(")");
470
471                if (first) {
472                    throw new IllegalStateException(
473                            "Version consistency failed for a new contact", e);
474                }
475
476                final RawContactDeltaList newState = RawContactDeltaList.fromQuery(
477                        isProfile
478                                ? RawContactsEntity.PROFILE_CONTENT_URI
479                                : RawContactsEntity.CONTENT_URI,
480                        resolver, sb.toString(), null, null);
481                state = RawContactDeltaList.mergeAfter(newState, state);
482
483                // Update the new state to use profile URIs if appropriate.
484                if (isProfile) {
485                    for (RawContactDelta delta : state) {
486                        delta.setProfileQueryUri();
487                    }
488                }
489            }
490        }
491
492        // Now save any updated photos.  We do this at the end to ensure that
493        // the ContactProvider already knows about newly-created contacts.
494        if (updatedPhotos != null) {
495            for (String key : updatedPhotos.keySet()) {
496                Uri photoUri = updatedPhotos.getParcelable(key);
497                long rawContactId = Long.parseLong(key);
498
499                // If the raw-contact ID is negative, we are saving a new raw-contact;
500                // replace the bogus ID with the new one that we actually saved the contact at.
501                if (rawContactId < 0) {
502                    rawContactId = insertedRawContactId;
503                }
504
505                // If the save failed, insertedRawContactId will be -1
506                if (rawContactId < 0 || !saveUpdatedPhoto(rawContactId, photoUri)) {
507                    succeeded = false;
508                }
509            }
510        }
511
512        Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
513        if (callbackIntent != null) {
514            if (succeeded) {
515                // Mark the intent to indicate that the save was successful (even if the lookup URI
516                // is now null).  For local contacts or the local profile, it's possible that the
517                // save triggered removal of the contact, so no lookup URI would exist..
518                callbackIntent.putExtra(EXTRA_SAVE_SUCCEEDED, true);
519            }
520            callbackIntent.setData(lookupUri);
521            deliverCallback(callbackIntent);
522        }
523    }
524
525    /**
526     * Save updated photo for the specified raw-contact.
527     * @return true for success, false for failure
528     */
529    private boolean saveUpdatedPhoto(long rawContactId, Uri photoUri) {
530        final Uri outputUri = Uri.withAppendedPath(
531                ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId),
532                RawContacts.DisplayPhoto.CONTENT_DIRECTORY);
533
534        return ContactPhotoUtils.savePhotoFromUriToUri(this, photoUri, outputUri, true);
535    }
536
537    /**
538     * Find the ID of an existing or newly-inserted raw-contact.  If none exists, return -1.
539     */
540    private long getRawContactId(RawContactDeltaList state,
541            final ArrayList<ContentProviderOperation> diff,
542            final ContentProviderResult[] results) {
543        long existingRawContactId = state.findRawContactId();
544        if (existingRawContactId != -1) {
545            return existingRawContactId;
546        }
547
548        return getInsertedRawContactId(diff, results);
549    }
550
551    /**
552     * Find the ID of a newly-inserted raw-contact.  If none exists, return -1.
553     */
554    private long getInsertedRawContactId(
555            final ArrayList<ContentProviderOperation> diff,
556            final ContentProviderResult[] results) {
557        if (results == null) {
558            return -1;
559        }
560        final int diffSize = diff.size();
561        final int numResults = results.length;
562        for (int i = 0; i < diffSize && i < numResults; i++) {
563            ContentProviderOperation operation = diff.get(i);
564            if (operation.isInsert() && operation.getUri().getEncodedPath().contains(
565                            RawContacts.CONTENT_URI.getEncodedPath())) {
566                return ContentUris.parseId(results[i].uri);
567            }
568        }
569        return -1;
570    }
571
572    /**
573     * Creates an intent that can be sent to this service to create a new group as
574     * well as add new members at the same time.
575     *
576     * @param context of the application
577     * @param account in which the group should be created
578     * @param label is the name of the group (cannot be null)
579     * @param rawContactsToAdd is an array of raw contact IDs for contacts that
580     *            should be added to the group
581     * @param callbackActivity is the activity to send the callback intent to
582     * @param callbackAction is the intent action for the callback intent
583     */
584    public static Intent createNewGroupIntent(Context context, AccountWithDataSet account,
585            String label, long[] rawContactsToAdd, Class<? extends Activity> callbackActivity,
586            String callbackAction) {
587        Intent serviceIntent = new Intent(context, ContactSaveService.class);
588        serviceIntent.setAction(ContactSaveService.ACTION_CREATE_GROUP);
589        serviceIntent.putExtra(ContactSaveService.EXTRA_ACCOUNT_TYPE, account.type);
590        serviceIntent.putExtra(ContactSaveService.EXTRA_ACCOUNT_NAME, account.name);
591        serviceIntent.putExtra(ContactSaveService.EXTRA_DATA_SET, account.dataSet);
592        serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_LABEL, label);
593        serviceIntent.putExtra(ContactSaveService.EXTRA_RAW_CONTACTS_TO_ADD, rawContactsToAdd);
594
595        // Callback intent will be invoked by the service once the new group is
596        // created.
597        Intent callbackIntent = new Intent(context, callbackActivity);
598        callbackIntent.setAction(callbackAction);
599        serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
600
601        return serviceIntent;
602    }
603
604    private void createGroup(Intent intent) {
605        String accountType = intent.getStringExtra(EXTRA_ACCOUNT_TYPE);
606        String accountName = intent.getStringExtra(EXTRA_ACCOUNT_NAME);
607        String dataSet = intent.getStringExtra(EXTRA_DATA_SET);
608        String label = intent.getStringExtra(EXTRA_GROUP_LABEL);
609        final long[] rawContactsToAdd = intent.getLongArrayExtra(EXTRA_RAW_CONTACTS_TO_ADD);
610
611        ContentValues values = new ContentValues();
612        values.put(Groups.ACCOUNT_TYPE, accountType);
613        values.put(Groups.ACCOUNT_NAME, accountName);
614        values.put(Groups.DATA_SET, dataSet);
615        values.put(Groups.TITLE, label);
616
617        final ContentResolver resolver = getContentResolver();
618
619        // Create the new group
620        final Uri groupUri = resolver.insert(Groups.CONTENT_URI, values);
621
622        // If there's no URI, then the insertion failed. Abort early because group members can't be
623        // added if the group doesn't exist
624        if (groupUri == null) {
625            Log.e(TAG, "Couldn't create group with label " + label);
626            return;
627        }
628
629        // Add new group members
630        addMembersToGroup(resolver, rawContactsToAdd, ContentUris.parseId(groupUri));
631
632        // TODO: Move this into the contact editor where it belongs. This needs to be integrated
633        // with the way other intent extras that are passed to the {@link ContactEditorActivity}.
634        values.clear();
635        values.put(Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE);
636        values.put(GroupMembership.GROUP_ROW_ID, ContentUris.parseId(groupUri));
637
638        Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
639        callbackIntent.setData(groupUri);
640        // TODO: This can be taken out when the above TODO is addressed
641        callbackIntent.putExtra(ContactsContract.Intents.Insert.DATA, Lists.newArrayList(values));
642        deliverCallback(callbackIntent);
643    }
644
645    /**
646     * Creates an intent that can be sent to this service to rename a group.
647     */
648    public static Intent createGroupRenameIntent(Context context, long groupId, String newLabel,
649            Class<? extends Activity> callbackActivity, String callbackAction) {
650        Intent serviceIntent = new Intent(context, ContactSaveService.class);
651        serviceIntent.setAction(ContactSaveService.ACTION_RENAME_GROUP);
652        serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_ID, groupId);
653        serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_LABEL, newLabel);
654
655        // Callback intent will be invoked by the service once the group is renamed.
656        Intent callbackIntent = new Intent(context, callbackActivity);
657        callbackIntent.setAction(callbackAction);
658        serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
659
660        return serviceIntent;
661    }
662
663    private void renameGroup(Intent intent) {
664        long groupId = intent.getLongExtra(EXTRA_GROUP_ID, -1);
665        String label = intent.getStringExtra(EXTRA_GROUP_LABEL);
666
667        if (groupId == -1) {
668            Log.e(TAG, "Invalid arguments for renameGroup request");
669            return;
670        }
671
672        ContentValues values = new ContentValues();
673        values.put(Groups.TITLE, label);
674        final Uri groupUri = ContentUris.withAppendedId(Groups.CONTENT_URI, groupId);
675        getContentResolver().update(groupUri, values, null, null);
676
677        Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
678        callbackIntent.setData(groupUri);
679        deliverCallback(callbackIntent);
680    }
681
682    /**
683     * Creates an intent that can be sent to this service to delete a group.
684     */
685    public static Intent createGroupDeletionIntent(Context context, long groupId) {
686        Intent serviceIntent = new Intent(context, ContactSaveService.class);
687        serviceIntent.setAction(ContactSaveService.ACTION_DELETE_GROUP);
688        serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_ID, groupId);
689        return serviceIntent;
690    }
691
692    private void deleteGroup(Intent intent) {
693        long groupId = intent.getLongExtra(EXTRA_GROUP_ID, -1);
694        if (groupId == -1) {
695            Log.e(TAG, "Invalid arguments for deleteGroup request");
696            return;
697        }
698
699        getContentResolver().delete(
700                ContentUris.withAppendedId(Groups.CONTENT_URI, groupId), null, null);
701    }
702
703    /**
704     * Creates an intent that can be sent to this service to rename a group as
705     * well as add and remove members from the group.
706     *
707     * @param context of the application
708     * @param groupId of the group that should be modified
709     * @param newLabel is the updated name of the group (can be null if the name
710     *            should not be updated)
711     * @param rawContactsToAdd is an array of raw contact IDs for contacts that
712     *            should be added to the group
713     * @param rawContactsToRemove is an array of raw contact IDs for contacts
714     *            that should be removed from the group
715     * @param callbackActivity is the activity to send the callback intent to
716     * @param callbackAction is the intent action for the callback intent
717     */
718    public static Intent createGroupUpdateIntent(Context context, long groupId, String newLabel,
719            long[] rawContactsToAdd, long[] rawContactsToRemove,
720            Class<? extends Activity> callbackActivity, String callbackAction) {
721        Intent serviceIntent = new Intent(context, ContactSaveService.class);
722        serviceIntent.setAction(ContactSaveService.ACTION_UPDATE_GROUP);
723        serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_ID, groupId);
724        serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_LABEL, newLabel);
725        serviceIntent.putExtra(ContactSaveService.EXTRA_RAW_CONTACTS_TO_ADD, rawContactsToAdd);
726        serviceIntent.putExtra(ContactSaveService.EXTRA_RAW_CONTACTS_TO_REMOVE,
727                rawContactsToRemove);
728
729        // Callback intent will be invoked by the service once the group is updated
730        Intent callbackIntent = new Intent(context, callbackActivity);
731        callbackIntent.setAction(callbackAction);
732        serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
733
734        return serviceIntent;
735    }
736
737    private void updateGroup(Intent intent) {
738        long groupId = intent.getLongExtra(EXTRA_GROUP_ID, -1);
739        String label = intent.getStringExtra(EXTRA_GROUP_LABEL);
740        long[] rawContactsToAdd = intent.getLongArrayExtra(EXTRA_RAW_CONTACTS_TO_ADD);
741        long[] rawContactsToRemove = intent.getLongArrayExtra(EXTRA_RAW_CONTACTS_TO_REMOVE);
742
743        if (groupId == -1) {
744            Log.e(TAG, "Invalid arguments for updateGroup request");
745            return;
746        }
747
748        final ContentResolver resolver = getContentResolver();
749        final Uri groupUri = ContentUris.withAppendedId(Groups.CONTENT_URI, groupId);
750
751        // Update group name if necessary
752        if (label != null) {
753            ContentValues values = new ContentValues();
754            values.put(Groups.TITLE, label);
755            resolver.update(groupUri, values, null, null);
756        }
757
758        // Add and remove members if necessary
759        addMembersToGroup(resolver, rawContactsToAdd, groupId);
760        removeMembersFromGroup(resolver, rawContactsToRemove, groupId);
761
762        Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
763        callbackIntent.setData(groupUri);
764        deliverCallback(callbackIntent);
765    }
766
767    private static void addMembersToGroup(ContentResolver resolver, long[] rawContactsToAdd,
768            long groupId) {
769        if (rawContactsToAdd == null) {
770            return;
771        }
772        for (long rawContactId : rawContactsToAdd) {
773            try {
774                final ArrayList<ContentProviderOperation> rawContactOperations =
775                        new ArrayList<ContentProviderOperation>();
776
777                // Build an assert operation to ensure the contact is not already in the group
778                final ContentProviderOperation.Builder assertBuilder = ContentProviderOperation
779                        .newAssertQuery(Data.CONTENT_URI);
780                assertBuilder.withSelection(Data.RAW_CONTACT_ID + "=? AND " +
781                        Data.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?",
782                        new String[] { String.valueOf(rawContactId),
783                        GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(groupId)});
784                assertBuilder.withExpectedCount(0);
785                rawContactOperations.add(assertBuilder.build());
786
787                // Build an insert operation to add the contact to the group
788                final ContentProviderOperation.Builder insertBuilder = ContentProviderOperation
789                        .newInsert(Data.CONTENT_URI);
790                insertBuilder.withValue(Data.RAW_CONTACT_ID, rawContactId);
791                insertBuilder.withValue(Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE);
792                insertBuilder.withValue(GroupMembership.GROUP_ROW_ID, groupId);
793                rawContactOperations.add(insertBuilder.build());
794
795                if (DEBUG) {
796                    for (ContentProviderOperation operation : rawContactOperations) {
797                        Log.v(TAG, operation.toString());
798                    }
799                }
800
801                // Apply batch
802                if (!rawContactOperations.isEmpty()) {
803                    resolver.applyBatch(ContactsContract.AUTHORITY, rawContactOperations);
804                }
805            } catch (RemoteException e) {
806                // Something went wrong, bail without success
807                Log.e(TAG, "Problem persisting user edits for raw contact ID " +
808                        String.valueOf(rawContactId), e);
809            } catch (OperationApplicationException e) {
810                // The assert could have failed because the contact is already in the group,
811                // just continue to the next contact
812                Log.w(TAG, "Assert failed in adding raw contact ID " +
813                        String.valueOf(rawContactId) + ". Already exists in group " +
814                        String.valueOf(groupId), e);
815            }
816        }
817    }
818
819    private static void removeMembersFromGroup(ContentResolver resolver, long[] rawContactsToRemove,
820            long groupId) {
821        if (rawContactsToRemove == null) {
822            return;
823        }
824        for (long rawContactId : rawContactsToRemove) {
825            // Apply the delete operation on the data row for the given raw contact's
826            // membership in the given group. If no contact matches the provided selection, then
827            // nothing will be done. Just continue to the next contact.
828            resolver.delete(Data.CONTENT_URI, Data.RAW_CONTACT_ID + "=? AND " +
829                    Data.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?",
830                    new String[] { String.valueOf(rawContactId),
831                    GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(groupId)});
832        }
833    }
834
835    /**
836     * Creates an intent that can be sent to this service to star or un-star a contact.
837     */
838    public static Intent createSetStarredIntent(Context context, Uri contactUri, boolean value) {
839        Intent serviceIntent = new Intent(context, ContactSaveService.class);
840        serviceIntent.setAction(ContactSaveService.ACTION_SET_STARRED);
841        serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_URI, contactUri);
842        serviceIntent.putExtra(ContactSaveService.EXTRA_STARRED_FLAG, value);
843
844        return serviceIntent;
845    }
846
847    private void setStarred(Intent intent) {
848        Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI);
849        boolean value = intent.getBooleanExtra(EXTRA_STARRED_FLAG, false);
850        if (contactUri == null) {
851            Log.e(TAG, "Invalid arguments for setStarred request");
852            return;
853        }
854
855        final ContentValues values = new ContentValues(1);
856        values.put(Contacts.STARRED, value);
857        getContentResolver().update(contactUri, values, null, null);
858
859        // Undemote the contact if necessary
860        final Cursor c = getContentResolver().query(contactUri, new String[] {Contacts._ID},
861                null, null, null);
862        if (c == null) {
863            return;
864        }
865        try {
866            if (c.moveToFirst()) {
867                final long id = c.getLong(0);
868
869                // Don't bother undemoting if this contact is the user's profile.
870                if (id < Profile.MIN_ID) {
871                    PinnedPositions.undemote(getContentResolver(), id);
872                }
873            }
874        } finally {
875            c.close();
876        }
877    }
878
879    /**
880     * Creates an intent that can be sent to this service to set the redirect to voicemail.
881     */
882    public static Intent createSetSendToVoicemail(Context context, Uri contactUri,
883            boolean value) {
884        Intent serviceIntent = new Intent(context, ContactSaveService.class);
885        serviceIntent.setAction(ContactSaveService.ACTION_SET_SEND_TO_VOICEMAIL);
886        serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_URI, contactUri);
887        serviceIntent.putExtra(ContactSaveService.EXTRA_SEND_TO_VOICEMAIL_FLAG, value);
888
889        return serviceIntent;
890    }
891
892    private void setSendToVoicemail(Intent intent) {
893        Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI);
894        boolean value = intent.getBooleanExtra(EXTRA_SEND_TO_VOICEMAIL_FLAG, false);
895        if (contactUri == null) {
896            Log.e(TAG, "Invalid arguments for setRedirectToVoicemail");
897            return;
898        }
899
900        final ContentValues values = new ContentValues(1);
901        values.put(Contacts.SEND_TO_VOICEMAIL, value);
902        getContentResolver().update(contactUri, values, null, null);
903    }
904
905    /**
906     * Creates an intent that can be sent to this service to save the contact's ringtone.
907     */
908    public static Intent createSetRingtone(Context context, Uri contactUri,
909            String value) {
910        Intent serviceIntent = new Intent(context, ContactSaveService.class);
911        serviceIntent.setAction(ContactSaveService.ACTION_SET_RINGTONE);
912        serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_URI, contactUri);
913        serviceIntent.putExtra(ContactSaveService.EXTRA_CUSTOM_RINGTONE, value);
914
915        return serviceIntent;
916    }
917
918    private void setRingtone(Intent intent) {
919        Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI);
920        String value = intent.getStringExtra(EXTRA_CUSTOM_RINGTONE);
921        if (contactUri == null) {
922            Log.e(TAG, "Invalid arguments for setRingtone");
923            return;
924        }
925        ContentValues values = new ContentValues(1);
926        values.put(Contacts.CUSTOM_RINGTONE, value);
927        getContentResolver().update(contactUri, values, null, null);
928    }
929
930    /**
931     * Creates an intent that sets the selected data item as super primary (default)
932     */
933    public static Intent createSetSuperPrimaryIntent(Context context, long dataId) {
934        Intent serviceIntent = new Intent(context, ContactSaveService.class);
935        serviceIntent.setAction(ContactSaveService.ACTION_SET_SUPER_PRIMARY);
936        serviceIntent.putExtra(ContactSaveService.EXTRA_DATA_ID, dataId);
937        return serviceIntent;
938    }
939
940    private void setSuperPrimary(Intent intent) {
941        long dataId = intent.getLongExtra(EXTRA_DATA_ID, -1);
942        if (dataId == -1) {
943            Log.e(TAG, "Invalid arguments for setSuperPrimary request");
944            return;
945        }
946
947        ContactUpdateUtils.setSuperPrimary(this, dataId);
948    }
949
950    /**
951     * Creates an intent that clears the primary flag of all data items that belong to the same
952     * raw_contact as the given data item. Will only clear, if the data item was primary before
953     * this call
954     */
955    public static Intent createClearPrimaryIntent(Context context, long dataId) {
956        Intent serviceIntent = new Intent(context, ContactSaveService.class);
957        serviceIntent.setAction(ContactSaveService.ACTION_CLEAR_PRIMARY);
958        serviceIntent.putExtra(ContactSaveService.EXTRA_DATA_ID, dataId);
959        return serviceIntent;
960    }
961
962    private void clearPrimary(Intent intent) {
963        long dataId = intent.getLongExtra(EXTRA_DATA_ID, -1);
964        if (dataId == -1) {
965            Log.e(TAG, "Invalid arguments for clearPrimary request");
966            return;
967        }
968
969        // Update the primary values in the data record.
970        ContentValues values = new ContentValues(1);
971        values.put(Data.IS_SUPER_PRIMARY, 0);
972        values.put(Data.IS_PRIMARY, 0);
973
974        getContentResolver().update(ContentUris.withAppendedId(Data.CONTENT_URI, dataId),
975                values, null, null);
976    }
977
978    /**
979     * Creates an intent that can be sent to this service to delete a contact.
980     */
981    public static Intent createDeleteContactIntent(Context context, Uri contactUri) {
982        Intent serviceIntent = new Intent(context, ContactSaveService.class);
983        serviceIntent.setAction(ContactSaveService.ACTION_DELETE_CONTACT);
984        serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_URI, contactUri);
985        return serviceIntent;
986    }
987
988    /**
989     * Creates an intent that can be sent to this service to delete multiple contacts.
990     */
991    public static Intent createDeleteMultipleContactsIntent(Context context,
992            long[] contactIds) {
993        Intent serviceIntent = new Intent(context, ContactSaveService.class);
994        serviceIntent.setAction(ContactSaveService.ACTION_DELETE_MULTIPLE_CONTACTS);
995        serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_IDS, contactIds);
996        return serviceIntent;
997    }
998
999    private void deleteContact(Intent intent) {
1000        Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI);
1001        if (contactUri == null) {
1002            Log.e(TAG, "Invalid arguments for deleteContact request");
1003            return;
1004        }
1005
1006        getContentResolver().delete(contactUri, null, null);
1007    }
1008
1009    private void deleteMultipleContacts(Intent intent) {
1010        final long[] contactIds = intent.getLongArrayExtra(EXTRA_CONTACT_IDS);
1011        if (contactIds == null) {
1012            Log.e(TAG, "Invalid arguments for deleteMultipleContacts request");
1013            return;
1014        }
1015        for (long contactId : contactIds) {
1016            final Uri contactUri = ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId);
1017            getContentResolver().delete(contactUri, null, null);
1018        }
1019        showToast(R.string.contacts_deleted_toast);
1020    }
1021
1022    /**
1023     * Creates an intent that can be sent to this service to join two contacts.
1024     * The resulting contact uses the name from {@param contactId1} if possible.
1025     */
1026    public static Intent createJoinContactsIntent(Context context, long contactId1,
1027            long contactId2, Class<? extends Activity> callbackActivity, String callbackAction) {
1028        Intent serviceIntent = new Intent(context, ContactSaveService.class);
1029        serviceIntent.setAction(ContactSaveService.ACTION_JOIN_CONTACTS);
1030        serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_ID1, contactId1);
1031        serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_ID2, contactId2);
1032
1033        // Callback intent will be invoked by the service once the contacts are joined.
1034        Intent callbackIntent = new Intent(context, callbackActivity);
1035        callbackIntent.setAction(callbackAction);
1036        serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
1037
1038        return serviceIntent;
1039    }
1040
1041    /**
1042     * Creates an intent to join all raw contacts inside {@param contactIds}'s contacts.
1043     * No special attention is paid to where the resulting contact's name is taken from.
1044     */
1045    public static Intent createJoinSeveralContactsIntent(Context context, long[] contactIds) {
1046        Intent serviceIntent = new Intent(context, ContactSaveService.class);
1047        serviceIntent.setAction(ContactSaveService.ACTION_JOIN_SEVERAL_CONTACTS);
1048        serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_IDS, contactIds);
1049        return serviceIntent;
1050    }
1051
1052
1053    private interface JoinContactQuery {
1054        String[] PROJECTION = {
1055                RawContacts._ID,
1056                RawContacts.CONTACT_ID,
1057                RawContacts.DISPLAY_NAME_SOURCE,
1058        };
1059
1060        int _ID = 0;
1061        int CONTACT_ID = 1;
1062        int DISPLAY_NAME_SOURCE = 2;
1063    }
1064
1065    private interface ContactEntityQuery {
1066        String[] PROJECTION = {
1067                Contacts.Entity.DATA_ID,
1068                Contacts.Entity.CONTACT_ID,
1069                Contacts.Entity.IS_SUPER_PRIMARY,
1070        };
1071        String SELECTION = Data.MIMETYPE + " = '" + StructuredName.CONTENT_ITEM_TYPE + "'" +
1072                " AND " + StructuredName.DISPLAY_NAME + "=" + Contacts.DISPLAY_NAME +
1073                " AND " + StructuredName.DISPLAY_NAME + " IS NOT NULL " +
1074                " AND " + StructuredName.DISPLAY_NAME + " != '' ";
1075
1076        int DATA_ID = 0;
1077        int CONTACT_ID = 1;
1078        int IS_SUPER_PRIMARY = 2;
1079    }
1080
1081    private void joinSeveralContacts(Intent intent) {
1082        final long[] contactIds = intent.getLongArrayExtra(EXTRA_CONTACT_IDS);
1083
1084        // Load raw contact IDs for all contacts involved.
1085        long rawContactIds[] = getRawContactIdsForAggregation(contactIds);
1086        if (rawContactIds == null) {
1087            Log.e(TAG, "Invalid arguments for joinSeveralContacts request");
1088            return;
1089        }
1090
1091        // For each pair of raw contacts, insert an aggregation exception
1092        final ContentResolver resolver = getContentResolver();
1093        // The maximum number of operations per batch (aka yield point) is 500. See b/22480225
1094        final int batchSize = MAX_CONTACTS_PROVIDER_BATCH_SIZE;
1095        final ArrayList<ContentProviderOperation> operations = new ArrayList<>(batchSize);
1096        for (int i = 0; i < rawContactIds.length; i++) {
1097            for (int j = 0; j < rawContactIds.length; j++) {
1098                if (i != j) {
1099                    buildJoinContactDiff(operations, rawContactIds[i], rawContactIds[j]);
1100                }
1101                // Before we get to 500 we need to flush the operations list
1102                if (operations.size() > 0 && operations.size() % batchSize == 0) {
1103                    if (!applyJoinOperations(resolver, operations)) {
1104                        return;
1105                    }
1106                    operations.clear();
1107                }
1108            }
1109        }
1110        if (operations.size() > 0 && !applyJoinOperations(resolver, operations)) {
1111            return;
1112        }
1113        showToast(R.string.contactsJoinedMessage);
1114    }
1115
1116    /** Returns true if the batch was successfully applied and false otherwise. */
1117    private boolean applyJoinOperations(ContentResolver resolver,
1118            ArrayList<ContentProviderOperation> operations) {
1119        try {
1120            resolver.applyBatch(ContactsContract.AUTHORITY, operations);
1121            return true;
1122        } catch (RemoteException | OperationApplicationException e) {
1123            Log.e(TAG, "Failed to apply aggregation exception batch", e);
1124            showToast(R.string.contactSavedErrorToast);
1125            return false;
1126        }
1127    }
1128
1129
1130    private void joinContacts(Intent intent) {
1131        long contactId1 = intent.getLongExtra(EXTRA_CONTACT_ID1, -1);
1132        long contactId2 = intent.getLongExtra(EXTRA_CONTACT_ID2, -1);
1133
1134        // Load raw contact IDs for all raw contacts involved - currently edited and selected
1135        // in the join UIs.
1136        long rawContactIds[] = getRawContactIdsForAggregation(contactId1, contactId2);
1137        if (rawContactIds == null) {
1138            Log.e(TAG, "Invalid arguments for joinContacts request");
1139            return;
1140        }
1141
1142        ArrayList<ContentProviderOperation> operations = new ArrayList<ContentProviderOperation>();
1143
1144        // For each pair of raw contacts, insert an aggregation exception
1145        for (int i = 0; i < rawContactIds.length; i++) {
1146            for (int j = 0; j < rawContactIds.length; j++) {
1147                if (i != j) {
1148                    buildJoinContactDiff(operations, rawContactIds[i], rawContactIds[j]);
1149                }
1150            }
1151        }
1152
1153        final ContentResolver resolver = getContentResolver();
1154
1155        // Use the name for contactId1 as the name for the newly aggregated contact.
1156        final Uri contactId1Uri = ContentUris.withAppendedId(
1157                Contacts.CONTENT_URI, contactId1);
1158        final Uri entityUri = Uri.withAppendedPath(
1159                contactId1Uri, Contacts.Entity.CONTENT_DIRECTORY);
1160        Cursor c = resolver.query(entityUri,
1161                ContactEntityQuery.PROJECTION, ContactEntityQuery.SELECTION, null, null);
1162        if (c == null) {
1163            Log.e(TAG, "Unable to open Contacts DB cursor");
1164            showToast(R.string.contactSavedErrorToast);
1165            return;
1166        }
1167        long dataIdToAddSuperPrimary = -1;
1168        try {
1169            if (c.moveToFirst()) {
1170                dataIdToAddSuperPrimary = c.getLong(ContactEntityQuery.DATA_ID);
1171            }
1172        } finally {
1173            c.close();
1174        }
1175
1176        // Mark the name from contactId1 IS_SUPER_PRIMARY to make sure that the contact
1177        // display name does not change as a result of the join.
1178        if (dataIdToAddSuperPrimary != -1) {
1179            Builder builder = ContentProviderOperation.newUpdate(
1180                    ContentUris.withAppendedId(Data.CONTENT_URI, dataIdToAddSuperPrimary));
1181            builder.withValue(Data.IS_SUPER_PRIMARY, 1);
1182            builder.withValue(Data.IS_PRIMARY, 1);
1183            operations.add(builder.build());
1184        }
1185
1186        boolean success = false;
1187        // Apply all aggregation exceptions as one batch
1188        try {
1189            resolver.applyBatch(ContactsContract.AUTHORITY, operations);
1190            showToast(R.string.contactsJoinedMessage);
1191            success = true;
1192        } catch (RemoteException | OperationApplicationException e) {
1193            Log.e(TAG, "Failed to apply aggregation exception batch", e);
1194            showToast(R.string.contactSavedErrorToast);
1195        }
1196
1197        Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
1198        if (success) {
1199            Uri uri = RawContacts.getContactLookupUri(resolver,
1200                    ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactIds[0]));
1201            callbackIntent.setData(uri);
1202        }
1203        deliverCallback(callbackIntent);
1204    }
1205
1206    private long[] getRawContactIdsForAggregation(long[] contactIds) {
1207        if (contactIds == null) {
1208            return null;
1209        }
1210
1211        final ContentResolver resolver = getContentResolver();
1212        long rawContactIds[];
1213
1214        final StringBuilder queryBuilder = new StringBuilder();
1215        final String stringContactIds[] = new String[contactIds.length];
1216        for (int i = 0; i < contactIds.length; i++) {
1217            queryBuilder.append(RawContacts.CONTACT_ID + "=?");
1218            stringContactIds[i] = String.valueOf(contactIds[i]);
1219            if (contactIds[i] == -1) {
1220                return null;
1221            }
1222            if (i == contactIds.length -1) {
1223                break;
1224            }
1225            queryBuilder.append(" OR ");
1226        }
1227
1228        final Cursor c = resolver.query(RawContacts.CONTENT_URI,
1229                JoinContactQuery.PROJECTION,
1230                queryBuilder.toString(),
1231                stringContactIds, null);
1232        if (c == null) {
1233            Log.e(TAG, "Unable to open Contacts DB cursor");
1234            showToast(R.string.contactSavedErrorToast);
1235            return null;
1236        }
1237        try {
1238            if (c.getCount() < 2) {
1239                Log.e(TAG, "Not enough raw contacts to aggregate together.");
1240                return null;
1241            }
1242            rawContactIds = new long[c.getCount()];
1243            for (int i = 0; i < rawContactIds.length; i++) {
1244                c.moveToPosition(i);
1245                long rawContactId = c.getLong(JoinContactQuery._ID);
1246                rawContactIds[i] = rawContactId;
1247            }
1248        } finally {
1249            c.close();
1250        }
1251        return rawContactIds;
1252    }
1253
1254    private long[] getRawContactIdsForAggregation(long contactId1, long contactId2) {
1255        return getRawContactIdsForAggregation(new long[] {contactId1, contactId2});
1256    }
1257
1258    /**
1259     * Construct a {@link AggregationExceptions#TYPE_KEEP_TOGETHER} ContentProviderOperation.
1260     */
1261    private void buildJoinContactDiff(ArrayList<ContentProviderOperation> operations,
1262            long rawContactId1, long rawContactId2) {
1263        Builder builder =
1264                ContentProviderOperation.newUpdate(AggregationExceptions.CONTENT_URI);
1265        builder.withValue(AggregationExceptions.TYPE, AggregationExceptions.TYPE_KEEP_TOGETHER);
1266        builder.withValue(AggregationExceptions.RAW_CONTACT_ID1, rawContactId1);
1267        builder.withValue(AggregationExceptions.RAW_CONTACT_ID2, rawContactId2);
1268        operations.add(builder.build());
1269    }
1270
1271    /**
1272     * Shows a toast on the UI thread.
1273     */
1274    private void showToast(final int message) {
1275        mMainHandler.post(new Runnable() {
1276
1277            @Override
1278            public void run() {
1279                Toast.makeText(ContactSaveService.this, message, Toast.LENGTH_LONG).show();
1280            }
1281        });
1282    }
1283
1284    private void deliverCallback(final Intent callbackIntent) {
1285        mMainHandler.post(new Runnable() {
1286
1287            @Override
1288            public void run() {
1289                deliverCallbackOnUiThread(callbackIntent);
1290            }
1291        });
1292    }
1293
1294    void deliverCallbackOnUiThread(final Intent callbackIntent) {
1295        // TODO: this assumes that if there are multiple instances of the same
1296        // activity registered, the last one registered is the one waiting for
1297        // the callback. Validity of this assumption needs to be verified.
1298        for (Listener listener : sListeners) {
1299            if (callbackIntent.getComponent().equals(
1300                    ((Activity) listener).getIntent().getComponent())) {
1301                listener.onServiceCompleted(callbackIntent);
1302                return;
1303            }
1304        }
1305    }
1306}
1307