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