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