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