Home | History | Annotate | Download | only in chips
      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