RecipientAlternatesAdapter.java revision 78f38a09c9753c0ac1838ce0bfd3a6bc1a307ff7
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> addresses); 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 = context.getContentResolver().query(DirectoryListQuery.URI, 140 DirectoryListQuery.PROJECTION, null, null, null); 141 paramsList = BaseRecipientAdapter.setupOtherDirectories(context, directoryCursor, 142 account); 143 // Run a directory query for each unmatched recipient. 144 HashSet<String> unresolvedAddresses = new HashSet<String>(); 145 for (String address : addresses) { 146 if (!recipientEntries.containsKey(address)) { 147 unresolvedAddresses.add(address); 148 } 149 } 150 151 matchesNotFound.addAll(unresolvedAddresses); 152 153 Cursor directoryContactsCursor = null; 154 for (String unresolvedAddress : unresolvedAddresses) { 155 for (int i = 0; i < paramsList.size(); i++) { 156 try { 157 directoryContactsCursor = doQuery(unresolvedAddress, 1, 158 paramsList.get(i).directoryId, account, 159 context.getContentResolver(), query); 160 } finally { 161 if (directoryContactsCursor != null 162 && directoryContactsCursor.getCount() == 0) { 163 directoryContactsCursor.close(); 164 directoryContactsCursor = null; 165 } else { 166 break; 167 } 168 } 169 } 170 if (directoryContactsCursor != null) { 171 try { 172 final Map<String, RecipientEntry> entries = 173 processContactEntries(directoryContactsCursor); 174 175 for (final String address : entries.keySet()) { 176 matchesNotFound.remove(address); 177 } 178 179 callback.matchesFound(entries); 180 } finally { 181 directoryContactsCursor.close(); 182 } 183 } 184 } 185 } 186 187 callback.matchesNotFound(matchesNotFound); 188 } 189 190 private static HashMap<String, RecipientEntry> processContactEntries(Cursor c) { 191 HashMap<String, RecipientEntry> recipientEntries = new HashMap<String, RecipientEntry>(); 192 if (c != null && c.moveToFirst()) { 193 do { 194 String address = c.getString(Queries.Query.DESTINATION); 195 196 final RecipientEntry newRecipientEntry = RecipientEntry.constructTopLevelEntry( 197 c.getString(Queries.Query.NAME), 198 c.getInt(Queries.Query.DISPLAY_NAME_SOURCE), 199 c.getString(Queries.Query.DESTINATION), 200 c.getInt(Queries.Query.DESTINATION_TYPE), 201 c.getString(Queries.Query.DESTINATION_LABEL), 202 c.getLong(Queries.Query.CONTACT_ID), 203 c.getLong(Queries.Query.DATA_ID), 204 c.getString(Queries.Query.PHOTO_THUMBNAIL_URI), 205 true); 206 207 /* 208 * In certain situations, we may have two results for one address, where one of the 209 * results is just the email address, and the other has a name and photo, so we want 210 * to use the better one. 211 */ 212 final RecipientEntry recipientEntry = 213 getBetterRecipient(recipientEntries.get(address), newRecipientEntry); 214 215 recipientEntries.put(address, recipientEntry); 216 if (Log.isLoggable(TAG, Log.DEBUG)) { 217 Log.d(TAG, "Received reverse look up information for " + address 218 + " RESULTS: " 219 + " NAME : " + c.getString(Queries.Query.NAME) 220 + " CONTACT ID : " + c.getLong(Queries.Query.CONTACT_ID) 221 + " ADDRESS :" + c.getString(Queries.Query.DESTINATION)); 222 } 223 } while (c.moveToNext()); 224 } 225 return recipientEntries; 226 } 227 228 /** 229 * Given two {@link RecipientEntry}s for the same email address, this will return the one that 230 * contains more complete information for display purposes. Defaults to <code>entry2</code> if 231 * no significant differences are found. 232 * TODO(skennedy) Add tests 233 */ 234 static RecipientEntry getBetterRecipient(final RecipientEntry entry1, 235 final RecipientEntry entry2) { 236 // If only one has passed in, use it 237 if (entry2 == null) { 238 return entry1; 239 } 240 241 if (entry1 == null) { 242 return entry2; 243 } 244 245 // If only one has a display name, use it 246 if (!TextUtils.isEmpty(entry1.getDisplayName()) 247 && TextUtils.isEmpty(entry2.getDisplayName())) { 248 return entry1; 249 } 250 251 if (!TextUtils.isEmpty(entry2.getDisplayName()) 252 && TextUtils.isEmpty(entry1.getDisplayName())) { 253 return entry2; 254 } 255 256 // If only one has a display name that is not the same as the destination, use it 257 if (!TextUtils.equals(entry1.getDisplayName(), entry1.getDestination()) 258 && TextUtils.equals(entry2.getDisplayName(), entry2.getDestination())) { 259 return entry1; 260 } 261 262 if (!TextUtils.equals(entry2.getDisplayName(), entry2.getDestination()) 263 && TextUtils.equals(entry1.getDisplayName(), entry1.getDestination())) { 264 return entry2; 265 } 266 267 // If only one has a photo, use it 268 if ((entry1.getPhotoThumbnailUri() != null || entry1.getPhotoBytes() != null) 269 && (entry2.getPhotoThumbnailUri() == null && entry2.getPhotoBytes() == null)) { 270 return entry1; 271 } 272 273 if ((entry2.getPhotoThumbnailUri() != null || entry2.getPhotoBytes() != null) 274 && (entry1.getPhotoThumbnailUri() == null && entry1.getPhotoBytes() == null)) { 275 return entry2; 276 } 277 278 // Go with the second option as a default 279 return entry2; 280 } 281 282 private static Cursor doQuery(CharSequence constraint, int limit, Long directoryId, 283 Account account, ContentResolver resolver, Query query) { 284 final Uri.Builder builder = query 285 .getContentFilterUri() 286 .buildUpon() 287 .appendPath(constraint.toString()) 288 .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY, 289 String.valueOf(limit + BaseRecipientAdapter.ALLOWANCE_FOR_DUPLICATES)); 290 if (directoryId != null) { 291 builder.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY, 292 String.valueOf(directoryId)); 293 } 294 if (account != null) { 295 builder.appendQueryParameter(BaseRecipientAdapter.PRIMARY_ACCOUNT_NAME, account.name); 296 builder.appendQueryParameter(BaseRecipientAdapter.PRIMARY_ACCOUNT_TYPE, account.type); 297 } 298 final Cursor cursor = resolver.query(builder.build(), query.getProjection(), null, null, 299 null); 300 return cursor; 301 } 302 303 public RecipientAlternatesAdapter(Context context, long contactId, long currentId, int viewId, 304 OnCheckedItemChangedListener listener) { 305 this(context, contactId, currentId, viewId, QUERY_TYPE_EMAIL, listener); 306 } 307 308 public RecipientAlternatesAdapter(Context context, long contactId, long currentId, int viewId, 309 int queryMode, OnCheckedItemChangedListener listener) { 310 super(context, getCursorForConstruction(context, contactId, queryMode), 0); 311 mLayoutInflater = LayoutInflater.from(context); 312 mCurrentId = currentId; 313 mCheckedItemChangedListener = listener; 314 315 if (queryMode == QUERY_TYPE_EMAIL) { 316 mQuery = Queries.EMAIL; 317 } else if (queryMode == QUERY_TYPE_PHONE) { 318 mQuery = Queries.PHONE; 319 } else { 320 mQuery = Queries.EMAIL; 321 Log.e(TAG, "Unsupported query type: " + queryMode); 322 } 323 } 324 325 private static Cursor getCursorForConstruction(Context context, long contactId, int queryType) { 326 final Cursor cursor; 327 if (queryType == QUERY_TYPE_EMAIL) { 328 cursor = context.getContentResolver().query( 329 Queries.EMAIL.getContentUri(), 330 Queries.EMAIL.getProjection(), 331 Queries.EMAIL.getProjection()[Queries.Query.CONTACT_ID] + " =?", new String[] { 332 String.valueOf(contactId) 333 }, null); 334 } else { 335 cursor = context.getContentResolver().query( 336 Queries.PHONE.getContentUri(), 337 Queries.PHONE.getProjection(), 338 Queries.PHONE.getProjection()[Queries.Query.CONTACT_ID] + " =?", new String[] { 339 String.valueOf(contactId) 340 }, null); 341 } 342 return removeDuplicateDestinations(cursor); 343 } 344 345 /** 346 * @return a new cursor based on the given cursor with all duplicate destinations removed. 347 * 348 * It's only intended to use for the alternate list, so... 349 * - This method ignores all other fields and dedupe solely on the destination. Normally, 350 * if a cursor contains multiple contacts and they have the same destination, we'd still want 351 * to show both. 352 * - This method creates a MatrixCursor, so all data will be kept in memory. We wouldn't want 353 * to do this if the original cursor is large, but it's okay here because the alternate list 354 * won't be that big. 355 */ 356 // Visible for testing 357 /* package */ static Cursor removeDuplicateDestinations(Cursor original) { 358 final MatrixCursor result = new MatrixCursor( 359 original.getColumnNames(), original.getCount()); 360 final HashSet<String> destinationsSeen = new HashSet<String>(); 361 362 original.moveToPosition(-1); 363 while (original.moveToNext()) { 364 final String destination = original.getString(Query.DESTINATION); 365 if (destinationsSeen.contains(destination)) { 366 continue; 367 } 368 destinationsSeen.add(destination); 369 370 result.addRow(new Object[] { 371 original.getString(Query.NAME), 372 original.getString(Query.DESTINATION), 373 original.getInt(Query.DESTINATION_TYPE), 374 original.getString(Query.DESTINATION_LABEL), 375 original.getLong(Query.CONTACT_ID), 376 original.getLong(Query.DATA_ID), 377 original.getString(Query.PHOTO_THUMBNAIL_URI), 378 original.getInt(Query.DISPLAY_NAME_SOURCE) 379 }); 380 } 381 382 return result; 383 } 384 385 @Override 386 public long getItemId(int position) { 387 Cursor c = getCursor(); 388 if (c.moveToPosition(position)) { 389 c.getLong(Queries.Query.DATA_ID); 390 } 391 return -1; 392 } 393 394 public RecipientEntry getRecipientEntry(int position) { 395 Cursor c = getCursor(); 396 c.moveToPosition(position); 397 return RecipientEntry.constructTopLevelEntry( 398 c.getString(Queries.Query.NAME), 399 c.getInt(Queries.Query.DISPLAY_NAME_SOURCE), 400 c.getString(Queries.Query.DESTINATION), 401 c.getInt(Queries.Query.DESTINATION_TYPE), 402 c.getString(Queries.Query.DESTINATION_LABEL), 403 c.getLong(Queries.Query.CONTACT_ID), 404 c.getLong(Queries.Query.DATA_ID), 405 c.getString(Queries.Query.PHOTO_THUMBNAIL_URI), 406 true); 407 } 408 409 @Override 410 public View getView(int position, View convertView, ViewGroup parent) { 411 Cursor cursor = getCursor(); 412 cursor.moveToPosition(position); 413 if (convertView == null) { 414 convertView = newView(); 415 } 416 if (cursor.getLong(Queries.Query.DATA_ID) == mCurrentId) { 417 mCheckedItemPosition = position; 418 if (mCheckedItemChangedListener != null) { 419 mCheckedItemChangedListener.onCheckedItemChanged(mCheckedItemPosition); 420 } 421 } 422 bindView(convertView, convertView.getContext(), cursor); 423 return convertView; 424 } 425 426 // TODO: this is VERY similar to the BaseRecipientAdapter. Can we combine 427 // somehow? 428 @Override 429 public void bindView(View view, Context context, Cursor cursor) { 430 int position = cursor.getPosition(); 431 432 TextView display = (TextView) view.findViewById(android.R.id.title); 433 ImageView imageView = (ImageView) view.findViewById(android.R.id.icon); 434 RecipientEntry entry = getRecipientEntry(position); 435 if (position == 0) { 436 display.setText(cursor.getString(Queries.Query.NAME)); 437 display.setVisibility(View.VISIBLE); 438 // TODO: see if this needs to be done outside the main thread 439 // as it may be too slow to get immediately. 440 imageView.setImageURI(entry.getPhotoThumbnailUri()); 441 imageView.setVisibility(View.VISIBLE); 442 } else { 443 display.setVisibility(View.GONE); 444 imageView.setVisibility(View.GONE); 445 } 446 TextView destination = (TextView) view.findViewById(android.R.id.text1); 447 destination.setText(cursor.getString(Queries.Query.DESTINATION)); 448 449 TextView destinationType = (TextView) view.findViewById(android.R.id.text2); 450 if (destinationType != null) { 451 destinationType.setText(mQuery.getTypeLabel(context.getResources(), 452 cursor.getInt(Queries.Query.DESTINATION_TYPE), 453 cursor.getString(Queries.Query.DESTINATION_LABEL)).toString().toUpperCase()); 454 } 455 } 456 457 @Override 458 public View newView(Context context, Cursor cursor, ViewGroup parent) { 459 return newView(); 460 } 461 462 private View newView() { 463 return mLayoutInflater.inflate(R.layout.chips_recipient_dropdown_item, null); 464 } 465 466 /*package*/ static interface OnCheckedItemChangedListener { 467 public void onCheckedItemChanged(int position); 468 } 469} 470