RecipientAlternatesAdapter.java revision b10d1c652d0416c284d9792fc9a0a92b3acd51ca
1/* 2 * Copyright (C) 2011 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package com.android.ex.chips; 18 19import android.accounts.Account; 20import android.content.ContentResolver; 21import android.content.Context; 22import android.database.Cursor; 23import android.database.MatrixCursor; 24import android.net.Uri; 25import android.provider.ContactsContract; 26import android.text.TextUtils; 27import android.text.util.Rfc822Token; 28import android.text.util.Rfc822Tokenizer; 29import android.util.Log; 30import android.view.View; 31import android.view.ViewGroup; 32import android.widget.CursorAdapter; 33 34import com.android.ex.chips.BaseRecipientAdapter.DirectoryListQuery; 35import com.android.ex.chips.BaseRecipientAdapter.DirectorySearchParams; 36import com.android.ex.chips.DropdownChipLayouter.AdapterType; 37import com.android.ex.chips.Queries.Query; 38 39import java.util.ArrayList; 40import java.util.HashMap; 41import java.util.HashSet; 42import java.util.List; 43import java.util.Map; 44import java.util.Set; 45 46/** 47 * RecipientAlternatesAdapter backs the RecipientEditTextView for managing contacts 48 * queried by email or by phone number. 49 */ 50public class RecipientAlternatesAdapter extends CursorAdapter { 51 static final int MAX_LOOKUPS = 50; 52 53 private final long mCurrentId; 54 55 private int mCheckedItemPosition = -1; 56 57 private OnCheckedItemChangedListener mCheckedItemChangedListener; 58 59 private static final String TAG = "RecipAlternates"; 60 61 public static final int QUERY_TYPE_EMAIL = 0; 62 public static final int QUERY_TYPE_PHONE = 1; 63 private Query mQuery; 64 private DropdownChipLayouter mDropdownChipLayouter; 65 66 public interface RecipientMatchCallback { 67 public void matchesFound(Map<String, RecipientEntry> results); 68 /** 69 * Called with all addresses that could not be resolved to valid recipients. 70 */ 71 public void matchesNotFound(Set<String> unfoundAddresses); 72 } 73 74 public static void getMatchingRecipients(Context context, BaseRecipientAdapter adapter, 75 ArrayList<String> inAddresses, Account account, RecipientMatchCallback callback) { 76 getMatchingRecipients(context, adapter, inAddresses, QUERY_TYPE_EMAIL, account, callback); 77 } 78 79 /** 80 * Get a HashMap of address to RecipientEntry that contains all contact 81 * information for a contact with the provided address, if one exists. This 82 * may block the UI, so run it in an async task. 83 * 84 * @param context Context. 85 * @param inAddresses Array of addresses on which to perform the lookup. 86 * @param callback RecipientMatchCallback called when a match or matches are found. 87 * @return HashMap<String,RecipientEntry> 88 */ 89 public static void getMatchingRecipients(Context context, BaseRecipientAdapter adapter, 90 ArrayList<String> inAddresses, int addressType, Account account, 91 RecipientMatchCallback callback) { 92 Queries.Query query; 93 if (addressType == QUERY_TYPE_EMAIL) { 94 query = Queries.EMAIL; 95 } else { 96 query = Queries.PHONE; 97 } 98 int addressesSize = Math.min(MAX_LOOKUPS, inAddresses.size()); 99 HashSet<String> addresses = new HashSet<String>(); 100 StringBuilder bindString = new StringBuilder(); 101 // Create the "?" string and set up arguments. 102 for (int i = 0; i < addressesSize; i++) { 103 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(inAddresses.get(i).toLowerCase()); 104 addresses.add(tokens.length > 0 ? tokens[0].getAddress() : inAddresses.get(i)); 105 bindString.append("?"); 106 if (i < addressesSize - 1) { 107 bindString.append(","); 108 } 109 } 110 111 if (Log.isLoggable(TAG, Log.DEBUG)) { 112 Log.d(TAG, "Doing reverse lookup for " + addresses.toString()); 113 } 114 115 String[] addressArray = new String[addresses.size()]; 116 addresses.toArray(addressArray); 117 HashMap<String, RecipientEntry> recipientEntries = null; 118 Cursor c = null; 119 120 try { 121 c = context.getContentResolver().query( 122 query.getContentUri(), 123 query.getProjection(), 124 query.getProjection()[Queries.Query.DESTINATION] + " IN (" 125 + bindString.toString() + ")", addressArray, null); 126 recipientEntries = processContactEntries(c); 127 callback.matchesFound(recipientEntries); 128 } finally { 129 if (c != null) { 130 c.close(); 131 } 132 } 133 // See if any entries did not resolve; if so, we need to check other 134 // directories 135 final Set<String> matchesNotFound = new HashSet<String>(); 136 if (recipientEntries.size() < addresses.size()) { 137 final List<DirectorySearchParams> paramsList; 138 Cursor directoryCursor = null; 139 try { 140 directoryCursor = context.getContentResolver().query(DirectoryListQuery.URI, 141 DirectoryListQuery.PROJECTION, null, null, null); 142 if (directoryCursor == null) { 143 paramsList = null; 144 } else { 145 paramsList = BaseRecipientAdapter.setupOtherDirectories(context, 146 directoryCursor, account); 147 } 148 } finally { 149 if (directoryCursor != null) { 150 directoryCursor.close(); 151 } 152 } 153 // Run a directory query for each unmatched recipient. 154 HashSet<String> unresolvedAddresses = new HashSet<String>(); 155 for (String address : addresses) { 156 if (!recipientEntries.containsKey(address)) { 157 unresolvedAddresses.add(address); 158 } 159 } 160 161 matchesNotFound.addAll(unresolvedAddresses); 162 163 if (paramsList != null) { 164 Cursor directoryContactsCursor = null; 165 for (String unresolvedAddress : unresolvedAddresses) { 166 for (int i = 0; i < paramsList.size(); i++) { 167 try { 168 directoryContactsCursor = doQuery(unresolvedAddress, 1, 169 paramsList.get(i).directoryId, account, 170 context.getContentResolver(), query); 171 } finally { 172 if (directoryContactsCursor != null 173 && directoryContactsCursor.getCount() == 0) { 174 directoryContactsCursor.close(); 175 directoryContactsCursor = null; 176 } else { 177 break; 178 } 179 } 180 } 181 if (directoryContactsCursor != null) { 182 try { 183 final Map<String, RecipientEntry> entries = 184 processContactEntries(directoryContactsCursor); 185 186 for (final String address : entries.keySet()) { 187 matchesNotFound.remove(address); 188 } 189 190 callback.matchesFound(entries); 191 } finally { 192 directoryContactsCursor.close(); 193 } 194 } 195 } 196 } 197 } 198 199 // If no matches found in contact provider or the directories, try the extension 200 // matcher. 201 // todo (aalbert): This whole method needs to be in the adapter? 202 if (adapter != null) { 203 final Map<String, RecipientEntry> entries = 204 adapter.getMatchingRecipients(matchesNotFound); 205 if (entries != null && entries.size() > 0) { 206 callback.matchesFound(entries); 207 for (final String address : entries.keySet()) { 208 matchesNotFound.remove(address); 209 } 210 } 211 } 212 callback.matchesNotFound(matchesNotFound); 213 } 214 215 private static HashMap<String, RecipientEntry> processContactEntries(Cursor c) { 216 HashMap<String, RecipientEntry> recipientEntries = new HashMap<String, RecipientEntry>(); 217 if (c != null && c.moveToFirst()) { 218 do { 219 String address = c.getString(Queries.Query.DESTINATION); 220 221 final RecipientEntry newRecipientEntry = RecipientEntry.constructTopLevelEntry( 222 c.getString(Queries.Query.NAME), 223 c.getInt(Queries.Query.DISPLAY_NAME_SOURCE), 224 c.getString(Queries.Query.DESTINATION), 225 c.getInt(Queries.Query.DESTINATION_TYPE), 226 c.getString(Queries.Query.DESTINATION_LABEL), 227 c.getLong(Queries.Query.CONTACT_ID), 228 c.getLong(Queries.Query.DATA_ID), 229 c.getString(Queries.Query.PHOTO_THUMBNAIL_URI), 230 true, 231 false /* isGalContact TODO(skennedy) We should look these up eventually */); 232 233 /* 234 * In certain situations, we may have two results for one address, where one of the 235 * results is just the email address, and the other has a name and photo, so we want 236 * to use the better one. 237 */ 238 final RecipientEntry recipientEntry = 239 getBetterRecipient(recipientEntries.get(address), newRecipientEntry); 240 241 recipientEntries.put(address, recipientEntry); 242 if (Log.isLoggable(TAG, Log.DEBUG)) { 243 Log.d(TAG, "Received reverse look up information for " + address 244 + " RESULTS: " 245 + " NAME : " + c.getString(Queries.Query.NAME) 246 + " CONTACT ID : " + c.getLong(Queries.Query.CONTACT_ID) 247 + " ADDRESS :" + c.getString(Queries.Query.DESTINATION)); 248 } 249 } while (c.moveToNext()); 250 } 251 return recipientEntries; 252 } 253 254 /** 255 * Given two {@link RecipientEntry}s for the same email address, this will return the one that 256 * contains more complete information for display purposes. Defaults to <code>entry2</code> if 257 * no significant differences are found. 258 */ 259 static RecipientEntry getBetterRecipient(final RecipientEntry entry1, 260 final RecipientEntry entry2) { 261 // If only one has passed in, use it 262 if (entry2 == null) { 263 return entry1; 264 } 265 266 if (entry1 == null) { 267 return entry2; 268 } 269 270 // If only one has a display name, use it 271 if (!TextUtils.isEmpty(entry1.getDisplayName()) 272 && TextUtils.isEmpty(entry2.getDisplayName())) { 273 return entry1; 274 } 275 276 if (!TextUtils.isEmpty(entry2.getDisplayName()) 277 && TextUtils.isEmpty(entry1.getDisplayName())) { 278 return entry2; 279 } 280 281 // If only one has a display name that is not the same as the destination, use it 282 if (!TextUtils.equals(entry1.getDisplayName(), entry1.getDestination()) 283 && TextUtils.equals(entry2.getDisplayName(), entry2.getDestination())) { 284 return entry1; 285 } 286 287 if (!TextUtils.equals(entry2.getDisplayName(), entry2.getDestination()) 288 && TextUtils.equals(entry1.getDisplayName(), entry1.getDestination())) { 289 return entry2; 290 } 291 292 // If only one has a photo, use it 293 if ((entry1.getPhotoThumbnailUri() != null || entry1.getPhotoBytes() != null) 294 && (entry2.getPhotoThumbnailUri() == null && entry2.getPhotoBytes() == null)) { 295 return entry1; 296 } 297 298 if ((entry2.getPhotoThumbnailUri() != null || entry2.getPhotoBytes() != null) 299 && (entry1.getPhotoThumbnailUri() == null && entry1.getPhotoBytes() == null)) { 300 return entry2; 301 } 302 303 // Go with the second option as a default 304 return entry2; 305 } 306 307 private static Cursor doQuery(CharSequence constraint, int limit, Long directoryId, 308 Account account, ContentResolver resolver, Query query) { 309 final Uri.Builder builder = query 310 .getContentFilterUri() 311 .buildUpon() 312 .appendPath(constraint.toString()) 313 .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY, 314 String.valueOf(limit + BaseRecipientAdapter.ALLOWANCE_FOR_DUPLICATES)); 315 if (directoryId != null) { 316 builder.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY, 317 String.valueOf(directoryId)); 318 } 319 if (account != null) { 320 builder.appendQueryParameter(BaseRecipientAdapter.PRIMARY_ACCOUNT_NAME, account.name); 321 builder.appendQueryParameter(BaseRecipientAdapter.PRIMARY_ACCOUNT_TYPE, account.type); 322 } 323 final Cursor cursor = resolver.query(builder.build(), query.getProjection(), null, null, 324 null); 325 return cursor; 326 } 327 328 public RecipientAlternatesAdapter(Context context, long contactId, long currentId, 329 int queryMode, OnCheckedItemChangedListener listener, 330 DropdownChipLayouter dropdownChipLayouter) { 331 super(context, getCursorForConstruction(context, contactId, queryMode), 0); 332 mCurrentId = currentId; 333 mCheckedItemChangedListener = listener; 334 335 if (queryMode == QUERY_TYPE_EMAIL) { 336 mQuery = Queries.EMAIL; 337 } else if (queryMode == QUERY_TYPE_PHONE) { 338 mQuery = Queries.PHONE; 339 } else { 340 mQuery = Queries.EMAIL; 341 Log.e(TAG, "Unsupported query type: " + queryMode); 342 } 343 344 mDropdownChipLayouter = dropdownChipLayouter; 345 } 346 347 private static Cursor getCursorForConstruction(Context context, long contactId, int queryType) { 348 final Cursor cursor; 349 if (queryType == QUERY_TYPE_EMAIL) { 350 cursor = context.getContentResolver().query( 351 Queries.EMAIL.getContentUri(), 352 Queries.EMAIL.getProjection(), 353 Queries.EMAIL.getProjection()[Queries.Query.CONTACT_ID] + " =?", new String[] { 354 String.valueOf(contactId) 355 }, null); 356 } else { 357 cursor = context.getContentResolver().query( 358 Queries.PHONE.getContentUri(), 359 Queries.PHONE.getProjection(), 360 Queries.PHONE.getProjection()[Queries.Query.CONTACT_ID] + " =?", new String[] { 361 String.valueOf(contactId) 362 }, null); 363 } 364 return removeDuplicateDestinations(cursor); 365 } 366 367 /** 368 * @return a new cursor based on the given cursor with all duplicate destinations removed. 369 * 370 * It's only intended to use for the alternate list, so... 371 * - This method ignores all other fields and dedupe solely on the destination. Normally, 372 * if a cursor contains multiple contacts and they have the same destination, we'd still want 373 * to show both. 374 * - This method creates a MatrixCursor, so all data will be kept in memory. We wouldn't want 375 * to do this if the original cursor is large, but it's okay here because the alternate list 376 * won't be that big. 377 */ 378 // Visible for testing 379 /* package */ static Cursor removeDuplicateDestinations(Cursor original) { 380 final MatrixCursor result = new MatrixCursor( 381 original.getColumnNames(), original.getCount()); 382 final HashSet<String> destinationsSeen = new HashSet<String>(); 383 384 original.moveToPosition(-1); 385 while (original.moveToNext()) { 386 final String destination = original.getString(Query.DESTINATION); 387 if (destinationsSeen.contains(destination)) { 388 continue; 389 } 390 destinationsSeen.add(destination); 391 392 result.addRow(new Object[] { 393 original.getString(Query.NAME), 394 original.getString(Query.DESTINATION), 395 original.getInt(Query.DESTINATION_TYPE), 396 original.getString(Query.DESTINATION_LABEL), 397 original.getLong(Query.CONTACT_ID), 398 original.getLong(Query.DATA_ID), 399 original.getString(Query.PHOTO_THUMBNAIL_URI), 400 original.getInt(Query.DISPLAY_NAME_SOURCE) 401 }); 402 } 403 404 return result; 405 } 406 407 @Override 408 public long getItemId(int position) { 409 Cursor c = getCursor(); 410 if (c.moveToPosition(position)) { 411 c.getLong(Queries.Query.DATA_ID); 412 } 413 return -1; 414 } 415 416 public RecipientEntry getRecipientEntry(int position) { 417 Cursor c = getCursor(); 418 c.moveToPosition(position); 419 return RecipientEntry.constructTopLevelEntry( 420 c.getString(Queries.Query.NAME), 421 c.getInt(Queries.Query.DISPLAY_NAME_SOURCE), 422 c.getString(Queries.Query.DESTINATION), 423 c.getInt(Queries.Query.DESTINATION_TYPE), 424 c.getString(Queries.Query.DESTINATION_LABEL), 425 c.getLong(Queries.Query.CONTACT_ID), 426 c.getLong(Queries.Query.DATA_ID), 427 c.getString(Queries.Query.PHOTO_THUMBNAIL_URI), 428 true, 429 false /* isGalContact TODO(skennedy) We should look these up eventually */); 430 } 431 432 @Override 433 public View getView(int position, View convertView, ViewGroup parent) { 434 Cursor cursor = getCursor(); 435 cursor.moveToPosition(position); 436 if (convertView == null) { 437 convertView = mDropdownChipLayouter.newView(); 438 } 439 if (cursor.getLong(Queries.Query.DATA_ID) == mCurrentId) { 440 mCheckedItemPosition = position; 441 if (mCheckedItemChangedListener != null) { 442 mCheckedItemChangedListener.onCheckedItemChanged(mCheckedItemPosition); 443 } 444 } 445 bindView(convertView, convertView.getContext(), cursor); 446 return convertView; 447 } 448 449 @Override 450 public void bindView(View view, Context context, Cursor cursor) { 451 int position = cursor.getPosition(); 452 RecipientEntry entry = getRecipientEntry(position); 453 454 mDropdownChipLayouter.bindView(view, null, entry, position, 455 AdapterType.RECIPIENT_ALTERNATES, null); 456 } 457 458 @Override 459 public View newView(Context context, Cursor cursor, ViewGroup parent) { 460 return mDropdownChipLayouter.newView(); 461 } 462 463 /*package*/ static interface OnCheckedItemChangedListener { 464 public void onCheckedItemChanged(int position); 465 } 466} 467