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