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