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 17package com.android.providers.contacts; 18 19import android.app.SearchManager; 20import android.content.res.Resources; 21import android.database.Cursor; 22import android.database.MatrixCursor; 23import android.database.sqlite.SQLiteDatabase; 24import android.net.Uri; 25import android.provider.ContactsContract; 26import android.provider.ContactsContract.CommonDataKinds.Email; 27import android.provider.ContactsContract.CommonDataKinds.Organization; 28import android.provider.ContactsContract.CommonDataKinds.Phone; 29import android.provider.ContactsContract.Contacts; 30import android.provider.ContactsContract.Data; 31import android.provider.ContactsContract.SearchSnippetColumns; 32import android.provider.ContactsContract.StatusUpdates; 33import android.text.TextUtils; 34 35import com.android.i18n.phonenumbers.PhoneNumberUtil; 36import com.android.i18n.phonenumbers.NumberParseException; 37import com.android.providers.contacts.ContactsDatabaseHelper.AggregatedPresenceColumns; 38import com.android.providers.contacts.ContactsDatabaseHelper.ContactsColumns; 39import com.android.providers.contacts.ContactsDatabaseHelper.Tables; 40import com.android.providers.contacts.ContactsDatabaseHelper.Views; 41 42import java.util.ArrayList; 43import java.util.Locale; 44 45/** 46 * Support for global search integration for Contacts. 47 */ 48public class GlobalSearchSupport { 49 50 private static final String[] SEARCH_SUGGESTIONS_BASED_ON_PHONE_NUMBER_COLUMNS = { 51 "_id", 52 SearchManager.SUGGEST_COLUMN_TEXT_1, 53 SearchManager.SUGGEST_COLUMN_TEXT_2, 54 SearchManager.SUGGEST_COLUMN_ICON_1, 55 SearchManager.SUGGEST_COLUMN_INTENT_DATA, 56 SearchManager.SUGGEST_COLUMN_INTENT_ACTION, 57 SearchManager.SUGGEST_COLUMN_SHORTCUT_ID, 58 }; 59 60 private static final String[] SEARCH_SUGGESTIONS_BASED_ON_NAME_COLUMNS = { 61 "_id", 62 SearchManager.SUGGEST_COLUMN_TEXT_1, 63 SearchManager.SUGGEST_COLUMN_TEXT_2, 64 SearchManager.SUGGEST_COLUMN_ICON_1, 65 SearchManager.SUGGEST_COLUMN_ICON_2, 66 SearchManager.SUGGEST_COLUMN_INTENT_DATA, 67 SearchManager.SUGGEST_COLUMN_SHORTCUT_ID, 68 SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA, 69 SearchManager.SUGGEST_COLUMN_LAST_ACCESS_HINT, 70 }; 71 72 private static final char SNIPPET_START_MATCH = '\u0001'; 73 private static final char SNIPPET_END_MATCH = '\u0001'; 74 private static final String SNIPPET_ELLIPSIS = "\u2026"; 75 private static final int SNIPPET_MAX_TOKENS = 5; 76 77 private static final String PRESENCE_SQL = 78 "(SELECT " + StatusUpdates.PRESENCE + 79 " FROM " + Tables.AGGREGATED_PRESENCE + 80 " WHERE " + AggregatedPresenceColumns.CONTACT_ID + "=" + ContactsColumns.CONCRETE_ID + ")"; 81 82 // Current contacts - those contacted within the last 3 days (in seconds) 83 private static final long CURRENT_CONTACTS = 3 * 24 * 60 * 60; 84 85 // Recent contacts - those contacted within the last 30 days (in seconds) 86 private static final long RECENT_CONTACTS = 30 * 24 * 60 * 60; 87 88 private static final String TIME_SINCE_LAST_CONTACTED = 89 "(strftime('%s', 'now') - contacts." + Contacts.LAST_TIME_CONTACTED + "/1000)"; 90 91 /* 92 * See {@link ContactsProvider2#EMAIL_FILTER_SORT_ORDER} for the discussion of this 93 * sorting order. 94 */ 95 private static final String SORT_ORDER = 96 "(CASE WHEN contacts." + Contacts.STARRED + "=1 THEN 0 ELSE 1 END), " 97 + "(CASE WHEN " + TIME_SINCE_LAST_CONTACTED + " < " + CURRENT_CONTACTS + " THEN 0 " 98 + " WHEN " + TIME_SINCE_LAST_CONTACTED + " < " + RECENT_CONTACTS + " THEN 1 " 99 + " ELSE 2 END)," 100 + "contacts." + Contacts.TIMES_CONTACTED + " DESC, " 101 + "contacts." + Contacts.DISPLAY_NAME_PRIMARY + ", " 102 + "contacts." + Contacts._ID; 103 104 private static final String RECENTLY_CONTACTED = 105 TIME_SINCE_LAST_CONTACTED + " < " + RECENT_CONTACTS; 106 107 private static class SearchSuggestion { 108 long contactId; 109 String photoUri; 110 String lookupKey; 111 int presence = -1; 112 String text1; 113 String text2; 114 String icon1; 115 String icon2; 116 String filter; 117 String lastAccessTime; 118 119 @SuppressWarnings({"unchecked"}) 120 public ArrayList asList(String[] projection) { 121 if (photoUri != null) { 122 icon1 = photoUri.toString(); 123 } else { 124 icon1 = String.valueOf(com.android.internal.R.drawable.ic_contact_picture); 125 } 126 127 if (presence != -1) { 128 icon2 = String.valueOf(StatusUpdates.getPresenceIconResourceId(presence)); 129 } 130 131 ArrayList<Object> list = new ArrayList<Object>(); 132 if (projection == null) { 133 list.add(contactId); 134 list.add(text1); 135 list.add(text2); 136 list.add(icon1); 137 list.add(icon2); 138 list.add(buildUri()); 139 list.add(lookupKey); 140 list.add(filter); 141 list.add(lastAccessTime); 142 } else { 143 for (int i = 0; i < projection.length; i++) { 144 addColumnValue(list, projection[i]); 145 } 146 } 147 return list; 148 } 149 150 private void addColumnValue(ArrayList<Object> list, String column) { 151 if ("_id".equals(column)) { 152 list.add(contactId); 153 } else if (SearchManager.SUGGEST_COLUMN_TEXT_1.equals(column)) { 154 list.add(text1); 155 } else if (SearchManager.SUGGEST_COLUMN_TEXT_2.equals(column)) { 156 list.add(text2); 157 } else if (SearchManager.SUGGEST_COLUMN_ICON_1.equals(column)) { 158 list.add(icon1); 159 } else if (SearchManager.SUGGEST_COLUMN_ICON_2.equals(column)) { 160 list.add(icon2); 161 } else if (SearchManager.SUGGEST_COLUMN_INTENT_DATA.equals(column)) { 162 list.add(buildUri()); 163 } else if (SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID.equals(column)) { 164 list.add(lookupKey); 165 } else if (SearchManager.SUGGEST_COLUMN_SHORTCUT_ID.equals(column)) { 166 list.add(lookupKey); 167 } else if (SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA.equals(column)) { 168 list.add(filter); 169 } else if (SearchManager.SUGGEST_COLUMN_LAST_ACCESS_HINT.equals(column)) { 170 list.add(lastAccessTime); 171 } else { 172 throw new IllegalArgumentException("Invalid column name: " + column); 173 } 174 } 175 176 private String buildUri() { 177 return Contacts.getLookupUri(contactId, lookupKey).toString(); 178 } 179 } 180 181 private final ContactsProvider2 mContactsProvider; 182 private final PhoneNumberUtil mPhoneNumberUtil; 183 184 @SuppressWarnings("all") 185 public GlobalSearchSupport(ContactsProvider2 contactsProvider) { 186 mContactsProvider = contactsProvider; 187 mPhoneNumberUtil = PhoneNumberUtil.getInstance(); 188 189 // To ensure the data column position. This is dead code if properly configured. 190 if (Organization.COMPANY != Data.DATA1 || Phone.NUMBER != Data.DATA1 191 || Email.DATA != Data.DATA1) { 192 throw new AssertionError("Some of ContactsContract.CommonDataKinds class primary" 193 + " data is not in DATA1 column"); 194 } 195 } 196 197 public Cursor handleSearchSuggestionsQuery( 198 SQLiteDatabase db, Uri uri, String[] projection, String limit) { 199 final String searchClause; 200 final String selection; 201 if (uri.getPathSegments().size() <= 1) { 202 searchClause = null; 203 selection = RECENTLY_CONTACTED; 204 } else { 205 searchClause = uri.getLastPathSegment(); 206 selection = null; 207 } 208 209 if (mContactsProvider.isPhone() && isPhoneNumber(searchClause)) { 210 return buildCursorForSearchSuggestionsBasedOnPhoneNumber(searchClause); 211 } else { 212 return buildCursorForSearchSuggestionsBasedOnFilter( 213 db, projection, selection, searchClause, limit); 214 } 215 } 216 217 private boolean isPhoneNumber(String query) { 218 return mPhoneNumberUtil.isPossibleNumber(query, Locale.getDefault().getCountry()); 219 } 220 221 /** 222 * Returns a search suggestions cursor for the contact bearing the provided lookup key. If the 223 * lookup key cannot be found in the database, the contact name is decoded from the lookup key 224 * and used to re-identify the contact. If the contact still cannot be found, an empty cursor 225 * is returned. 226 * 227 * <p>Note that if {@code lookupKey} is not a valid lookup key, an empty cursor is returned 228 * silently. This would occur with old-style shortcuts that were created using the contact id 229 * instead of the lookup key. 230 */ 231 public Cursor handleSearchShortcutRefresh(SQLiteDatabase db, String[] projection, 232 String lookupKey, String filter) { 233 long contactId; 234 try { 235 contactId = mContactsProvider.lookupContactIdByLookupKey(db, lookupKey); 236 } catch (IllegalArgumentException e) { 237 contactId = -1L; 238 } 239 return buildCursorForSearchSuggestionsBasedOnFilter( 240 db, projection, ContactsColumns.CONCRETE_ID + "=" + contactId, filter, null); 241 } 242 243 private boolean isVoiceCapable() { 244 // this copied from com.android.phone.PhoneApp.onCreate(): 245 246 // "voice capable" flag. 247 // This flag currently comes from a resource (which is 248 // overrideable on a per-product basis): 249 return mContactsProvider.getContext().getResources() 250 .getBoolean(com.android.internal.R.bool.config_voice_capable); 251 // ...but this might eventually become a PackageManager "system 252 // feature" instead, in which case we'd do something like: 253 // return 254 // getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEPHONY_VOICE_CALLS); 255 } 256 257 private Cursor buildCursorForSearchSuggestionsBasedOnPhoneNumber(String searchClause) { 258 MatrixCursor cursor = new MatrixCursor(SEARCH_SUGGESTIONS_BASED_ON_PHONE_NUMBER_COLUMNS); 259 Resources r = mContactsProvider.getContext().getResources(); 260 String s; 261 int i; 262 263 if (isVoiceCapable()) { 264 ArrayList<Object> dialNumber = new ArrayList<Object>(); 265 dialNumber.add(0); // _id 266 s = r.getString(com.android.internal.R.string.dial_number_using, searchClause); 267 i = s.indexOf('\n'); 268 if (i < 0) { 269 dialNumber.add(s); 270 dialNumber.add(""); 271 } else { 272 dialNumber.add(s.substring(0, i)); 273 dialNumber.add(s.substring(i + 1)); 274 } 275 dialNumber.add(String.valueOf(com.android.internal.R.drawable.call_contact)); 276 dialNumber.add("tel:" + searchClause); 277 dialNumber.add(ContactsContract.Intents.SEARCH_SUGGESTION_DIAL_NUMBER_CLICKED); 278 dialNumber.add(null); 279 cursor.addRow(dialNumber); 280 } 281 282 ArrayList<Object> createContact = new ArrayList<Object>(); 283 createContact.add(1); // _id 284 s = r.getString(com.android.internal.R.string.create_contact_using, searchClause); 285 i = s.indexOf('\n'); 286 if (i < 0) { 287 createContact.add(s); 288 createContact.add(""); 289 } else { 290 createContact.add(s.substring(0, i)); 291 createContact.add(s.substring(i + 1)); 292 } 293 createContact.add(String.valueOf(com.android.internal.R.drawable.create_contact)); 294 createContact.add("tel:" + searchClause); 295 createContact.add(ContactsContract.Intents.SEARCH_SUGGESTION_CREATE_CONTACT_CLICKED); 296 createContact.add(SearchManager.SUGGEST_NEVER_MAKE_SHORTCUT); 297 cursor.addRow(createContact); 298 299 return cursor; 300 } 301 302 private Cursor buildCursorForSearchSuggestionsBasedOnFilter(SQLiteDatabase db, 303 String[] projection, String selection, String filter, String limit) { 304 MatrixCursor cursor = new MatrixCursor( 305 projection != null ? projection : SEARCH_SUGGESTIONS_BASED_ON_NAME_COLUMNS); 306 StringBuilder sb = new StringBuilder(); 307 final boolean haveFilter = !TextUtils.isEmpty(filter); 308 sb.append("SELECT " 309 + Contacts._ID + ", " 310 + Contacts.LOOKUP_KEY + ", " 311 + Contacts.PHOTO_THUMBNAIL_URI + ", " 312 + Contacts.DISPLAY_NAME + ", " 313 + PRESENCE_SQL + " AS " + Contacts.CONTACT_PRESENCE + ", " 314 + Contacts.LAST_TIME_CONTACTED); 315 if (haveFilter) { 316 sb.append(", " + SearchSnippetColumns.SNIPPET); 317 } 318 sb.append(" FROM "); 319 sb.append(Views.CONTACTS); 320 sb.append(" AS contacts"); 321 if (haveFilter) { 322 mContactsProvider.appendSearchIndexJoin(sb, filter, true, 323 String.valueOf(SNIPPET_START_MATCH), String.valueOf(SNIPPET_END_MATCH), 324 SNIPPET_ELLIPSIS, SNIPPET_MAX_TOKENS, false); 325 } 326 if (selection != null) { 327 sb.append(" WHERE ").append(selection); 328 } 329 sb.append(" ORDER BY " + SORT_ORDER); 330 if (limit != null) { 331 sb.append(" LIMIT " + limit); 332 } 333 Cursor c = db.rawQuery(sb.toString(), null); 334 SearchSuggestion suggestion = new SearchSuggestion(); 335 suggestion.filter = filter; 336 try { 337 while (c.moveToNext()) { 338 suggestion.contactId = c.getLong(0); 339 suggestion.lookupKey = c.getString(1); 340 suggestion.photoUri = c.getString(2); 341 suggestion.text1 = c.getString(3); 342 suggestion.presence = c.isNull(4) ? -1 : c.getInt(4); 343 suggestion.lastAccessTime = c.getString(5); 344 if (haveFilter) { 345 suggestion.text2 = shortenSnippet(c.getString(6)); 346 } 347 cursor.addRow(suggestion.asList(projection)); 348 } 349 } finally { 350 c.close(); 351 } 352 return cursor; 353 } 354 355 private String shortenSnippet(final String snippet) { 356 if (snippet == null) { 357 return null; 358 } 359 360 int from = 0; 361 int to = snippet.length(); 362 int start = snippet.indexOf(SNIPPET_START_MATCH); 363 if (start == -1) { 364 return null; 365 } 366 367 int firstNl = snippet.lastIndexOf('\n', start); 368 if (firstNl != -1) { 369 from = firstNl + 1; 370 } 371 int end = snippet.lastIndexOf(SNIPPET_END_MATCH); 372 if (end != -1) { 373 int lastNl = snippet.indexOf('\n', end); 374 if (lastNl != -1) { 375 to = lastNl; 376 } 377 } 378 379 StringBuilder sb = new StringBuilder(); 380 for (int i = from; i < to; i++) { 381 char c = snippet.charAt(i); 382 if (c != SNIPPET_START_MATCH && c != SNIPPET_END_MATCH) { 383 sb.append(c); 384 } 385 } 386 return sb.toString(); 387 } 388} 389