/* * 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.android.exchange.provider; import android.accounts.AccountManager; import android.content.ContentProvider; import android.content.ContentValues; import android.content.Context; import android.content.UriMatcher; import android.database.Cursor; import android.database.MatrixCursor; import android.net.Uri; import android.os.Binder; import android.os.Bundle; import android.provider.ContactsContract; import android.provider.ContactsContract.CommonDataKinds.Email; import android.provider.ContactsContract.CommonDataKinds.Phone; import android.provider.ContactsContract.CommonDataKinds.StructuredName; import android.provider.ContactsContract.Contacts; import android.provider.ContactsContract.Contacts.Data; import android.provider.ContactsContract.Directory; import android.provider.ContactsContract.DisplayNameSources; import android.provider.ContactsContract.RawContacts; import android.text.TextUtils; import android.util.Log; import android.util.Pair; import com.android.emailcommon.Configuration; import com.android.emailcommon.mail.PackedString; import com.android.emailcommon.provider.Account; import com.android.emailcommon.provider.EmailContent; import com.android.emailcommon.provider.EmailContent.AccountColumns; import com.android.emailcommon.service.AccountServiceProxy; import com.android.emailcommon.utility.Utility; import com.android.exchange.Eas; import com.android.exchange.R; import com.android.exchange.provider.GalResult.GalData; import com.android.exchange.service.EasService; import com.android.mail.utils.LogUtils; import java.text.Collator; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.TreeMap; /** * ExchangeDirectoryProvider provides real-time data from the Exchange server; at the moment, it is * used solely to provide GAL (Global Address Lookup) service to email address adapters */ public class ExchangeDirectoryProvider extends ContentProvider { private static final String TAG = Eas.LOG_TAG; public static final String EXCHANGE_GAL_AUTHORITY = com.android.exchange.Configuration.EXCHANGE_GAL_AUTHORITY; private static final int DEFAULT_CONTACT_ID = 1; private static final int DEFAULT_LOOKUP_LIMIT = 20; private static final int MAX_LOOKUP_LIMIT = 100; private static final int GAL_BASE = 0; private static final int GAL_DIRECTORIES = GAL_BASE; private static final int GAL_FILTER = GAL_BASE + 1; private static final int GAL_CONTACT = GAL_BASE + 2; private static final int GAL_CONTACT_WITH_ID = GAL_BASE + 3; private static final int GAL_EMAIL_FILTER = GAL_BASE + 4; private static final int GAL_PHONE_FILTER = GAL_BASE + 5; private static final UriMatcher sURIMatcher = new UriMatcher(UriMatcher.NO_MATCH); /*package*/ final HashMap mAccountIdMap = new HashMap(); static { sURIMatcher.addURI(EXCHANGE_GAL_AUTHORITY, "directories", GAL_DIRECTORIES); sURIMatcher.addURI(EXCHANGE_GAL_AUTHORITY, "contacts/filter/*", GAL_FILTER); sURIMatcher.addURI(EXCHANGE_GAL_AUTHORITY, "contacts/lookup/*/entities", GAL_CONTACT); sURIMatcher.addURI(EXCHANGE_GAL_AUTHORITY, "contacts/lookup/*/#/entities", GAL_CONTACT_WITH_ID); sURIMatcher.addURI(EXCHANGE_GAL_AUTHORITY, "data/emails/filter/*", GAL_EMAIL_FILTER); sURIMatcher.addURI(EXCHANGE_GAL_AUTHORITY, "data/phones/filter/*", GAL_PHONE_FILTER); } @Override public boolean onCreate() { EmailContent.init(getContext()); return true; } static class GalProjection { final int size; final HashMap columnMap = new HashMap(); GalProjection(String[] projection) { size = projection.length; for (int i = 0; i < projection.length; i++) { columnMap.put(projection[i], i); } } } static class GalContactRow { private final GalProjection mProjection; private Object[] row; static long dataId = 1; GalContactRow(GalProjection projection, long contactId, String accountName, String displayName) { this.mProjection = projection; row = new Object[projection.size]; put(Contacts.Entity.CONTACT_ID, contactId); // We only have one raw contact per aggregate, so they can have the same ID put(Contacts.Entity.RAW_CONTACT_ID, contactId); put(Contacts.Entity.DATA_ID, dataId++); put(Contacts.DISPLAY_NAME, displayName); // TODO alternative display name put(Contacts.DISPLAY_NAME_ALTERNATIVE, displayName); put(RawContacts.ACCOUNT_TYPE, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE); put(RawContacts.ACCOUNT_NAME, accountName); put(RawContacts.RAW_CONTACT_IS_READ_ONLY, 1); put(Data.IS_READ_ONLY, 1); } Object[] getRow () { return row; } void put(String columnName, Object value) { final Integer integer = mProjection.columnMap.get(columnName); if (integer != null) { row[integer] = value; } else { LogUtils.e(TAG, "Unsupported column: " + columnName); } } static void addEmailAddress(MatrixCursor cursor, GalProjection galProjection, long contactId, String accountName, String displayName, String address) { if (!TextUtils.isEmpty(address)) { final GalContactRow r = new GalContactRow( galProjection, contactId, accountName, displayName); r.put(Data.MIMETYPE, Email.CONTENT_ITEM_TYPE); r.put(Email.TYPE, Email.TYPE_WORK); r.put(Email.ADDRESS, address); cursor.addRow(r.getRow()); } } static void addPhoneRow(MatrixCursor cursor, GalProjection projection, long contactId, String accountName, String displayName, int type, String number) { if (!TextUtils.isEmpty(number)) { final GalContactRow r = new GalContactRow( projection, contactId, accountName, displayName); r.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE); r.put(Phone.TYPE, type); r.put(Phone.NUMBER, number); cursor.addRow(r.getRow()); } } public static void addNameRow(MatrixCursor cursor, GalProjection galProjection, long contactId, String accountName, String displayName, String firstName, String lastName) { final GalContactRow r = new GalContactRow( galProjection, contactId, accountName, displayName); r.put(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE); r.put(StructuredName.GIVEN_NAME, firstName); r.put(StructuredName.FAMILY_NAME, lastName); r.put(StructuredName.DISPLAY_NAME, displayName); cursor.addRow(r.getRow()); } } /** * Find the record id of an Account, given its name (email address) * @param accountName the name of the account * @return the record id of the Account, or -1 if not found */ /*package*/ long getAccountIdByName(Context context, String accountName) { Long accountId = mAccountIdMap.get(accountName); if (accountId == null) { accountId = Utility.getFirstRowLong(context, Account.CONTENT_URI, EmailContent.ID_PROJECTION, AccountColumns.EMAIL_ADDRESS + "=?", new String[] {accountName}, null, EmailContent.ID_PROJECTION_COLUMN , -1L); if (accountId != -1) { mAccountIdMap.put(accountName, accountId); } } return accountId; } @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { LogUtils.d(TAG, "ExchangeDirectoryProvider: query: %s", uri.toString()); final int match = sURIMatcher.match(uri); final MatrixCursor cursor; Object[] row; final PackedString ps; final String lookupKey; switch (match) { case GAL_DIRECTORIES: { // Assuming that GAL can be used with all exchange accounts final android.accounts.Account[] accounts = AccountManager.get(getContext()) .getAccountsByType(Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE); cursor = new MatrixCursor(projection); if (accounts != null) { for (android.accounts.Account account : accounts) { row = new Object[projection.length]; for (int i = 0; i < projection.length; i++) { final String column = projection[i]; if (column.equals(Directory.ACCOUNT_NAME)) { row[i] = account.name; } else if (column.equals(Directory.ACCOUNT_TYPE)) { row[i] = account.type; } else if (column.equals(Directory.TYPE_RESOURCE_ID)) { final String accountType = Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE; final Bundle bundle = new AccountServiceProxy(getContext()) .getConfigurationData(accountType); // Default to the alternative name, erring on the conservative side int exchangeName = R.string.exchange_name_alternate; if (bundle != null && !bundle.getBoolean( Configuration.EXCHANGE_CONFIGURATION_USE_ALTERNATE_STRINGS, true)) { exchangeName = R.string.exchange_name; } row[i] = exchangeName; } else if (column.equals(Directory.DISPLAY_NAME)) { // If the account name is an email address, extract // the domain name and use it as the directory display name final String accountName = account.name; final int atIndex = accountName.indexOf('@'); if (atIndex != -1 && atIndex < accountName.length() - 2) { final char firstLetter = Character.toUpperCase( accountName.charAt(atIndex + 1)); row[i] = firstLetter + accountName.substring(atIndex + 2); } else { row[i] = account.name; } } else if (column.equals(Directory.EXPORT_SUPPORT)) { row[i] = Directory.EXPORT_SUPPORT_SAME_ACCOUNT_ONLY; } else if (column.equals(Directory.SHORTCUT_SUPPORT)) { row[i] = Directory.SHORTCUT_SUPPORT_NONE; } } cursor.addRow(row); } } return cursor; } case GAL_FILTER: case GAL_PHONE_FILTER: case GAL_EMAIL_FILTER: { final String filter = uri.getLastPathSegment(); // We should have at least two characters before doing a GAL search if (filter == null || filter.length() < 2) { return null; } final String accountName = uri.getQueryParameter(RawContacts.ACCOUNT_NAME); if (accountName == null) { return null; } // Enforce a limit on the number of lookup responses final String limitString = uri.getQueryParameter(ContactsContract.LIMIT_PARAM_KEY); int limit = DEFAULT_LOOKUP_LIMIT; if (limitString != null) { try { limit = Integer.parseInt(limitString); } catch (NumberFormatException e) { limit = 0; } if (limit <= 0) { throw new IllegalArgumentException("Limit not valid: " + limitString); } } final long callingId = Binder.clearCallingIdentity(); try { // Find the account id to pass along to EasSyncService final long accountId = getAccountIdByName(getContext(), accountName); if (accountId == -1) { // The account was deleted? return null; } final boolean isEmail = match == GAL_EMAIL_FILTER; final boolean isPhone = match == GAL_PHONE_FILTER; // For phone filter queries we request more results from the server // than requested by the caller because we omit contacts without // phone numbers, and the server lacks the ability to do this filtering // for us. We then enforce the limit when constructing the cursor // containing the results. int queryLimit = limit; if (isPhone) { queryLimit = 3 * queryLimit; } if (queryLimit > MAX_LOOKUP_LIMIT) { queryLimit = MAX_LOOKUP_LIMIT; } // Get results from the Exchange account final GalResult galResult = EasService.searchGal(getContext(), accountId, filter, queryLimit); if (galResult != null && (galResult.getNumEntries() > 0)) { return buildGalResultCursor( projection, galResult, sortOrder, limit, isEmail, isPhone); } } finally { Binder.restoreCallingIdentity(callingId); } break; } case GAL_CONTACT: case GAL_CONTACT_WITH_ID: { final String accountName = uri.getQueryParameter(RawContacts.ACCOUNT_NAME); if (accountName == null) { return null; } final GalProjection galProjection = new GalProjection(projection); cursor = new MatrixCursor(projection); // Handle the decomposition of the key into rows suitable for CP2 final List pathSegments = uri.getPathSegments(); lookupKey = pathSegments.get(2); final long contactId = (match == GAL_CONTACT_WITH_ID) ? Long.parseLong(pathSegments.get(3)) : DEFAULT_CONTACT_ID; ps = new PackedString(lookupKey); final String displayName = ps.get(GalData.DISPLAY_NAME); GalContactRow.addEmailAddress(cursor, galProjection, contactId, accountName, displayName, ps.get(GalData.EMAIL_ADDRESS)); GalContactRow.addPhoneRow(cursor, galProjection, contactId, displayName, displayName, Phone.TYPE_HOME, ps.get(GalData.HOME_PHONE)); GalContactRow.addPhoneRow(cursor, galProjection, contactId, displayName, displayName, Phone.TYPE_WORK, ps.get(GalData.WORK_PHONE)); GalContactRow.addPhoneRow(cursor, galProjection, contactId, displayName, displayName, Phone.TYPE_MOBILE, ps.get(GalData.MOBILE_PHONE)); GalContactRow.addNameRow(cursor, galProjection, contactId, displayName, ps.get(GalData.FIRST_NAME), ps.get(GalData.LAST_NAME), displayName); return cursor; } } return null; } /*package*/ Cursor buildGalResultCursor(String[] projection, GalResult galResult, String sortOrder, int limit, boolean isEmailFilter, boolean isPhoneFilter) { int displayNameIndex = -1; int displayNameSourceIndex = -1; int alternateDisplayNameIndex = -1; int emailIndex = -1; int emailTypeIndex = -1; int phoneNumberIndex = -1; int phoneTypeIndex = -1; int hasPhoneNumberIndex = -1; int idIndex = -1; int contactIdIndex = -1; int lookupIndex = -1; for (int i = 0; i < projection.length; i++) { final String column = projection[i]; if (Contacts.DISPLAY_NAME.equals(column) || Contacts.DISPLAY_NAME_PRIMARY.equals(column)) { displayNameIndex = i; } else if (Contacts.DISPLAY_NAME_ALTERNATIVE.equals(column)) { alternateDisplayNameIndex = i; } else if (Contacts.DISPLAY_NAME_SOURCE.equals(column)) { displayNameSourceIndex = i; } else if (Contacts.HAS_PHONE_NUMBER.equals(column)) { hasPhoneNumberIndex = i; } else if (Contacts._ID.equals(column)) { idIndex = i; } else if (Phone.CONTACT_ID.equals(column)) { contactIdIndex = i; } else if (Contacts.LOOKUP_KEY.equals(column)) { lookupIndex = i; } else if (isPhoneFilter) { if (Phone.NUMBER.equals(column)) { phoneNumberIndex = i; } else if (Phone.TYPE.equals(column)) { phoneTypeIndex = i; } } else { // Cannot support for Email and Phone in same query, so default // is to return email addresses. if (Email.ADDRESS.equals(column)) { emailIndex = i; } else if (Email.TYPE.equals(column)) { emailTypeIndex = i; } } } boolean usePrimarySortKey = false; boolean useAlternateSortKey = false; if (Contacts.SORT_KEY_PRIMARY.equals(sortOrder)) { usePrimarySortKey = true; } else if (Contacts.SORT_KEY_ALTERNATIVE.equals(sortOrder)) { useAlternateSortKey = true; } else if (sortOrder != null && sortOrder.length() > 0) { Log.w(TAG, "Ignoring unsupported sort order: " + sortOrder); } final TreeMap sortedResultsMap = new TreeMap(new NameComparator()); // id populates the _ID column and is incremented for each row in the // result set, so each row has a unique id. int id = 1; // contactId populates the CONTACT_ID column and is incremented for // each contact. For the email and phone filters, there may be more // than one row with the same contactId if a given contact has multiple // email addresses or multiple phone numbers. int contactId = 1; final int count = galResult.galData.size(); for (int i = 0; i < count; i++) { final GalData galDataRow = galResult.galData.get(i); final List phones = new ArrayList(); addPhoneInfo(phones, galDataRow.get(GalData.WORK_PHONE), Phone.TYPE_WORK); addPhoneInfo(phones, galDataRow.get(GalData.OFFICE), Phone.TYPE_COMPANY_MAIN); addPhoneInfo(phones, galDataRow.get(GalData.HOME_PHONE), Phone.TYPE_HOME); addPhoneInfo(phones, galDataRow.get(GalData.MOBILE_PHONE), Phone.TYPE_MOBILE); // Track whether we added a result for this contact or not, in // order to stop once we have maxResult contacts. boolean addedContact = false; Pair displayName = getDisplayName(galDataRow, phones); if (TextUtils.isEmpty(displayName.first)) { // can't use a contact if we can't find a decent name for it. continue; } galDataRow.put(GalData.DISPLAY_NAME, displayName.first); final String alternateDisplayName = getAlternateDisplayName( galDataRow, displayName.first); final String sortName = usePrimarySortKey ? displayName.first : (useAlternateSortKey ? alternateDisplayName : ""); final Object[] row = new Object[projection.length]; if (displayNameIndex != -1) { row[displayNameIndex] = displayName.first; } if (displayNameSourceIndex != -1) { row[displayNameSourceIndex] = displayName.second; } if (alternateDisplayNameIndex != -1) { row[alternateDisplayNameIndex] = alternateDisplayName; } if (hasPhoneNumberIndex != -1) { if (phones.size() > 0) { row[hasPhoneNumberIndex] = true; } } if (contactIdIndex != -1) { row[contactIdIndex] = contactId; } if (lookupIndex != -1) { // We use the packed string as our lookup key; it contains ALL of the gal data // We do this because we are not able to provide a stable id to ContactsProvider row[lookupIndex] = Uri.encode(galDataRow.toPackedString()); } if (isPhoneFilter) { final Set uniqueNumbers = new HashSet(); for (PhoneInfo phone : phones) { if (!uniqueNumbers.add(phone.mNumber)) { continue; } if (phoneNumberIndex != -1) { row[phoneNumberIndex] = phone.mNumber; } if (phoneTypeIndex != -1) { row[phoneTypeIndex] = phone.mType; } if (idIndex != -1) { row[idIndex] = id; } sortedResultsMap.put(new GalSortKey(sortName, id), row.clone()); addedContact = true; id++; } } else { boolean haveEmail = false; Object address = galDataRow.get(GalData.EMAIL_ADDRESS); if (address != null && !TextUtils.isEmpty(address.toString())) { if (emailIndex != -1) { row[emailIndex] = address; } if (emailTypeIndex != -1) { row[emailTypeIndex] = Email.TYPE_WORK; } haveEmail = true; } if (!isEmailFilter || haveEmail) { if (idIndex != -1) { row[idIndex] = id; } sortedResultsMap.put(new GalSortKey(sortName, id), row.clone()); addedContact = true; id++; } } if (addedContact) { contactId++; if (contactId > limit) { break; } } } final MatrixCursor cursor = new MatrixCursor(projection, sortedResultsMap.size()); for(Object[] result : sortedResultsMap.values()) { cursor.addRow(result); } return cursor; } /** * Try to create a display name from various fields. * * @return a display name for contact and its source */ private static Pair getDisplayName(GalData galDataRow, List phones) { String displayName = galDataRow.get(GalData.DISPLAY_NAME); if (!TextUtils.isEmpty(displayName)) { return Pair.create(displayName, DisplayNameSources.STRUCTURED_NAME); } // try to get displayName from name fields final String firstName = galDataRow.get(GalData.FIRST_NAME); final String lastName = galDataRow.get(GalData.LAST_NAME); if (!TextUtils.isEmpty(firstName) || !TextUtils.isEmpty(lastName)) { if (!TextUtils.isEmpty(firstName) && !TextUtils.isEmpty(lastName)) { displayName = firstName + " " + lastName; } else if (!TextUtils.isEmpty(firstName)) { displayName = firstName; } else { displayName = lastName; } return Pair.create(displayName, DisplayNameSources.STRUCTURED_NAME); } // try to get displayName from email final String emailAddress = galDataRow.get(GalData.EMAIL_ADDRESS); if (!TextUtils.isEmpty(emailAddress)) { return Pair.create(emailAddress, DisplayNameSources.EMAIL); } // try to get displayName from phone numbers if (phones != null && phones.size() > 0) { final PhoneInfo phone = (PhoneInfo) phones.get(0); if (phone != null && !TextUtils.isEmpty(phone.mNumber)) { return Pair.create(phone.mNumber, DisplayNameSources.PHONE); } } return Pair.create(null, null); } /** * Try to create the alternate display name from various fields. The CP2 * Alternate Display Name field is LastName FirstName to support user * choice of how to order names for display. * * @return alternate display name for contact and its source */ private static String getAlternateDisplayName(GalData galDataRow, String displayName) { // try to get displayName from name fields final String firstName = galDataRow.get(GalData.FIRST_NAME); final String lastName = galDataRow.get(GalData.LAST_NAME); if (!TextUtils.isEmpty(firstName) && !TextUtils.isEmpty(lastName)) { return lastName + " " + firstName; } else if (!TextUtils.isEmpty(lastName)) { return lastName; } return displayName; } private void addPhoneInfo(List phones, String number, int type) { if (!TextUtils.isEmpty(number)) { phones.add(new PhoneInfo(number, type)); } } @Override public String getType(Uri uri) { final int match = sURIMatcher.match(uri); switch (match) { case GAL_FILTER: return Contacts.CONTENT_ITEM_TYPE; } return null; } @Override public int delete(Uri uri, String selection, String[] selectionArgs) { throw new UnsupportedOperationException(); } @Override public Uri insert(Uri uri, ContentValues values) { throw new UnsupportedOperationException(); } @Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { throw new UnsupportedOperationException(); } /** * Sort key for Gal filter results. * - primary key is name * for SORT_KEY_PRIMARY, this is displayName * for SORT_KEY_ALTERNATIVE, this is alternativeDisplayName * if no sort order is specified, this key is empty * - secondary key is id, so ordering of the original results are * preserved both between contacts with the same name and for * multiple results within a given contact */ protected static class GalSortKey { final String sortName; final int id; public GalSortKey(final String sortName, final int id) { this.sortName = sortName; this.id = id; } } /** * The Comparator that is used by ExchangeDirectoryProvider */ protected static class NameComparator implements Comparator { private final Collator collator; public NameComparator() { collator = Collator.getInstance(); // Case insensitive sorting collator.setStrength(Collator.SECONDARY); } @Override public int compare(final GalSortKey lhs, final GalSortKey rhs) { if (lhs.sortName != null && rhs.sortName != null) { final int res = collator.compare(lhs.sortName, rhs.sortName); if (res != 0) { return res; } } else if (lhs.sortName != null) { return 1; } else if (rhs.sortName != null) { return -1; } // Either the names compared equally or both were null, use the id to compare. if (lhs.id != rhs.id) { return lhs.id > rhs.id ? 1 : -1; } return 0; } } private static class PhoneInfo { private String mNumber; private int mType; private PhoneInfo(String number, int type) { mNumber = number; mType = type; } } }