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