Home | History | Annotate | Download | only in contacts
      1 /*
      2  * Copyright (C) 2009 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 
     17 package com.android.providers.contacts;
     18 
     19 import com.android.common.ArrayListCursor;
     20 import com.android.providers.contacts.ContactsDatabaseHelper.AggregatedPresenceColumns;
     21 import com.android.providers.contacts.ContactsDatabaseHelper.ContactsColumns;
     22 import com.android.providers.contacts.ContactsDatabaseHelper.DataColumns;
     23 import com.android.providers.contacts.ContactsDatabaseHelper.RawContactsColumns;
     24 import com.android.providers.contacts.ContactsDatabaseHelper.Tables;
     25 
     26 import android.app.SearchManager;
     27 import android.content.ContentUris;
     28 import android.content.res.Resources;
     29 import android.database.Cursor;
     30 import android.database.sqlite.SQLiteDatabase;
     31 import android.net.Uri;
     32 import android.provider.Contacts.Intents;
     33 import android.provider.ContactsContract.Contacts;
     34 import android.provider.ContactsContract.Data;
     35 import android.provider.ContactsContract.RawContacts;
     36 import android.provider.ContactsContract.StatusUpdates;
     37 import android.provider.ContactsContract.CommonDataKinds.Email;
     38 import android.provider.ContactsContract.CommonDataKinds.Organization;
     39 import android.provider.ContactsContract.CommonDataKinds.Phone;
     40 import android.provider.ContactsContract.CommonDataKinds.StructuredName;
     41 import android.provider.ContactsContract.Contacts.Photo;
     42 import android.text.TextUtils;
     43 
     44 import java.util.ArrayList;
     45 import java.util.Collections;
     46 import java.util.Comparator;
     47 import java.util.HashMap;
     48 
     49 /**
     50  * Support for global search integration for Contacts.
     51  */
     52 public class GlobalSearchSupport {
     53 
     54     private static final String[] SEARCH_SUGGESTIONS_BASED_ON_PHONE_NUMBER_COLUMNS = {
     55             "_id",
     56             SearchManager.SUGGEST_COLUMN_TEXT_1,
     57             SearchManager.SUGGEST_COLUMN_TEXT_2,
     58             SearchManager.SUGGEST_COLUMN_ICON_1,
     59             SearchManager.SUGGEST_COLUMN_INTENT_DATA,
     60             SearchManager.SUGGEST_COLUMN_INTENT_ACTION,
     61             SearchManager.SUGGEST_COLUMN_SHORTCUT_ID,
     62     };
     63 
     64     private static final String[] SEARCH_SUGGESTIONS_BASED_ON_NAME_COLUMNS = {
     65             "_id",
     66             SearchManager.SUGGEST_COLUMN_TEXT_1,
     67             SearchManager.SUGGEST_COLUMN_TEXT_2,
     68             SearchManager.SUGGEST_COLUMN_ICON_1,
     69             SearchManager.SUGGEST_COLUMN_ICON_2,
     70             SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID,
     71             SearchManager.SUGGEST_COLUMN_SHORTCUT_ID,
     72     };
     73 
     74     private interface SearchSuggestionQuery {
     75         public static final String TABLE = "data "
     76                 + " JOIN raw_contacts ON (data.raw_contact_id = raw_contacts._id) "
     77                 + " JOIN contacts ON (raw_contacts.contact_id = contacts._id)"
     78                 + " JOIN " + Tables.RAW_CONTACTS + " AS name_raw_contact ON ("
     79                 +   Contacts.NAME_RAW_CONTACT_ID + "=name_raw_contact." + RawContacts._ID + ")";
     80 
     81         public static final String PRESENCE_SQL =
     82                 "(SELECT " + StatusUpdates.PRESENCE_STATUS +
     83                 " FROM " + Tables.AGGREGATED_PRESENCE +
     84                 " WHERE " + AggregatedPresenceColumns.CONTACT_ID
     85                         + "=" + ContactsColumns.CONCRETE_ID + ")";
     86 
     87         public static final String[] COLUMNS = {
     88             ContactsColumns.CONCRETE_ID + " AS " + Contacts._ID,
     89             "name_raw_contact." + RawContactsColumns.DISPLAY_NAME
     90                     + " AS " + Contacts.DISPLAY_NAME,
     91             PRESENCE_SQL + " AS " + Contacts.CONTACT_PRESENCE,
     92             DataColumns.CONCRETE_ID + " AS data_id",
     93             DataColumns.MIMETYPE_ID,
     94             Data.IS_SUPER_PRIMARY,
     95             Data.DATA1,
     96             Contacts.PHOTO_ID,
     97             Contacts.LOOKUP_KEY,
     98         };
     99 
    100         public static final int CONTACT_ID = 0;
    101         public static final int DISPLAY_NAME = 1;
    102         public static final int PRESENCE_STATUS = 2;
    103         public static final int DATA_ID = 3;
    104         public static final int MIMETYPE_ID = 4;
    105         public static final int IS_SUPER_PRIMARY = 5;
    106         public static final int ORGANIZATION = 6;
    107         public static final int EMAIL = 6;
    108         public static final int PHONE = 6;
    109         public static final int PHOTO_ID = 7;
    110         public static final int LOOKUP_KEY = 8;
    111     }
    112 
    113     private static class SearchSuggestion {
    114         long contactId;
    115         boolean titleIsName;
    116         String organization;
    117         String email;
    118         String phoneNumber;
    119         Uri photoUri;
    120         String lookupKey;
    121         String normalizedName;
    122         int presence = -1;
    123         boolean processed;
    124         String text1;
    125         String text2;
    126         String icon1;
    127         String icon2;
    128 
    129         public SearchSuggestion(long contactId) {
    130             this.contactId = contactId;
    131         }
    132 
    133         private void process() {
    134             if (processed) {
    135                 return;
    136             }
    137 
    138             boolean hasOrganization = !TextUtils.isEmpty(organization);
    139             boolean hasEmail = !TextUtils.isEmpty(email);
    140             boolean hasPhone = !TextUtils.isEmpty(phoneNumber);
    141 
    142             boolean titleIsOrganization = !titleIsName && hasOrganization;
    143             boolean titleIsEmail = !titleIsName && !titleIsOrganization && hasEmail;
    144             boolean titleIsPhone = !titleIsName && !titleIsOrganization && !titleIsEmail
    145                     && hasPhone;
    146 
    147             if (!titleIsOrganization && hasOrganization) {
    148                 text2 = organization;
    149             } else if (!titleIsPhone && hasPhone) {
    150                 text2 = phoneNumber;
    151             } else if (!titleIsEmail && hasEmail) {
    152                 text2 = email;
    153             }
    154 
    155             if (photoUri != null) {
    156                 icon1 = photoUri.toString();
    157             } else {
    158                 icon1 = String.valueOf(com.android.internal.R.drawable.ic_contact_picture);
    159             }
    160 
    161             if (presence != -1) {
    162                 icon2 = String.valueOf(StatusUpdates.getPresenceIconResourceId(presence));
    163             }
    164 
    165             processed = true;
    166         }
    167 
    168         /**
    169          * Returns key for sorting search suggestions.
    170          *
    171          * <p>TODO: switch to new sort key
    172          */
    173         public String getSortKey() {
    174             if (normalizedName == null) {
    175                 process();
    176                 normalizedName = text1 == null ? "" : NameNormalizer.normalize(text1);
    177             }
    178             return normalizedName;
    179         }
    180 
    181         @SuppressWarnings({"unchecked"})
    182         public ArrayList asList(String[] projection) {
    183             process();
    184 
    185             ArrayList<Object> list = new ArrayList<Object>();
    186             if (projection == null) {
    187                 list.add(contactId);
    188                 list.add(text1);
    189                 list.add(text2);
    190                 list.add(icon1);
    191                 list.add(icon2);
    192                 list.add(lookupKey);
    193                 list.add(lookupKey);
    194             } else {
    195                 for (int i = 0; i < projection.length; i++) {
    196                     addColumnValue(list, projection[i]);
    197                 }
    198             }
    199             return list;
    200         }
    201 
    202         private void addColumnValue(ArrayList<Object> list, String column) {
    203             if ("_id".equals(column)) {
    204                 list.add(contactId);
    205             } else if (SearchManager.SUGGEST_COLUMN_TEXT_1.equals(column)) {
    206                 list.add(text1);
    207             } else if (SearchManager.SUGGEST_COLUMN_TEXT_2.equals(column)) {
    208                 list.add(text2);
    209             } else if (SearchManager.SUGGEST_COLUMN_ICON_1.equals(column)) {
    210                 list.add(icon1);
    211             } else if (SearchManager.SUGGEST_COLUMN_ICON_2.equals(column)) {
    212                 list.add(icon2);
    213             } else if (SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID.equals(column)) {
    214                 list.add(lookupKey);
    215             } else if (SearchManager.SUGGEST_COLUMN_SHORTCUT_ID.equals(column)) {
    216                 list.add(lookupKey);
    217             } else {
    218                 throw new IllegalArgumentException("Invalid column name: " + column);
    219             }
    220         }
    221     }
    222 
    223     private final ContactsProvider2 mContactsProvider;
    224     private boolean mMimeTypeIdsLoaded;
    225     private long mMimeTypeIdEmail;
    226     private long mMimeTypeIdStructuredName;
    227     private long mMimeTypeIdOrganization;
    228     private long mMimeTypeIdPhone;
    229 
    230     @SuppressWarnings("all")
    231     public GlobalSearchSupport(ContactsProvider2 contactsProvider) {
    232         mContactsProvider = contactsProvider;
    233 
    234         // To ensure the data column position. This is dead code if properly configured.
    235         if (Organization.COMPANY != Data.DATA1 || Phone.NUMBER != Data.DATA1
    236                 || Email.DATA != Data.DATA1) {
    237             throw new AssertionError("Some of ContactsContract.CommonDataKinds class primary"
    238                     + " data is not in DATA1 column");
    239         }
    240     }
    241 
    242     private void ensureMimetypeIdsLoaded() {
    243         if (!mMimeTypeIdsLoaded) {
    244             ContactsDatabaseHelper dbHelper = (ContactsDatabaseHelper)mContactsProvider
    245                     .getDatabaseHelper();
    246             mMimeTypeIdStructuredName = dbHelper.getMimeTypeId(StructuredName.CONTENT_ITEM_TYPE);
    247             mMimeTypeIdOrganization = dbHelper.getMimeTypeId(Organization.CONTENT_ITEM_TYPE);
    248             mMimeTypeIdPhone = dbHelper.getMimeTypeId(Phone.CONTENT_ITEM_TYPE);
    249             mMimeTypeIdEmail = dbHelper.getMimeTypeId(Email.CONTENT_ITEM_TYPE);
    250             mMimeTypeIdsLoaded = true;
    251         }
    252     }
    253 
    254     public Cursor handleSearchSuggestionsQuery(SQLiteDatabase db, Uri uri, String limit) {
    255         if (uri.getPathSegments().size() <= 1) {
    256             return null;
    257         }
    258 
    259         final String searchClause = uri.getLastPathSegment();
    260         if (TextUtils.isDigitsOnly(searchClause)) {
    261             return buildCursorForSearchSuggestionsBasedOnPhoneNumber(searchClause);
    262         } else {
    263             return buildCursorForSearchSuggestionsBasedOnName(db, searchClause, limit);
    264         }
    265     }
    266 
    267     /**
    268      * Returns a search suggestions cursor for the contact bearing the provided lookup key.  If the
    269      * lookup key cannot be found in the database, the contact name is decoded from the lookup key
    270      * and used to re-identify the contact.  If the contact still cannot be found, an empty cursor
    271      * is returned.
    272      *
    273      * <p>Note that if {@code lookupKey} is not a valid lookup key, an empty cursor is returned
    274      * silently.  This would occur with old-style shortcuts that were created using the contact id
    275      * instead of the lookup key.
    276      */
    277     public Cursor handleSearchShortcutRefresh(SQLiteDatabase db, String lookupKey,
    278             String[] projection) {
    279         ensureMimetypeIdsLoaded();
    280         long contactId;
    281         try {
    282             contactId = mContactsProvider.lookupContactIdByLookupKey(db, lookupKey);
    283         } catch (IllegalArgumentException e) {
    284             contactId = -1L;
    285         }
    286         StringBuilder sb = new StringBuilder();
    287         sb.append(mContactsProvider.getContactsRestrictions());
    288         appendMimeTypeFilter(sb);
    289         sb.append(" AND " + ContactsColumns.CONCRETE_ID + "=" + contactId);
    290         return buildCursorForSearchSuggestions(db, sb.toString(), projection, null);
    291     }
    292 
    293     private Cursor buildCursorForSearchSuggestionsBasedOnPhoneNumber(String searchClause) {
    294         Resources r = mContactsProvider.getContext().getResources();
    295         String s;
    296         int i;
    297 
    298         ArrayList<Object> dialNumber = new ArrayList<Object>();
    299         dialNumber.add(0);  // _id
    300         s = r.getString(com.android.internal.R.string.dial_number_using, searchClause);
    301         i = s.indexOf('\n');
    302         if (i < 0) {
    303             dialNumber.add(s);
    304             dialNumber.add("");
    305         } else {
    306             dialNumber.add(s.substring(0, i));
    307             dialNumber.add(s.substring(i + 1));
    308         }
    309         dialNumber.add(String.valueOf(com.android.internal.R.drawable.call_contact));
    310         dialNumber.add("tel:" + searchClause);
    311         dialNumber.add(Intents.SEARCH_SUGGESTION_DIAL_NUMBER_CLICKED);
    312         dialNumber.add(null);
    313 
    314         ArrayList<Object> createContact = new ArrayList<Object>();
    315         createContact.add(1);  // _id
    316         s = r.getString(com.android.internal.R.string.create_contact_using, searchClause);
    317         i = s.indexOf('\n');
    318         if (i < 0) {
    319             createContact.add(s);
    320             createContact.add("");
    321         } else {
    322             createContact.add(s.substring(0, i));
    323             createContact.add(s.substring(i + 1));
    324         }
    325         createContact.add(String.valueOf(com.android.internal.R.drawable.create_contact));
    326         createContact.add("tel:" + searchClause);
    327         createContact.add(Intents.SEARCH_SUGGESTION_CREATE_CONTACT_CLICKED);
    328         createContact.add(SearchManager.SUGGEST_NEVER_MAKE_SHORTCUT);
    329 
    330         @SuppressWarnings({"unchecked"}) ArrayList<ArrayList> rows = new ArrayList<ArrayList>();
    331         rows.add(dialNumber);
    332         rows.add(createContact);
    333 
    334         return new ArrayListCursor(SEARCH_SUGGESTIONS_BASED_ON_PHONE_NUMBER_COLUMNS, rows);
    335     }
    336 
    337     private Cursor buildCursorForSearchSuggestionsBasedOnName(SQLiteDatabase db,
    338             String searchClause, String limit) {
    339         ensureMimetypeIdsLoaded();
    340         StringBuilder sb = new StringBuilder();
    341         sb.append(mContactsProvider.getContactsRestrictions());
    342         appendMimeTypeFilter(sb);
    343         sb.append(" AND " + DataColumns.CONCRETE_RAW_CONTACT_ID + " IN ");
    344         mContactsProvider.appendRawContactsByFilterAsNestedQuery(sb, searchClause);
    345 
    346         /*
    347          *  Prepending "+" to the IN_VISIBLE_GROUP column disables the index on the
    348          *  that column.  The logic is this:  let's say we have 10,000 contacts
    349          *  of which 500 are visible.  The first letter we type narrows this down
    350          *  to 10,000/26 = 384, which is already less than 500 that we would get
    351          *  from the IN_VISIBLE_GROUP index.  Typing the second letter will narrow
    352          *  the search down to 10,000/26/26 = 14 contacts. And a lot of people
    353          *  will have more that 5% of their contacts visible, while the alphabet
    354          *  will always have 26 letters.
    355          */
    356         sb.append(" AND " + "+" + Contacts.IN_VISIBLE_GROUP + "=1");
    357         String selection = sb.toString();
    358 
    359         return buildCursorForSearchSuggestions(db, selection, null, limit);
    360     }
    361 
    362     private void appendMimeTypeFilter(StringBuilder sb) {
    363 
    364         /*
    365          * The "+" syntax prevents the mime type index from being used - we just want
    366          * to reduce the size of the result set, not actually search by mime types.
    367          */
    368         sb.append(" AND " + "+" + DataColumns.MIMETYPE_ID + " IN (" + mMimeTypeIdEmail + "," +
    369                 mMimeTypeIdOrganization + "," + mMimeTypeIdPhone + "," +
    370                 mMimeTypeIdStructuredName + ")");
    371     }
    372 
    373     private Cursor buildCursorForSearchSuggestions(SQLiteDatabase db,
    374             String selection, String[] projection, String limit) {
    375         ArrayList<SearchSuggestion> suggestionList = new ArrayList<SearchSuggestion>();
    376         HashMap<Long, SearchSuggestion> suggestionMap = new HashMap<Long, SearchSuggestion>();
    377         Cursor c = db.query(false, SearchSuggestionQuery.TABLE,
    378                 SearchSuggestionQuery.COLUMNS, selection, null, null, null, null, limit);
    379         try {
    380             while (c.moveToNext()) {
    381 
    382                 long contactId = c.getLong(SearchSuggestionQuery.CONTACT_ID);
    383                 SearchSuggestion suggestion = suggestionMap.get(contactId);
    384                 if (suggestion == null) {
    385                     suggestion = new SearchSuggestion(contactId);
    386                     suggestionList.add(suggestion);
    387                     suggestionMap.put(contactId, suggestion);
    388                 }
    389 
    390                 boolean isSuperPrimary = c.getInt(SearchSuggestionQuery.IS_SUPER_PRIMARY) != 0;
    391                 suggestion.text1 = c.getString(SearchSuggestionQuery.DISPLAY_NAME);
    392 
    393                 if (!c.isNull(SearchSuggestionQuery.PRESENCE_STATUS)) {
    394                     suggestion.presence = c.getInt(SearchSuggestionQuery.PRESENCE_STATUS);
    395                 }
    396 
    397                 long mimetype = c.getLong(SearchSuggestionQuery.MIMETYPE_ID);
    398                 if (mimetype == mMimeTypeIdStructuredName) {
    399                     suggestion.titleIsName = true;
    400                 } else if (mimetype == mMimeTypeIdOrganization) {
    401                     if (isSuperPrimary || suggestion.organization == null) {
    402                         suggestion.organization = c.getString(SearchSuggestionQuery.ORGANIZATION);
    403                     }
    404                 } else if (mimetype == mMimeTypeIdEmail) {
    405                     if (isSuperPrimary || suggestion.email == null) {
    406                         suggestion.email = c.getString(SearchSuggestionQuery.EMAIL);
    407                     }
    408                 } else if (mimetype == mMimeTypeIdPhone) {
    409                     if (isSuperPrimary || suggestion.phoneNumber == null) {
    410                         suggestion.phoneNumber = c.getString(SearchSuggestionQuery.PHONE);
    411                     }
    412                 }
    413 
    414                 if (!c.isNull(SearchSuggestionQuery.PHOTO_ID)) {
    415                     suggestion.photoUri = Uri.withAppendedPath(
    416                             ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId),
    417                             Photo.CONTENT_DIRECTORY);
    418                 }
    419 
    420                 suggestion.lookupKey = c.getString(SearchSuggestionQuery.LOOKUP_KEY);
    421             }
    422         } finally {
    423             c.close();
    424         }
    425 
    426         Collections.sort(suggestionList, new Comparator<SearchSuggestion>() {
    427             public int compare(SearchSuggestion row1, SearchSuggestion row2) {
    428                 return row1.getSortKey().compareTo(row2.getSortKey());
    429             }
    430         });
    431 
    432         @SuppressWarnings({"unchecked"}) ArrayList<ArrayList> rows = new ArrayList<ArrayList>();
    433         for (int i = 0; i < suggestionList.size(); i++) {
    434             rows.add(suggestionList.get(i).asList(projection));
    435         }
    436 
    437         return new ArrayListCursor(projection != null ? projection
    438                 : SEARCH_SUGGESTIONS_BASED_ON_NAME_COLUMNS, rows);
    439     }
    440 }
    441