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 com.android.contacts.model.AccountTypeManager;
20import com.android.contacts.model.AccountWithDataSet;
21import com.android.contacts.model.EntityDelta;
22import com.android.contacts.model.EntityDeltaList;
23import com.android.contacts.model.EntityModifier;
24import com.google.android.collect.Lists;
25import com.google.android.collect.Sets;
26
27import android.app.Activity;
28import android.app.IntentService;
29import android.content.ContentProviderOperation;
30import android.content.ContentProviderOperation.Builder;
31import android.content.ContentProviderResult;
32import android.content.ContentResolver;
33import android.content.ContentUris;
34import android.content.ContentValues;
35import android.content.Context;
36import android.content.Intent;
37import android.content.OperationApplicationException;
38import android.database.Cursor;
39import android.net.Uri;
40import android.os.Handler;
41import android.os.Looper;
42import android.os.Parcelable;
43import android.os.RemoteException;
44import android.provider.ContactsContract;
45import android.provider.ContactsContract.AggregationExceptions;
46import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
47import android.provider.ContactsContract.Contacts;
48import android.provider.ContactsContract.Data;
49import android.provider.ContactsContract.Groups;
50import android.provider.ContactsContract.Profile;
51import android.provider.ContactsContract.RawContacts;
52import android.provider.ContactsContract.RawContactsEntity;
53import android.util.Log;
54import android.widget.Toast;
55
56import java.util.ArrayList;
57import java.util.HashSet;
58import java.util.List;
59import java.util.concurrent.CopyOnWriteArrayList;
60
61/**
62 * A service responsible for saving changes to the content provider.
63 */
64public class ContactSaveService extends IntentService {
65    private static final String TAG = "ContactSaveService";
66
67    /** Set to true in order to view logs on content provider operations */
68    private static final boolean DEBUG = false;
69
70    public static final String ACTION_NEW_RAW_CONTACT = "newRawContact";
71
72    public static final String EXTRA_ACCOUNT_NAME = "accountName";
73    public static final String EXTRA_ACCOUNT_TYPE = "accountType";
74    public static final String EXTRA_DATA_SET = "dataSet";
75    public static final String EXTRA_CONTENT_VALUES = "contentValues";
76    public static final String EXTRA_CALLBACK_INTENT = "callbackIntent";
77
78    public static final String ACTION_SAVE_CONTACT = "saveContact";
79    public static final String EXTRA_CONTACT_STATE = "state";
80    public static final String EXTRA_SAVE_MODE = "saveMode";
81    public static final String EXTRA_SAVE_IS_PROFILE = "saveIsProfile";
82    public static final String EXTRA_SAVE_SUCCEEDED = "saveSucceeded";
83
84    public static final String ACTION_CREATE_GROUP = "createGroup";
85    public static final String ACTION_RENAME_GROUP = "renameGroup";
86    public static final String ACTION_DELETE_GROUP = "deleteGroup";
87    public static final String ACTION_UPDATE_GROUP = "updateGroup";
88    public static final String EXTRA_GROUP_ID = "groupId";
89    public static final String EXTRA_GROUP_LABEL = "groupLabel";
90    public static final String EXTRA_RAW_CONTACTS_TO_ADD = "rawContactsToAdd";
91    public static final String EXTRA_RAW_CONTACTS_TO_REMOVE = "rawContactsToRemove";
92
93    public static final String ACTION_SET_STARRED = "setStarred";
94    public static final String ACTION_DELETE_CONTACT = "delete";
95    public static final String EXTRA_CONTACT_URI = "contactUri";
96    public static final String EXTRA_STARRED_FLAG = "starred";
97
98    public static final String ACTION_SET_SUPER_PRIMARY = "setSuperPrimary";
99    public static final String ACTION_CLEAR_PRIMARY = "clearPrimary";
100    public static final String EXTRA_DATA_ID = "dataId";
101
102    public static final String ACTION_JOIN_CONTACTS = "joinContacts";
103    public static final String EXTRA_CONTACT_ID1 = "contactId1";
104    public static final String EXTRA_CONTACT_ID2 = "contactId2";
105    public static final String EXTRA_CONTACT_WRITABLE = "contactWritable";
106
107    public static final String ACTION_SET_SEND_TO_VOICEMAIL = "sendToVoicemail";
108    public static final String EXTRA_SEND_TO_VOICEMAIL_FLAG = "sendToVoicemailFlag";
109
110    public static final String ACTION_SET_RINGTONE = "setRingtone";
111    public static final String EXTRA_CUSTOM_RINGTONE = "customRingtone";
112
113    private static final HashSet<String> ALLOWED_DATA_COLUMNS = Sets.newHashSet(
114        Data.MIMETYPE,
115        Data.IS_PRIMARY,
116        Data.DATA1,
117        Data.DATA2,
118        Data.DATA3,
119        Data.DATA4,
120        Data.DATA5,
121        Data.DATA6,
122        Data.DATA7,
123        Data.DATA8,
124        Data.DATA9,
125        Data.DATA10,
126        Data.DATA11,
127        Data.DATA12,
128        Data.DATA13,
129        Data.DATA14,
130        Data.DATA15
131    );
132
133    private static final int PERSIST_TRIES = 3;
134
135    public interface Listener {
136        public void onServiceCompleted(Intent callbackIntent);
137    }
138
139    private static final CopyOnWriteArrayList<Listener> sListeners =
140            new CopyOnWriteArrayList<Listener>();
141
142    private Handler mMainHandler;
143
144    public ContactSaveService() {
145        super(TAG);
146        setIntentRedelivery(true);
147        mMainHandler = new Handler(Looper.getMainLooper());
148    }
149
150    public static void registerListener(Listener listener) {
151        if (!(listener instanceof Activity)) {
152            throw new ClassCastException("Only activities can be registered to"
153                    + " receive callback from " + ContactSaveService.class.getName());
154        }
155        sListeners.add(0, listener);
156    }
157
158    public static void unregisterListener(Listener listener) {
159        sListeners.remove(listener);
160    }
161
162    @Override
163    public Object getSystemService(String name) {
164        Object service = super.getSystemService(name);
165        if (service != null) {
166            return service;
167        }
168
169        return getApplicationContext().getSystemService(name);
170    }
171
172    @Override
173    protected void onHandleIntent(Intent intent) {
174        String action = intent.getAction();
175        if (ACTION_NEW_RAW_CONTACT.equals(action)) {
176            createRawContact(intent);
177        } else if (ACTION_SAVE_CONTACT.equals(action)) {
178            saveContact(intent);
179        } else if (ACTION_CREATE_GROUP.equals(action)) {
180            createGroup(intent);
181        } else if (ACTION_RENAME_GROUP.equals(action)) {
182            renameGroup(intent);
183        } else if (ACTION_DELETE_GROUP.equals(action)) {
184            deleteGroup(intent);
185        } else if (ACTION_UPDATE_GROUP.equals(action)) {
186            updateGroup(intent);
187        } else if (ACTION_SET_STARRED.equals(action)) {
188            setStarred(intent);
189        } else if (ACTION_SET_SUPER_PRIMARY.equals(action)) {
190            setSuperPrimary(intent);
191        } else if (ACTION_CLEAR_PRIMARY.equals(action)) {
192            clearPrimary(intent);
193        } else if (ACTION_DELETE_CONTACT.equals(action)) {
194            deleteContact(intent);
195        } else if (ACTION_JOIN_CONTACTS.equals(action)) {
196            joinContacts(intent);
197        } else if (ACTION_SET_SEND_TO_VOICEMAIL.equals(action)) {
198            setSendToVoicemail(intent);
199        } else if (ACTION_SET_RINGTONE.equals(action)) {
200            setRingtone(intent);
201        }
202    }
203
204    /**
205     * Creates an intent that can be sent to this service to create a new raw contact
206     * using data presented as a set of ContentValues.
207     */
208    public static Intent createNewRawContactIntent(Context context,
209            ArrayList<ContentValues> values, AccountWithDataSet account,
210            Class<?> callbackActivity, String callbackAction) {
211        Intent serviceIntent = new Intent(
212                context, ContactSaveService.class);
213        serviceIntent.setAction(ContactSaveService.ACTION_NEW_RAW_CONTACT);
214        if (account != null) {
215            serviceIntent.putExtra(ContactSaveService.EXTRA_ACCOUNT_NAME, account.name);
216            serviceIntent.putExtra(ContactSaveService.EXTRA_ACCOUNT_TYPE, account.type);
217            serviceIntent.putExtra(ContactSaveService.EXTRA_DATA_SET, account.dataSet);
218        }
219        serviceIntent.putParcelableArrayListExtra(
220                ContactSaveService.EXTRA_CONTENT_VALUES, values);
221
222        // Callback intent will be invoked by the service once the new contact is
223        // created.  The service will put the URI of the new contact as "data" on
224        // the callback intent.
225        Intent callbackIntent = new Intent(context, callbackActivity);
226        callbackIntent.setAction(callbackAction);
227        serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
228        return serviceIntent;
229    }
230
231    private void createRawContact(Intent intent) {
232        String accountName = intent.getStringExtra(EXTRA_ACCOUNT_NAME);
233        String accountType = intent.getStringExtra(EXTRA_ACCOUNT_TYPE);
234        String dataSet = intent.getStringExtra(EXTRA_DATA_SET);
235        List<ContentValues> valueList = intent.getParcelableArrayListExtra(EXTRA_CONTENT_VALUES);
236        Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
237
238        ArrayList<ContentProviderOperation> operations = new ArrayList<ContentProviderOperation>();
239        operations.add(ContentProviderOperation.newInsert(RawContacts.CONTENT_URI)
240                .withValue(RawContacts.ACCOUNT_NAME, accountName)
241                .withValue(RawContacts.ACCOUNT_TYPE, accountType)
242                .withValue(RawContacts.DATA_SET, dataSet)
243                .build());
244
245        int size = valueList.size();
246        for (int i = 0; i < size; i++) {
247            ContentValues values = valueList.get(i);
248            values.keySet().retainAll(ALLOWED_DATA_COLUMNS);
249            operations.add(ContentProviderOperation.newInsert(Data.CONTENT_URI)
250                    .withValueBackReference(Data.RAW_CONTACT_ID, 0)
251                    .withValues(values)
252                    .build());
253        }
254
255        ContentResolver resolver = getContentResolver();
256        ContentProviderResult[] results;
257        try {
258            results = resolver.applyBatch(ContactsContract.AUTHORITY, operations);
259        } catch (Exception e) {
260            throw new RuntimeException("Failed to store new contact", e);
261        }
262
263        Uri rawContactUri = results[0].uri;
264        callbackIntent.setData(RawContacts.getContactLookupUri(resolver, rawContactUri));
265
266        deliverCallback(callbackIntent);
267    }
268
269    /**
270     * Creates an intent that can be sent to this service to create a new raw contact
271     * using data presented as a set of ContentValues.
272     */
273    public static Intent createSaveContactIntent(Context context, EntityDeltaList state,
274            String saveModeExtraKey, int saveMode, boolean isProfile, Class<?> callbackActivity,
275            String callbackAction) {
276        Intent serviceIntent = new Intent(
277                context, ContactSaveService.class);
278        serviceIntent.setAction(ContactSaveService.ACTION_SAVE_CONTACT);
279        serviceIntent.putExtra(EXTRA_CONTACT_STATE, (Parcelable) state);
280        serviceIntent.putExtra(EXTRA_SAVE_IS_PROFILE, isProfile);
281
282        // Callback intent will be invoked by the service once the contact is
283        // saved.  The service will put the URI of the new contact as "data" on
284        // the callback intent.
285        Intent callbackIntent = new Intent(context, callbackActivity);
286        callbackIntent.putExtra(saveModeExtraKey, saveMode);
287        callbackIntent.setAction(callbackAction);
288        serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
289        return serviceIntent;
290    }
291
292    private void saveContact(Intent intent) {
293        EntityDeltaList state = intent.getParcelableExtra(EXTRA_CONTACT_STATE);
294        Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
295        boolean isProfile = intent.getBooleanExtra(EXTRA_SAVE_IS_PROFILE, false);
296
297        // Trim any empty fields, and RawContacts, before persisting
298        final AccountTypeManager accountTypes = AccountTypeManager.getInstance(this);
299        EntityModifier.trimEmpty(state, accountTypes);
300
301        Uri lookupUri = null;
302
303        final ContentResolver resolver = getContentResolver();
304
305        // Attempt to persist changes
306        int tries = 0;
307        while (tries++ < PERSIST_TRIES) {
308            try {
309                // Build operations and try applying
310                final ArrayList<ContentProviderOperation> diff = state.buildDiff();
311                if (DEBUG) {
312                    Log.v(TAG, "Content Provider Operations:");
313                    for (ContentProviderOperation operation : diff) {
314                        Log.v(TAG, operation.toString());
315                    }
316                }
317
318                ContentProviderResult[] results = null;
319                if (!diff.isEmpty()) {
320                    results = resolver.applyBatch(ContactsContract.AUTHORITY, diff);
321                }
322
323                final long rawContactId = getRawContactId(state, diff, results);
324                if (rawContactId == -1) {
325                    throw new IllegalStateException("Could not determine RawContact ID after save");
326                }
327                if (isProfile) {
328                    // Since the profile supports local raw contacts, which may have been completely
329                    // removed if all information was removed, we need to do a special query to
330                    // get the lookup URI for the profile contact (if it still exists).
331                    Cursor c = resolver.query(Profile.CONTENT_URI,
332                            new String[] {Contacts._ID, Contacts.LOOKUP_KEY},
333                            null, null, null);
334                    try {
335                        if (c.moveToFirst()) {
336                            final long contactId = c.getLong(0);
337                            final String lookupKey = c.getString(1);
338                            lookupUri = Contacts.getLookupUri(contactId, lookupKey);
339                        }
340                    } finally {
341                        c.close();
342                    }
343                } else {
344                    final Uri rawContactUri = ContentUris.withAppendedId(RawContacts.CONTENT_URI,
345                                    rawContactId);
346                    lookupUri = RawContacts.getContactLookupUri(resolver, rawContactUri);
347                }
348                Log.v(TAG, "Saved contact. New URI: " + lookupUri);
349                // Mark the intent to indicate that the save was successful (even if the lookup URI
350                // is now null).  For local contacts or the local profile, it's possible that the
351                // save triggered removal of the contact, so no lookup URI would exist..
352                callbackIntent.putExtra(EXTRA_SAVE_SUCCEEDED, true);
353                break;
354
355            } catch (RemoteException e) {
356                // Something went wrong, bail without success
357                Log.e(TAG, "Problem persisting user edits", e);
358                break;
359
360            } catch (OperationApplicationException e) {
361                // Version consistency failed, re-parent change and try again
362                Log.w(TAG, "Version consistency failed, re-parenting: " + e.toString());
363                final StringBuilder sb = new StringBuilder(RawContacts._ID + " IN(");
364                boolean first = true;
365                final int count = state.size();
366                for (int i = 0; i < count; i++) {
367                    Long rawContactId = state.getRawContactId(i);
368                    if (rawContactId != null && rawContactId != -1) {
369                        if (!first) {
370                            sb.append(',');
371                        }
372                        sb.append(rawContactId);
373                        first = false;
374                    }
375                }
376                sb.append(")");
377
378                if (first) {
379                    throw new IllegalStateException("Version consistency failed for a new contact");
380                }
381
382                final EntityDeltaList newState = EntityDeltaList.fromQuery(
383                        isProfile
384                                ? RawContactsEntity.PROFILE_CONTENT_URI
385                                : RawContactsEntity.CONTENT_URI,
386                        resolver, sb.toString(), null, null);
387                state = EntityDeltaList.mergeAfter(newState, state);
388
389                // Update the new state to use profile URIs if appropriate.
390                if (isProfile) {
391                    for (EntityDelta delta : state) {
392                        delta.setProfileQueryUri();
393                    }
394                }
395            }
396        }
397
398        callbackIntent.setData(lookupUri);
399
400        deliverCallback(callbackIntent);
401    }
402
403    private long getRawContactId(EntityDeltaList state,
404            final ArrayList<ContentProviderOperation> diff,
405            final ContentProviderResult[] results) {
406        long rawContactId = state.findRawContactId();
407        if (rawContactId != -1) {
408            return rawContactId;
409        }
410
411        final int diffSize = diff.size();
412        for (int i = 0; i < diffSize; i++) {
413            ContentProviderOperation operation = diff.get(i);
414            if (operation.getType() == ContentProviderOperation.TYPE_INSERT
415                    && operation.getUri().getEncodedPath().contains(
416                            RawContacts.CONTENT_URI.getEncodedPath())) {
417                return ContentUris.parseId(results[i].uri);
418            }
419        }
420        return -1;
421    }
422
423    /**
424     * Creates an intent that can be sent to this service to create a new group as
425     * well as add new members at the same time.
426     *
427     * @param context of the application
428     * @param account in which the group should be created
429     * @param label is the name of the group (cannot be null)
430     * @param rawContactsToAdd is an array of raw contact IDs for contacts that
431     *            should be added to the group
432     * @param callbackActivity is the activity to send the callback intent to
433     * @param callbackAction is the intent action for the callback intent
434     */
435    public static Intent createNewGroupIntent(Context context, AccountWithDataSet account,
436            String label, long[] rawContactsToAdd, Class<?> callbackActivity,
437            String callbackAction) {
438        Intent serviceIntent = new Intent(context, ContactSaveService.class);
439        serviceIntent.setAction(ContactSaveService.ACTION_CREATE_GROUP);
440        serviceIntent.putExtra(ContactSaveService.EXTRA_ACCOUNT_TYPE, account.type);
441        serviceIntent.putExtra(ContactSaveService.EXTRA_ACCOUNT_NAME, account.name);
442        serviceIntent.putExtra(ContactSaveService.EXTRA_DATA_SET, account.dataSet);
443        serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_LABEL, label);
444        serviceIntent.putExtra(ContactSaveService.EXTRA_RAW_CONTACTS_TO_ADD, rawContactsToAdd);
445
446        // Callback intent will be invoked by the service once the new group is
447        // created.
448        Intent callbackIntent = new Intent(context, callbackActivity);
449        callbackIntent.setAction(callbackAction);
450        serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
451
452        return serviceIntent;
453    }
454
455    private void createGroup(Intent intent) {
456        String accountType = intent.getStringExtra(EXTRA_ACCOUNT_TYPE);
457        String accountName = intent.getStringExtra(EXTRA_ACCOUNT_NAME);
458        String dataSet = intent.getStringExtra(EXTRA_DATA_SET);
459        String label = intent.getStringExtra(EXTRA_GROUP_LABEL);
460        final long[] rawContactsToAdd = intent.getLongArrayExtra(EXTRA_RAW_CONTACTS_TO_ADD);
461
462        ContentValues values = new ContentValues();
463        values.put(Groups.ACCOUNT_TYPE, accountType);
464        values.put(Groups.ACCOUNT_NAME, accountName);
465        values.put(Groups.DATA_SET, dataSet);
466        values.put(Groups.TITLE, label);
467
468        final ContentResolver resolver = getContentResolver();
469
470        // Create the new group
471        final Uri groupUri = resolver.insert(Groups.CONTENT_URI, values);
472
473        // If there's no URI, then the insertion failed. Abort early because group members can't be
474        // added if the group doesn't exist
475        if (groupUri == null) {
476            Log.e(TAG, "Couldn't create group with label " + label);
477            return;
478        }
479
480        // Add new group members
481        addMembersToGroup(resolver, rawContactsToAdd, ContentUris.parseId(groupUri));
482
483        // TODO: Move this into the contact editor where it belongs. This needs to be integrated
484        // with the way other intent extras that are passed to the {@link ContactEditorActivity}.
485        values.clear();
486        values.put(Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE);
487        values.put(GroupMembership.GROUP_ROW_ID, ContentUris.parseId(groupUri));
488
489        Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
490        callbackIntent.setData(groupUri);
491        // TODO: This can be taken out when the above TODO is addressed
492        callbackIntent.putExtra(ContactsContract.Intents.Insert.DATA, Lists.newArrayList(values));
493        deliverCallback(callbackIntent);
494    }
495
496    /**
497     * Creates an intent that can be sent to this service to rename a group.
498     */
499    public static Intent createGroupRenameIntent(Context context, long groupId, String newLabel,
500            Class<?> callbackActivity, String callbackAction) {
501        Intent serviceIntent = new Intent(context, ContactSaveService.class);
502        serviceIntent.setAction(ContactSaveService.ACTION_RENAME_GROUP);
503        serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_ID, groupId);
504        serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_LABEL, newLabel);
505
506        // Callback intent will be invoked by the service once the group is renamed.
507        Intent callbackIntent = new Intent(context, callbackActivity);
508        callbackIntent.setAction(callbackAction);
509        serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
510
511        return serviceIntent;
512    }
513
514    private void renameGroup(Intent intent) {
515        long groupId = intent.getLongExtra(EXTRA_GROUP_ID, -1);
516        String label = intent.getStringExtra(EXTRA_GROUP_LABEL);
517
518        if (groupId == -1) {
519            Log.e(TAG, "Invalid arguments for renameGroup request");
520            return;
521        }
522
523        ContentValues values = new ContentValues();
524        values.put(Groups.TITLE, label);
525        final Uri groupUri = ContentUris.withAppendedId(Groups.CONTENT_URI, groupId);
526        getContentResolver().update(groupUri, values, null, null);
527
528        Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
529        callbackIntent.setData(groupUri);
530        deliverCallback(callbackIntent);
531    }
532
533    /**
534     * Creates an intent that can be sent to this service to delete a group.
535     */
536    public static Intent createGroupDeletionIntent(Context context, long groupId) {
537        Intent serviceIntent = new Intent(context, ContactSaveService.class);
538        serviceIntent.setAction(ContactSaveService.ACTION_DELETE_GROUP);
539        serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_ID, groupId);
540        return serviceIntent;
541    }
542
543    private void deleteGroup(Intent intent) {
544        long groupId = intent.getLongExtra(EXTRA_GROUP_ID, -1);
545        if (groupId == -1) {
546            Log.e(TAG, "Invalid arguments for deleteGroup request");
547            return;
548        }
549
550        getContentResolver().delete(
551                ContentUris.withAppendedId(Groups.CONTENT_URI, groupId), null, null);
552    }
553
554    /**
555     * Creates an intent that can be sent to this service to rename a group as
556     * well as add and remove members from the group.
557     *
558     * @param context of the application
559     * @param groupId of the group that should be modified
560     * @param newLabel is the updated name of the group (can be null if the name
561     *            should not be updated)
562     * @param rawContactsToAdd is an array of raw contact IDs for contacts that
563     *            should be added to the group
564     * @param rawContactsToRemove is an array of raw contact IDs for contacts
565     *            that should be removed from the group
566     * @param callbackActivity is the activity to send the callback intent to
567     * @param callbackAction is the intent action for the callback intent
568     */
569    public static Intent createGroupUpdateIntent(Context context, long groupId, String newLabel,
570            long[] rawContactsToAdd, long[] rawContactsToRemove,
571            Class<?> callbackActivity, String callbackAction) {
572        Intent serviceIntent = new Intent(context, ContactSaveService.class);
573        serviceIntent.setAction(ContactSaveService.ACTION_UPDATE_GROUP);
574        serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_ID, groupId);
575        serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_LABEL, newLabel);
576        serviceIntent.putExtra(ContactSaveService.EXTRA_RAW_CONTACTS_TO_ADD, rawContactsToAdd);
577        serviceIntent.putExtra(ContactSaveService.EXTRA_RAW_CONTACTS_TO_REMOVE,
578                rawContactsToRemove);
579
580        // Callback intent will be invoked by the service once the group is updated
581        Intent callbackIntent = new Intent(context, callbackActivity);
582        callbackIntent.setAction(callbackAction);
583        serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
584
585        return serviceIntent;
586    }
587
588    private void updateGroup(Intent intent) {
589        long groupId = intent.getLongExtra(EXTRA_GROUP_ID, -1);
590        String label = intent.getStringExtra(EXTRA_GROUP_LABEL);
591        long[] rawContactsToAdd = intent.getLongArrayExtra(EXTRA_RAW_CONTACTS_TO_ADD);
592        long[] rawContactsToRemove = intent.getLongArrayExtra(EXTRA_RAW_CONTACTS_TO_REMOVE);
593
594        if (groupId == -1) {
595            Log.e(TAG, "Invalid arguments for updateGroup request");
596            return;
597        }
598
599        final ContentResolver resolver = getContentResolver();
600        final Uri groupUri = ContentUris.withAppendedId(Groups.CONTENT_URI, groupId);
601
602        // Update group name if necessary
603        if (label != null) {
604            ContentValues values = new ContentValues();
605            values.put(Groups.TITLE, label);
606            resolver.update(groupUri, values, null, null);
607        }
608
609        // Add and remove members if necessary
610        addMembersToGroup(resolver, rawContactsToAdd, groupId);
611        removeMembersFromGroup(resolver, rawContactsToRemove, groupId);
612
613        Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
614        callbackIntent.setData(groupUri);
615        deliverCallback(callbackIntent);
616    }
617
618    private void addMembersToGroup(ContentResolver resolver, long[] rawContactsToAdd,
619            long groupId) {
620        if (rawContactsToAdd == null) {
621            return;
622        }
623        for (long rawContactId : rawContactsToAdd) {
624            try {
625                final ArrayList<ContentProviderOperation> rawContactOperations =
626                        new ArrayList<ContentProviderOperation>();
627
628                // Build an assert operation to ensure the contact is not already in the group
629                final ContentProviderOperation.Builder assertBuilder = ContentProviderOperation
630                        .newAssertQuery(Data.CONTENT_URI);
631                assertBuilder.withSelection(Data.RAW_CONTACT_ID + "=? AND " +
632                        Data.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?",
633                        new String[] { String.valueOf(rawContactId),
634                        GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(groupId)});
635                assertBuilder.withExpectedCount(0);
636                rawContactOperations.add(assertBuilder.build());
637
638                // Build an insert operation to add the contact to the group
639                final ContentProviderOperation.Builder insertBuilder = ContentProviderOperation
640                        .newInsert(Data.CONTENT_URI);
641                insertBuilder.withValue(Data.RAW_CONTACT_ID, rawContactId);
642                insertBuilder.withValue(Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE);
643                insertBuilder.withValue(GroupMembership.GROUP_ROW_ID, groupId);
644                rawContactOperations.add(insertBuilder.build());
645
646                if (DEBUG) {
647                    for (ContentProviderOperation operation : rawContactOperations) {
648                        Log.v(TAG, operation.toString());
649                    }
650                }
651
652                // Apply batch
653                ContentProviderResult[] results = null;
654                if (!rawContactOperations.isEmpty()) {
655                    results = resolver.applyBatch(ContactsContract.AUTHORITY, rawContactOperations);
656                }
657            } catch (RemoteException e) {
658                // Something went wrong, bail without success
659                Log.e(TAG, "Problem persisting user edits for raw contact ID " +
660                        String.valueOf(rawContactId), e);
661            } catch (OperationApplicationException e) {
662                // The assert could have failed because the contact is already in the group,
663                // just continue to the next contact
664                Log.w(TAG, "Assert failed in adding raw contact ID " +
665                        String.valueOf(rawContactId) + ". Already exists in group " +
666                        String.valueOf(groupId), e);
667            }
668        }
669    }
670
671    private void removeMembersFromGroup(ContentResolver resolver, long[] rawContactsToRemove,
672            long groupId) {
673        if (rawContactsToRemove == null) {
674            return;
675        }
676        for (long rawContactId : rawContactsToRemove) {
677            // Apply the delete operation on the data row for the given raw contact's
678            // membership in the given group. If no contact matches the provided selection, then
679            // nothing will be done. Just continue to the next contact.
680            getContentResolver().delete(Data.CONTENT_URI, Data.RAW_CONTACT_ID + "=? AND " +
681                    Data.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?",
682                    new String[] { String.valueOf(rawContactId),
683                    GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(groupId)});
684        }
685    }
686
687    /**
688     * Creates an intent that can be sent to this service to star or un-star a contact.
689     */
690    public static Intent createSetStarredIntent(Context context, Uri contactUri, boolean value) {
691        Intent serviceIntent = new Intent(context, ContactSaveService.class);
692        serviceIntent.setAction(ContactSaveService.ACTION_SET_STARRED);
693        serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_URI, contactUri);
694        serviceIntent.putExtra(ContactSaveService.EXTRA_STARRED_FLAG, value);
695
696        return serviceIntent;
697    }
698
699    private void setStarred(Intent intent) {
700        Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI);
701        boolean value = intent.getBooleanExtra(EXTRA_STARRED_FLAG, false);
702        if (contactUri == null) {
703            Log.e(TAG, "Invalid arguments for setStarred request");
704            return;
705        }
706
707        final ContentValues values = new ContentValues(1);
708        values.put(Contacts.STARRED, value);
709        getContentResolver().update(contactUri, values, null, null);
710    }
711
712    /**
713     * Creates an intent that can be sent to this service to set the redirect to voicemail.
714     */
715    public static Intent createSetSendToVoicemail(Context context, Uri contactUri,
716            boolean value) {
717        Intent serviceIntent = new Intent(context, ContactSaveService.class);
718        serviceIntent.setAction(ContactSaveService.ACTION_SET_SEND_TO_VOICEMAIL);
719        serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_URI, contactUri);
720        serviceIntent.putExtra(ContactSaveService.EXTRA_SEND_TO_VOICEMAIL_FLAG, value);
721
722        return serviceIntent;
723    }
724
725    private void setSendToVoicemail(Intent intent) {
726        Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI);
727        boolean value = intent.getBooleanExtra(EXTRA_SEND_TO_VOICEMAIL_FLAG, false);
728        if (contactUri == null) {
729            Log.e(TAG, "Invalid arguments for setRedirectToVoicemail");
730            return;
731        }
732
733        final ContentValues values = new ContentValues(1);
734        values.put(Contacts.SEND_TO_VOICEMAIL, value);
735        getContentResolver().update(contactUri, values, null, null);
736    }
737
738    /**
739     * Creates an intent that can be sent to this service to save the contact's ringtone.
740     */
741    public static Intent createSetRingtone(Context context, Uri contactUri,
742            String value) {
743        Intent serviceIntent = new Intent(context, ContactSaveService.class);
744        serviceIntent.setAction(ContactSaveService.ACTION_SET_RINGTONE);
745        serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_URI, contactUri);
746        serviceIntent.putExtra(ContactSaveService.EXTRA_CUSTOM_RINGTONE, value);
747
748        return serviceIntent;
749    }
750
751    private void setRingtone(Intent intent) {
752        Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI);
753        String value = intent.getStringExtra(EXTRA_CUSTOM_RINGTONE);
754        if (contactUri == null) {
755            Log.e(TAG, "Invalid arguments for setRingtone");
756            return;
757        }
758        ContentValues values = new ContentValues(1);
759        values.put(Contacts.CUSTOM_RINGTONE, value);
760        getContentResolver().update(contactUri, values, null, null);
761    }
762
763    /**
764     * Creates an intent that sets the selected data item as super primary (default)
765     */
766    public static Intent createSetSuperPrimaryIntent(Context context, long dataId) {
767        Intent serviceIntent = new Intent(context, ContactSaveService.class);
768        serviceIntent.setAction(ContactSaveService.ACTION_SET_SUPER_PRIMARY);
769        serviceIntent.putExtra(ContactSaveService.EXTRA_DATA_ID, dataId);
770        return serviceIntent;
771    }
772
773    private void setSuperPrimary(Intent intent) {
774        long dataId = intent.getLongExtra(EXTRA_DATA_ID, -1);
775        if (dataId == -1) {
776            Log.e(TAG, "Invalid arguments for setSuperPrimary request");
777            return;
778        }
779
780        // Update the primary values in the data record.
781        ContentValues values = new ContentValues(1);
782        values.put(Data.IS_SUPER_PRIMARY, 1);
783        values.put(Data.IS_PRIMARY, 1);
784
785        getContentResolver().update(ContentUris.withAppendedId(Data.CONTENT_URI, dataId),
786                values, null, null);
787    }
788
789    /**
790     * Creates an intent that clears the primary flag of all data items that belong to the same
791     * raw_contact as the given data item. Will only clear, if the data item was primary before
792     * this call
793     */
794    public static Intent createClearPrimaryIntent(Context context, long dataId) {
795        Intent serviceIntent = new Intent(context, ContactSaveService.class);
796        serviceIntent.setAction(ContactSaveService.ACTION_CLEAR_PRIMARY);
797        serviceIntent.putExtra(ContactSaveService.EXTRA_DATA_ID, dataId);
798        return serviceIntent;
799    }
800
801    private void clearPrimary(Intent intent) {
802        long dataId = intent.getLongExtra(EXTRA_DATA_ID, -1);
803        if (dataId == -1) {
804            Log.e(TAG, "Invalid arguments for clearPrimary request");
805            return;
806        }
807
808        // Update the primary values in the data record.
809        ContentValues values = new ContentValues(1);
810        values.put(Data.IS_SUPER_PRIMARY, 0);
811        values.put(Data.IS_PRIMARY, 0);
812
813        getContentResolver().update(ContentUris.withAppendedId(Data.CONTENT_URI, dataId),
814                values, null, null);
815    }
816
817    /**
818     * Creates an intent that can be sent to this service to delete a contact.
819     */
820    public static Intent createDeleteContactIntent(Context context, Uri contactUri) {
821        Intent serviceIntent = new Intent(context, ContactSaveService.class);
822        serviceIntent.setAction(ContactSaveService.ACTION_DELETE_CONTACT);
823        serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_URI, contactUri);
824        return serviceIntent;
825    }
826
827    private void deleteContact(Intent intent) {
828        Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI);
829        if (contactUri == null) {
830            Log.e(TAG, "Invalid arguments for deleteContact request");
831            return;
832        }
833
834        getContentResolver().delete(contactUri, null, null);
835    }
836
837    /**
838     * Creates an intent that can be sent to this service to join two contacts.
839     */
840    public static Intent createJoinContactsIntent(Context context, long contactId1,
841            long contactId2, boolean contactWritable,
842            Class<?> callbackActivity, String callbackAction) {
843        Intent serviceIntent = new Intent(context, ContactSaveService.class);
844        serviceIntent.setAction(ContactSaveService.ACTION_JOIN_CONTACTS);
845        serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_ID1, contactId1);
846        serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_ID2, contactId2);
847        serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_WRITABLE, contactWritable);
848
849        // Callback intent will be invoked by the service once the contacts are joined.
850        Intent callbackIntent = new Intent(context, callbackActivity);
851        callbackIntent.setAction(callbackAction);
852        serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
853
854        return serviceIntent;
855    }
856
857
858    private interface JoinContactQuery {
859        String[] PROJECTION = {
860                RawContacts._ID,
861                RawContacts.CONTACT_ID,
862                RawContacts.NAME_VERIFIED,
863                RawContacts.DISPLAY_NAME_SOURCE,
864        };
865
866        String SELECTION = RawContacts.CONTACT_ID + "=? OR " + RawContacts.CONTACT_ID + "=?";
867
868        int _ID = 0;
869        int CONTACT_ID = 1;
870        int NAME_VERIFIED = 2;
871        int DISPLAY_NAME_SOURCE = 3;
872    }
873
874    private void joinContacts(Intent intent) {
875        long contactId1 = intent.getLongExtra(EXTRA_CONTACT_ID1, -1);
876        long contactId2 = intent.getLongExtra(EXTRA_CONTACT_ID2, -1);
877        boolean writable = intent.getBooleanExtra(EXTRA_CONTACT_WRITABLE, false);
878        if (contactId1 == -1 || contactId2 == -1) {
879            Log.e(TAG, "Invalid arguments for joinContacts request");
880            return;
881        }
882
883        final ContentResolver resolver = getContentResolver();
884
885        // Load raw contact IDs for all raw contacts involved - currently edited and selected
886        // in the join UIs
887        Cursor c = resolver.query(RawContacts.CONTENT_URI,
888                JoinContactQuery.PROJECTION,
889                JoinContactQuery.SELECTION,
890                new String[]{String.valueOf(contactId1), String.valueOf(contactId2)}, null);
891
892        long rawContactIds[];
893        long verifiedNameRawContactId = -1;
894        try {
895            int maxDisplayNameSource = -1;
896            rawContactIds = new long[c.getCount()];
897            for (int i = 0; i < rawContactIds.length; i++) {
898                c.moveToPosition(i);
899                long rawContactId = c.getLong(JoinContactQuery._ID);
900                rawContactIds[i] = rawContactId;
901                int nameSource = c.getInt(JoinContactQuery.DISPLAY_NAME_SOURCE);
902                if (nameSource > maxDisplayNameSource) {
903                    maxDisplayNameSource = nameSource;
904                }
905            }
906
907            // Find an appropriate display name for the joined contact:
908            // if should have a higher DisplayNameSource or be the name
909            // of the original contact that we are joining with another.
910            if (writable) {
911                for (int i = 0; i < rawContactIds.length; i++) {
912                    c.moveToPosition(i);
913                    if (c.getLong(JoinContactQuery.CONTACT_ID) == contactId1) {
914                        int nameSource = c.getInt(JoinContactQuery.DISPLAY_NAME_SOURCE);
915                        if (nameSource == maxDisplayNameSource
916                                && (verifiedNameRawContactId == -1
917                                        || c.getInt(JoinContactQuery.NAME_VERIFIED) != 0)) {
918                            verifiedNameRawContactId = c.getLong(JoinContactQuery._ID);
919                        }
920                    }
921                }
922            }
923        } finally {
924            c.close();
925        }
926
927        // For each pair of raw contacts, insert an aggregation exception
928        ArrayList<ContentProviderOperation> operations = new ArrayList<ContentProviderOperation>();
929        for (int i = 0; i < rawContactIds.length; i++) {
930            for (int j = 0; j < rawContactIds.length; j++) {
931                if (i != j) {
932                    buildJoinContactDiff(operations, rawContactIds[i], rawContactIds[j]);
933                }
934            }
935        }
936
937        // Mark the original contact as "name verified" to make sure that the contact
938        // display name does not change as a result of the join
939        if (verifiedNameRawContactId != -1) {
940            Builder builder = ContentProviderOperation.newUpdate(
941                    ContentUris.withAppendedId(RawContacts.CONTENT_URI, verifiedNameRawContactId));
942            builder.withValue(RawContacts.NAME_VERIFIED, 1);
943            operations.add(builder.build());
944        }
945
946        boolean success = false;
947        // Apply all aggregation exceptions as one batch
948        try {
949            resolver.applyBatch(ContactsContract.AUTHORITY, operations);
950            showToast(R.string.contactsJoinedMessage);
951            success = true;
952        } catch (RemoteException e) {
953            Log.e(TAG, "Failed to apply aggregation exception batch", e);
954            showToast(R.string.contactSavedErrorToast);
955        } catch (OperationApplicationException e) {
956            Log.e(TAG, "Failed to apply aggregation exception batch", e);
957            showToast(R.string.contactSavedErrorToast);
958        }
959
960        Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
961        if (success) {
962            Uri uri = RawContacts.getContactLookupUri(resolver,
963                    ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactIds[0]));
964            callbackIntent.setData(uri);
965        }
966        deliverCallback(callbackIntent);
967    }
968
969    /**
970     * Construct a {@link AggregationExceptions#TYPE_KEEP_TOGETHER} ContentProviderOperation.
971     */
972    private void buildJoinContactDiff(ArrayList<ContentProviderOperation> operations,
973            long rawContactId1, long rawContactId2) {
974        Builder builder =
975                ContentProviderOperation.newUpdate(AggregationExceptions.CONTENT_URI);
976        builder.withValue(AggregationExceptions.TYPE, AggregationExceptions.TYPE_KEEP_TOGETHER);
977        builder.withValue(AggregationExceptions.RAW_CONTACT_ID1, rawContactId1);
978        builder.withValue(AggregationExceptions.RAW_CONTACT_ID2, rawContactId2);
979        operations.add(builder.build());
980    }
981
982    /**
983     * Shows a toast on the UI thread.
984     */
985    private void showToast(final int message) {
986        mMainHandler.post(new Runnable() {
987
988            @Override
989            public void run() {
990                Toast.makeText(ContactSaveService.this, message, Toast.LENGTH_LONG).show();
991            }
992        });
993    }
994
995    private void deliverCallback(final Intent callbackIntent) {
996        mMainHandler.post(new Runnable() {
997
998            @Override
999            public void run() {
1000                deliverCallbackOnUiThread(callbackIntent);
1001            }
1002        });
1003    }
1004
1005    void deliverCallbackOnUiThread(final Intent callbackIntent) {
1006        // TODO: this assumes that if there are multiple instances of the same
1007        // activity registered, the last one registered is the one waiting for
1008        // the callback. Validity of this assumption needs to be verified.
1009        for (Listener listener : sListeners) {
1010            if (callbackIntent.getComponent().equals(
1011                    ((Activity) listener).getIntent().getComponent())) {
1012                listener.onServiceCompleted(callbackIntent);
1013                return;
1014            }
1015        }
1016    }
1017}
1018