/* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.messaging.ui.contact; import android.content.Context; import android.database.Cursor; import android.database.MergeCursor; import android.support.v4.util.Pair; import android.text.TextUtils; import android.text.util.Rfc822Token; import android.text.util.Rfc822Tokenizer; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.Filter; import android.widget.TextView; import com.android.ex.chips.BaseRecipientAdapter; import com.android.ex.chips.RecipientAlternatesAdapter; import com.android.ex.chips.RecipientAlternatesAdapter.RecipientMatchCallback; import com.android.ex.chips.RecipientEntry; import com.android.messaging.R; import com.android.messaging.util.Assert; import com.android.messaging.util.Assert.DoesNotRunOnMainThread; import com.android.messaging.util.BugleGservices; import com.android.messaging.util.BugleGservicesKeys; import com.android.messaging.util.ContactRecipientEntryUtils; import com.android.messaging.util.ContactUtil; import com.android.messaging.util.OsUtil; import com.android.messaging.util.PhoneUtils; import java.text.Collator; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; /** * An extension on the base {@link BaseRecipientAdapter} that uses data layer from Bugle, * such as the ContactRecipientPhotoManager that uses our own MediaResourceManager, and * contact lookup that relies on ContactUtil. It provides data source and filtering ability * for {@link ContactRecipientAutoCompleteView} */ public final class ContactRecipientAdapter extends BaseRecipientAdapter { private static final int WORD_DIRECTORY_HEADER_POS_NONE = -1; /** * Stores the index of work directory header. */ private int mWorkDirectoryHeaderPos = WORD_DIRECTORY_HEADER_POS_NONE; private final LayoutInflater mInflater; /** * Type of directory entry. */ private static final int ENTRY_TYPE_DIRECTORY = RecipientEntry.ENTRY_TYPE_SIZE; public ContactRecipientAdapter(final Context context, final ContactListItemView.HostInterface clivHost) { this(context, Integer.MAX_VALUE, QUERY_TYPE_PHONE, clivHost); } public ContactRecipientAdapter(final Context context, final int preferredMaxResultCount, final int queryMode, final ContactListItemView.HostInterface clivHost) { super(context, preferredMaxResultCount, queryMode); setPhotoManager(new ContactRecipientPhotoManager(context, clivHost)); mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); } @Override public boolean forceShowAddress() { // We should always use the SingleRecipientAddressAdapter // And never use the RecipientAlternatesAdapter return true; } @Override public Filter getFilter() { return new ContactFilter(); } /** * A Filter for a RecipientEditTextView that queries Bugle's ContactUtil for auto-complete * results. */ public class ContactFilter extends Filter { // Used to sort filtered contacts when it has combined results from email and phone. private final RecipientEntryComparator mComparator = new RecipientEntryComparator(); /** * Returns a cursor containing the filtered results in contacts given the search text, * and a boolean indicating whether the results are sorted. * * The queries are synchronously performed since this is not run on the main thread. * * Some locales (e.g. JPN) expect email addresses to be auto-completed for MMS. * If this is the case, perform two queries on phone number followed by email and * return the merged results. */ @DoesNotRunOnMainThread private CursorResult getFilteredResultsCursor(final String searchText) { Assert.isNotMainThread(); if (BugleGservices.get().getBoolean( BugleGservicesKeys.ALWAYS_AUTOCOMPLETE_EMAIL_ADDRESS, BugleGservicesKeys.ALWAYS_AUTOCOMPLETE_EMAIL_ADDRESS_DEFAULT)) { final Cursor personalFilterPhonesCursor = ContactUtil .filterPhones(getContext(), searchText).performSynchronousQuery(); final Cursor personalFilterEmailsCursor = ContactUtil .filterEmails(getContext(), searchText).performSynchronousQuery(); final Cursor personalCursor = new MergeCursor( new Cursor[]{personalFilterEmailsCursor, personalFilterPhonesCursor}); final CursorResult cursorResult = new CursorResult(personalCursor, false /* sorted */); if (OsUtil.isAtLeastN()) { // Including enterprise result starting from N. final Cursor enterpriseFilterPhonesCursor = ContactUtil.filterPhonesEnterprise( getContext(), searchText).performSynchronousQuery(); final Cursor enterpriseFilterEmailsCursor = ContactUtil.filterEmailsEnterprise( getContext(), searchText).performSynchronousQuery(); final Cursor enterpriseCursor = new MergeCursor( new Cursor[]{enterpriseFilterEmailsCursor, enterpriseFilterPhonesCursor}); cursorResult.enterpriseCursor = enterpriseCursor; } return cursorResult; } else { final Cursor personalFilterDestinationCursor = ContactUtil .filterDestination(getContext(), searchText).performSynchronousQuery(); final CursorResult cursorResult = new CursorResult(personalFilterDestinationCursor, true); if (OsUtil.isAtLeastN()) { // Including enterprise result starting from N. final Cursor enterpriseFilterDestinationCursor = ContactUtil .filterDestinationEnterprise(getContext(), searchText) .performSynchronousQuery(); cursorResult.enterpriseCursor = enterpriseFilterDestinationCursor; } return cursorResult; } } @Override protected FilterResults performFiltering(final CharSequence constraint) { Assert.isNotMainThread(); final FilterResults results = new FilterResults(); // No query, return empty results. if (TextUtils.isEmpty(constraint)) { clearTempEntries(); return results; } final String searchText = constraint.toString(); // Query for auto-complete results, since performFiltering() is not done on the // main thread, perform the cursor loader queries directly. final CursorResult cursorResult = getFilteredResultsCursor(searchText); final List entries = new ArrayList<>(); // First check if the constraint is a valid SMS destination. If so, add the // destination as a suggestion item to the drop down. if (PhoneUtils.isValidSmsMmsDestination(searchText)) { entries.add(ContactRecipientEntryUtils .constructSendToDestinationEntry(searchText)); } // Only show work directory header if more than one result in work directory. int workDirectoryHeaderPos = WORD_DIRECTORY_HEADER_POS_NONE; if (cursorResult.enterpriseCursor != null && cursorResult.enterpriseCursor.getCount() > 0) { if (cursorResult.personalCursor != null) { workDirectoryHeaderPos = entries.size(); workDirectoryHeaderPos += cursorResult.personalCursor.getCount(); } } final Cursor[] cursors = new Cursor[]{cursorResult.personalCursor, cursorResult.enterpriseCursor}; for (Cursor cursor : cursors) { if (cursor != null) { try { final List tempEntries = new ArrayList<>(); HashSet existingContactIds = new HashSet<>(); while (cursor.moveToNext()) { // Make sure there's only one first-level contact (i.e. contact for // which we show the avatar picture and name) for every contact id. final long contactId = cursor.getLong(ContactUtil.INDEX_CONTACT_ID); final boolean isFirstLevel = !existingContactIds.contains(contactId); if (isFirstLevel) { existingContactIds.add(contactId); } tempEntries.add(ContactUtil.createRecipientEntryForPhoneQuery(cursor, isFirstLevel)); } if (!cursorResult.isSorted) { Collections.sort(tempEntries, mComparator); } entries.addAll(tempEntries); } finally { cursor.close(); } } } results.values = new ContactReceipientFilterResult(entries, workDirectoryHeaderPos); results.count = 1; return results; } @Override protected void publishResults(final CharSequence constraint, final FilterResults results) { mCurrentConstraint = constraint; clearTempEntries(); final ContactReceipientFilterResult contactReceipientFilterResult = (ContactReceipientFilterResult) results.values; if (contactReceipientFilterResult != null) { mWorkDirectoryHeaderPos = contactReceipientFilterResult.workDirectoryPos; if (contactReceipientFilterResult.recipientEntries != null) { updateEntries(contactReceipientFilterResult.recipientEntries); } else { updateEntries(Collections.emptyList()); } } } private class RecipientEntryComparator implements Comparator { private final Collator mCollator; public RecipientEntryComparator() { mCollator = Collator.getInstance(Locale.getDefault()); mCollator.setStrength(Collator.PRIMARY); } /** * Compare two RecipientEntry's, first by locale-aware display name comparison, then by * contact id comparison, finally by first-level-ness comparison. */ @Override public int compare(RecipientEntry lhs, RecipientEntry rhs) { // Send-to-destinations always appear before everything else. final boolean sendToLhs = ContactRecipientEntryUtils .isSendToDestinationContact(lhs); final boolean sendToRhs = ContactRecipientEntryUtils .isSendToDestinationContact(lhs); if (sendToLhs != sendToRhs) { if (sendToLhs) { return -1; } else if (sendToRhs) { return 1; } } final int displayNameCompare = mCollator.compare(lhs.getDisplayName(), rhs.getDisplayName()); if (displayNameCompare != 0) { return displayNameCompare; } // Long.compare could accomplish the following three lines, but this is only // available in API 19+ final long lhsContactId = lhs.getContactId(); final long rhsContactId = rhs.getContactId(); final int contactCompare = lhsContactId < rhsContactId ? -1 : (lhsContactId == rhsContactId ? 0 : 1); if (contactCompare != 0) { return contactCompare; } // These are the same contact. Make sure first-level contacts always // appear at the front. if (lhs.isFirstLevel()) { return -1; } else if (rhs.isFirstLevel()) { return 1; } else { return 0; } } } private class CursorResult { public final Cursor personalCursor; public Cursor enterpriseCursor; public final boolean isSorted; public CursorResult(Cursor personalCursor, boolean isSorted) { this.personalCursor = personalCursor; this.isSorted = isSorted; } } private class ContactReceipientFilterResult { /** * Recipient entries in all directories. */ public final List recipientEntries; /** * Index of row that showing work directory header. */ public final int workDirectoryPos; public ContactReceipientFilterResult(List recipientEntries, int workDirectoryPos) { this.recipientEntries = recipientEntries; this.workDirectoryPos = workDirectoryPos; } } } /** * Called when we need to substitute temporary recipient chips with better alternatives. * For example, if a list of comma-delimited phone numbers are pasted into the edit box, * we want to be able to look up in the ContactUtil for exact matches and get contact * details such as name and photo thumbnail for the contact to display a better chip. */ @Override public void getMatchingRecipients(final ArrayList inAddresses, final RecipientMatchCallback callback) { final int addressesSize = Math.min( RecipientAlternatesAdapter.MAX_LOOKUPS, inAddresses.size()); final HashSet addresses = new HashSet(); for (int i = 0; i < addressesSize; i++) { final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(inAddresses.get(i).toLowerCase()); addresses.add(tokens.length > 0 ? tokens[0].getAddress() : inAddresses.get(i)); } final Map recipientEntries = new HashMap(); // query for each address for (final String address : addresses) { final Cursor cursor = ContactUtil.lookupDestination(getContext(), address) .performSynchronousQuery(); if (cursor != null) { try { if (cursor.moveToNext()) { // There may be multiple matches to the same number, always take the // first match. // TODO: May need to consider if there's an existing conversation // that matches this particular contact and prioritize that contact. final RecipientEntry entry = ContactUtil.createRecipientEntryForPhoneQuery(cursor, true); recipientEntries.put(address, entry); } } finally { cursor.close(); } } } // report matches callback.matchesFound(recipientEntries); } /** * We handle directory header here and then delegate the work of creating recipient views to * the {@link BaseRecipientAdapter}. Please notice that we need to fix the position * before passing to {@link BaseRecipientAdapter} because it is not aware of the existence of * directory headers. */ @Override public View getView(int position, View convertView, ViewGroup parent) { TextView textView; if (isDirectoryEntry(position)) { if (convertView == null) { textView = (TextView) mInflater.inflate(R.layout.work_directory_header, parent, false); } else { textView = (TextView) convertView; } return textView; } return super.getView(fixPosition(position), convertView, parent); } @Override public RecipientEntry getItem(int position) { if (isDirectoryEntry(position)) { return null; } return super.getItem(fixPosition(position)); } @Override public int getViewTypeCount() { return RecipientEntry.ENTRY_TYPE_SIZE + 1; } @Override public int getItemViewType(int position) { if (isDirectoryEntry(position)) { return ENTRY_TYPE_DIRECTORY; } return super.getItemViewType(fixPosition(position)); } @Override public boolean isEnabled(int position) { if (isDirectoryEntry(position)) { return false; } return super.isEnabled(fixPosition(position)); } @Override public int getCount() { return super.getCount() + ((hasWorkDirectoryHeader()) ? 1 : 0); } private boolean isDirectoryEntry(int position) { return position == mWorkDirectoryHeaderPos; } /** * @return the position of items without counting directory headers. */ private int fixPosition(int position) { if (hasWorkDirectoryHeader()) { Assert.isTrue(position != mWorkDirectoryHeaderPos); if (position > mWorkDirectoryHeaderPos) { return position - 1; } } return position; } private boolean hasWorkDirectoryHeader() { return mWorkDirectoryHeaderPos != WORD_DIRECTORY_HEADER_POS_NONE; } }