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