1 /* 2 * Copyright (C) 2015 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 package com.android.messaging.ui.contact; 17 18 import android.content.Context; 19 import android.database.Cursor; 20 import android.database.MergeCursor; 21 import android.support.v4.util.Pair; 22 import android.text.TextUtils; 23 import android.text.util.Rfc822Token; 24 import android.text.util.Rfc822Tokenizer; 25 import android.view.LayoutInflater; 26 import android.view.View; 27 import android.view.ViewGroup; 28 import android.widget.Filter; 29 import android.widget.TextView; 30 31 import com.android.ex.chips.BaseRecipientAdapter; 32 import com.android.ex.chips.RecipientAlternatesAdapter; 33 import com.android.ex.chips.RecipientAlternatesAdapter.RecipientMatchCallback; 34 import com.android.ex.chips.RecipientEntry; 35 import com.android.messaging.R; 36 import com.android.messaging.util.Assert; 37 import com.android.messaging.util.Assert.DoesNotRunOnMainThread; 38 import com.android.messaging.util.BugleGservices; 39 import com.android.messaging.util.BugleGservicesKeys; 40 import com.android.messaging.util.ContactRecipientEntryUtils; 41 import com.android.messaging.util.ContactUtil; 42 import com.android.messaging.util.OsUtil; 43 import com.android.messaging.util.PhoneUtils; 44 45 import java.text.Collator; 46 import java.util.ArrayList; 47 import java.util.Collections; 48 import java.util.Comparator; 49 import java.util.HashMap; 50 import java.util.HashSet; 51 import java.util.List; 52 import java.util.Locale; 53 import java.util.Map; 54 55 /** 56 * An extension on the base {@link BaseRecipientAdapter} that uses data layer from Bugle, 57 * such as the ContactRecipientPhotoManager that uses our own MediaResourceManager, and 58 * contact lookup that relies on ContactUtil. It provides data source and filtering ability 59 * for {@link ContactRecipientAutoCompleteView} 60 */ 61 public final class ContactRecipientAdapter extends BaseRecipientAdapter { 62 private static final int WORD_DIRECTORY_HEADER_POS_NONE = -1; 63 /** 64 * Stores the index of work directory header. 65 */ 66 private int mWorkDirectoryHeaderPos = WORD_DIRECTORY_HEADER_POS_NONE; 67 private final LayoutInflater mInflater; 68 69 /** 70 * Type of directory entry. 71 */ 72 private static final int ENTRY_TYPE_DIRECTORY = RecipientEntry.ENTRY_TYPE_SIZE; 73 74 public ContactRecipientAdapter(final Context context, 75 final ContactListItemView.HostInterface clivHost) { 76 this(context, Integer.MAX_VALUE, QUERY_TYPE_PHONE, clivHost); 77 } 78 79 public ContactRecipientAdapter(final Context context, final int preferredMaxResultCount, 80 final int queryMode, final ContactListItemView.HostInterface clivHost) { 81 super(context, preferredMaxResultCount, queryMode); 82 setPhotoManager(new ContactRecipientPhotoManager(context, clivHost)); 83 mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); 84 } 85 86 @Override 87 public boolean forceShowAddress() { 88 // We should always use the SingleRecipientAddressAdapter 89 // And never use the RecipientAlternatesAdapter 90 return true; 91 } 92 93 @Override 94 public Filter getFilter() { 95 return new ContactFilter(); 96 } 97 98 /** 99 * A Filter for a RecipientEditTextView that queries Bugle's ContactUtil for auto-complete 100 * results. 101 */ 102 public class ContactFilter extends Filter { 103 104 // Used to sort filtered contacts when it has combined results from email and phone. 105 private final RecipientEntryComparator mComparator = new RecipientEntryComparator(); 106 107 /** 108 * Returns a cursor containing the filtered results in contacts given the search text, 109 * and a boolean indicating whether the results are sorted. 110 * 111 * The queries are synchronously performed since this is not run on the main thread. 112 * 113 * Some locales (e.g. JPN) expect email addresses to be auto-completed for MMS. 114 * If this is the case, perform two queries on phone number followed by email and 115 * return the merged results. 116 */ 117 @DoesNotRunOnMainThread 118 private CursorResult getFilteredResultsCursor(final String searchText) { 119 Assert.isNotMainThread(); 120 if (BugleGservices.get().getBoolean( 121 BugleGservicesKeys.ALWAYS_AUTOCOMPLETE_EMAIL_ADDRESS, 122 BugleGservicesKeys.ALWAYS_AUTOCOMPLETE_EMAIL_ADDRESS_DEFAULT)) { 123 124 final Cursor personalFilterPhonesCursor = ContactUtil 125 .filterPhones(getContext(), searchText).performSynchronousQuery(); 126 final Cursor personalFilterEmailsCursor = ContactUtil 127 .filterEmails(getContext(), searchText).performSynchronousQuery(); 128 final Cursor personalCursor = new MergeCursor( 129 new Cursor[]{personalFilterEmailsCursor, personalFilterPhonesCursor}); 130 final CursorResult cursorResult = 131 new CursorResult(personalCursor, false /* sorted */); 132 if (OsUtil.isAtLeastN()) { 133 // Including enterprise result starting from N. 134 final Cursor enterpriseFilterPhonesCursor = ContactUtil.filterPhonesEnterprise( 135 getContext(), searchText).performSynchronousQuery(); 136 final Cursor enterpriseFilterEmailsCursor = ContactUtil.filterEmailsEnterprise( 137 getContext(), searchText).performSynchronousQuery(); 138 final Cursor enterpriseCursor = new MergeCursor( 139 new Cursor[]{enterpriseFilterEmailsCursor, 140 enterpriseFilterPhonesCursor}); 141 cursorResult.enterpriseCursor = enterpriseCursor; 142 } 143 return cursorResult; 144 } else { 145 final Cursor personalFilterDestinationCursor = ContactUtil 146 .filterDestination(getContext(), searchText).performSynchronousQuery(); 147 final CursorResult cursorResult = new CursorResult(personalFilterDestinationCursor, 148 true); 149 if (OsUtil.isAtLeastN()) { 150 // Including enterprise result starting from N. 151 final Cursor enterpriseFilterDestinationCursor = ContactUtil 152 .filterDestinationEnterprise(getContext(), searchText) 153 .performSynchronousQuery(); 154 cursorResult.enterpriseCursor = enterpriseFilterDestinationCursor; 155 } 156 return cursorResult; 157 } 158 } 159 160 @Override 161 protected FilterResults performFiltering(final CharSequence constraint) { 162 Assert.isNotMainThread(); 163 final FilterResults results = new FilterResults(); 164 165 // No query, return empty results. 166 if (TextUtils.isEmpty(constraint)) { 167 clearTempEntries(); 168 return results; 169 } 170 171 final String searchText = constraint.toString(); 172 173 // Query for auto-complete results, since performFiltering() is not done on the 174 // main thread, perform the cursor loader queries directly. 175 176 final CursorResult cursorResult = getFilteredResultsCursor(searchText); 177 final List<RecipientEntry> entries = new ArrayList<>(); 178 179 // First check if the constraint is a valid SMS destination. If so, add the 180 // destination as a suggestion item to the drop down. 181 if (PhoneUtils.isValidSmsMmsDestination(searchText)) { 182 entries.add(ContactRecipientEntryUtils 183 .constructSendToDestinationEntry(searchText)); 184 } 185 186 // Only show work directory header if more than one result in work directory. 187 int workDirectoryHeaderPos = WORD_DIRECTORY_HEADER_POS_NONE; 188 if (cursorResult.enterpriseCursor != null 189 && cursorResult.enterpriseCursor.getCount() > 0) { 190 if (cursorResult.personalCursor != null) { 191 workDirectoryHeaderPos = entries.size(); 192 workDirectoryHeaderPos += cursorResult.personalCursor.getCount(); 193 } 194 } 195 196 final Cursor[] cursors = new Cursor[]{cursorResult.personalCursor, 197 cursorResult.enterpriseCursor}; 198 for (Cursor cursor : cursors) { 199 if (cursor != null) { 200 try { 201 final List<RecipientEntry> tempEntries = new ArrayList<>(); 202 HashSet<Long> existingContactIds = new HashSet<>(); 203 while (cursor.moveToNext()) { 204 // Make sure there's only one first-level contact (i.e. contact for 205 // which we show the avatar picture and name) for every contact id. 206 final long contactId = cursor.getLong(ContactUtil.INDEX_CONTACT_ID); 207 final boolean isFirstLevel = !existingContactIds.contains(contactId); 208 if (isFirstLevel) { 209 existingContactIds.add(contactId); 210 } 211 tempEntries.add(ContactUtil.createRecipientEntryForPhoneQuery(cursor, 212 isFirstLevel)); 213 } 214 215 if (!cursorResult.isSorted) { 216 Collections.sort(tempEntries, mComparator); 217 } 218 entries.addAll(tempEntries); 219 } finally { 220 cursor.close(); 221 } 222 } 223 } 224 results.values = new ContactReceipientFilterResult(entries, workDirectoryHeaderPos); 225 results.count = 1; 226 return results; 227 } 228 229 @Override 230 protected void publishResults(final CharSequence constraint, final FilterResults results) { 231 mCurrentConstraint = constraint; 232 clearTempEntries(); 233 234 final ContactReceipientFilterResult contactReceipientFilterResult 235 = (ContactReceipientFilterResult) results.values; 236 if (contactReceipientFilterResult != null) { 237 mWorkDirectoryHeaderPos = contactReceipientFilterResult.workDirectoryPos; 238 if (contactReceipientFilterResult.recipientEntries != null) { 239 updateEntries(contactReceipientFilterResult.recipientEntries); 240 } else { 241 updateEntries(Collections.<RecipientEntry>emptyList()); 242 } 243 } 244 } 245 246 private class RecipientEntryComparator implements Comparator<RecipientEntry> { 247 248 private final Collator mCollator; 249 250 public RecipientEntryComparator() { 251 mCollator = Collator.getInstance(Locale.getDefault()); 252 mCollator.setStrength(Collator.PRIMARY); 253 } 254 255 /** 256 * Compare two RecipientEntry's, first by locale-aware display name comparison, then by 257 * contact id comparison, finally by first-level-ness comparison. 258 */ 259 @Override 260 public int compare(RecipientEntry lhs, RecipientEntry rhs) { 261 // Send-to-destinations always appear before everything else. 262 final boolean sendToLhs = ContactRecipientEntryUtils 263 .isSendToDestinationContact(lhs); 264 final boolean sendToRhs = ContactRecipientEntryUtils 265 .isSendToDestinationContact(lhs); 266 if (sendToLhs != sendToRhs) { 267 if (sendToLhs) { 268 return -1; 269 } else if (sendToRhs) { 270 return 1; 271 } 272 } 273 274 final int displayNameCompare = mCollator.compare(lhs.getDisplayName(), 275 rhs.getDisplayName()); 276 if (displayNameCompare != 0) { 277 return displayNameCompare; 278 } 279 280 // Long.compare could accomplish the following three lines, but this is only 281 // available in API 19+ 282 final long lhsContactId = lhs.getContactId(); 283 final long rhsContactId = rhs.getContactId(); 284 final int contactCompare = lhsContactId < rhsContactId ? -1 : 285 (lhsContactId == rhsContactId ? 0 : 1); 286 if (contactCompare != 0) { 287 return contactCompare; 288 } 289 290 // These are the same contact. Make sure first-level contacts always 291 // appear at the front. 292 if (lhs.isFirstLevel()) { 293 return -1; 294 } else if (rhs.isFirstLevel()) { 295 return 1; 296 } else { 297 return 0; 298 } 299 } 300 } 301 302 private class CursorResult { 303 304 public final Cursor personalCursor; 305 306 public Cursor enterpriseCursor; 307 308 public final boolean isSorted; 309 310 public CursorResult(Cursor personalCursor, boolean isSorted) { 311 this.personalCursor = personalCursor; 312 this.isSorted = isSorted; 313 } 314 } 315 316 private class ContactReceipientFilterResult { 317 /** 318 * Recipient entries in all directories. 319 */ 320 public final List<RecipientEntry> recipientEntries; 321 322 /** 323 * Index of row that showing work directory header. 324 */ 325 public final int workDirectoryPos; 326 327 public ContactReceipientFilterResult(List<RecipientEntry> recipientEntries, 328 int workDirectoryPos) { 329 this.recipientEntries = recipientEntries; 330 this.workDirectoryPos = workDirectoryPos; 331 } 332 } 333 } 334 335 /** 336 * Called when we need to substitute temporary recipient chips with better alternatives. 337 * For example, if a list of comma-delimited phone numbers are pasted into the edit box, 338 * we want to be able to look up in the ContactUtil for exact matches and get contact 339 * details such as name and photo thumbnail for the contact to display a better chip. 340 */ 341 @Override 342 public void getMatchingRecipients(final ArrayList<String> inAddresses, 343 final RecipientMatchCallback callback) { 344 final int addressesSize = Math.min( 345 RecipientAlternatesAdapter.MAX_LOOKUPS, inAddresses.size()); 346 final HashSet<String> addresses = new HashSet<String>(); 347 for (int i = 0; i < addressesSize; i++) { 348 final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(inAddresses.get(i).toLowerCase()); 349 addresses.add(tokens.length > 0 ? tokens[0].getAddress() : inAddresses.get(i)); 350 } 351 352 final Map<String, RecipientEntry> recipientEntries = 353 new HashMap<String, RecipientEntry>(); 354 // query for each address 355 for (final String address : addresses) { 356 final Cursor cursor = ContactUtil.lookupDestination(getContext(), address) 357 .performSynchronousQuery(); 358 if (cursor != null) { 359 try { 360 if (cursor.moveToNext()) { 361 // There may be multiple matches to the same number, always take the 362 // first match. 363 // TODO: May need to consider if there's an existing conversation 364 // that matches this particular contact and prioritize that contact. 365 final RecipientEntry entry = 366 ContactUtil.createRecipientEntryForPhoneQuery(cursor, true); 367 recipientEntries.put(address, entry); 368 } 369 370 } finally { 371 cursor.close(); 372 } 373 } 374 } 375 376 // report matches 377 callback.matchesFound(recipientEntries); 378 } 379 380 /** 381 * We handle directory header here and then delegate the work of creating recipient views to 382 * the {@link BaseRecipientAdapter}. Please notice that we need to fix the position 383 * before passing to {@link BaseRecipientAdapter} because it is not aware of the existence of 384 * directory headers. 385 */ 386 @Override 387 public View getView(int position, View convertView, ViewGroup parent) { 388 TextView textView; 389 if (isDirectoryEntry(position)) { 390 if (convertView == null) { 391 textView = (TextView) mInflater.inflate(R.layout.work_directory_header, parent, 392 false); 393 } else { 394 textView = (TextView) convertView; 395 } 396 return textView; 397 } 398 return super.getView(fixPosition(position), convertView, parent); 399 } 400 401 @Override 402 public RecipientEntry getItem(int position) { 403 if (isDirectoryEntry(position)) { 404 return null; 405 } 406 return super.getItem(fixPosition(position)); 407 } 408 409 @Override 410 public int getViewTypeCount() { 411 return RecipientEntry.ENTRY_TYPE_SIZE + 1; 412 } 413 414 @Override 415 public int getItemViewType(int position) { 416 if (isDirectoryEntry(position)) { 417 return ENTRY_TYPE_DIRECTORY; 418 } 419 return super.getItemViewType(fixPosition(position)); 420 } 421 422 @Override 423 public boolean isEnabled(int position) { 424 if (isDirectoryEntry(position)) { 425 return false; 426 } 427 return super.isEnabled(fixPosition(position)); 428 } 429 430 @Override 431 public int getCount() { 432 return super.getCount() + ((hasWorkDirectoryHeader()) ? 1 : 0); 433 } 434 435 private boolean isDirectoryEntry(int position) { 436 return position == mWorkDirectoryHeaderPos; 437 } 438 439 /** 440 * @return the position of items without counting directory headers. 441 */ 442 private int fixPosition(int position) { 443 if (hasWorkDirectoryHeader()) { 444 Assert.isTrue(position != mWorkDirectoryHeaderPos); 445 if (position > mWorkDirectoryHeaderPos) { 446 return position - 1; 447 } 448 } 449 return position; 450 } 451 452 private boolean hasWorkDirectoryHeader() { 453 return mWorkDirectoryHeaderPos != WORD_DIRECTORY_HEADER_POS_NONE; 454 } 455 456 } 457