diff -r 000000000000 -r 6474c204b198 mobile/android/base/ContactService.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mobile/android/base/ContactService.java Wed Dec 31 06:09:35 2014 +0100 @@ -0,0 +1,2015 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map.Entry; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.ThreadUtils; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.app.AlertDialog; +import android.content.ContentProviderOperation; +import android.content.ContentProviderResult; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.DialogInterface; +import android.content.OperationApplicationException; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.RemoteException; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.BaseTypes; +import android.provider.ContactsContract.CommonDataKinds.Email; +import android.provider.ContactsContract.CommonDataKinds.Event; +import android.provider.ContactsContract.CommonDataKinds.GroupMembership; +import android.provider.ContactsContract.CommonDataKinds.Im; +import android.provider.ContactsContract.CommonDataKinds.Nickname; +import android.provider.ContactsContract.CommonDataKinds.Note; +import android.provider.ContactsContract.CommonDataKinds.Organization; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.provider.ContactsContract.CommonDataKinds.StructuredPostal; +import android.provider.ContactsContract.CommonDataKinds.Website; +import android.provider.ContactsContract.Data; +import android.provider.ContactsContract.Groups; +import android.provider.ContactsContract.RawContacts; +import android.provider.ContactsContract.RawContacts.Entity; +import android.telephony.PhoneNumberUtils; +import android.util.Log; + +public class ContactService implements GeckoEventListener { + private static final String LOGTAG = "GeckoContactService"; + private static final boolean DEBUG = false; + + private final static int GROUP_ACCOUNT_NAME = 0; + private final static int GROUP_ACCOUNT_TYPE = 1; + private final static int GROUP_ID = 2; + private final static int GROUP_TITLE = 3; + private final static int GROUP_AUTO_ADD = 4; + + private final static String CARRIER_COLUMN = Data.DATA5; + private final static String CUSTOM_DATA_COLUMN = Data.DATA1; + + // Pre-Honeycomb versions of Android have a "My Contacts" system group that all contacts are + // assigned to by default for a given account. After Honeycomb, an AUTO_ADD database column + // was added to denote groups that contacts are automatically added to + private final static String PRE_HONEYCOMB_DEFAULT_GROUP = "System Group: My Contacts"; + private final static String MIMETYPE_ADDITIONAL_NAME = "org.mozilla.gecko/additional_name"; + private final static String MIMETYPE_SEX = "org.mozilla.gecko/sex"; + private final static String MIMETYPE_GENDER_IDENTITY = "org.mozilla.gecko/gender_identity"; + private final static String MIMETYPE_KEY = "org.mozilla.gecko/key"; + private final static String MIMETYPE_MOZILLA_CONTACTS_FLAG = "org.mozilla.gecko/contact_flag"; + + private final EventDispatcher mEventDispatcher; + + private String mAccountName; + private String mAccountType; + private String mGroupTitle; + private long mGroupId; + private boolean mGotDeviceAccount; + + private HashMap mColumnNameConstantsMap; + private HashMap mMimeTypeConstantsMap; + private HashMap mAddressTypesMap; + private HashMap mPhoneTypesMap; + private HashMap mEmailTypesMap; + private HashMap mWebsiteTypesMap; + private HashMap mImTypesMap; + + private ContentResolver mContentResolver; + private GeckoApp mActivity; + + ContactService(EventDispatcher eventDispatcher, GeckoApp activity) { + mEventDispatcher = eventDispatcher; + mActivity = activity; + mContentResolver = mActivity.getContentResolver(); + mGotDeviceAccount = false; + + registerEventListener("Android:Contacts:Clear"); + registerEventListener("Android:Contacts:Find"); + registerEventListener("Android:Contacts:GetAll"); + registerEventListener("Android:Contacts:GetCount"); + registerEventListener("Android:Contact:Remove"); + registerEventListener("Android:Contact:Save"); + } + + public void destroy() { + unregisterEventListener("Android:Contacts:Clear"); + unregisterEventListener("Android:Contacts:Find"); + unregisterEventListener("Android:Contacts:GetAll"); + unregisterEventListener("Android:Contacts:GetCount"); + unregisterEventListener("Android:Contact:Remove"); + unregisterEventListener("Android:Contact:Save"); + } + + @Override + public void handleMessage(final String event, final JSONObject message) { + // If the account chooser dialog needs shown to the user, the message handling becomes + // asychronous so it needs posted to a background thread from the UI thread when the + // account chooser dialog is dismissed by the user. + Runnable handleMessage = new Runnable() { + @Override + public void run() { + try { + if (DEBUG) { + Log.d(LOGTAG, "Event: " + event + "\nMessage: " + message.toString(3)); + } + + final JSONObject messageData = message.getJSONObject("data"); + final String requestID = messageData.getString("requestID"); + + // Options may not exist for all operations + JSONObject contactOptions = messageData.optJSONObject("options"); + + if ("Android:Contacts:Find".equals(event)) { + findContacts(contactOptions, requestID); + } else if ("Android:Contacts:GetAll".equals(event)) { + getAllContacts(messageData, requestID); + } else if ("Android:Contacts:Clear".equals(event)) { + clearAllContacts(contactOptions, requestID); + } else if ("Android:Contact:Save".equals(event)) { + saveContact(contactOptions, requestID); + } else if ("Android:Contact:Remove".equals(event)) { + removeContact(contactOptions, requestID); + } else if ("Android:Contacts:GetCount".equals(event)) { + getContactsCount(requestID); + } else { + throw new IllegalArgumentException("Unexpected event: " + event); + } + } catch (JSONException e) { + throw new IllegalArgumentException("Message: " + e); + } + } + }; + + // Get the account name/type if they haven't been set yet + if (!mGotDeviceAccount) { + getDeviceAccount(handleMessage); + } else { + handleMessage.run(); + } + } + + private void findContacts(final JSONObject contactOptions, final String requestID) { + long[] rawContactIds = findContactsRawIds(contactOptions); + Log.i(LOGTAG, "Got " + (rawContactIds != null ? rawContactIds.length : "null") + " raw contact IDs"); + + final String[] sortOptions = getSortOptionsFromJSON(contactOptions); + + if (rawContactIds == null || sortOptions == null) { + sendCallbackToJavascript("Android:Contacts:Find:Return:KO", requestID, null, null); + } else { + sendCallbackToJavascript("Android:Contacts:Find:Return:OK", requestID, + new String[] {"contacts"}, + new Object[] {getContactsAsJSONArray(rawContactIds, sortOptions[0], + sortOptions[1])}); + } + } + + private void getAllContacts(final JSONObject contactOptions, final String requestID) { + long[] rawContactIds = getAllRawContactIds(); + Log.i(LOGTAG, "Got " + rawContactIds.length + " raw contact IDs"); + + final String[] sortOptions = getSortOptionsFromJSON(contactOptions); + + if (rawContactIds == null || sortOptions == null) { + // There's no failure message for getAll + return; + } else { + sendCallbackToJavascript("Android:Contacts:GetAll:Next", requestID, + new String[] {"contacts"}, + new Object[] {getContactsAsJSONArray(rawContactIds, sortOptions[0], + sortOptions[1])}); + } + } + + private static String[] getSortOptionsFromJSON(final JSONObject contactOptions) { + String sortBy = null; + String sortOrder = null; + + try { + final JSONObject findOptions = contactOptions.getJSONObject("findOptions"); + sortBy = findOptions.optString("sortBy").toLowerCase(); + sortOrder = findOptions.optString("sortOrder").toLowerCase(); + + if ("".equals(sortBy)) { + sortBy = null; + } + if ("".equals(sortOrder)) { + sortOrder = "ascending"; + } + + // Only "familyname" and "givenname" are valid sortBy values and only "ascending" + // and "descending" are valid sortOrder values + if ((sortBy != null && !"familyname".equals(sortBy) && !"givenname".equals(sortBy)) || + (!"ascending".equals(sortOrder) && !"descending".equals(sortOrder))) { + return null; + } + } catch (JSONException e) { + throw new IllegalArgumentException(e); + } + + return new String[] {sortBy, sortOrder}; + } + + private long[] findContactsRawIds(final JSONObject contactOptions) { + List rawContactIds = new ArrayList(); + Cursor cursor = null; + + try { + final JSONObject findOptions = contactOptions.getJSONObject("findOptions"); + String filterValue = findOptions.optString("filterValue"); + JSONArray filterBy = findOptions.optJSONArray("filterBy"); + final String filterOp = findOptions.optString("filterOp"); + final int filterLimit = findOptions.getInt("filterLimit"); + final int substringMatching = findOptions.getInt("substringMatching"); + + // If filter value is undefined, avoid all the logic below and just return + // all available raw contact IDs + if ("".equals(filterValue) || "".equals(filterOp)) { + long[] allRawContactIds = getAllRawContactIds(); + + // Truncate the raw contacts IDs array if necessary + if (filterLimit > 0 && allRawContactIds.length > filterLimit) { + long[] truncatedRawContactIds = new long[filterLimit]; + for (int i = 0; i < filterLimit; i++) { + truncatedRawContactIds[i] = allRawContactIds[i]; + } + return truncatedRawContactIds; + } + return allRawContactIds; + } + + // "match" can only be used with the "tel" field + if ("match".equals(filterOp)) { + for (int i = 0; i < filterBy.length(); i++) { + if (!"tel".equals(filterBy.getString(i))) { + Log.w(LOGTAG, "\"match\" filterBy option is only valid for the \"tel\" field"); + return null; + } + } + } + + // Only select contacts from the selected account + String selection = null; + String[] selectionArgs = null; + + if (mAccountName != null) { + selection = RawContacts.ACCOUNT_NAME + "=? AND " + RawContacts.ACCOUNT_TYPE + "=?"; + selectionArgs = new String[] {mAccountName, mAccountType}; + } + + + final String[] columnsToGet; + + // If a filterBy value was not specified, search all columns + if (filterBy == null || filterBy.length() == 0) { + columnsToGet = null; + } else { + // Only get the columns given in the filterBy array + List columnsToGetList = new ArrayList(); + + columnsToGetList.add(Data.RAW_CONTACT_ID); + columnsToGetList.add(Data.MIMETYPE); + for (int i = 0; i < filterBy.length(); i++) { + final String field = filterBy.getString(i); + + // If one of the filterBy fields is the ID, just return the filter value + // which should be the ID + if ("id".equals(field)) { + try { + return new long[] {Long.valueOf(filterValue)}; + } catch (NumberFormatException e) { + // If the ID couldn't be converted to a long, it's invalid data + // so return null for failure + return null; + } + } + + final String columnName = getColumnNameConstant(field); + + if (columnName != null) { + columnsToGetList.add(columnName); + } else { + Log.w(LOGTAG, "Unknown filter option: " + field); + } + } + + columnsToGet = columnsToGetList.toArray(new String[columnsToGetList.size()]); + } + + // Execute the query + cursor = mContentResolver.query(Data.CONTENT_URI, columnsToGet, selection, + selectionArgs, null); + + if (cursor.getCount() > 0) { + cursor.moveToPosition(-1); + while (cursor.moveToNext()) { + String mimeType = cursor.getString(cursor.getColumnIndex(Data.MIMETYPE)); + + // Check if the current mimetype is one of the types to filter by + if (filterBy != null && filterBy.length() > 0) { + for (int i = 0; i < filterBy.length(); i++) { + String currentFilterBy = filterBy.getString(i); + + if (mimeType.equals(getMimeTypeOfField(currentFilterBy))) { + String columnName = getColumnNameConstant(currentFilterBy); + int columnIndex = cursor.getColumnIndex(columnName); + String databaseValue = cursor.getString(columnIndex); + + boolean isPhone = false; + if (Phone.CONTENT_ITEM_TYPE.equals(mimeType)) { + isPhone = true; + } else if (GroupMembership.CONTENT_ITEM_TYPE.equals(mimeType)) { + // Translate the group ID to the group name for matching + try { + databaseValue = getGroupName(Long.valueOf(databaseValue)); + } catch (NumberFormatException e) { + Log.e(LOGTAG, "Number Format Exception", e); + continue; + } + } else if (databaseValue == null) { + continue; + } + + // Check if the value matches the filter value + if (isFindMatch(filterOp, filterValue, databaseValue, isPhone, substringMatching)) { + addMatchToList(cursor, rawContactIds); + break; + } + } + } + } else { + // If no filterBy options were given, check each column for a match + int numColumns = cursor.getColumnCount(); + for (int i = 0; i < numColumns; i++) { + String databaseValue = cursor.getString(i); + if (databaseValue != null && isFindMatch(filterOp, filterValue, databaseValue, false, substringMatching)) { + addMatchToList(cursor, rawContactIds); + break; + } + } + } + + // If the max found contacts size has been hit, stop looking for contacts + // A filter limit of 0 denotes there is no limit + if (filterLimit > 0 && filterLimit <= rawContactIds.size()) { + break; + } + } + } + } catch (JSONException e) { + throw new IllegalArgumentException(e); + } finally { + if (cursor != null) { + cursor.close(); + } + } + + // Return the contact IDs list converted to an array + return convertLongListToArray(rawContactIds); + } + + private boolean isFindMatch(final String filterOp, String filterValue, String databaseValue, + final boolean isPhone, final int substringMatching) { + Log.i(LOGTAG, "matching: filterOp: " + filterOp); + if (DEBUG) { + Log.d(LOGTAG, "matching: filterValue: " + filterValue); + Log.d(LOGTAG, "matching: databaseValue: " + databaseValue); + } + Log.i(LOGTAG, "matching: isPhone: " + isPhone); + Log.i(LOGTAG, "matching: substringMatching: " + substringMatching); + + if (databaseValue == null) { + return false; + } + + filterValue = filterValue.toLowerCase(); + databaseValue = databaseValue.toLowerCase(); + + if ("match".equals(filterOp)) { + // If substring matching is a positive number, only pay attention to the last X characters + // of both the filter and database values + if (substringMatching > 0) { + databaseValue = substringStartFromEnd(cleanPhoneNumber(databaseValue), substringMatching); + filterValue = substringStartFromEnd(cleanPhoneNumber(filterValue), substringMatching); + return databaseValue.startsWith(filterValue); + } + return databaseValue.equals(filterValue); + } else if ("equals".equals(filterOp)) { + if (isPhone) { + return PhoneNumberUtils.compare(filterValue, databaseValue); + } + return databaseValue.equals(filterValue); + } else if ("contains".equals(filterOp)) { + if (isPhone) { + filterValue = cleanPhoneNumber(filterValue); + databaseValue = cleanPhoneNumber(databaseValue); + } + return databaseValue.contains(filterValue); + } else if ("startsWith".equals(filterOp)) { + // If a phone number, remove non-dialable characters and then only pay attention to + // the last X digits given by the substring matching values (see bug 877302) + if (isPhone) { + String cleanedDatabasePhone = cleanPhoneNumber(databaseValue); + if (substringMatching > 0) { + cleanedDatabasePhone = substringStartFromEnd(cleanedDatabasePhone, substringMatching); + } + + if (cleanedDatabasePhone.startsWith(filterValue)) { + return true; + } + } + return databaseValue.startsWith(filterValue); + } + return false; + } + + private static String cleanPhoneNumber(String phone) { + return phone.replace(" ", "").replace("(", "").replace(")", "").replace("-", ""); + } + + private static String substringStartFromEnd(final String string, final int distanceFromEnd) { + int stringLen = string.length(); + if (stringLen < distanceFromEnd) { + return string; + } + return string.substring(stringLen - distanceFromEnd); + } + + private static void addMatchToList(final Cursor cursor, List rawContactIds) { + long rawContactId = cursor.getLong(cursor.getColumnIndex(Data.RAW_CONTACT_ID)); + if (!rawContactIds.contains(rawContactId)) { + rawContactIds.add(rawContactId); + } + } + + private JSONArray getContactsAsJSONArray(final long[] rawContactIds, final String sortBy, final String sortOrder) { + List contactsList = new ArrayList(); + JSONArray contactsArray = new JSONArray(); + + // Get each contact as a JSON object + for (int i = 0; i < rawContactIds.length; i++) { + contactsList.add(getContactAsJSONObject(rawContactIds[i])); + } + + // Sort the contacts + if (sortBy != null) { + Collections.sort(contactsList, new ContactsComparator(sortBy, sortOrder)); + } + + // Convert the contacts list to a JSON array + for (int i = 0; i < contactsList.size(); i++) { + contactsArray.put(contactsList.get(i)); + } + + return contactsArray; + } + + private JSONObject getContactAsJSONObject(long rawContactId) { + // ContactManager wants a contact object with it's properties wrapped in an array of objects + JSONObject contact = new JSONObject(); + JSONObject contactProperties = new JSONObject(); + + JSONArray names = new JSONArray(); + JSONArray givenNames = new JSONArray(); + JSONArray familyNames = new JSONArray(); + JSONArray honorificPrefixes = new JSONArray(); + JSONArray honorificSuffixes = new JSONArray(); + JSONArray additionalNames = new JSONArray(); + JSONArray nicknames = new JSONArray(); + JSONArray addresses = new JSONArray(); + JSONArray phones = new JSONArray(); + JSONArray emails = new JSONArray(); + JSONArray organizations = new JSONArray(); + JSONArray jobTitles = new JSONArray(); + JSONArray notes = new JSONArray(); + JSONArray urls = new JSONArray(); + JSONArray impps = new JSONArray(); + JSONArray categories = new JSONArray(); + String bday = null; + String anniversary = null; + String sex = null; + String genderIdentity = null; + JSONArray key = new JSONArray(); + + // Get all the data columns + final String[] columnsToGet = getAllColumns(); + + Uri rawContactUri = ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId); + Uri entityUri = Uri.withAppendedPath(rawContactUri, Entity.CONTENT_DIRECTORY); + + Cursor cursor = mContentResolver.query(entityUri, columnsToGet, null, null, null); + cursor.moveToPosition(-1); + while (cursor.moveToNext()) { + String mimeType = cursor.getString(cursor.getColumnIndex(Data.MIMETYPE)); + + // Put the proper fields for each mimetype into the JSON arrays + try { + if (StructuredName.CONTENT_ITEM_TYPE.equals(mimeType)) { + final String displayName = cursor.getString(cursor.getColumnIndex(StructuredName.DISPLAY_NAME)); + final String givenName = cursor.getString(cursor.getColumnIndex(StructuredName.GIVEN_NAME)); + final String familyName = cursor.getString(cursor.getColumnIndex(StructuredName.FAMILY_NAME)); + final String prefix = cursor.getString(cursor.getColumnIndex(StructuredName.PREFIX)); + final String suffix = cursor.getString(cursor.getColumnIndex(StructuredName.SUFFIX)); + + if (displayName != null) { + names.put(displayName); + } + if (givenName != null) { + givenNames.put(givenName); + } + if (familyName != null) { + familyNames.put(familyName); + } + if (prefix != null) { + honorificPrefixes.put(prefix); + } + if (suffix != null) { + honorificSuffixes.put(suffix); + } + + } else if (MIMETYPE_ADDITIONAL_NAME.equals(mimeType)) { + additionalNames.put(cursor.getString(cursor.getColumnIndex(CUSTOM_DATA_COLUMN))); + + } else if (Nickname.CONTENT_ITEM_TYPE.equals(mimeType)) { + nicknames.put(cursor.getString(cursor.getColumnIndex(Nickname.NAME))); + + } else if (StructuredPostal.CONTENT_ITEM_TYPE.equals(mimeType)) { + initAddressTypesMap(); + getAddressDataAsJSONObject(cursor, addresses); + + } else if (Phone.CONTENT_ITEM_TYPE.equals(mimeType)) { + initPhoneTypesMap(); + getPhoneDataAsJSONObject(cursor, phones); + + } else if (Email.CONTENT_ITEM_TYPE.equals(mimeType)) { + initEmailTypesMap(); + getGenericDataAsJSONObject(cursor, emails, Email.ADDRESS, Email.TYPE, Email.LABEL, mEmailTypesMap); + + } else if (Organization.CONTENT_ITEM_TYPE.equals(mimeType)) { + getOrganizationDataAsJSONObject(cursor, organizations, jobTitles); + + } else if (Note.CONTENT_ITEM_TYPE.equals(mimeType)) { + notes.put(cursor.getString(cursor.getColumnIndex(Note.NOTE))); + + } else if (Website.CONTENT_ITEM_TYPE.equals(mimeType)) { + initWebsiteTypesMap(); + getGenericDataAsJSONObject(cursor, urls, Website.URL, Website.TYPE, Website.LABEL, mWebsiteTypesMap); + + } else if (Im.CONTENT_ITEM_TYPE.equals(mimeType)) { + initImTypesMap(); + getGenericDataAsJSONObject(cursor, impps, Im.DATA, Im.TYPE, Im.LABEL, mImTypesMap); + + } else if (GroupMembership.CONTENT_ITEM_TYPE.equals(mimeType)) { + long groupId = cursor.getLong(cursor.getColumnIndex(GroupMembership.GROUP_ROW_ID)); + String groupName = getGroupName(groupId); + if (!doesJSONArrayContainString(categories, groupName)) { + categories.put(groupName); + } + + } else if (Event.CONTENT_ITEM_TYPE.equals(mimeType)) { + int type = cursor.getInt(cursor.getColumnIndex(Event.TYPE)); + String date = cursor.getString(cursor.getColumnIndex(Event.START_DATE)); + + // Add the time info onto the date so it correctly parses into a JS date object + date += "T00:00:00"; + + switch (type) { + case Event.TYPE_BIRTHDAY: + bday = date; + break; + + case Event.TYPE_ANNIVERSARY: + anniversary = date; + break; + } + + } else if (MIMETYPE_SEX.equals(mimeType)) { + sex = cursor.getString(cursor.getColumnIndex(CUSTOM_DATA_COLUMN)); + + } else if (MIMETYPE_GENDER_IDENTITY.equals(mimeType)) { + genderIdentity = cursor.getString(cursor.getColumnIndex(CUSTOM_DATA_COLUMN)); + + } else if (MIMETYPE_KEY.equals(mimeType)) { + key.put(cursor.getString(cursor.getColumnIndex(CUSTOM_DATA_COLUMN))); + } + } catch (JSONException e) { + throw new IllegalArgumentException(e); + } + } + cursor.close(); + + try { + // Add the fields to the contact properties object + contactProperties.put("name", names); + contactProperties.put("givenName", givenNames); + contactProperties.put("familyName", familyNames); + contactProperties.put("honorificPrefix", honorificPrefixes); + contactProperties.put("honorificSuffix", honorificSuffixes); + contactProperties.put("additionalName", additionalNames); + contactProperties.put("nickname", nicknames); + contactProperties.put("adr", addresses); + contactProperties.put("tel", phones); + contactProperties.put("email", emails); + contactProperties.put("org", organizations); + contactProperties.put("jobTitle", jobTitles); + contactProperties.put("note", notes); + contactProperties.put("url", urls); + contactProperties.put("impp", impps); + contactProperties.put("category", categories); + contactProperties.put("key", key); + + putPossibleNullValueInJSONObject("bday", bday, contactProperties); + putPossibleNullValueInJSONObject("anniversary", anniversary, contactProperties); + putPossibleNullValueInJSONObject("sex", sex, contactProperties); + putPossibleNullValueInJSONObject("genderIdentity", genderIdentity, contactProperties); + + // Add the raw contact ID and the properties to the contact + contact.put("id", String.valueOf(rawContactId)); + contact.put("updated", null); + contact.put("published", null); + contact.put("properties", contactProperties); + } catch (JSONException e) { + throw new IllegalArgumentException(e); + } + + if (DEBUG) { + try { + Log.d(LOGTAG, "Got contact: " + contact.toString(3)); + } catch (JSONException e) {} + } + + return contact; + } + + private boolean bool(int integer) { + return integer != 0 ? true : false; + } + + private void getGenericDataAsJSONObject(Cursor cursor, JSONArray array, final String dataColumn, + final String typeColumn, final String typeLabelColumn, + final HashMap typeMap) throws JSONException { + String value = cursor.getString(cursor.getColumnIndex(dataColumn)); + int typeConstant = cursor.getInt(cursor.getColumnIndex(typeColumn)); + String type; + if (typeConstant == BaseTypes.TYPE_CUSTOM) { + type = cursor.getString(cursor.getColumnIndex(typeLabelColumn)); + } else { + type = getKeyFromMapValue(typeMap, Integer.valueOf(typeConstant)); + } + + // Since an object may have multiple types, it may have already been added, + // but still needs the new type added + boolean found = false; + if (type != null) { + for (int i = 0; i < array.length(); i++) { + JSONObject object = array.getJSONObject(i); + if (value.equals(object.getString("value"))) { + found = true; + + JSONArray types = object.getJSONArray("type"); + if (!doesJSONArrayContainString(types, type)) { + types.put(type); + break; + } + } + } + } + + // If an existing object wasn't found, make a new one + if (!found) { + JSONObject object = new JSONObject(); + JSONArray types = new JSONArray(); + object.put("value", value); + types.put(type); + object.put("type", types); + object.put("pref", bool(cursor.getInt(cursor.getColumnIndex(Data.IS_SUPER_PRIMARY)))); + + array.put(object); + } + } + + private void getPhoneDataAsJSONObject(Cursor cursor, JSONArray phones) throws JSONException { + String value = cursor.getString(cursor.getColumnIndex(Phone.NUMBER)); + int typeConstant = cursor.getInt(cursor.getColumnIndex(Phone.TYPE)); + String type; + if (typeConstant == Phone.TYPE_CUSTOM) { + type = cursor.getString(cursor.getColumnIndex(Phone.LABEL)); + } else { + type = getKeyFromMapValue(mPhoneTypesMap, Integer.valueOf(typeConstant)); + } + + // Since a phone may have multiple types, it may have already been added, + // but still needs the new type added + boolean found = false; + if (type != null) { + for (int i = 0; i < phones.length(); i++) { + JSONObject phone = phones.getJSONObject(i); + if (value.equals(phone.getString("value"))) { + found = true; + + JSONArray types = phone.getJSONArray("type"); + if (!doesJSONArrayContainString(types, type)) { + types.put(type); + break; + } + } + } + } + + // If an existing phone wasn't found, make a new one + if (!found) { + JSONObject phone = new JSONObject(); + JSONArray types = new JSONArray(); + phone.put("value", value); + phone.put("type", type); + types.put(type); + phone.put("type", types); + phone.put("carrier", cursor.getString(cursor.getColumnIndex(CARRIER_COLUMN))); + phone.put("pref", bool(cursor.getInt(cursor.getColumnIndex(Phone.IS_SUPER_PRIMARY)))); + + phones.put(phone); + } + } + + private void getAddressDataAsJSONObject(Cursor cursor, JSONArray addresses) throws JSONException { + String streetAddress = cursor.getString(cursor.getColumnIndex(StructuredPostal.STREET)); + String locality = cursor.getString(cursor.getColumnIndex(StructuredPostal.CITY)); + String region = cursor.getString(cursor.getColumnIndex(StructuredPostal.REGION)); + String postalCode = cursor.getString(cursor.getColumnIndex(StructuredPostal.POSTCODE)); + String countryName = cursor.getString(cursor.getColumnIndex(StructuredPostal.COUNTRY)); + int typeConstant = cursor.getInt(cursor.getColumnIndex(StructuredPostal.TYPE)); + String type; + if (typeConstant == StructuredPostal.TYPE_CUSTOM) { + type = cursor.getString(cursor.getColumnIndex(StructuredPostal.LABEL)); + } else { + type = getKeyFromMapValue(mAddressTypesMap, Integer.valueOf(typeConstant)); + } + + // Since an email may have multiple types, it may have already been added, + // but still needs the new type added + boolean found = false; + if (type != null) { + for (int i = 0; i < addresses.length(); i++) { + JSONObject address = addresses.getJSONObject(i); + if (streetAddress.equals(address.getString("streetAddress")) && + locality.equals(address.getString("locality")) && + region.equals(address.getString("region")) && + countryName.equals(address.getString("countryName")) && + postalCode.equals(address.getString("postalCode"))) { + found = true; + + JSONArray types = address.getJSONArray("type"); + if (!doesJSONArrayContainString(types, type)) { + types.put(type); + break; + } + } + } + } + + // If an existing email wasn't found, make a new one + if (!found) { + JSONObject address = new JSONObject(); + JSONArray types = new JSONArray(); + address.put("streetAddress", streetAddress); + address.put("locality", locality); + address.put("region", region); + address.put("countryName", countryName); + address.put("postalCode", postalCode); + types.put(type); + address.put("type", types); + address.put("pref", bool(cursor.getInt(cursor.getColumnIndex(StructuredPostal.IS_SUPER_PRIMARY)))); + + addresses.put(address); + } + } + + private void getOrganizationDataAsJSONObject(Cursor cursor, JSONArray organizations, + JSONArray jobTitles) throws JSONException { + int organizationColumnIndex = cursor.getColumnIndex(Organization.COMPANY); + int titleColumnIndex = cursor.getColumnIndex(Organization.TITLE); + + if (!cursor.isNull(organizationColumnIndex)) { + organizations.put(cursor.getString(organizationColumnIndex)); + } + if (!cursor.isNull(titleColumnIndex)) { + jobTitles.put(cursor.getString(titleColumnIndex)); + } + } + + private class ContactsComparator implements Comparator { + final String mSortBy; + final String mSortOrder; + + public ContactsComparator(final String sortBy, final String sortOrder) { + mSortBy = sortBy.toLowerCase(); + mSortOrder = sortOrder.toLowerCase(); + } + + @Override + public int compare(JSONObject left, JSONObject right) { + // Determine if sorting by "family name, given name" or "given name, family name" + boolean familyFirst = false; + if ("familyname".equals(mSortBy)) { + familyFirst = true; + } + + JSONObject leftProperties; + JSONObject rightProperties; + try { + leftProperties = left.getJSONObject("properties"); + rightProperties = right.getJSONObject("properties"); + } catch (JSONException e) { + throw new IllegalArgumentException(e); + } + + JSONArray leftFamilyNames = leftProperties.optJSONArray("familyName"); + JSONArray leftGivenNames = leftProperties.optJSONArray("givenName"); + JSONArray rightFamilyNames = rightProperties.optJSONArray("familyName"); + JSONArray rightGivenNames = rightProperties.optJSONArray("givenName"); + + // If any of the name arrays didn't exist (are null), create empty arrays + // to avoid doing a bunch of null checking below + if (leftFamilyNames == null) { + leftFamilyNames = new JSONArray(); + } + if (leftGivenNames == null) { + leftGivenNames = new JSONArray(); + } + if (rightFamilyNames == null) { + rightFamilyNames = new JSONArray(); + } + if (rightGivenNames == null) { + rightGivenNames = new JSONArray(); + } + + int maxArrayLength = max(leftFamilyNames.length(), leftGivenNames.length(), + rightFamilyNames.length(), rightGivenNames.length()); + + int index = 0; + int compareResult; + do { + // Join together the given name and family name per the pattern above + String leftName = ""; + String rightName = ""; + + if (familyFirst) { + leftName = leftFamilyNames.optString(index, "") + leftGivenNames.optString(index, ""); + rightName = rightFamilyNames.optString(index, "") + rightGivenNames.optString(index, ""); + } else { + leftName = leftGivenNames.optString(index, "") + leftFamilyNames.optString(index, ""); + rightName = rightGivenNames.optString(index, "") + rightFamilyNames.optString(index, ""); + } + + index++; + compareResult = leftName.compareTo(rightName); + + } while (compareResult == 0 && index < maxArrayLength); + + // If descending order, flip the result + if (compareResult != 0 && "descending".equals(mSortOrder)) { + compareResult = -compareResult; + } + + return compareResult; + } + } + + private void clearAllContacts(final JSONObject contactOptions, final String requestID) { + ArrayList deleteOptions = new ArrayList(); + + // Delete all contacts from the selected account + ContentProviderOperation.Builder deleteOptionsBuilder = ContentProviderOperation.newDelete(RawContacts.CONTENT_URI); + if (mAccountName != null) { + deleteOptionsBuilder.withSelection(RawContacts.ACCOUNT_NAME + "=?", new String[] {mAccountName}) + .withSelection(RawContacts.ACCOUNT_TYPE + "=?", new String[] {mAccountType}); + } + + deleteOptions.add(deleteOptionsBuilder.build()); + + // Clear the contacts + String returnStatus = "KO"; + if (applyBatch(deleteOptions) != null) { + returnStatus = "OK"; + } + + Log.i(LOGTAG, "Sending return status: " + returnStatus); + + sendCallbackToJavascript("Android:Contacts:Clear:Return:" + returnStatus, requestID, + new String[] {"contactID"}, new Object[] {"undefined"}); + + } + + private boolean deleteContact(String rawContactId) { + ContentProviderOperation deleteOptions = ContentProviderOperation.newDelete(RawContacts.CONTENT_URI) + .withSelection(RawContacts._ID + "=?", + new String[] {rawContactId}) + .build(); + + ArrayList deleteOptionsList = new ArrayList(); + deleteOptionsList.add(deleteOptions); + + return checkForPositiveCountInResults(applyBatch(deleteOptionsList)); + } + + private void removeContact(final JSONObject contactOptions, final String requestID) { + String rawContactId; + try { + rawContactId = contactOptions.getString("id"); + Log.i(LOGTAG, "Removing contact with ID: " + rawContactId); + } catch (JSONException e) { + // We can't continue without a raw contact ID + sendCallbackToJavascript("Android:Contact:Remove:Return:KO", requestID, null, null); + return; + } + + String returnStatus = "KO"; + if(deleteContact(rawContactId)) { + returnStatus = "OK"; + } + + sendCallbackToJavascript("Android:Contact:Remove:Return:" + returnStatus, requestID, + new String[] {"contactID"}, new Object[] {rawContactId}); + } + + private void saveContact(final JSONObject contactOptions, final String requestID) { + try { + String reason = contactOptions.getString("reason"); + JSONObject contact = contactOptions.getJSONObject("contact"); + JSONObject contactProperties = contact.getJSONObject("properties"); + + if ("update".equals(reason)) { + updateContact(contactProperties, contact.getLong("id"), requestID); + } else { + insertContact(contactProperties, requestID); + } + } catch (JSONException e) { + throw new IllegalArgumentException(e); + } + } + + private void insertContact(final JSONObject contactProperties, final String requestID) throws JSONException { + ArrayList newContactOptions = new ArrayList(); + + // Account to save the contact under + newContactOptions.add(ContentProviderOperation.newInsert(RawContacts.CONTENT_URI) + .withValue(RawContacts.ACCOUNT_NAME, mAccountName) + .withValue(RawContacts.ACCOUNT_TYPE, mAccountType) + .build()); + + List newContactValues = getContactValues(contactProperties); + + for (ContentValues values : newContactValues) { + newContactOptions.add(ContentProviderOperation.newInsert(Data.CONTENT_URI) + .withValueBackReference(Data.RAW_CONTACT_ID, 0) + .withValues(values) + .build()); + } + + String returnStatus = "KO"; + Long newRawContactId = new Long(-1); + + // Insert the contact! + ContentProviderResult[] insertResults = applyBatch(newContactOptions); + + if (insertResults != null) { + try { + // Get the ID of the newly created contact + newRawContactId = getRawContactIdFromContentProviderResults(insertResults); + + if (newRawContactId != null) { + returnStatus = "OK"; + } + } catch (NumberFormatException e) { + Log.e(LOGTAG, "NumberFormatException", e); + } + + Log.i(LOGTAG, "Newly created contact ID: " + newRawContactId); + } + + Log.i(LOGTAG, "Sending return status: " + returnStatus); + + sendCallbackToJavascript("Android:Contact:Save:Return:" + returnStatus, requestID, + new String[] {"contactID", "reason"}, + new Object[] {newRawContactId, "create"}); + } + + private void updateContact(final JSONObject contactProperties, final long rawContactId, final String requestID) throws JSONException { + // Why is updating a contact so weird and horribly inefficient? Because Android doesn't + // like multiple values for contact fields, but the Mozilla contacts API calls for this. + // This means the Android update function is essentially completely useless. Why not just + // delete the contact and re-insert it? Because that would change the contact ID and the + // Mozilla contacts API shouldn't have this behavior. The solution is to delete each + // row from the contacts data table that belongs to the contact, and insert the new + // fields. But then why not just delete all the data from the data in one go and + // insert the new data in another? Because if all the data relating to a contact is + // deleted, Android will "conviently" remove the ID making it impossible to insert data + // under the old ID. To work around this, we put a Mozilla contact flag in the database + + ContentProviderOperation removeOptions = ContentProviderOperation.newDelete(Data.CONTENT_URI) + .withSelection(Data.RAW_CONTACT_ID + "=? AND " + + Data.MIMETYPE + " != '" + MIMETYPE_MOZILLA_CONTACTS_FLAG + "'", + new String[] {String.valueOf(rawContactId)}) + .build(); + + ArrayList removeOptionsList = new ArrayList(); + removeOptionsList.add(removeOptions); + + ContentProviderResult[] removeResults = applyBatch(removeOptionsList); + + // Check if the remove failed + if (removeResults == null || !checkForPositiveCountInResults(removeResults)) { + Log.w(LOGTAG, "Null or 0 remove results"); + + sendCallbackToJavascript("Android:Contact:Save:Return:KO", requestID, null, null); + return; + } + + List updateContactValues = getContactValues(contactProperties); + ArrayList updateContactOptions = new ArrayList(); + + for (ContentValues values : updateContactValues) { + updateContactOptions.add(ContentProviderOperation.newInsert(Data.CONTENT_URI) + .withValue(Data.RAW_CONTACT_ID, rawContactId) + .withValues(values) + .build()); + } + + String returnStatus = "KO"; + + // Update the contact! + applyBatch(updateContactOptions); + + sendCallbackToJavascript("Android:Contact:Save:Return:OK", requestID, + new String[] {"contactID", "reason"}, + new Object[] {rawContactId, "update"}); + } + + private List getContactValues(final JSONObject contactProperties) throws JSONException { + List contactValues = new ArrayList(); + + // Add the contact to the default group so it is shown in other apps + // like the Contacts or People app + ContentValues defaultGroupValues = new ContentValues(); + defaultGroupValues.put(Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE); + defaultGroupValues.put(GroupMembership.GROUP_ROW_ID, mGroupId); + contactValues.add(defaultGroupValues); + + // Create all the values that will be inserted into the new contact + getNameValues(contactProperties.optJSONArray("name"), + contactProperties.optJSONArray("givenName"), + contactProperties.optJSONArray("familyName"), + contactProperties.optJSONArray("honorificPrefix"), + contactProperties.optJSONArray("honorificSuffix"), + contactValues); + + getGenericValues(MIMETYPE_ADDITIONAL_NAME, CUSTOM_DATA_COLUMN, + contactProperties.optJSONArray("additionalName"), contactValues); + + getNicknamesValues(contactProperties.optJSONArray("nickname"), contactValues); + + getAddressesValues(contactProperties.optJSONArray("adr"), contactValues); + + getPhonesValues(contactProperties.optJSONArray("tel"), contactValues); + + getEmailsValues(contactProperties.optJSONArray("email"), contactValues); + + //getPhotosValues(contactProperties.optJSONArray("photo"), contactValues); + + getGenericValues(Organization.CONTENT_ITEM_TYPE, Organization.COMPANY, + contactProperties.optJSONArray("org"), contactValues); + + getGenericValues(Organization.CONTENT_ITEM_TYPE, Organization.TITLE, + contactProperties.optJSONArray("jobTitle"), contactValues); + + getNotesValues(contactProperties.optJSONArray("note"), contactValues); + + getWebsitesValues(contactProperties.optJSONArray("url"), contactValues); + + getImsValues(contactProperties.optJSONArray("impp"), contactValues); + + getCategoriesValues(contactProperties.optJSONArray("category"), contactValues); + + getEventValues(contactProperties.optString("bday"), Event.TYPE_BIRTHDAY, contactValues); + + getEventValues(contactProperties.optString("anniversary"), Event.TYPE_ANNIVERSARY, contactValues); + + getCustomMimetypeValues(contactProperties.optString("sex"), MIMETYPE_SEX, contactValues); + + getCustomMimetypeValues(contactProperties.optString("genderIdentity"), MIMETYPE_GENDER_IDENTITY, contactValues); + + getGenericValues(MIMETYPE_KEY, CUSTOM_DATA_COLUMN, contactProperties.optJSONArray("key"), + contactValues); + + return contactValues; + } + + private void getGenericValues(final String mimeType, final String dataType, final JSONArray fields, + List newContactValues) throws JSONException { + if (fields == null) { + return; + } + + for (int i = 0; i < fields.length(); i++) { + ContentValues contentValues = new ContentValues(); + contentValues.put(Data.MIMETYPE, mimeType); + contentValues.put(dataType, fields.getString(i)); + newContactValues.add(contentValues); + } + } + + private void getNameValues(final JSONArray displayNames, final JSONArray givenNames, + final JSONArray familyNames, final JSONArray prefixes, + final JSONArray suffixes, List newContactValues) throws JSONException { + int maxLen = max((displayNames != null ? displayNames.length() : 0), + (givenNames != null ? givenNames.length() : 0), + (familyNames != null ? familyNames.length() : 0), + (prefixes != null ? prefixes.length() : 0), + (suffixes != null ? suffixes.length() : 0)); + + for (int i = 0; i < maxLen; i++) { + ContentValues contentValues = new ContentValues(); + contentValues.put(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE); + + final String displayName = (displayNames != null ? displayNames.optString(i, null) : null); + final String givenName = (givenNames != null ? givenNames.optString(i, null) : null); + final String familyName = (familyNames != null ? familyNames.optString(i, null) : null); + final String prefix = (prefixes != null ? prefixes.optString(i, null) : null); + final String suffix = (suffixes != null ? suffixes.optString(i, null) : null); + + if (displayName != null) { + contentValues.put(StructuredName.DISPLAY_NAME, displayName); + } + if (givenName != null) { + contentValues.put(StructuredName.GIVEN_NAME, givenName); + } + if (familyName != null) { + contentValues.put(StructuredName.FAMILY_NAME, familyName); + } + if (prefix != null) { + contentValues.put(StructuredName.PREFIX, prefix); + } + if (suffix != null) { + contentValues.put(StructuredName.SUFFIX, suffix); + } + + newContactValues.add(contentValues); + } + } + + private void getNicknamesValues(final JSONArray nicknames, List newContactValues) throws JSONException { + if (nicknames == null) { + return; + } + + for (int i = 0; i < nicknames.length(); i++) { + ContentValues contentValues = new ContentValues(); + contentValues.put(Data.MIMETYPE, Nickname.CONTENT_ITEM_TYPE); + contentValues.put(Nickname.NAME, nicknames.getString(i)); + contentValues.put(Nickname.TYPE, Nickname.TYPE_DEFAULT); + newContactValues.add(contentValues); + } + } + + private void getAddressesValues(final JSONArray addresses, List newContactValues) throws JSONException { + if (addresses == null) { + return; + } + + for (int i = 0; i < addresses.length(); i++) { + JSONObject address = addresses.getJSONObject(i); + JSONArray addressTypes = address.optJSONArray("type"); + + if (addressTypes != null) { + for (int j = 0; j < addressTypes.length(); j++) { + // Translate the address type string to an integer constant + // provided by the ContactsContract API + final String type = addressTypes.getString(j); + final int typeConstant = getAddressType(type); + + newContactValues.add(createAddressContentValues(address, typeConstant, type)); + } + } else { + newContactValues.add(createAddressContentValues(address, -1, null)); + } + } + } + + private ContentValues createAddressContentValues(final JSONObject address, final int typeConstant, + final String type) throws JSONException { + ContentValues contentValues = new ContentValues(); + contentValues.put(Data.MIMETYPE, StructuredPostal.CONTENT_ITEM_TYPE); + contentValues.put(StructuredPostal.STREET, address.optString("streetAddress")); + contentValues.put(StructuredPostal.CITY, address.optString("locality")); + contentValues.put(StructuredPostal.REGION, address.optString("region")); + contentValues.put(StructuredPostal.POSTCODE, address.optString("postalCode")); + contentValues.put(StructuredPostal.COUNTRY, address.optString("countryName")); + + if (type != null) { + contentValues.put(StructuredPostal.TYPE, typeConstant); + + // If a custom type, add a label + if (typeConstant == BaseTypes.TYPE_CUSTOM) { + contentValues.put(StructuredPostal.LABEL, type); + } + } + + if (address.has("pref")) { + contentValues.put(Data.IS_SUPER_PRIMARY, address.getBoolean("pref") ? 1 : 0); + } + + return contentValues; + } + + private void getPhonesValues(final JSONArray phones, List newContactValues) throws JSONException { + if (phones == null) { + return; + } + + for (int i = 0; i < phones.length(); i++) { + JSONObject phone = phones.getJSONObject(i); + JSONArray phoneTypes = phone.optJSONArray("type"); + ContentValues contentValues; + + if (phoneTypes != null && phoneTypes.length() > 0) { + for (int j = 0; j < phoneTypes.length(); j++) { + // Translate the phone type string to an integer constant + // provided by the ContactsContract API + final String type = phoneTypes.getString(j); + final int typeConstant = getPhoneType(type); + + contentValues = createContentValues(Phone.CONTENT_ITEM_TYPE, phone.optString("value"), + typeConstant, type, phone.optBoolean("pref")); + if (phone.has("carrier")) { + contentValues.put(CARRIER_COLUMN, phone.optString("carrier")); + } + newContactValues.add(contentValues); + } + } else { + contentValues = createContentValues(Phone.CONTENT_ITEM_TYPE, phone.optString("value"), + -1, null, phone.optBoolean("pref")); + if (phone.has("carrier")) { + contentValues.put(CARRIER_COLUMN, phone.optString("carrier")); + } + newContactValues.add(contentValues); + } + } + } + + private void getEmailsValues(final JSONArray emails, List newContactValues) throws JSONException { + if (emails == null) { + return; + } + + for (int i = 0; i < emails.length(); i++) { + JSONObject email = emails.getJSONObject(i); + JSONArray emailTypes = email.optJSONArray("type"); + + if (emailTypes != null && emailTypes.length() > 0) { + for (int j = 0; j < emailTypes.length(); j++) { + // Translate the email type string to an integer constant + // provided by the ContactsContract API + final String type = emailTypes.getString(j); + final int typeConstant = getEmailType(type); + + newContactValues.add(createContentValues(Email.CONTENT_ITEM_TYPE, + email.optString("value"), + typeConstant, type, + email.optBoolean("pref"))); + } + } else { + newContactValues.add(createContentValues(Email.CONTENT_ITEM_TYPE, + email.optString("value"), + -1, null, email.optBoolean("pref"))); + } + } + } + + private void getPhotosValues(final JSONArray photos, List newContactValues) throws JSONException { + if (photos == null) { + return; + } + + // TODO: implement this + } + + private void getNotesValues(final JSONArray notes, List newContactValues) throws JSONException { + if (notes == null) { + return; + } + + for (int i = 0; i < notes.length(); i++) { + ContentValues contentValues = new ContentValues(); + contentValues.put(Data.MIMETYPE, Note.CONTENT_ITEM_TYPE); + contentValues.put(Note.NOTE, notes.getString(i)); + newContactValues.add(contentValues); + } + } + + private void getWebsitesValues(final JSONArray websites, List newContactValues) throws JSONException { + if (websites == null) { + return; + } + + for (int i = 0; i < websites.length(); i++) { + JSONObject website = websites.getJSONObject(i); + JSONArray websiteTypes = website.optJSONArray("type"); + + if (websiteTypes != null && websiteTypes.length() > 0) { + for (int j = 0; j < websiteTypes.length(); j++) { + // Translate the website type string to an integer constant + // provided by the ContactsContract API + final String type = websiteTypes.getString(j); + final int typeConstant = getWebsiteType(type); + + newContactValues.add(createContentValues(Website.CONTENT_ITEM_TYPE, + website.optString("value"), + typeConstant, type, + website.optBoolean("pref"))); + } + } else { + newContactValues.add(createContentValues(Website.CONTENT_ITEM_TYPE, + website.optString("value"), + -1, null, website.optBoolean("pref"))); + } + } + } + + private void getImsValues(final JSONArray ims, List newContactValues) throws JSONException { + if (ims == null) { + return; + } + + for (int i = 0; i < ims.length(); i++) { + JSONObject im = ims.getJSONObject(i); + JSONArray imTypes = im.optJSONArray("type"); + + if (imTypes != null && imTypes.length() > 0) { + for (int j = 0; j < imTypes.length(); j++) { + // Translate the IM type string to an integer constant + // provided by the ContactsContract API + final String type = imTypes.getString(j); + final int typeConstant = getImType(type); + + newContactValues.add(createContentValues(Im.CONTENT_ITEM_TYPE, + im.optString("value"), + typeConstant, type, + im.optBoolean("pref"))); + } + } else { + newContactValues.add(createContentValues(Im.CONTENT_ITEM_TYPE, + im.optString("value"), + -1, null, im.optBoolean("pref"))); + } + } + } + + private void getCategoriesValues(final JSONArray categories, List newContactValues) throws JSONException { + if (categories == null) { + return; + } + + for (int i = 0; i < categories.length(); i++) { + String category = categories.getString(i); + + if ("my contacts".equals(category.toLowerCase()) || + PRE_HONEYCOMB_DEFAULT_GROUP.equalsIgnoreCase(category)) { + Log.w(LOGTAG, "New contacts are implicitly added to the default group."); + continue; + } + + // Find the group ID of the given category + long groupId = getGroupId(category); + + // Create the group if it doesn't already exist + if (groupId == -1) { + groupId = createGroup(category); + // If the group is still -1, we failed to create the group + if (groupId == -1) { + // Only log the category name if in debug + if (DEBUG) { + Log.d(LOGTAG, "Failed to create new group for category \"" + category + "\""); + } else { + Log.w(LOGTAG, "Failed to create new group for given category."); + } + continue; + } + } + + ContentValues contentValues = new ContentValues(); + contentValues.put(Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE); + contentValues.put(GroupMembership.GROUP_ROW_ID, groupId); + newContactValues.add(contentValues); + + newContactValues.add(contentValues); + } + } + + private void getEventValues(final String event, final int type, List newContactValues) { + if (event == null || event.length() < 11) { + return; + } + + ContentValues contentValues = new ContentValues(); + contentValues.put(Data.MIMETYPE, Event.CONTENT_ITEM_TYPE); + contentValues.put(Event.START_DATE, event.substring(0, 10)); + contentValues.put(Event.TYPE, type); + newContactValues.add(contentValues); + } + + private void getCustomMimetypeValues(final String value, final String mimeType, List newContactValues) { + if (value == null || "null".equals(value)) { + return; + } + + ContentValues contentValues = new ContentValues(); + contentValues.put(Data.MIMETYPE, mimeType); + contentValues.put(CUSTOM_DATA_COLUMN, value); + newContactValues.add(contentValues); + } + + private void getMozillaContactFlagValues(List newContactValues) { + try { + JSONArray mozillaContactsFlag = new JSONArray(); + mozillaContactsFlag.put("1"); + getGenericValues(MIMETYPE_MOZILLA_CONTACTS_FLAG, CUSTOM_DATA_COLUMN, mozillaContactsFlag, newContactValues); + } catch (JSONException e) { + throw new IllegalArgumentException(e); + } + } + + private ContentValues createContentValues(final String mimeType, final String value, final int typeConstant, + final String type, final boolean preferredValue) { + ContentValues contentValues = new ContentValues(); + contentValues.put(Data.MIMETYPE, mimeType); + contentValues.put(Data.DATA1, value); + contentValues.put(Data.IS_SUPER_PRIMARY, preferredValue ? 1 : 0); + + if (type != null) { + contentValues.put(Data.DATA2, typeConstant); + + // If a custom type, add a label + if (typeConstant == BaseTypes.TYPE_CUSTOM) { + contentValues.put(Data.DATA3, type); + } + } + + return contentValues; + } + + private void getContactsCount(final String requestID) { + Cursor cursor = getAllRawContactIdsCursor(); + Integer numContacts = Integer.valueOf(cursor.getCount()); + cursor.close(); + + sendCallbackToJavascript("Android:Contacts:Count", requestID, new String[] {"count"}, + new Object[] {numContacts}); + } + + private void sendCallbackToJavascript(final String subject, final String requestID, + final String[] argNames, final Object[] argValues) { + // Check the same number of argument names and arguments were given + if (argNames != null && argNames.length != argValues.length) { + throw new IllegalArgumentException("Argument names and argument values lengths do not match. " + + "Names length = " + argNames.length + ", Values length = " + + argValues.length); + } + + try { + JSONObject callbackMessage = new JSONObject(); + callbackMessage.put("requestID", requestID); + + if (argNames != null) { + for (int i = 0; i < argNames.length; i++) { + callbackMessage.put(argNames[i], argValues[i]); + } + } + + GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent(subject, callbackMessage.toString())); + } catch (JSONException e) { + throw new IllegalArgumentException(e); + } + } + + private void registerEventListener(final String event) { + mEventDispatcher.registerEventListener(event, this); + } + + private void unregisterEventListener(final String event) { + mEventDispatcher.unregisterEventListener(event, this); + } + + private ContentProviderResult[] applyBatch(ArrayList operations) { + try { + return mContentResolver.applyBatch(ContactsContract.AUTHORITY, operations); + } catch (RemoteException e) { + Log.e(LOGTAG, "RemoteException", e); + } catch (OperationApplicationException e) { + Log.e(LOGTAG, "OperationApplicationException", e); + } + return null; + } + + private void getDeviceAccount(final Runnable handleMessage) { + Account[] accounts = AccountManager.get(mActivity).getAccounts(); + + if (accounts.length == 0) { + Log.w(LOGTAG, "No accounts available"); + gotDeviceAccount(handleMessage); + } else if (accounts.length > 1) { + // Show the accounts chooser dialog if more than one dialog exists + showAccountsDialog(accounts, handleMessage); + } else { + // If only one account exists, use it + mAccountName = accounts[0].name; + mAccountType = accounts[0].type; + gotDeviceAccount(handleMessage); + } + + mGotDeviceAccount = true; + } + + private void showAccountsDialog(final Account[] accounts, final Runnable handleMessage) { + String[] accountNames = new String[accounts.length]; + for (int i = 0; i < accounts.length; i++) { + accountNames[i] = accounts[i].name; + } + + final AlertDialog.Builder builder = new AlertDialog.Builder(mActivity); + builder.setTitle(mActivity.getResources().getString(R.string.contacts_account_chooser_dialog_title)) + .setSingleChoiceItems(accountNames, 0, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int position) { + // Set the account name and type when an item is selected and dismiss the dialog + mAccountName = accounts[position].name; + mAccountType = accounts[position].type; + dialog.dismiss(); + gotDeviceAccount(handleMessage); + } + }); + + mActivity.runOnUiThread(new Runnable() { + public void run() { + builder.show(); + } + }); + } + + private void gotDeviceAccount(final Runnable handleMessage) { + // Force the handleMessage runnable and getDefaultGroupId to run on the background thread + Runnable runnable = new Runnable() { + @Override + public void run() { + getDefaultGroupId(); + + // Don't log a user's account if not debug mode. Otherwise, just log a message + // saying that we got an account to use + if (mAccountName == null) { + Log.i(LOGTAG, "No device account selected. Leaving account as null."); + } else if (DEBUG) { + Log.d(LOGTAG, "Using account: " + mAccountName + " (type: " + mAccountType + ")"); + } else { + Log.i(LOGTAG, "Got device account to use for contact operations."); + } + handleMessage.run(); + } + }; + + ThreadUtils.postToBackgroundThread(runnable); + } + + private void getDefaultGroupId() { + Cursor cursor = getAllGroups(); + + cursor.moveToPosition(-1); + while (cursor.moveToNext()) { + // Check if the account name and type for the group match the account name and type of + // the account we're working with + final String groupAccountName = cursor.getString(GROUP_ACCOUNT_NAME); + if (!groupAccountName.equals(mAccountName)) { + continue; + } + + final String groupAccountType = cursor.getString(GROUP_ACCOUNT_TYPE); + if (!groupAccountType.equals(mAccountType)) { + continue; + } + + // For all honeycomb and up, the default group is the first one which has the AUTO_ADD flag set + if (isAutoAddGroup(cursor)) { + mGroupTitle = cursor.getString(GROUP_TITLE); + mGroupId = cursor.getLong(GROUP_ID); + break; + } else if (PRE_HONEYCOMB_DEFAULT_GROUP.equals(cursor.getString(GROUP_TITLE))) { + mGroupId = cursor.getLong(GROUP_ID); + mGroupTitle = PRE_HONEYCOMB_DEFAULT_GROUP; + break; + } + } + cursor.close(); + + if (mGroupId == 0) { + Log.w(LOGTAG, "Default group ID not found. Newly created contacts will not belong to any groups."); + } else if (DEBUG) { + Log.i(LOGTAG, "Using group ID: " + mGroupId + " (" + mGroupTitle + ")"); + } + } + + private static boolean isAutoAddGroup(Cursor cursor) { + // For Honeycomb and up, the default group is the first one which has the AUTO_ADD flag set. + // For everything below Honeycomb, use the default "System Group: My Contacts" group + return (Build.VERSION.SDK_INT >= 11 && !cursor.isNull(GROUP_AUTO_ADD) && + cursor.getInt(GROUP_AUTO_ADD) != 0); + } + + private long getGroupId(String groupName) { + long groupId = -1; + Cursor cursor = getGroups(Groups.TITLE + " = '" + groupName + "'"); + + cursor.moveToPosition(-1); + while (cursor.moveToNext()) { + String groupAccountName = cursor.getString(GROUP_ACCOUNT_NAME); + String groupAccountType = cursor.getString(GROUP_ACCOUNT_TYPE); + + // Check if the account name and type for the group match the account name and type of + // the account we're working with or the default "Phone" account if no account was found + if (groupAccountName.equals(mAccountName) && groupAccountType.equals(mAccountType) || + (mAccountName == null && "Phone".equals(groupAccountType))) { + if (groupName.equals(cursor.getString(GROUP_TITLE))) { + groupId = cursor.getLong(GROUP_ID); + break; + } + } + } + cursor.close(); + + return groupId; + } + + private String getGroupName(long groupId) { + Cursor cursor = getGroups(Groups._ID + " = " + groupId); + + if (cursor.getCount() == 0) { + cursor.close(); + return null; + } + + cursor.moveToPosition(0); + String groupName = cursor.getString(cursor.getColumnIndex(Groups.TITLE)); + cursor.close(); + + return groupName; + } + + private Cursor getAllGroups() { + return getGroups(null); + } + + private Cursor getGroups(String selectArg) { + String[] columns = new String[] { + Groups.ACCOUNT_NAME, + Groups.ACCOUNT_TYPE, + Groups._ID, + Groups.TITLE, + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB ? Groups.AUTO_ADD : Groups._ID) + }; + + if (selectArg != null) { + selectArg = "AND " + selectArg; + } else { + selectArg = ""; + } + + return mContentResolver.query(Groups.CONTENT_URI, columns, + Groups.ACCOUNT_TYPE + " NOT NULL AND " + + Groups.ACCOUNT_NAME + " NOT NULL " + selectArg, null, null); + } + + private long createGroup(String groupName) { + if (DEBUG) { + Log.d(LOGTAG, "Creating group: " + groupName); + } + + ArrayList newGroupOptions = new ArrayList(); + + // Create the group under the account we're using + // If no account is selected, use a default account name/type for the group + newGroupOptions.add(ContentProviderOperation.newInsert(Groups.CONTENT_URI) + .withValue(Groups.ACCOUNT_NAME, (mAccountName == null ? "Phone" : mAccountName)) + .withValue(Groups.ACCOUNT_TYPE, (mAccountType == null ? "Phone" : mAccountType)) + .withValue(Groups.TITLE, groupName) + .withValue(Groups.GROUP_VISIBLE, true) + .build()); + + applyBatch(newGroupOptions); + + // Return the ID of the newly created group + return getGroupId(groupName); + } + + private long[] getAllRawContactIds() { + Cursor cursor = getAllRawContactIdsCursor(); + + // Put the ids into an array + long[] ids = new long[cursor.getCount()]; + int index = 0; + cursor.moveToPosition(-1); + while(cursor.moveToNext()) { + ids[index] = cursor.getLong(cursor.getColumnIndex(RawContacts._ID)); + index++; + } + cursor.close(); + + return ids; + } + + private Cursor getAllRawContactIdsCursor() { + // When a contact is deleted, it actually just sets the deleted field to 1 until the + // sync adapter actually deletes the contact later so ignore any contacts with the deleted + // flag set + String selection = RawContacts.DELETED + "=0"; + String[] selectionArgs = null; + + // Only get contacts from the selected account + if (mAccountName != null) { + selection += " AND " + RawContacts.ACCOUNT_NAME + "=? AND " + RawContacts.ACCOUNT_TYPE + "=?"; + selectionArgs = new String[] {mAccountName, mAccountType}; + } + + // Get the ID's of all contacts and use the number of contact ID's as + // the total number of contacts + return mContentResolver.query(RawContacts.CONTENT_URI, new String[] {RawContacts._ID}, + selection, selectionArgs, null); + } + + private static Long getRawContactIdFromContentProviderResults(ContentProviderResult[] results) throws NumberFormatException { + for (int i = 0; i < results.length; i++) { + if (results[i].uri == null) { + continue; + } + + String uri = results[i].uri.toString(); + // Check if the uri is from the raw contacts table + if (uri.contains("raw_contacts")) { + // The ID is the after the final forward slash in the URI + return Long.parseLong(uri.substring(uri.lastIndexOf("/") + 1)); + } + } + + return null; + } + + private static boolean checkForPositiveCountInResults(ContentProviderResult[] results) { + for (int i = 0; i < results.length; i++) { + Integer count = results[i].count; + + if (DEBUG) { + Log.d(LOGTAG, "Results count: " + count); + } + + if (count != null && count > 0) { + return true; + } + } + + return false; + } + + private static long[] convertLongListToArray(List list) { + long[] array = new long[list.size()]; + + for (int i = 0; i < list.size(); i++) { + array[i] = list.get(i); + } + + return array; + } + + private static boolean doesJSONArrayContainString(final JSONArray array, final String value) { + for (int i = 0; i < array.length(); i++) { + if (value.equals(array.optString(i))) { + return true; + } + } + + return false; + } + + private static int max(int... values) { + int max = values[0]; + for (int value : values) { + if (value > max) { + max = value; + } + } + return max; + } + + private static void putPossibleNullValueInJSONObject(final String key, final Object value, JSONObject jsonObject) throws JSONException{ + if (value != null) { + jsonObject.put(key, value); + } else { + jsonObject.put(key, JSONObject.NULL); + } + } + + private static String getKeyFromMapValue(final HashMap map, Integer value) { + for (Entry entry : map.entrySet()) { + if (value == entry.getValue()) { + return entry.getKey(); + } + } + return null; + } + + private String getColumnNameConstant(String field) { + initColumnNameConstantsMap(); + return mColumnNameConstantsMap.get(field.toLowerCase()); + } + + private void initColumnNameConstantsMap() { + if (mColumnNameConstantsMap != null) { + return; + } + mColumnNameConstantsMap = new HashMap(); + + mColumnNameConstantsMap.put("name", StructuredName.DISPLAY_NAME); + mColumnNameConstantsMap.put("givenname", StructuredName.GIVEN_NAME); + mColumnNameConstantsMap.put("familyname", StructuredName.FAMILY_NAME); + mColumnNameConstantsMap.put("honorificprefix", StructuredName.PREFIX); + mColumnNameConstantsMap.put("honorificsuffix", StructuredName.SUFFIX); + mColumnNameConstantsMap.put("additionalname", CUSTOM_DATA_COLUMN); + mColumnNameConstantsMap.put("nickname", Nickname.NAME); + mColumnNameConstantsMap.put("adr", StructuredPostal.STREET); + mColumnNameConstantsMap.put("email", Email.ADDRESS); + mColumnNameConstantsMap.put("url", Website.URL); + mColumnNameConstantsMap.put("category", GroupMembership.GROUP_ROW_ID); + mColumnNameConstantsMap.put("tel", Phone.NUMBER); + mColumnNameConstantsMap.put("org", Organization.COMPANY); + mColumnNameConstantsMap.put("jobTitle", Organization.TITLE); + mColumnNameConstantsMap.put("note", Note.NOTE); + mColumnNameConstantsMap.put("impp", Im.DATA); + mColumnNameConstantsMap.put("sex", CUSTOM_DATA_COLUMN); + mColumnNameConstantsMap.put("genderidentity", CUSTOM_DATA_COLUMN); + mColumnNameConstantsMap.put("key", CUSTOM_DATA_COLUMN); + } + + private String getMimeTypeOfField(String field) { + initMimeTypeConstantsMap(); + return mMimeTypeConstantsMap.get(field.toLowerCase()); + } + + private void initMimeTypeConstantsMap() { + if (mMimeTypeConstantsMap != null) { + return; + } + mMimeTypeConstantsMap = new HashMap(); + + mMimeTypeConstantsMap.put("name", StructuredName.CONTENT_ITEM_TYPE); + mMimeTypeConstantsMap.put("givenname", StructuredName.CONTENT_ITEM_TYPE); + mMimeTypeConstantsMap.put("familyname", StructuredName.CONTENT_ITEM_TYPE); + mMimeTypeConstantsMap.put("honorificprefix", StructuredName.CONTENT_ITEM_TYPE); + mMimeTypeConstantsMap.put("honorificsuffix", StructuredName.CONTENT_ITEM_TYPE); + mMimeTypeConstantsMap.put("additionalname", MIMETYPE_ADDITIONAL_NAME); + mMimeTypeConstantsMap.put("nickname", Nickname.CONTENT_ITEM_TYPE); + mMimeTypeConstantsMap.put("email", Email.CONTENT_ITEM_TYPE); + mMimeTypeConstantsMap.put("url", Website.CONTENT_ITEM_TYPE); + mMimeTypeConstantsMap.put("category", GroupMembership.CONTENT_ITEM_TYPE); + mMimeTypeConstantsMap.put("tel", Phone.CONTENT_ITEM_TYPE); + mMimeTypeConstantsMap.put("org", Organization.CONTENT_ITEM_TYPE); + mMimeTypeConstantsMap.put("jobTitle", Organization.CONTENT_ITEM_TYPE); + mMimeTypeConstantsMap.put("note", Note.CONTENT_ITEM_TYPE); + mMimeTypeConstantsMap.put("impp", Im.CONTENT_ITEM_TYPE); + mMimeTypeConstantsMap.put("sex", MIMETYPE_SEX); + mMimeTypeConstantsMap.put("genderidentity", MIMETYPE_GENDER_IDENTITY); + mMimeTypeConstantsMap.put("key", MIMETYPE_KEY); + } + + private int getAddressType(String addressType) { + initAddressTypesMap(); + Integer type = mAddressTypesMap.get(addressType.toLowerCase()); + return (type != null ? Integer.valueOf(type) : StructuredPostal.TYPE_CUSTOM); + } + + private void initAddressTypesMap() { + if (mAddressTypesMap != null) { + return; + } + mAddressTypesMap = new HashMap(); + + mAddressTypesMap.put("home", StructuredPostal.TYPE_HOME); + mAddressTypesMap.put("work", StructuredPostal.TYPE_WORK); + } + + private int getPhoneType(String phoneType) { + initPhoneTypesMap(); + Integer type = mPhoneTypesMap.get(phoneType.toLowerCase()); + return (type != null ? Integer.valueOf(type) : Phone.TYPE_CUSTOM); + } + + private void initPhoneTypesMap() { + if (mPhoneTypesMap != null) { + return; + } + mPhoneTypesMap = new HashMap(); + + mPhoneTypesMap.put("home", Phone.TYPE_HOME); + mPhoneTypesMap.put("mobile", Phone.TYPE_MOBILE); + mPhoneTypesMap.put("work", Phone.TYPE_WORK); + mPhoneTypesMap.put("fax home", Phone.TYPE_FAX_HOME); + mPhoneTypesMap.put("fax work", Phone.TYPE_FAX_WORK); + mPhoneTypesMap.put("pager", Phone.TYPE_PAGER); + mPhoneTypesMap.put("callback", Phone.TYPE_CALLBACK); + mPhoneTypesMap.put("car", Phone.TYPE_CAR); + mPhoneTypesMap.put("company main", Phone.TYPE_COMPANY_MAIN); + mPhoneTypesMap.put("isdn", Phone.TYPE_ISDN); + mPhoneTypesMap.put("main", Phone.TYPE_MAIN); + mPhoneTypesMap.put("fax other", Phone.TYPE_OTHER_FAX); + mPhoneTypesMap.put("other fax", Phone.TYPE_OTHER_FAX); + mPhoneTypesMap.put("radio", Phone.TYPE_RADIO); + mPhoneTypesMap.put("telex", Phone.TYPE_TELEX); + mPhoneTypesMap.put("tty", Phone.TYPE_TTY_TDD); + mPhoneTypesMap.put("ttd", Phone.TYPE_TTY_TDD); + mPhoneTypesMap.put("work mobile", Phone.TYPE_WORK_MOBILE); + mPhoneTypesMap.put("work pager", Phone.TYPE_WORK_PAGER); + mPhoneTypesMap.put("assistant", Phone.TYPE_ASSISTANT); + mPhoneTypesMap.put("mms", Phone.TYPE_MMS); + } + + private int getEmailType(String emailType) { + initEmailTypesMap(); + Integer type = mEmailTypesMap.get(emailType.toLowerCase()); + return (type != null ? Integer.valueOf(type) : Email.TYPE_CUSTOM); + } + + private void initEmailTypesMap() { + if (mEmailTypesMap != null) { + return; + } + mEmailTypesMap = new HashMap(); + + mEmailTypesMap.put("home", Email.TYPE_HOME); + mEmailTypesMap.put("mobile", Email.TYPE_MOBILE); + mEmailTypesMap.put("work", Email.TYPE_WORK); + } + + private int getWebsiteType(String webisteType) { + initWebsiteTypesMap(); + Integer type = mWebsiteTypesMap.get(webisteType.toLowerCase()); + return (type != null ? Integer.valueOf(type) : Website.TYPE_CUSTOM); + } + + private void initWebsiteTypesMap() { + if (mWebsiteTypesMap != null) { + return; + } + mWebsiteTypesMap = new HashMap(); + + mWebsiteTypesMap.put("homepage", Website.TYPE_HOMEPAGE); + mWebsiteTypesMap.put("blog", Website.TYPE_BLOG); + mWebsiteTypesMap.put("profile", Website.TYPE_PROFILE); + mWebsiteTypesMap.put("home", Website.TYPE_HOME); + mWebsiteTypesMap.put("work", Website.TYPE_WORK); + mWebsiteTypesMap.put("ftp", Website.TYPE_FTP); + } + + private int getImType(String imType) { + initImTypesMap(); + Integer type = mImTypesMap.get(imType.toLowerCase()); + return (type != null ? Integer.valueOf(type) : Im.TYPE_CUSTOM); + } + + private void initImTypesMap() { + if (mImTypesMap != null) { + return; + } + mImTypesMap = new HashMap(); + + mImTypesMap.put("home", Im.TYPE_HOME); + mImTypesMap.put("work", Im.TYPE_WORK); + } + + private String[] getAllColumns() { + return new String[] {Entity.DATA_ID, Data.MIMETYPE, Data.IS_SUPER_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}; + } +}