/* Copyright © 2013-2014, Silent Circle, LLC. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Any redistribution, use, or modification is done solely for personal benefit and not for any commercial purpose or for monetary gain * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name Silent Circle nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL SILENT CIRCLE, LLC BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ /* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.silentcircle.contacts; import android.app.Activity; import android.app.IntentService; import android.content.ContentProviderOperation; import android.content.ContentProviderResult; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.OperationApplicationException; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.os.Parcelable; import android.os.RemoteException; import android.util.Log; import android.widget.Toast; import com.silentcircle.contacts.model.RawContactDeltaList; import com.silentcircle.contacts.model.RawContactModifier; import com.silentcircle.contacts.utils.CallerInfoCacheUtils; import com.silentcircle.contacts.utils.ContactPhotoUtils; import com.silentcircle.contacts.utils.ContactPhotoUtils19; import com.silentcircle.silentcontacts.ScContactsContract; import com.silentcircle.contacts.model.AccountTypeManager; import com.silentcircle.silentcontacts.ScContactsContract.CommonDataKinds.GroupMembership; import com.silentcircle.silentcontacts.ScContactsContract.Data; import com.silentcircle.silentcontacts.ScContactsContract.Groups; import com.silentcircle.silentcontacts.ScContactsContract.RawContacts; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; /** * A service responsible for saving changes to the content provider. */ public class ScContactSaveService extends IntentService { private static final String TAG = "ContactSaveService"; /** Set to true in order to view logs on content provider operations */ private static final boolean DEBUG = false; // true; public static final String ACTION_NEW_RAW_CONTACT = "newRawContact"; public static final String EXTRA_CONTENT_VALUES = "contentValues"; public static final String EXTRA_CALLBACK_INTENT = "callbackIntent"; public static final String ACTION_SAVE_CONTACT = "saveContact"; public static final String EXTRA_CONTACT_STATE = "state"; public static final String EXTRA_SAVE_MODE = "saveMode"; public static final String EXTRA_SAVE_IS_PROFILE = "saveIsProfile"; public static final String EXTRA_SAVE_SUCCEEDED = "saveSucceeded"; public static final String EXTRA_UPDATED_PHOTOS = "updatedPhotos"; public static final String ACTION_CREATE_GROUP = "createGroup"; public static final String ACTION_RENAME_GROUP = "renameGroup"; public static final String ACTION_DELETE_GROUP = "deleteGroup"; public static final String ACTION_UPDATE_GROUP = "updateGroup"; public static final String EXTRA_GROUP_ID = "groupId"; public static final String EXTRA_GROUP_LABEL = "groupLabel"; public static final String EXTRA_RAW_CONTACTS_TO_ADD = "rawContactsToAdd"; public static final String EXTRA_RAW_CONTACTS_TO_REMOVE = "rawContactsToRemove"; public static final String ACTION_SET_STARRED = "setStarred"; public static final String ACTION_DELETE_CONTACT = "delete"; public static final String EXTRA_CONTACT_URI = "contactUri"; public static final String EXTRA_STARRED_FLAG = "starred"; public static final String ACTION_SET_SUPER_PRIMARY = "setSuperPrimary"; public static final String ACTION_CLEAR_PRIMARY = "clearPrimary"; public static final String EXTRA_DATA_ID = "dataId"; public static final String ACTION_JOIN_CONTACTS = "joinContacts"; public static final String EXTRA_CONTACT_ID1 = "contactId1"; public static final String EXTRA_CONTACT_ID2 = "contactId2"; public static final String EXTRA_CONTACT_WRITABLE = "contactWritable"; public static final String ACTION_SET_SEND_TO_VOICEMAIL = "sendToVoicemail"; public static final String EXTRA_SEND_TO_VOICEMAIL_FLAG = "sendToVoicemailFlag"; public static final String ACTION_SET_RINGTONE = "setRingtone"; public static final String EXTRA_CUSTOM_RINGTONE = "customRingtone"; private static final HashSet<String> ALLOWED_DATA_COLUMNS; static { ALLOWED_DATA_COLUMNS = new HashSet<String>(); String cols[] = { Data.MIMETYPE, Data.IS_PRIMARY, Data.DATA1, Data.DATA2, Data.DATA3, Data.DATA4, Data.DATA5, Data.DATA6, Data.DATA7, Data.DATA8, Data.DATA9, Data.DATA10, Data.DATA11, Data.DATA12, Data.DATA13, Data.DATA14, Data.DATA15}; ALLOWED_DATA_COLUMNS.addAll(Arrays.asList(cols)); } private static final int PERSIST_TRIES = 3; public interface Listener { public void onServiceCompleted(Intent callbackIntent); } private static final CopyOnWriteArrayList<Listener> sListeners = new CopyOnWriteArrayList<Listener>(); private Handler mMainHandler; public ScContactSaveService() { super(TAG); setIntentRedelivery(true); mMainHandler = new Handler(Looper.getMainLooper()); } public static void registerListener(Listener listener) { if (!(listener instanceof Activity)) { throw new ClassCastException("Only activities can be registered to" + " receive callback from " + ScContactSaveService.class.getName()); } sListeners.add(0, listener); } public static void unregisterListener(Listener listener) { sListeners.remove(listener); } @Override public Object getSystemService(String name) { Object service = super.getSystemService(name); if (service != null) { return service; } return getApplicationContext().getSystemService(name); } @Override protected void onHandleIntent(Intent intent) { // Call an appropriate method. If we're sure it affects how incoming phone calls are // handled, then notify the fact to in-call screen. String action = intent.getAction(); if (ACTION_NEW_RAW_CONTACT.equals(action)) { createRawContact(intent); CallerInfoCacheUtils.sendUpdateCallerInfoCacheIntent(this); } else if (ACTION_SAVE_CONTACT.equals(action)) { saveContact(intent); CallerInfoCacheUtils.sendUpdateCallerInfoCacheIntent(this); } else if (ACTION_CREATE_GROUP.equals(action)) { createGroup(intent); } else if (ACTION_RENAME_GROUP.equals(action)) { renameGroup(intent); } else if (ACTION_DELETE_GROUP.equals(action)) { deleteGroup(intent); } else if (ACTION_UPDATE_GROUP.equals(action)) { updateGroup(intent); } else if (ACTION_SET_STARRED.equals(action)) { setStarred(intent); } else if (ACTION_SET_SUPER_PRIMARY.equals(action)) { setSuperPrimary(intent); } else if (ACTION_CLEAR_PRIMARY.equals(action)) { clearPrimary(intent); } else if (ACTION_DELETE_CONTACT.equals(action)) { deleteContact(intent); CallerInfoCacheUtils.sendUpdateCallerInfoCacheIntent(this); // } else if (ACTION_JOIN_CONTACTS.equals(action)) { // joinContacts(intent); // CallerInfoCacheUtils.sendUpdateCallerInfoCacheIntent(this); // } else if (ACTION_SET_SEND_TO_VOICEMAIL.equals(action)) { // setSendToVoicemail(intent); // CallerInfoCacheUtils.sendUpdateCallerInfoCacheIntent(this); } else if (ACTION_SET_RINGTONE.equals(action)) { setRingtone(intent); CallerInfoCacheUtils.sendUpdateCallerInfoCacheIntent(this); } } /** * Creates an intent that can be sent to this service to create a new raw contact * using data presented as a set of ContentValues. */ public static Intent createNewRawContactIntent(Context context, ArrayList<ContentValues> values, Class<? extends Activity> callbackActivity, String callbackAction) { Intent serviceIntent = new Intent(context, ScContactSaveService.class); serviceIntent.setAction(ScContactSaveService.ACTION_NEW_RAW_CONTACT); serviceIntent.putParcelableArrayListExtra(ScContactSaveService.EXTRA_CONTENT_VALUES, values); // Callback intent will be invoked by the service once the new contact is // created. The service will put the URI of the new contact as "data" on // the callback intent. Intent callbackIntent = new Intent(context, callbackActivity); callbackIntent.setAction(callbackAction); serviceIntent.putExtra(ScContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent); return serviceIntent; } private void createRawContact(Intent intent) { List<ContentValues> valueList = intent.getParcelableArrayListExtra(EXTRA_CONTENT_VALUES); Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT); ArrayList<ContentProviderOperation> operations = new ArrayList<ContentProviderOperation>(); operations.add(ContentProviderOperation.newInsert(RawContacts.CONTENT_URI) .build()); int size = valueList.size(); for (int i = 0; i < size; i++) { ContentValues values = valueList.get(i); HashMap<String, Object> mValues = new HashMap<String, Object>(10); for (Map.Entry<String, Object> entry: values.valueSet()) mValues.put(entry.getKey(), entry.getValue()); mValues.keySet().retainAll(ALLOWED_DATA_COLUMNS); operations.add(ContentProviderOperation.newInsert(Data.CONTENT_URI) .withValueBackReference(Data.RAW_CONTACT_ID, 0) .withValues(values) .build()); } ContentResolver resolver = getContentResolver(); ContentProviderResult[] results; try { results = resolver.applyBatch(ScContactsContract.AUTHORITY, operations); } catch (Exception e) { throw new RuntimeException("Failed to store new contact", e); } Uri rawContactUri = results[0].uri; callbackIntent.setData(rawContactUri); deliverCallback(callbackIntent); } /** * Creates an intent that can be sent to this service to create a new raw contact * using data presented as a set of ContentValues. * This variant is more convenient to use when there is only one photo that can * possibly be updated, as in the Contact Details screen. * @param rawContactId identifies a writable raw-contact whose photo is to be updated. * @param updatedPhotoPath denotes a temporary file containing the contact's new photo. */ public static Intent createSaveContactIntent(Context context, RawContactDeltaList state, String saveModeExtraKey, int saveMode, boolean isProfile, Class<? extends Activity> callbackActivity, String callbackAction, long rawContactId, String updatedPhotoPath) { Bundle bundle = new Bundle(); bundle.putString(String.valueOf(rawContactId), updatedPhotoPath); return createSaveContactIntent(context, state, saveModeExtraKey, saveMode, isProfile, callbackActivity, callbackAction, bundle); } /** * Creates an intent that can be sent to this service to create a new raw contact * using data presented as a set of ContentValues. * This variant is used when multiple contacts' photos may be updated, as in the * Contact Editor. * @param updatedPhotos maps each raw-contact's ID to the file-path of the new photo. */ public static Intent createSaveContactIntent(Context context, RawContactDeltaList state, String saveModeExtraKey, int saveMode, boolean isProfile, Class<? extends Activity> callbackActivity, String callbackAction, Bundle updatedPhotos) { Intent serviceIntent = new Intent( context, ScContactSaveService.class); serviceIntent.setAction(ScContactSaveService.ACTION_SAVE_CONTACT); serviceIntent.putExtra(EXTRA_CONTACT_STATE, (Parcelable) state); serviceIntent.putExtra(EXTRA_SAVE_IS_PROFILE, isProfile); if (updatedPhotos != null) { serviceIntent.putExtra(EXTRA_UPDATED_PHOTOS, (Parcelable) updatedPhotos); } if (callbackActivity != null) { // Callback intent will be invoked by the service once the contact is // saved. The service will put the URI of the new contact as "data" on // the callback intent. Intent callbackIntent = new Intent(context, callbackActivity); callbackIntent.putExtra(saveModeExtraKey, saveMode); callbackIntent.setAction(callbackAction); serviceIntent.putExtra(ScContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent); } return serviceIntent; } private void saveContact(Intent intent) { RawContactDeltaList state = intent.getParcelableExtra(EXTRA_CONTACT_STATE); Bundle updatedPhotos = intent.getParcelableExtra(EXTRA_UPDATED_PHOTOS); // Log.v(TAG, "** Saving raw contact contact. state: " + state.toString()); // Trim any empty fields, and RawContacts, before persisting final AccountTypeManager accountTypes = AccountTypeManager.getInstance(this); RawContactModifier.trimEmpty(state, accountTypes); Uri lookupUri = null; final ContentResolver resolver = getContentResolver(); boolean succeeded = false; // Keep track of the id of a newly raw-contact (if any... there can be at most one). long insertedRawContactId = -1; // Attempt to persist changes int tries = 0; while (tries++ < PERSIST_TRIES) { try { // Build operations and try applying final ArrayList<ContentProviderOperation> diff = state.buildDiff(); if (DEBUG) { Log.v(TAG, "Content Provider Operations:"); for (ContentProviderOperation operation : diff) { Log.v(TAG, operation.toString()); } } ContentProviderResult[] results = null; if (!diff.isEmpty()) { results = resolver.applyBatch(ScContactsContract.AUTHORITY, diff); } final long rawContactId = getRawContactId(state, diff, results); if (rawContactId == -1) { throw new IllegalStateException("Could not determine RawContact ID after save"); } // Save the inserted raw contact id insertedRawContactId = rawContactId; final Uri rawContactUri = ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId); lookupUri = rawContactUri; // Log.v(TAG, "Saved contact. New URI: " + lookupUri); // We can change this back to false later, if we fail to save the contact photo. succeeded = true; break; } catch (RemoteException e) { // Something went wrong, bail without success Log.e(TAG, "Problem persisting user edits", e); break; } catch (OperationApplicationException e) { // Version consistency failed, re-parent change and try again Log.w(TAG, "Version consistency failed, re-parenting: " + e.toString()); final StringBuilder sb = new StringBuilder(RawContacts._ID + " IN("); boolean first = true; final int count = state.size(); for (int i = 0; i < count; i++) { Long rawContactId = state.getRawContactId(i); if (rawContactId != null && rawContactId != -1) { if (!first) { sb.append(','); } sb.append(rawContactId); first = false; } } sb.append(")"); if (first) { throw new IllegalStateException("Version consistency failed for a new contact"); } // final RawContactDeltaList newState = RawContactDeltaList.fromQuery(RawContactsEntity.CONTENT_URI, // resolver, sb.toString(), null, null); // state = RawContactDeltaList.mergeAfter(newState, state); // Update the new state to use profile URIs if appropriate. } } } // Now save any updated photos. We do this at the end to ensure that // the ContactProvider already knows about newly-created contacts. if (updatedPhotos != null) { for (String key : updatedPhotos.keySet()) { String photoFilePath = null; Uri photoUri = null; if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) photoFilePath = updatedPhotos.getString(key); else photoUri = updatedPhotos.getParcelable(key); long rawContactId = Long.parseLong(key); // If the raw-contact ID is negative, we are saving a new raw-contact; // replace the bogus ID with the new one that we actually saved the contact at. if (rawContactId < 0) { rawContactId = insertedRawContactId; if (rawContactId == -1) { throw new IllegalStateException("Could not determine RawContact ID for image insertion"); } } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { File photoFile = new File(photoFilePath); if (!saveUpdatedPhoto(rawContactId, photoFile, null)) succeeded = false; } else { if (!saveUpdatedPhoto(rawContactId, null, photoUri)) succeeded = false; } } } Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT); if (callbackIntent != null) { if (succeeded) { // Mark the intent to indicate that the save was successful (even if the lookup URI // is now null). For local contacts or the local profile, it's possible that the // save triggered removal of the contact, so no lookup URI would exist.. callbackIntent.putExtra(EXTRA_SAVE_SUCCEEDED, true); } callbackIntent.setData(lookupUri); deliverCallback(callbackIntent); } } /** * Save updated photo for the specified raw-contact. * @return true for success, false for failure */ private boolean saveUpdatedPhoto(long rawContactId, File photoFile, Uri photoUri) { final Uri outputUri = Uri.withAppendedPath( ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId), RawContacts.DisplayPhoto.CONTENT_DIRECTORY); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { try { final FileOutputStream outputStream = getContentResolver() .openAssetFileDescriptor(outputUri, "rw").createOutputStream(); try { final FileInputStream inputStream = new FileInputStream(photoFile); try { final byte[] buffer = new byte[16 * 1024]; int length; int totalLength = 0; while ((length = inputStream.read(buffer)) > 0) { outputStream.write(buffer, 0, length); totalLength += length; } Log.v(TAG, "Wrote " + totalLength + " bytes for photo " + photoFile.toString()); } finally { inputStream.close(); } } finally { outputStream.close(); photoFile.delete(); } } catch (IOException e) { Log.e(TAG, "Failed to write photo: " + photoFile.toString() + " because: " + e); return false; } return true; } else return ContactPhotoUtils19.savePhotoFromUriToUri(this, photoUri, outputUri, true); } /** * Find the ID of an existing or newly-inserted raw-contact. If none exists, return -1. */ private long getRawContactId(RawContactDeltaList state, final ArrayList<ContentProviderOperation> diff, final ContentProviderResult[] results) { long existingRawContactId = state.findRawContactId(); if (existingRawContactId != -1) { return existingRawContactId; } return getInsertedRawContactId(diff, results); } /** * Find the ID of a newly-inserted raw-contact. If none exists, return -1. */ private long getInsertedRawContactId(final ArrayList<ContentProviderOperation> diff, final ContentProviderResult[] results) { final int diffSize = diff.size(); for (int i = 0; i < diffSize; i++) { ContentProviderOperation operation = diff.get(i); if (// operation.getType() == ContentProviderOperation.TYPE_INSERT && operation.getUri().getEncodedPath().contains(RawContacts.CONTENT_URI.getEncodedPath())) { return ContentUris.parseId(results[i].uri); } } return -1; } /** * Creates an intent that can be sent to this service to create a new group as * well as add new members at the same time. * * @param context of the application * @param label is the name of the group (cannot be null) * @param rawContactsToAdd is an array of raw contact IDs for contacts that * should be added to the group * @param callbackActivity is the activity to send the callback intent to * @param callbackAction is the intent action for the callback intent */ public static Intent createNewGroupIntent(Context context, String label, long[] rawContactsToAdd, Class<? extends Activity> callbackActivity, String callbackAction) { Intent serviceIntent = new Intent(context, ScContactSaveService.class); serviceIntent.setAction(ScContactSaveService.ACTION_CREATE_GROUP); serviceIntent.putExtra(ScContactSaveService.EXTRA_GROUP_LABEL, label); serviceIntent.putExtra(ScContactSaveService.EXTRA_RAW_CONTACTS_TO_ADD, rawContactsToAdd); // Callback intent will be invoked by the service once the new group is created. Intent callbackIntent = new Intent(context, callbackActivity); callbackIntent.setAction(callbackAction); serviceIntent.putExtra(ScContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent); return serviceIntent; } private void createGroup(Intent intent) { String label = intent.getStringExtra(EXTRA_GROUP_LABEL); final long[] rawContactsToAdd = intent.getLongArrayExtra(EXTRA_RAW_CONTACTS_TO_ADD); ContentValues values = new ContentValues(); values.put(Groups.TITLE, label); final ContentResolver resolver = getContentResolver(); // Create the new group final Uri groupUri = resolver.insert(Groups.CONTENT_URI, values); // If there's no URI, then the insertion failed. Abort early because group members can't be // added if the group doesn't exist if (groupUri == null) { Log.e(TAG, "Couldn't create group with label " + label); return; } // Add new group members addMembersToGroup(resolver, rawContactsToAdd, ContentUris.parseId(groupUri)); // TODO: Move this into the contact editor where it belongs. This needs to be integrated // with the way other intent extras that are passed to the {@link ContactEditorActivity}. values.clear(); values.put(Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE); values.put(GroupMembership.GROUP_ROW_ID, ContentUris.parseId(groupUri)); Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT); callbackIntent.setData(groupUri); // TODO: This can be taken out when the above TODO is addressed callbackIntent.putExtra(ScContactsContract.Intents.Insert.DATA, newArrayList(values)); deliverCallback(callbackIntent); } static <E> ArrayList<E> newArrayList(E... elements) { int capacity = (elements.length * 110) / 100 + 5; ArrayList<E> list = new ArrayList<E>(capacity); Collections.addAll(list, elements); return list; } /** * Creates an intent that can be sent to this service to rename a group. */ public static Intent createGroupRenameIntent(Context context, long groupId, String newLabel, Class<? extends Activity> callbackActivity, String callbackAction) { Intent serviceIntent = new Intent(context, ScContactSaveService.class); serviceIntent.setAction(ScContactSaveService.ACTION_RENAME_GROUP); serviceIntent.putExtra(ScContactSaveService.EXTRA_GROUP_ID, groupId); serviceIntent.putExtra(ScContactSaveService.EXTRA_GROUP_LABEL, newLabel); // Callback intent will be invoked by the service once the group is renamed. Intent callbackIntent = new Intent(context, callbackActivity); callbackIntent.setAction(callbackAction); serviceIntent.putExtra(ScContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent); return serviceIntent; } private void renameGroup(Intent intent) { long groupId = intent.getLongExtra(EXTRA_GROUP_ID, -1); String label = intent.getStringExtra(EXTRA_GROUP_LABEL); if (groupId == -1) { Log.e(TAG, "Invalid arguments for renameGroup request"); return; } ContentValues values = new ContentValues(); values.put(Groups.TITLE, label); final Uri groupUri = ContentUris.withAppendedId(Groups.CONTENT_URI, groupId); getContentResolver().update(groupUri, values, null, null); Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT); callbackIntent.setData(groupUri); deliverCallback(callbackIntent); } /** * Creates an intent that can be sent to this service to delete a group. */ public static Intent createGroupDeletionIntent(Context context, long groupId) { Intent serviceIntent = new Intent(context, ScContactSaveService.class); serviceIntent.setAction(ScContactSaveService.ACTION_DELETE_GROUP); serviceIntent.putExtra(ScContactSaveService.EXTRA_GROUP_ID, groupId); return serviceIntent; } private void deleteGroup(Intent intent) { long groupId = intent.getLongExtra(EXTRA_GROUP_ID, -1); if (groupId == -1) { Log.e(TAG, "Invalid arguments for deleteGroup request"); return; } getContentResolver().delete( ContentUris.withAppendedId(Groups.CONTENT_URI, groupId), null, null); } /** * Creates an intent that can be sent to this service to rename a group as * well as add and remove members from the group. * * @param context of the application * @param groupId of the group that should be modified * @param newLabel is the updated name of the group (can be null if the name * should not be updated) * @param rawContactsToAdd is an array of raw contact IDs for contacts that * should be added to the group * @param rawContactsToRemove is an array of raw contact IDs for contacts * that should be removed from the group * @param callbackActivity is the activity to send the callback intent to * @param callbackAction is the intent action for the callback intent */ public static Intent createGroupUpdateIntent(Context context, long groupId, String newLabel, long[] rawContactsToAdd, long[] rawContactsToRemove, Class<? extends Activity> callbackActivity, String callbackAction) { Intent serviceIntent = new Intent(context, ScContactSaveService.class); serviceIntent.setAction(ScContactSaveService.ACTION_UPDATE_GROUP); serviceIntent.putExtra(ScContactSaveService.EXTRA_GROUP_ID, groupId); serviceIntent.putExtra(ScContactSaveService.EXTRA_GROUP_LABEL, newLabel); serviceIntent.putExtra(ScContactSaveService.EXTRA_RAW_CONTACTS_TO_ADD, rawContactsToAdd); serviceIntent.putExtra(ScContactSaveService.EXTRA_RAW_CONTACTS_TO_REMOVE, rawContactsToRemove); // Callback intent will be invoked by the service once the group is updated Intent callbackIntent = new Intent(context, callbackActivity); callbackIntent.setAction(callbackAction); serviceIntent.putExtra(ScContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent); return serviceIntent; } private void updateGroup(Intent intent) { long groupId = intent.getLongExtra(EXTRA_GROUP_ID, -1); String label = intent.getStringExtra(EXTRA_GROUP_LABEL); long[] rawContactsToAdd = intent.getLongArrayExtra(EXTRA_RAW_CONTACTS_TO_ADD); long[] rawContactsToRemove = intent.getLongArrayExtra(EXTRA_RAW_CONTACTS_TO_REMOVE); if (groupId == -1) { Log.e(TAG, "Invalid arguments for updateGroup request"); return; } final ContentResolver resolver = getContentResolver(); final Uri groupUri = ContentUris.withAppendedId(Groups.CONTENT_URI, groupId); // Update group name if necessary if (label != null) { ContentValues values = new ContentValues(); values.put(Groups.TITLE, label); resolver.update(groupUri, values, null, null); } // Add and remove members if necessary addMembersToGroup(resolver, rawContactsToAdd, groupId); removeMembersFromGroup(resolver, rawContactsToRemove, groupId); Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT); callbackIntent.setData(groupUri); deliverCallback(callbackIntent); } private static void addMembersToGroup(ContentResolver resolver, long[] rawContactsToAdd, long groupId) { if (rawContactsToAdd == null) { return; } for (long rawContactId : rawContactsToAdd) { try { final ArrayList<ContentProviderOperation> rawContactOperations = new ArrayList<ContentProviderOperation>(); // Build an assert operation to ensure the contact is not already in the group final ContentProviderOperation.Builder assertBuilder = ContentProviderOperation .newAssertQuery(Data.CONTENT_URI); assertBuilder.withSelection(Data.RAW_CONTACT_ID + "=? AND " + Data.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?", new String[] { String.valueOf(rawContactId), GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(groupId)}); assertBuilder.withExpectedCount(0); rawContactOperations.add(assertBuilder.build()); // Build an insert operation to add the contact to the group final ContentProviderOperation.Builder insertBuilder = ContentProviderOperation .newInsert(Data.CONTENT_URI); insertBuilder.withValue(Data.RAW_CONTACT_ID, rawContactId); insertBuilder.withValue(Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE); insertBuilder.withValue(GroupMembership.GROUP_ROW_ID, groupId); rawContactOperations.add(insertBuilder.build()); if (DEBUG) { for (ContentProviderOperation operation : rawContactOperations) { Log.v(TAG, operation.toString()); } } // Apply batch if (!rawContactOperations.isEmpty()) { resolver.applyBatch(ScContactsContract.AUTHORITY, rawContactOperations); } } catch (RemoteException e) { // Something went wrong, bail without success Log.e(TAG, "Problem persisting user edits for raw contact ID " + String.valueOf(rawContactId), e); } catch (OperationApplicationException e) { // The assert could have failed because the contact is already in the group, // just continue to the next contact Log.w(TAG, "Assert failed in adding raw contact ID " + String.valueOf(rawContactId) + ". Already exists in group " + String.valueOf(groupId), e); } } } private static void removeMembersFromGroup(ContentResolver resolver, long[] rawContactsToRemove, long groupId) { if (rawContactsToRemove == null) { return; } for (long rawContactId : rawContactsToRemove) { // Apply the delete operation on the data row for the given raw contact's // membership in the given group. If no contact matches the provided selection, then // nothing will be done. Just continue to the next contact. resolver.delete(Data.CONTENT_URI, Data.RAW_CONTACT_ID + "=? AND " + Data.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?", new String[] { String.valueOf(rawContactId), GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(groupId)}); } } /** * Creates an intent that can be sent to this service to star or un-star a contact. */ public static Intent createSetStarredIntent(Context context, Uri contactUri, boolean value) { Intent serviceIntent = new Intent(context, ScContactSaveService.class); serviceIntent.setAction(ScContactSaveService.ACTION_SET_STARRED); serviceIntent.putExtra(ScContactSaveService.EXTRA_CONTACT_URI, contactUri); serviceIntent.putExtra(ScContactSaveService.EXTRA_STARRED_FLAG, value); return serviceIntent; } private void setStarred(Intent intent) { Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI); boolean value = intent.getBooleanExtra(EXTRA_STARRED_FLAG, false); if (contactUri == null) { Log.e(TAG, "Invalid arguments for setStarred request"); return; } final ContentValues values = new ContentValues(1); values.put(RawContacts.STARRED, value); getContentResolver().update(contactUri, values, null, null); } /** * Creates an intent that can be sent to this service to set the redirect to voicemail. */ public static Intent createSetSendToVoicemail(Context context, Uri contactUri, boolean value) { Intent serviceIntent = new Intent(context, ScContactSaveService.class); serviceIntent.setAction(ScContactSaveService.ACTION_SET_SEND_TO_VOICEMAIL); serviceIntent.putExtra(ScContactSaveService.EXTRA_CONTACT_URI, contactUri); serviceIntent.putExtra(ScContactSaveService.EXTRA_SEND_TO_VOICEMAIL_FLAG, value); return serviceIntent; } /** * Creates an intent that can be sent to this service to save the contact's ringtone. */ public static Intent createSetRingtone(Context context, Uri contactUri, String value) { Intent serviceIntent = new Intent(context, ScContactSaveService.class); serviceIntent.setAction(ScContactSaveService.ACTION_SET_RINGTONE); serviceIntent.putExtra(ScContactSaveService.EXTRA_CONTACT_URI, contactUri); serviceIntent.putExtra(ScContactSaveService.EXTRA_CUSTOM_RINGTONE, value); return serviceIntent; } private void setRingtone(Intent intent) { Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI); String value = intent.getStringExtra(EXTRA_CUSTOM_RINGTONE); if (contactUri == null) { Log.e(TAG, "Invalid arguments for setRingtone"); return; } ContentValues values = new ContentValues(1); values.put(RawContacts.CUSTOM_RINGTONE, value); getContentResolver().update(contactUri, values, null, null); } /** * Creates an intent that sets the selected data item as super primary (default) */ public static Intent createSetSuperPrimaryIntent(Context context, long dataId) { Intent serviceIntent = new Intent(context, ScContactSaveService.class); serviceIntent.setAction(ScContactSaveService.ACTION_SET_SUPER_PRIMARY); serviceIntent.putExtra(ScContactSaveService.EXTRA_DATA_ID, dataId); return serviceIntent; } private void setSuperPrimary(Intent intent) { long dataId = intent.getLongExtra(EXTRA_DATA_ID, -1); if (dataId == -1) { Log.e(TAG, "Invalid arguments for setSuperPrimary request"); return; } // Update the primary values in the data record. ContentValues values = new ContentValues(1); values.put(Data.IS_SUPER_PRIMARY, 1); values.put(Data.IS_PRIMARY, 1); getContentResolver().update(ContentUris.withAppendedId(Data.CONTENT_URI, dataId), values, null, null); } /** * Creates an intent that clears the primary flag of all data items that belong to the same * raw_contact as the given data item. Will only clear, if the data item was primary before * this call */ public static Intent createClearPrimaryIntent(Context context, long dataId) { Intent serviceIntent = new Intent(context, ScContactSaveService.class); serviceIntent.setAction(ScContactSaveService.ACTION_CLEAR_PRIMARY); serviceIntent.putExtra(ScContactSaveService.EXTRA_DATA_ID, dataId); return serviceIntent; } private void clearPrimary(Intent intent) { long dataId = intent.getLongExtra(EXTRA_DATA_ID, -1); if (dataId == -1) { Log.e(TAG, "Invalid arguments for clearPrimary request"); return; } // Update the primary values in the data record. ContentValues values = new ContentValues(1); values.put(Data.IS_SUPER_PRIMARY, 0); values.put(Data.IS_PRIMARY, 0); getContentResolver().update(ContentUris.withAppendedId(Data.CONTENT_URI, dataId), values, null, null); } /** * Creates an intent that can be sent to this service to delete a contact. */ public static Intent createDeleteContactIntent(Context context, Uri contactUri) { Intent serviceIntent = new Intent(context, ScContactSaveService.class); serviceIntent.setAction(ScContactSaveService.ACTION_DELETE_CONTACT); serviceIntent.putExtra(ScContactSaveService.EXTRA_CONTACT_URI, contactUri); return serviceIntent; } private void deleteContact(Intent intent) { Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI); if (contactUri == null) { Log.e(TAG, "Invalid arguments for deleteContact request"); return; } getContentResolver().delete(contactUri, null, null); } /** * Creates an intent that can be sent to this service to join two contacts. */ public static Intent createJoinContactsIntent(Context context, long contactId1, long contactId2, boolean contactWritable, Class<? extends Activity> callbackActivity, String callbackAction) { Intent serviceIntent = new Intent(context, ScContactSaveService.class); serviceIntent.setAction(ScContactSaveService.ACTION_JOIN_CONTACTS); serviceIntent.putExtra(ScContactSaveService.EXTRA_CONTACT_ID1, contactId1); serviceIntent.putExtra(ScContactSaveService.EXTRA_CONTACT_ID2, contactId2); serviceIntent.putExtra(ScContactSaveService.EXTRA_CONTACT_WRITABLE, contactWritable); // Callback intent will be invoked by the service once the contacts are joined. Intent callbackIntent = new Intent(context, callbackActivity); callbackIntent.setAction(callbackAction); serviceIntent.putExtra(ScContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent); return serviceIntent; } // private interface JoinContactQuery { // String[] PROJECTION = { // RawContacts._ID, // RawContacts.NAME_VERIFIED, // RawContacts.DISPLAY_NAME_SOURCE, // }; // // String SELECTION = RawContacts.CONTACT_ID + "=? OR " + RawContacts.CONTACT_ID + "=?"; // // int _ID = 0; // int CONTACT_ID = 1; // int NAME_VERIFIED = 2; // int DISPLAY_NAME_SOURCE = 3; // } // // private void joinContacts(Intent intent) { // long contactId1 = intent.getLongExtra(EXTRA_CONTACT_ID1, -1); // long contactId2 = intent.getLongExtra(EXTRA_CONTACT_ID2, -1); // boolean writable = intent.getBooleanExtra(EXTRA_CONTACT_WRITABLE, false); // if (contactId1 == -1 || contactId2 == -1) { // Log.e(TAG, "Invalid arguments for joinContacts request"); // return; // } // // final ContentResolver resolver = getContentResolver(); // // // Load raw contact IDs for all raw contacts involved - currently edited and selected // // in the join UIs // Cursor c = resolver.query(RawContacts.CONTENT_URI, // JoinContactQuery.PROJECTION, // JoinContactQuery.SELECTION, // new String[]{String.valueOf(contactId1), String.valueOf(contactId2)}, null); // // long rawContactIds[]; // long verifiedNameRawContactId = -1; // try { // int maxDisplayNameSource = -1; // rawContactIds = new long[c.getCount()]; // for (int i = 0; i < rawContactIds.length; i++) { // c.moveToPosition(i); // long rawContactId = c.getLong(JoinContactQuery._ID); // rawContactIds[i] = rawContactId; // int nameSource = c.getInt(JoinContactQuery.DISPLAY_NAME_SOURCE); // if (nameSource > maxDisplayNameSource) { // maxDisplayNameSource = nameSource; // } // } // // // Find an appropriate display name for the joined contact: // // if should have a higher DisplayNameSource or be the name // // of the original contact that we are joining with another. // if (writable) { // for (int i = 0; i < rawContactIds.length; i++) { // c.moveToPosition(i); // if (c.getLong(JoinContactQuery.CONTACT_ID) == contactId1) { // int nameSource = c.getInt(JoinContactQuery.DISPLAY_NAME_SOURCE); // if (nameSource == maxDisplayNameSource // && (verifiedNameRawContactId == -1 // || c.getInt(JoinContactQuery.NAME_VERIFIED) != 0)) { // verifiedNameRawContactId = c.getLong(JoinContactQuery._ID); // } // } // } // } // } finally { // c.close(); // } // // // For each pair of raw contacts, insert an aggregation exception // ArrayList<ContentProviderOperation> operations = new ArrayList<ContentProviderOperation>(); // for (int i = 0; i < rawContactIds.length; i++) { // for (int j = 0; j < rawContactIds.length; j++) { // if (i != j) { // buildJoinContactDiff(operations, rawContactIds[i], rawContactIds[j]); // } // } // } // // // Mark the original contact as "name verified" to make sure that the contact // // display name does not change as a result of the join // if (verifiedNameRawContactId != -1) { // Builder builder = ContentProviderOperation.newUpdate( // ContentUris.withAppendedId(RawContacts.CONTENT_URI, verifiedNameRawContactId)); // builder.withValue(RawContacts.NAME_VERIFIED, 1); // operations.add(builder.build()); // } // // boolean success = false; // // Apply all aggregation exceptions as one batch // try { // resolver.applyBatch(ContactsContract.AUTHORITY, operations); // showToast(R.string.contactsJoinedMessage); // success = true; // } catch (RemoteException e) { // Log.e(TAG, "Failed to apply aggregation exception batch", e); // showToast(R.string.contactSavedErrorToast); // } catch (OperationApplicationException e) { // Log.e(TAG, "Failed to apply aggregation exception batch", e); // showToast(R.string.contactSavedErrorToast); // } // // Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT); // if (success) { // Uri uri = RawContacts.getContactLookupUri(resolver, // ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactIds[0])); // callbackIntent.setData(uri); // } // deliverCallback(callbackIntent); // } // // /** // * Construct a {@link AggregationExceptions#TYPE_KEEP_TOGETHER} ContentProviderOperation. // */ // private void buildJoinContactDiff(ArrayList<ContentProviderOperation> operations, // long rawContactId1, long rawContactId2) { // Builder builder = // ContentProviderOperation.newUpdate(AggregationExceptions.CONTENT_URI); // builder.withValue(AggregationExceptions.TYPE, AggregationExceptions.TYPE_KEEP_TOGETHER); // builder.withValue(AggregationExceptions.RAW_CONTACT_ID1, rawContactId1); // builder.withValue(AggregationExceptions.RAW_CONTACT_ID2, rawContactId2); // operations.add(builder.build()); // } // /** * Shows a toast on the UI thread. */ private void showToast(final int message) { mMainHandler.post(new Runnable() { @Override public void run() { Toast.makeText(ScContactSaveService.this, message, Toast.LENGTH_LONG).show(); } }); } private void deliverCallback(final Intent callbackIntent) { mMainHandler.post(new Runnable() { @Override public void run() { deliverCallbackOnUiThread(callbackIntent); } }); } void deliverCallbackOnUiThread(final Intent callbackIntent) { // TODO: this assumes that if there are multiple instances of the same // activity registered, the last one registered is the one waiting for // the callback. Validity of this assumption needs to be verified. for (Listener listener : sListeners) { if (callbackIntent.getComponent().equals( ((Activity) listener).getIntent().getComponent())) { listener.onServiceCompleted(callbackIntent); return; } } } }