RecipientAlternatesAdapter.java revision 7a4e67708498ec46c2e9b3bad69d3807d88c064e
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.provider.ContactsContract.Contacts;
27import android.text.TextUtils;
28import android.text.util.Rfc822Token;
29import android.text.util.Rfc822Tokenizer;
30import android.util.Log;
31import android.view.View;
32import android.view.ViewGroup;
33import android.widget.CursorAdapter;
34
35import com.android.ex.chips.BaseRecipientAdapter.DirectoryListQuery;
36import com.android.ex.chips.BaseRecipientAdapter.DirectorySearchParams;
37import com.android.ex.chips.DropdownChipLayouter.AdapterType;
38import com.android.ex.chips.Queries.Query;
39
40import java.util.ArrayList;
41import java.util.HashMap;
42import java.util.HashSet;
43import java.util.List;
44import java.util.Map;
45import java.util.Set;
46
47/**
48 * RecipientAlternatesAdapter backs the RecipientEditTextView for managing contacts
49 * queried by email or by phone number.
50 */
51public class RecipientAlternatesAdapter extends CursorAdapter {
52    static final int MAX_LOOKUPS = 50;
53
54    private final long mCurrentId;
55
56    private int mCheckedItemPosition = -1;
57
58    private OnCheckedItemChangedListener mCheckedItemChangedListener;
59
60    private static final String TAG = "RecipAlternates";
61
62    public static final int QUERY_TYPE_EMAIL = 0;
63    public static final int QUERY_TYPE_PHONE = 1;
64    private final Long mDirectoryId;
65    private DropdownChipLayouter mDropdownChipLayouter;
66
67    private static final Map<String, String> sCorrectedPhotoUris = new HashMap<String, String>();
68
69    public interface RecipientMatchCallback {
70        public void matchesFound(Map<String, RecipientEntry> results);
71        /**
72         * Called with all addresses that could not be resolved to valid recipients.
73         */
74        public void matchesNotFound(Set<String> unfoundAddresses);
75    }
76
77    public static void getMatchingRecipients(Context context, BaseRecipientAdapter adapter,
78            ArrayList<String> inAddresses, Account account, RecipientMatchCallback callback) {
79        getMatchingRecipients(context, adapter, inAddresses, QUERY_TYPE_EMAIL, account, callback);
80    }
81
82    /**
83     * Get a HashMap of address to RecipientEntry that contains all contact
84     * information for a contact with the provided address, if one exists. This
85     * may block the UI, so run it in an async task.
86     *
87     * @param context Context.
88     * @param inAddresses Array of addresses on which to perform the lookup.
89     * @param callback RecipientMatchCallback called when a match or matches are found.
90     * @return HashMap<String,RecipientEntry>
91     */
92    public static void getMatchingRecipients(Context context, BaseRecipientAdapter adapter,
93            ArrayList<String> inAddresses, int addressType, Account account,
94            RecipientMatchCallback callback) {
95        Queries.Query query;
96        if (addressType == QUERY_TYPE_EMAIL) {
97            query = Queries.EMAIL;
98        } else {
99            query = Queries.PHONE;
100        }
101        int addressesSize = Math.min(MAX_LOOKUPS, inAddresses.size());
102        HashSet<String> addresses = new HashSet<String>();
103        StringBuilder bindString = new StringBuilder();
104        // Create the "?" string and set up arguments.
105        for (int i = 0; i < addressesSize; i++) {
106            Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(inAddresses.get(i).toLowerCase());
107            addresses.add(tokens.length > 0 ? tokens[0].getAddress() : inAddresses.get(i));
108            bindString.append("?");
109            if (i < addressesSize - 1) {
110                bindString.append(",");
111            }
112        }
113
114        if (Log.isLoggable(TAG, Log.DEBUG)) {
115            Log.d(TAG, "Doing reverse lookup for " + addresses.toString());
116        }
117
118        String[] addressArray = new String[addresses.size()];
119        addresses.toArray(addressArray);
120        HashMap<String, RecipientEntry> recipientEntries = null;
121        Cursor c = null;
122
123        try {
124            c = context.getContentResolver().query(
125                    query.getContentUri(),
126                    query.getProjection(),
127                    query.getProjection()[Queries.Query.DESTINATION] + " IN ("
128                            + bindString.toString() + ")", addressArray, null);
129            recipientEntries = processContactEntries(c, null /* directoryId */);
130            callback.matchesFound(recipientEntries);
131        } finally {
132            if (c != null) {
133                c.close();
134            }
135        }
136        // See if any entries did not resolve; if so, we need to check other
137        // directories
138        final Set<String> matchesNotFound = new HashSet<String>();
139        if (recipientEntries.size() < addresses.size()) {
140            final List<DirectorySearchParams> paramsList;
141            Cursor directoryCursor = null;
142            try {
143                directoryCursor = context.getContentResolver().query(DirectoryListQuery.URI,
144                        DirectoryListQuery.PROJECTION, null, null, null);
145                if (directoryCursor == null) {
146                    paramsList = null;
147                } else {
148                    paramsList = BaseRecipientAdapter.setupOtherDirectories(context,
149                            directoryCursor, account);
150                }
151            } finally {
152                if (directoryCursor != null) {
153                    directoryCursor.close();
154                }
155            }
156            // Run a directory query for each unmatched recipient.
157            HashSet<String> unresolvedAddresses = new HashSet<String>();
158            for (String address : addresses) {
159                if (!recipientEntries.containsKey(address)) {
160                    unresolvedAddresses.add(address);
161                }
162            }
163
164            matchesNotFound.addAll(unresolvedAddresses);
165
166            if (paramsList != null) {
167                Cursor directoryContactsCursor = null;
168                for (String unresolvedAddress : unresolvedAddresses) {
169                    Long directoryId = null;
170                    for (int i = 0; i < paramsList.size(); i++) {
171                        try {
172                            directoryContactsCursor = doQuery(unresolvedAddress, 1,
173                                    paramsList.get(i).directoryId, account,
174                                    context.getContentResolver(), query);
175                        } finally {
176                            if (directoryContactsCursor != null
177                                    && directoryContactsCursor.getCount() == 0) {
178                                directoryContactsCursor.close();
179                                directoryContactsCursor = null;
180                            } else {
181                                directoryId = paramsList.get(i).directoryId;
182                                break;
183                            }
184                        }
185                    }
186                    if (directoryContactsCursor != null) {
187                        try {
188                            final Map<String, RecipientEntry> entries =
189                                    processContactEntries(directoryContactsCursor, directoryId);
190
191                            for (final String address : entries.keySet()) {
192                                matchesNotFound.remove(address);
193                            }
194
195                            callback.matchesFound(entries);
196                        } finally {
197                            directoryContactsCursor.close();
198                        }
199                    }
200                }
201            }
202        }
203
204        // If no matches found in contact provider or the directories, try the extension
205        // matcher.
206        // todo (aalbert): This whole method needs to be in the adapter?
207        if (adapter != null) {
208            final Map<String, RecipientEntry> entries =
209                    adapter.getMatchingRecipients(matchesNotFound);
210            if (entries != null && entries.size() > 0) {
211                callback.matchesFound(entries);
212                for (final String address : entries.keySet()) {
213                    matchesNotFound.remove(address);
214                }
215            }
216        }
217        callback.matchesNotFound(matchesNotFound);
218    }
219
220    private static HashMap<String, RecipientEntry> processContactEntries(Cursor c,
221            Long directoryId) {
222        HashMap<String, RecipientEntry> recipientEntries = new HashMap<String, RecipientEntry>();
223        if (c != null && c.moveToFirst()) {
224            do {
225                String address = c.getString(Queries.Query.DESTINATION);
226
227                final RecipientEntry newRecipientEntry = RecipientEntry.constructTopLevelEntry(
228                        c.getString(Queries.Query.NAME),
229                        c.getInt(Queries.Query.DISPLAY_NAME_SOURCE),
230                        c.getString(Queries.Query.DESTINATION),
231                        c.getInt(Queries.Query.DESTINATION_TYPE),
232                        c.getString(Queries.Query.DESTINATION_LABEL),
233                        c.getLong(Queries.Query.CONTACT_ID),
234                        directoryId,
235                        c.getLong(Queries.Query.DATA_ID),
236                        c.getString(Queries.Query.PHOTO_THUMBNAIL_URI),
237                        true,
238                        c.getString(Queries.Query.LOOKUP_KEY));
239
240                /*
241                 * In certain situations, we may have two results for one address, where one of the
242                 * results is just the email address, and the other has a name and photo, so we want
243                 * to use the better one.
244                 */
245                final RecipientEntry recipientEntry =
246                        getBetterRecipient(recipientEntries.get(address), newRecipientEntry);
247
248                recipientEntries.put(address, recipientEntry);
249                if (Log.isLoggable(TAG, Log.DEBUG)) {
250                    Log.d(TAG, "Received reverse look up information for " + address
251                            + " RESULTS: "
252                            + " NAME : " + c.getString(Queries.Query.NAME)
253                            + " CONTACT ID : " + c.getLong(Queries.Query.CONTACT_ID)
254                            + " ADDRESS :" + c.getString(Queries.Query.DESTINATION));
255                }
256            } while (c.moveToNext());
257        }
258        return recipientEntries;
259    }
260
261    /**
262     * Given two {@link RecipientEntry}s for the same email address, this will return the one that
263     * contains more complete information for display purposes. Defaults to <code>entry2</code> if
264     * no significant differences are found.
265     */
266    static RecipientEntry getBetterRecipient(final RecipientEntry entry1,
267            final RecipientEntry entry2) {
268        // If only one has passed in, use it
269        if (entry2 == null) {
270            return entry1;
271        }
272
273        if (entry1 == null) {
274            return entry2;
275        }
276
277        // If only one has a display name, use it
278        if (!TextUtils.isEmpty(entry1.getDisplayName())
279                && TextUtils.isEmpty(entry2.getDisplayName())) {
280            return entry1;
281        }
282
283        if (!TextUtils.isEmpty(entry2.getDisplayName())
284                && TextUtils.isEmpty(entry1.getDisplayName())) {
285            return entry2;
286        }
287
288        // If only one has a display name that is not the same as the destination, use it
289        if (!TextUtils.equals(entry1.getDisplayName(), entry1.getDestination())
290                && TextUtils.equals(entry2.getDisplayName(), entry2.getDestination())) {
291            return entry1;
292        }
293
294        if (!TextUtils.equals(entry2.getDisplayName(), entry2.getDestination())
295                && TextUtils.equals(entry1.getDisplayName(), entry1.getDestination())) {
296            return entry2;
297        }
298
299        // If only one has a photo, use it
300        if ((entry1.getPhotoThumbnailUri() != null || entry1.getPhotoBytes() != null)
301                && (entry2.getPhotoThumbnailUri() == null && entry2.getPhotoBytes() == null)) {
302            return entry1;
303        }
304
305        if ((entry2.getPhotoThumbnailUri() != null || entry2.getPhotoBytes() != null)
306                && (entry1.getPhotoThumbnailUri() == null && entry1.getPhotoBytes() == null)) {
307            return entry2;
308        }
309
310        // Go with the second option as a default
311        return entry2;
312    }
313
314    private static Cursor doQuery(CharSequence constraint, int limit, Long directoryId,
315            Account account, ContentResolver resolver, Query query) {
316        final Uri.Builder builder = query
317                .getContentFilterUri()
318                .buildUpon()
319                .appendPath(constraint.toString())
320                .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY,
321                        String.valueOf(limit + BaseRecipientAdapter.ALLOWANCE_FOR_DUPLICATES));
322        if (directoryId != null) {
323            builder.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY,
324                    String.valueOf(directoryId));
325        }
326        if (account != null) {
327            builder.appendQueryParameter(BaseRecipientAdapter.PRIMARY_ACCOUNT_NAME, account.name);
328            builder.appendQueryParameter(BaseRecipientAdapter.PRIMARY_ACCOUNT_TYPE, account.type);
329        }
330        final Cursor cursor = resolver.query(builder.build(), query.getProjection(), null, null,
331                null);
332        return cursor;
333    }
334
335    public RecipientAlternatesAdapter(Context context, long contactId, Long directoryId,
336            String lookupKey, long currentId, int queryMode, OnCheckedItemChangedListener listener,
337            DropdownChipLayouter dropdownChipLayouter) {
338        super(context,
339                getCursorForConstruction(context, contactId, directoryId, lookupKey, queryMode), 0);
340        mCurrentId = currentId;
341        mDirectoryId = directoryId;
342        mCheckedItemChangedListener = listener;
343
344        mDropdownChipLayouter = dropdownChipLayouter;
345    }
346
347    private static Cursor getCursorForConstruction(Context context, long contactId,
348            Long directoryId, String lookupKey, int queryType) {
349        final Cursor cursor;
350        final String desiredMimeType;
351        if (queryType == QUERY_TYPE_EMAIL) {
352            final Uri uri;
353            final StringBuilder selection = new StringBuilder();
354            selection.append(Queries.EMAIL.getProjection()[Queries.Query.CONTACT_ID]);
355            selection.append(" = ?");
356
357            if (directoryId == null || lookupKey == null) {
358                uri = Queries.EMAIL.getContentUri();
359                desiredMimeType = null;
360            } else {
361                final Uri.Builder builder = Contacts.getLookupUri(contactId, lookupKey).buildUpon();
362                builder.appendPath(Contacts.Entity.CONTENT_DIRECTORY)
363                        .appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY,
364                                String.valueOf(directoryId));
365                uri = builder.build();
366                desiredMimeType = ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE;
367            }
368            cursor = context.getContentResolver().query(
369                    uri,
370                    Queries.EMAIL.getProjection(),
371                    selection.toString(), new String[] {
372                        String.valueOf(contactId)
373                    }, null);
374        } else {
375            final Uri uri;
376            final StringBuilder selection = new StringBuilder();
377            selection.append(Queries.PHONE.getProjection()[Queries.Query.CONTACT_ID]);
378            selection.append(" = ?");
379
380            if (lookupKey == null) {
381                uri = Queries.PHONE.getContentUri();
382                desiredMimeType = null;
383            } else {
384                final Uri.Builder builder = Contacts.getLookupUri(contactId, lookupKey).buildUpon();
385                builder.appendPath(Contacts.Entity.CONTENT_DIRECTORY)
386                        .appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY,
387                                String.valueOf(directoryId));
388                uri = builder.build();
389                desiredMimeType = ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE;
390            }
391            cursor = context.getContentResolver().query(
392                    uri,
393                    Queries.PHONE.getProjection(),
394                    selection.toString(), new String[] {
395                        String.valueOf(contactId)
396                    }, null);
397        }
398
399        final Cursor resultCursor = removeUndesiredDestinations(cursor, desiredMimeType, lookupKey);
400        cursor.close();
401
402        return resultCursor;
403    }
404
405    /**
406     * @return a new cursor based on the given cursor with all duplicate destinations removed.
407     *
408     * It's only intended to use for the alternate list, so...
409     * - This method ignores all other fields and dedupe solely on the destination.  Normally,
410     * if a cursor contains multiple contacts and they have the same destination, we'd still want
411     * to show both.
412     * - This method creates a MatrixCursor, so all data will be kept in memory.  We wouldn't want
413     * to do this if the original cursor is large, but it's okay here because the alternate list
414     * won't be that big.
415     *
416     * @param desiredMimeType If this is non-<code>null</code>, only entries with this mime type
417     *            will be added to the cursor
418     * @param lookupKey The lookup key used for this contact if there isn't one in the cursor. This
419     *            should be the same one used in the query that returned the cursor
420     */
421    // Visible for testing
422    static Cursor removeUndesiredDestinations(final Cursor original, final String desiredMimeType,
423            final String lookupKey) {
424        final MatrixCursor result = new MatrixCursor(
425                original.getColumnNames(), original.getCount());
426        final HashSet<String> destinationsSeen = new HashSet<String>();
427
428        String defaultDisplayName = null;
429        String defaultPhotoThumbnailUri = null;
430        int defaultDisplayNameSource = 0;
431
432        // Find some nice defaults in case we need them
433        original.moveToPosition(-1);
434        while (original.moveToNext()) {
435            final String mimeType = original.getString(Query.MIME_TYPE);
436
437            if (ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE.equals(
438                    mimeType)) {
439                // Store this data
440                defaultDisplayName = original.getString(Query.NAME);
441                defaultPhotoThumbnailUri = original.getString(Query.PHOTO_THUMBNAIL_URI);
442                defaultDisplayNameSource = original.getInt(Query.DISPLAY_NAME_SOURCE);
443                break;
444            }
445        }
446
447        original.moveToPosition(-1);
448        while (original.moveToNext()) {
449            if (desiredMimeType != null) {
450                final String mimeType = original.getString(Query.MIME_TYPE);
451                if (!desiredMimeType.equals(mimeType)) {
452                    continue;
453                }
454            }
455            final String destination = original.getString(Query.DESTINATION);
456            if (destinationsSeen.contains(destination)) {
457                continue;
458            }
459            destinationsSeen.add(destination);
460
461            final Object[] row = new Object[] {
462                    original.getString(Query.NAME),
463                    original.getString(Query.DESTINATION),
464                    original.getInt(Query.DESTINATION_TYPE),
465                    original.getString(Query.DESTINATION_LABEL),
466                    original.getLong(Query.CONTACT_ID),
467                    original.getLong(Query.DATA_ID),
468                    original.getString(Query.PHOTO_THUMBNAIL_URI),
469                    original.getInt(Query.DISPLAY_NAME_SOURCE),
470                    original.getString(Query.LOOKUP_KEY),
471                    original.getString(Query.MIME_TYPE)
472            };
473
474            if (row[Query.NAME] == null) {
475                row[Query.NAME] = defaultDisplayName;
476            }
477            if (row[Query.PHOTO_THUMBNAIL_URI] == null) {
478                row[Query.PHOTO_THUMBNAIL_URI] = defaultPhotoThumbnailUri;
479            }
480            if ((Integer) row[Query.DISPLAY_NAME_SOURCE] == 0) {
481                row[Query.DISPLAY_NAME_SOURCE] = defaultDisplayNameSource;
482            }
483            if (row[Query.LOOKUP_KEY] == null) {
484                row[Query.LOOKUP_KEY] = lookupKey;
485            }
486
487            // Ensure we don't have two '?' like content://.../...?account_name=...?sz=...
488            final String photoThumbnailUri = (String) row[Query.PHOTO_THUMBNAIL_URI];
489            if (photoThumbnailUri != null) {
490                if (sCorrectedPhotoUris.containsKey(photoThumbnailUri)) {
491                    row[Query.PHOTO_THUMBNAIL_URI] = sCorrectedPhotoUris.get(photoThumbnailUri);
492                } else if (photoThumbnailUri.indexOf('?') != photoThumbnailUri.lastIndexOf('?')) {
493                    final String[] parts = photoThumbnailUri.split("\\?");
494                    final StringBuilder correctedUriBuilder = new StringBuilder();
495                    for (int i = 0; i < parts.length; i++) {
496                        if (i == 1) {
497                            correctedUriBuilder.append("?"); // We only want one of these
498                        } else if (i > 1) {
499                            correctedUriBuilder.append("&"); // And we want these elsewhere
500                        }
501                        correctedUriBuilder.append(parts[i]);
502                    }
503
504                    final String correctedUri = correctedUriBuilder.toString();
505                    sCorrectedPhotoUris.put(photoThumbnailUri, correctedUri);
506                    row[Query.PHOTO_THUMBNAIL_URI] = correctedUri;
507                }
508            }
509
510            result.addRow(row);
511        }
512
513        return result;
514    }
515
516    @Override
517    public long getItemId(int position) {
518        Cursor c = getCursor();
519        if (c.moveToPosition(position)) {
520            c.getLong(Queries.Query.DATA_ID);
521        }
522        return -1;
523    }
524
525    public RecipientEntry getRecipientEntry(int position) {
526        Cursor c = getCursor();
527        c.moveToPosition(position);
528        return RecipientEntry.constructTopLevelEntry(
529                c.getString(Queries.Query.NAME),
530                c.getInt(Queries.Query.DISPLAY_NAME_SOURCE),
531                c.getString(Queries.Query.DESTINATION),
532                c.getInt(Queries.Query.DESTINATION_TYPE),
533                c.getString(Queries.Query.DESTINATION_LABEL),
534                c.getLong(Queries.Query.CONTACT_ID),
535                mDirectoryId,
536                c.getLong(Queries.Query.DATA_ID),
537                c.getString(Queries.Query.PHOTO_THUMBNAIL_URI),
538                true,
539                c.getString(Queries.Query.LOOKUP_KEY));
540    }
541
542    @Override
543    public View getView(int position, View convertView, ViewGroup parent) {
544        Cursor cursor = getCursor();
545        cursor.moveToPosition(position);
546        if (convertView == null) {
547            convertView = mDropdownChipLayouter.newView();
548        }
549        if (cursor.getLong(Queries.Query.DATA_ID) == mCurrentId) {
550            mCheckedItemPosition = position;
551            if (mCheckedItemChangedListener != null) {
552                mCheckedItemChangedListener.onCheckedItemChanged(mCheckedItemPosition);
553            }
554        }
555        bindView(convertView, convertView.getContext(), cursor);
556        return convertView;
557    }
558
559    @Override
560    public void bindView(View view, Context context, Cursor cursor) {
561        int position = cursor.getPosition();
562        RecipientEntry entry = getRecipientEntry(position);
563
564        mDropdownChipLayouter.bindView(view, null, entry, position,
565                AdapterType.RECIPIENT_ALTERNATES, null);
566    }
567
568    @Override
569    public View newView(Context context, Cursor cursor, ViewGroup parent) {
570        return mDropdownChipLayouter.newView();
571    }
572
573    /*package*/ static interface OnCheckedItemChangedListener {
574        public void onCheckedItemChanged(int position);
575    }
576}
577