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