1/*
2 * Copyright (C) 2010 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 */
16package com.android.contacts.common.list;
17
18import android.content.ContentUris;
19import android.content.Context;
20import android.content.CursorLoader;
21import android.database.Cursor;
22import android.net.Uri;
23import android.net.Uri.Builder;
24import android.provider.ContactsContract;
25import android.provider.ContactsContract.CommonDataKinds.Callable;
26import android.provider.ContactsContract.CommonDataKinds.Phone;
27import android.provider.ContactsContract.CommonDataKinds.SipAddress;
28import android.provider.ContactsContract.Contacts;
29import android.provider.ContactsContract.Data;
30import android.provider.ContactsContract.Directory;
31import android.telephony.PhoneNumberUtils;
32import android.text.TextUtils;
33import android.util.Log;
34import android.view.View;
35import android.view.ViewGroup;
36
37import com.android.contacts.common.GeoUtil;
38import com.android.contacts.common.R;
39import com.android.contacts.common.ContactPhotoManager.DefaultImageRequest;
40import com.android.contacts.common.extensions.ExtendedPhoneDirectoriesManager;
41import com.android.contacts.common.extensions.ExtensionsFactory;
42import com.android.contacts.common.preference.ContactsPreferences;
43import com.android.contacts.common.util.Constants;
44
45import java.util.ArrayList;
46import java.util.List;
47
48/**
49 * A cursor adapter for the {@link Phone#CONTENT_ITEM_TYPE} and
50 * {@link SipAddress#CONTENT_ITEM_TYPE}.
51 *
52 * By default this adapter just handles phone numbers. When {@link #setUseCallableUri(boolean)} is
53 * called with "true", this adapter starts handling SIP addresses too, by using {@link Callable}
54 * API instead of {@link Phone}.
55 */
56public class PhoneNumberListAdapter extends ContactEntryListAdapter {
57
58    private static final String TAG = PhoneNumberListAdapter.class.getSimpleName();
59
60    // A list of extended directories to add to the directories from the database
61    private final List<DirectoryPartition> mExtendedDirectories;
62
63    // Extended directories will have ID's that are higher than any of the id's from the database.
64    // Thi sis so that we can identify them and set them up properly. If no extended directories
65    // exist, this will be Long.MAX_VALUE
66    private long mFirstExtendedDirectoryId = Long.MAX_VALUE;
67
68    public static class PhoneQuery {
69        public static final String[] PROJECTION_PRIMARY = new String[] {
70            Phone._ID,                          // 0
71            Phone.TYPE,                         // 1
72            Phone.LABEL,                        // 2
73            Phone.NUMBER,                       // 3
74            Phone.CONTACT_ID,                   // 4
75            Phone.LOOKUP_KEY,                   // 5
76            Phone.PHOTO_ID,                     // 6
77            Phone.DISPLAY_NAME_PRIMARY,         // 7
78            Phone.PHOTO_THUMBNAIL_URI,          // 8
79        };
80
81        public static final String[] PROJECTION_ALTERNATIVE = new String[] {
82            Phone._ID,                          // 0
83            Phone.TYPE,                         // 1
84            Phone.LABEL,                        // 2
85            Phone.NUMBER,                       // 3
86            Phone.CONTACT_ID,                   // 4
87            Phone.LOOKUP_KEY,                   // 5
88            Phone.PHOTO_ID,                     // 6
89            Phone.DISPLAY_NAME_ALTERNATIVE,     // 7
90            Phone.PHOTO_THUMBNAIL_URI,          // 8
91        };
92
93        public static final int PHONE_ID                = 0;
94        public static final int PHONE_TYPE              = 1;
95        public static final int PHONE_LABEL             = 2;
96        public static final int PHONE_NUMBER            = 3;
97        public static final int CONTACT_ID              = 4;
98        public static final int LOOKUP_KEY              = 5;
99        public static final int PHOTO_ID                = 6;
100        public static final int DISPLAY_NAME            = 7;
101        public static final int PHOTO_URI               = 8;
102    }
103
104    private static final String IGNORE_NUMBER_TOO_LONG_CLAUSE =
105            "length(" + Phone.NUMBER + ") < 1000";
106
107    private final CharSequence mUnknownNameText;
108    private final String mCountryIso;
109
110    private ContactListItemView.PhotoPosition mPhotoPosition;
111
112    private boolean mUseCallableUri;
113
114    public PhoneNumberListAdapter(Context context) {
115        super(context);
116        setDefaultFilterHeaderText(R.string.list_filter_phones);
117        mUnknownNameText = context.getText(android.R.string.unknownName);
118        mCountryIso = GeoUtil.getCurrentCountryIso(context);
119
120        final ExtendedPhoneDirectoriesManager manager
121                = ExtensionsFactory.getExtendedPhoneDirectoriesManager();
122        if (manager != null) {
123            mExtendedDirectories = manager.getExtendedDirectories(mContext);
124        } else {
125            // Empty list to avoid sticky NPE's
126            mExtendedDirectories = new ArrayList<DirectoryPartition>();
127        }
128    }
129
130    protected CharSequence getUnknownNameText() {
131        return mUnknownNameText;
132    }
133
134    @Override
135    public void configureLoader(CursorLoader loader, long directoryId) {
136        String query = getQueryString();
137        if (query == null) {
138            query = "";
139        }
140        if (isExtendedDirectory(directoryId)) {
141            final DirectoryPartition directory = getExtendedDirectoryFromId(directoryId);
142            final String contentUri = directory.getContentUri();
143            if (contentUri == null) {
144                throw new IllegalStateException("Extended directory must have a content URL: "
145                        + directory);
146            }
147            final Builder builder = Uri.parse(contentUri).buildUpon();
148            builder.appendPath(query);
149            builder.appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY,
150                    String.valueOf(getDirectoryResultLimit(directory)));
151            loader.setUri(builder.build());
152            loader.setProjection(PhoneQuery.PROJECTION_PRIMARY);
153        } else {
154            final boolean isRemoteDirectoryQuery = isRemoteDirectory(directoryId);
155            final Builder builder;
156            if (isSearchMode()) {
157                final Uri baseUri;
158                if (isRemoteDirectoryQuery) {
159                    baseUri = Phone.CONTENT_FILTER_URI;
160                } else if (mUseCallableUri) {
161                    baseUri = Callable.CONTENT_FILTER_URI;
162                } else {
163                    baseUri = Phone.CONTENT_FILTER_URI;
164                }
165                builder = baseUri.buildUpon();
166                builder.appendPath(query);      // Builder will encode the query
167                builder.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY,
168                        String.valueOf(directoryId));
169                if (isRemoteDirectoryQuery) {
170                    builder.appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY,
171                            String.valueOf(getDirectoryResultLimit(getDirectoryById(directoryId))));
172                }
173            } else {
174                final Uri baseUri = mUseCallableUri ? Callable.CONTENT_URI : Phone.CONTENT_URI;
175                builder = baseUri.buildUpon().appendQueryParameter(
176                        ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT));
177                if (isSectionHeaderDisplayEnabled()) {
178                    builder.appendQueryParameter(Phone.EXTRA_ADDRESS_BOOK_INDEX, "true");
179                }
180                applyFilter(loader, builder, directoryId, getFilter());
181            }
182
183            // Ignore invalid phone numbers that are too long. These can potentially cause freezes
184            // in the UI and there is no reason to display them.
185            final String prevSelection = loader.getSelection();
186            final String newSelection;
187            if (!TextUtils.isEmpty(prevSelection)) {
188                newSelection = prevSelection + " AND " + IGNORE_NUMBER_TOO_LONG_CLAUSE;
189            } else {
190                newSelection = IGNORE_NUMBER_TOO_LONG_CLAUSE;
191            }
192            loader.setSelection(newSelection);
193
194            // Remove duplicates when it is possible.
195            builder.appendQueryParameter(ContactsContract.REMOVE_DUPLICATE_ENTRIES, "true");
196            loader.setUri(builder.build());
197
198            // TODO a projection that includes the search snippet
199            if (getContactNameDisplayOrder() == ContactsPreferences.DISPLAY_ORDER_PRIMARY) {
200                loader.setProjection(PhoneQuery.PROJECTION_PRIMARY);
201            } else {
202                loader.setProjection(PhoneQuery.PROJECTION_ALTERNATIVE);
203            }
204
205            if (getSortOrder() == ContactsPreferences.SORT_ORDER_PRIMARY) {
206                loader.setSortOrder(Phone.SORT_KEY_PRIMARY);
207            } else {
208                loader.setSortOrder(Phone.SORT_KEY_ALTERNATIVE);
209            }
210        }
211    }
212
213    protected boolean isExtendedDirectory(long directoryId) {
214        return directoryId >= mFirstExtendedDirectoryId;
215    }
216
217    private DirectoryPartition getExtendedDirectoryFromId(long directoryId) {
218        final int directoryIndex = (int) (directoryId - mFirstExtendedDirectoryId);
219        return mExtendedDirectories.get(directoryIndex);
220    }
221
222    /**
223     * Configure {@code loader} and {@code uriBuilder} according to {@code directoryId} and {@code
224     * filter}.
225     */
226    private void applyFilter(CursorLoader loader, Uri.Builder uriBuilder, long directoryId,
227            ContactListFilter filter) {
228        if (filter == null || directoryId != Directory.DEFAULT) {
229            return;
230        }
231
232        final StringBuilder selection = new StringBuilder();
233        final List<String> selectionArgs = new ArrayList<String>();
234
235        switch (filter.filterType) {
236            case ContactListFilter.FILTER_TYPE_CUSTOM: {
237                selection.append(Contacts.IN_VISIBLE_GROUP + "=1");
238                selection.append(" AND " + Contacts.HAS_PHONE_NUMBER + "=1");
239                break;
240            }
241            case ContactListFilter.FILTER_TYPE_ACCOUNT: {
242                filter.addAccountQueryParameterToUrl(uriBuilder);
243                break;
244            }
245            case ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS:
246            case ContactListFilter.FILTER_TYPE_DEFAULT:
247                break; // No selection needed.
248            case ContactListFilter.FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY:
249                break; // This adapter is always "phone only", so no selection needed either.
250            default:
251                Log.w(TAG, "Unsupported filter type came " +
252                        "(type: " + filter.filterType + ", toString: " + filter + ")" +
253                        " showing all contacts.");
254                // No selection.
255                break;
256        }
257        loader.setSelection(selection.toString());
258        loader.setSelectionArgs(selectionArgs.toArray(new String[0]));
259    }
260
261    @Override
262    public String getContactDisplayName(int position) {
263        return ((Cursor) getItem(position)).getString(PhoneQuery.DISPLAY_NAME);
264    }
265
266    public String getPhoneNumber(int position) {
267        final Cursor item = (Cursor)getItem(position);
268        return item != null ? item.getString(PhoneQuery.PHONE_NUMBER) : null;
269    }
270
271    /**
272     * Builds a {@link Data#CONTENT_URI} for the given cursor position.
273     *
274     * @return Uri for the data. may be null if the cursor is not ready.
275     */
276    public Uri getDataUri(int position) {
277        final int partitionIndex = getPartitionForPosition(position);
278        final Cursor item = (Cursor)getItem(position);
279        return item != null ? getDataUri(partitionIndex, item) : null;
280    }
281
282    public Uri getDataUri(int partitionIndex, Cursor cursor) {
283        final long directoryId =
284                ((DirectoryPartition)getPartition(partitionIndex)).getDirectoryId();
285        if (!isRemoteDirectory(directoryId)) {
286            final long phoneId = cursor.getLong(PhoneQuery.PHONE_ID);
287            return ContentUris.withAppendedId(Data.CONTENT_URI, phoneId);
288        }
289        return null;
290    }
291
292    @Override
293    protected ContactListItemView newView(
294            Context context, int partition, Cursor cursor, int position, ViewGroup parent) {
295        ContactListItemView view = super.newView(context, partition, cursor, position, parent);
296        view.setUnknownNameText(mUnknownNameText);
297        view.setQuickContactEnabled(isQuickContactEnabled());
298        view.setPhotoPosition(mPhotoPosition);
299        return view;
300    }
301
302    protected void setHighlight(ContactListItemView view, Cursor cursor) {
303        view.setHighlightedPrefix(isSearchMode() ? getUpperCaseQueryString() : null);
304    }
305
306    // Override default, which would return number of phone numbers, so we
307    // instead return number of contacts.
308    @Override
309    protected int getResultCount(Cursor cursor) {
310        if (cursor == null) {
311            return 0;
312        }
313        cursor.moveToPosition(-1);
314        long curContactId = -1;
315        int numContacts = 0;
316        while(cursor.moveToNext()) {
317            final long contactId = cursor.getLong(PhoneQuery.CONTACT_ID);
318            if (contactId != curContactId) {
319                curContactId = contactId;
320                ++numContacts;
321            }
322        }
323        return numContacts;
324    }
325
326    @Override
327    protected void bindView(View itemView, int partition, Cursor cursor, int position) {
328        super.bindView(itemView, partition, cursor, position);
329        ContactListItemView view = (ContactListItemView)itemView;
330
331        setHighlight(view, cursor);
332
333        // Look at elements before and after this position, checking if contact IDs are same.
334        // If they have one same contact ID, it means they can be grouped.
335        //
336        // In one group, only the first entry will show its photo and its name, and the other
337        // entries in the group show just their data (e.g. phone number, email address).
338        cursor.moveToPosition(position);
339        boolean isFirstEntry = true;
340        boolean showBottomDivider = true;
341        final long currentContactId = cursor.getLong(PhoneQuery.CONTACT_ID);
342        if (cursor.moveToPrevious() && !cursor.isBeforeFirst()) {
343            final long previousContactId = cursor.getLong(PhoneQuery.CONTACT_ID);
344            if (currentContactId == previousContactId) {
345                isFirstEntry = false;
346            }
347        }
348        cursor.moveToPosition(position);
349        if (cursor.moveToNext() && !cursor.isAfterLast()) {
350            final long nextContactId = cursor.getLong(PhoneQuery.CONTACT_ID);
351            if (currentContactId == nextContactId) {
352                // The following entry should be in the same group, which means we don't want a
353                // divider between them.
354                // TODO: we want a different divider than the divider between groups. Just hiding
355                // this divider won't be enough.
356                showBottomDivider = false;
357            }
358        }
359        cursor.moveToPosition(position);
360
361        bindViewId(view, cursor, PhoneQuery.PHONE_ID);
362
363        bindSectionHeaderAndDivider(view, position);
364        if (isFirstEntry) {
365            bindName(view, cursor);
366            if (isQuickContactEnabled()) {
367                bindQuickContact(view, partition, cursor, PhoneQuery.PHOTO_ID,
368                        PhoneQuery.PHOTO_URI, PhoneQuery.CONTACT_ID,
369                        PhoneQuery.LOOKUP_KEY, PhoneQuery.DISPLAY_NAME);
370            } else {
371                if (getDisplayPhotos()) {
372                    bindPhoto(view, partition, cursor);
373                }
374            }
375        } else {
376            unbindName(view);
377
378            view.removePhotoView(true, false);
379        }
380
381        final DirectoryPartition directory = (DirectoryPartition) getPartition(partition);
382        bindPhoneNumber(view, cursor, directory.isDisplayNumber());
383    }
384
385    protected void bindPhoneNumber(ContactListItemView view, Cursor cursor, boolean displayNumber) {
386        CharSequence label = null;
387        if (displayNumber &&  !cursor.isNull(PhoneQuery.PHONE_TYPE)) {
388            final int type = cursor.getInt(PhoneQuery.PHONE_TYPE);
389            final String customLabel = cursor.getString(PhoneQuery.PHONE_LABEL);
390
391            // TODO cache
392            label = Phone.getTypeLabel(getContext().getResources(), type, customLabel);
393        }
394        view.setLabel(label);
395        final String text;
396        if (displayNumber) {
397            text = cursor.getString(PhoneQuery.PHONE_NUMBER);
398        } else {
399            // Display phone label. If that's null, display geocoded location for the number
400            final String phoneLabel = cursor.getString(PhoneQuery.PHONE_LABEL);
401            if (phoneLabel != null) {
402                text = phoneLabel;
403            } else {
404                final String phoneNumber = cursor.getString(PhoneQuery.PHONE_NUMBER);
405                text = GeoUtil.getGeocodedLocationFor(mContext, phoneNumber);
406            }
407        }
408        view.setPhoneNumber(text, mCountryIso);
409    }
410
411    protected void bindSectionHeaderAndDivider(final ContactListItemView view, int position) {
412        if (isSectionHeaderDisplayEnabled()) {
413            Placement placement = getItemPlacementInSection(position);
414            view.setSectionHeader(placement.firstInSection ? placement.sectionHeader : null);
415        } else {
416            view.setSectionHeader(null);
417        }
418    }
419
420    protected void bindName(final ContactListItemView view, Cursor cursor) {
421        view.showDisplayName(cursor, PhoneQuery.DISPLAY_NAME, getContactNameDisplayOrder());
422        // Note: we don't show phonetic names any more (see issue 5265330)
423    }
424
425    protected void unbindName(final ContactListItemView view) {
426        view.hideDisplayName();
427    }
428
429    protected void bindPhoto(final ContactListItemView view, int partitionIndex, Cursor cursor) {
430        if (!isPhotoSupported(partitionIndex)) {
431            view.removePhotoView();
432            return;
433        }
434
435        long photoId = 0;
436        if (!cursor.isNull(PhoneQuery.PHOTO_ID)) {
437            photoId = cursor.getLong(PhoneQuery.PHOTO_ID);
438        }
439
440        if (photoId != 0) {
441            getPhotoLoader().loadThumbnail(view.getPhotoView(), photoId, false,
442                    getCircularPhotos(), null);
443        } else {
444            final String photoUriString = cursor.getString(PhoneQuery.PHOTO_URI);
445            final Uri photoUri = photoUriString == null ? null : Uri.parse(photoUriString);
446
447            DefaultImageRequest request = null;
448            if (photoUri == null) {
449                final String displayName = cursor.getString(PhoneQuery.DISPLAY_NAME);
450                final String lookupKey = cursor.getString(PhoneQuery.LOOKUP_KEY);
451                request = new DefaultImageRequest(displayName, lookupKey, getCircularPhotos());
452            }
453            getPhotoLoader().loadDirectoryPhoto(view.getPhotoView(), photoUri, false,
454                    getCircularPhotos(), request);
455        }
456    }
457
458    public void setPhotoPosition(ContactListItemView.PhotoPosition photoPosition) {
459        mPhotoPosition = photoPosition;
460    }
461
462    public ContactListItemView.PhotoPosition getPhotoPosition() {
463        return mPhotoPosition;
464    }
465
466    public void setUseCallableUri(boolean useCallableUri) {
467        mUseCallableUri = useCallableUri;
468    }
469
470    public boolean usesCallableUri() {
471        return mUseCallableUri;
472    }
473
474    /**
475     * Override base implementation to inject extended directories between local & remote
476     * directories. This is done in the following steps:
477     * 1. Call base implementation to add directories from the cursor.
478     * 2. Iterate all base directories and establish the following information:
479     *   a. The highest directory id so that we can assign unused id's to the extended directories.
480     *   b. The index of the last non-remote directory. This is where we will insert extended
481     *      directories.
482     * 3. Iterate the extended directories and for each one, assign an ID and insert it in the
483     *    proper location.
484     */
485    @Override
486    public void changeDirectories(Cursor cursor) {
487        super.changeDirectories(cursor);
488        if (getDirectorySearchMode() == DirectoryListLoader.SEARCH_MODE_NONE) {
489            return;
490        }
491        final int numExtendedDirectories = mExtendedDirectories.size();
492        if (getPartitionCount() == cursor.getCount() + numExtendedDirectories) {
493            // already added all directories;
494            return;
495        }
496        //
497        mFirstExtendedDirectoryId = Long.MAX_VALUE;
498        if (numExtendedDirectories > 0) {
499            // The Directory.LOCAL_INVISIBLE is not in the cursor but we can't reuse it's
500            // "special" ID.
501            long maxId = Directory.LOCAL_INVISIBLE;
502            int insertIndex = 0;
503            for (int i = 0, n = getPartitionCount(); i < n; i++) {
504                final DirectoryPartition partition = (DirectoryPartition) getPartition(i);
505                final long id = partition.getDirectoryId();
506                if (id > maxId) {
507                    maxId = id;
508                }
509                if (!isRemoteDirectory(id)) {
510                    // assuming remote directories come after local, we will end up with the index
511                    // where we should insert extended directories. This also works if there are no
512                    // remote directories at all.
513                    insertIndex = i + 1;
514                }
515            }
516            // Extended directories ID's cannot collide with base directories
517            mFirstExtendedDirectoryId = maxId + 1;
518            for (int i = 0; i < numExtendedDirectories; i++) {
519                final long id = mFirstExtendedDirectoryId + i;
520                final DirectoryPartition directory = mExtendedDirectories.get(i);
521                if (getPartitionByDirectoryId(id) == -1) {
522                    addPartition(insertIndex, directory);
523                    directory.setDirectoryId(id);
524                }
525            }
526        }
527    }
528
529    protected Uri getContactUri(int partitionIndex, Cursor cursor,
530            int contactIdColumn, int lookUpKeyColumn) {
531        final DirectoryPartition directory = (DirectoryPartition) getPartition(partitionIndex);
532        final long directoryId = directory.getDirectoryId();
533        if (!isExtendedDirectory(directoryId)) {
534            return super.getContactUri(partitionIndex, cursor, contactIdColumn, lookUpKeyColumn);
535        }
536        return Contacts.CONTENT_LOOKUP_URI.buildUpon()
537                .appendPath(Constants.LOOKUP_URI_ENCODED)
538                .appendQueryParameter(Directory.DISPLAY_NAME, directory.getLabel())
539                .appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY,
540                        String.valueOf(directoryId))
541                .encodedFragment(cursor.getString(lookUpKeyColumn))
542                .build();
543    }
544}
545