SearchableSource.java revision cc10dcf07c4e787abd66f4e2cfed31a09580a3b0
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.quicksearchbox; 18 19import android.app.PendingIntent; 20import android.app.SearchManager; 21import android.app.SearchableInfo; 22import android.content.ComponentName; 23import android.content.ContentResolver; 24import android.content.Context; 25import android.content.Intent; 26import android.content.pm.ActivityInfo; 27import android.content.pm.PackageInfo; 28import android.content.pm.PackageManager; 29import android.content.pm.PathPermission; 30import android.content.pm.ProviderInfo; 31import android.content.pm.PackageManager.NameNotFoundException; 32import android.database.Cursor; 33import android.graphics.drawable.Drawable; 34import android.net.Uri; 35import android.os.Bundle; 36import android.speech.RecognizerIntent; 37import android.util.Log; 38 39import java.util.Arrays; 40 41/** 42 * Represents a single suggestion source, e.g. Contacts. 43 * 44 */ 45public class SearchableSource implements Source { 46 47 private static final boolean DBG = true; 48 private static final String TAG = "QSB.SearchableSource"; 49 50 // TODO: This should be exposed or moved to android-common, see http://b/issue?id=2440614 51 // The extra key used in an intent to the speech recognizer for in-app voice search. 52 private static final String EXTRA_CALLING_PACKAGE = "calling_package"; 53 54 private final Context mContext; 55 56 private final SearchableInfo mSearchable; 57 58 private final String mName; 59 60 private final ActivityInfo mActivityInfo; 61 62 private final int mVersionCode; 63 64 // Cached label for the activity 65 private CharSequence mLabel = null; 66 67 // Cached icon for the activity 68 private Drawable.ConstantState mSourceIcon = null; 69 70 private final IconLoader mIconLoader; 71 72 public SearchableSource(Context context, SearchableInfo searchable) 73 throws NameNotFoundException { 74 ComponentName componentName = searchable.getSearchActivity(); 75 mContext = context; 76 mSearchable = searchable; 77 mName = componentName.flattenToShortString(); 78 PackageManager pm = context.getPackageManager(); 79 mActivityInfo = pm.getActivityInfo(componentName, 0); 80 PackageInfo pkgInfo = pm.getPackageInfo(componentName.getPackageName(), 0); 81 mVersionCode = pkgInfo.versionCode; 82 mIconLoader = createIconLoader(context, searchable.getSuggestPackage()); 83 } 84 85 protected Context getContext() { 86 return mContext; 87 } 88 89 protected SearchableInfo getSearchableInfo() { 90 return mSearchable; 91 } 92 93 /** 94 * Checks if the current process can read the suggestion provider in this source. 95 */ 96 public boolean canRead() { 97 String authority = mSearchable.getSuggestAuthority(); 98 if (authority == null) { 99 Log.w(TAG, getName() + " has no searchSuggestAuthority"); 100 return false; 101 } 102 103 Uri.Builder uriBuilder = new Uri.Builder() 104 .scheme(ContentResolver.SCHEME_CONTENT) 105 .authority(authority); 106 // if content path provided, insert it now 107 String contentPath = mSearchable.getSuggestPath(); 108 if (contentPath != null) { 109 uriBuilder.appendEncodedPath(contentPath); 110 } 111 // append standard suggestion query path 112 uriBuilder.appendEncodedPath(SearchManager.SUGGEST_URI_PATH_QUERY); 113 Uri uri = uriBuilder.build(); 114 return canRead(uri); 115 } 116 117 /** 118 * Checks if the current process can read the given content URI. 119 * 120 * TODO: Shouldn't this be a PackageManager / Context / ContentResolver method? 121 */ 122 private boolean canRead(Uri uri) { 123 ProviderInfo provider = mContext.getPackageManager().resolveContentProvider( 124 uri.getAuthority(), 0); 125 if (provider == null) { 126 Log.w(TAG, getName() + " has bad suggestion authority " + uri.getAuthority()); 127 return false; 128 } 129 String readPermission = provider.readPermission; 130 if (readPermission == null) { 131 // No permission required to read anything in the content provider 132 return true; 133 } 134 int pid = android.os.Process.myPid(); 135 int uid = android.os.Process.myUid(); 136 if (mContext.checkPermission(readPermission, pid, uid) 137 == PackageManager.PERMISSION_GRANTED) { 138 // We have permission to read everything in the content provider 139 return true; 140 } 141 PathPermission[] pathPermissions = provider.pathPermissions; 142 if (pathPermissions == null || pathPermissions.length == 0) { 143 // We don't have the readPermission, and there are no pathPermissions 144 if (DBG) Log.d(TAG, "Missing " + readPermission); 145 return false; 146 } 147 String path = uri.getPath(); 148 for (PathPermission perm : pathPermissions) { 149 String pathReadPermission = perm.getReadPermission(); 150 if (pathReadPermission != null 151 && perm.match(path) 152 && mContext.checkPermission(pathReadPermission, pid, uid) 153 == PackageManager.PERMISSION_GRANTED) { 154 // We have the path permission 155 return true; 156 } 157 } 158 if (DBG) Log.d(TAG, "Missing " + readPermission + " and no path permission applies"); 159 return false; 160 } 161 162 private IconLoader createIconLoader(Context context, String providerPackage) { 163 if (providerPackage == null) return null; 164 try { 165 return new CachingIconLoader(new PackageIconLoader(context, providerPackage)); 166 } catch (PackageManager.NameNotFoundException ex) { 167 Log.e(TAG, "Suggestion provider package not found: " + providerPackage); 168 return null; 169 } 170 } 171 172 public ComponentName getComponentName() { 173 return mSearchable.getSearchActivity(); 174 } 175 176 public int getVersionCode() { 177 return mVersionCode; 178 } 179 180 public String getName() { 181 return mName; 182 } 183 184 public Drawable getIcon(String drawableId) { 185 return mIconLoader == null ? null : mIconLoader.getIcon(drawableId); 186 } 187 188 public Uri getIconUri(String drawableId) { 189 return mIconLoader == null ? null : mIconLoader.getIconUri(drawableId); 190 } 191 192 public CharSequence getLabel() { 193 if (mLabel == null) { 194 // Load label lazily 195 mLabel = mActivityInfo.loadLabel(mContext.getPackageManager()); 196 } 197 return mLabel; 198 } 199 200 public CharSequence getHint() { 201 return getText(mSearchable.getHintId()); 202 } 203 204 public int getQueryThreshold() { 205 return mSearchable.getSuggestThreshold(); 206 } 207 208 public CharSequence getSettingsDescription() { 209 return getText(mSearchable.getSettingsDescriptionId()); 210 } 211 212 public Drawable getSourceIcon() { 213 if (mSourceIcon == null) { 214 // Load icon lazily 215 int iconRes = getSourceIconResource(); 216 PackageManager pm = mContext.getPackageManager(); 217 Drawable icon = pm.getDrawable(mActivityInfo.packageName, iconRes, 218 mActivityInfo.applicationInfo); 219 // Can't share Drawable instances, save constant state instead. 220 mSourceIcon = (icon != null) ? icon.getConstantState() : null; 221 // Optimization, return the Drawable the first time 222 return icon; 223 } 224 return (mSourceIcon != null) ? mSourceIcon.newDrawable() : null; 225 } 226 227 public Uri getSourceIconUri() { 228 int resourceId = getSourceIconResource(); 229 return new Uri.Builder() 230 .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) 231 .authority(getComponentName().getPackageName()) 232 .appendEncodedPath(String.valueOf(resourceId)) 233 .build(); 234 } 235 236 private int getSourceIconResource() { 237 int icon = mActivityInfo.getIconResource(); 238 return (icon != 0) ? icon : android.R.drawable.sym_def_app_icon; 239 } 240 241 public boolean voiceSearchEnabled() { 242 return mSearchable.getVoiceSearchEnabled(); 243 } 244 245 // TODO: not all apps handle ACTION_SEARCH properly, e.g. ApplicationsProvider. 246 // Maybe we should add a flag to searchable, so that QSB can hide the search button? 247 public Intent createSearchIntent(String query, Bundle appData) { 248 Intent intent = new Intent(Intent.ACTION_SEARCH); 249 intent.setComponent(getComponentName()); 250 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 251 // We need CLEAR_TOP to avoid reusing an old task that has other activities 252 // on top of the one we want. 253 intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); 254 intent.putExtra(SearchManager.USER_QUERY, query); 255 intent.putExtra(SearchManager.QUERY, query); 256 if (appData != null) { 257 intent.putExtra(SearchManager.APP_DATA, appData); 258 } 259 return intent; 260 } 261 262 public Intent createVoiceSearchIntent(Bundle appData) { 263 if (mSearchable.getVoiceSearchLaunchWebSearch()) { 264 return WebCorpus.createVoiceWebSearchIntent(appData); 265 } else if (mSearchable.getVoiceSearchLaunchRecognizer()) { 266 return createVoiceAppSearchIntent(appData); 267 } 268 return null; 269 } 270 271 /** 272 * Create and return an Intent that can launch the voice search activity, perform a specific 273 * voice transcription, and forward the results to the searchable activity. 274 * 275 * This code is copied from SearchDialog 276 * 277 * @return A completely-configured intent ready to send to the voice search activity 278 */ 279 private Intent createVoiceAppSearchIntent(Bundle appData) { 280 ComponentName searchActivity = mSearchable.getSearchActivity(); 281 282 // create the necessary intent to set up a search-and-forward operation 283 // in the voice search system. We have to keep the bundle separate, 284 // because it becomes immutable once it enters the PendingIntent 285 Intent queryIntent = new Intent(Intent.ACTION_SEARCH); 286 queryIntent.setComponent(searchActivity); 287 PendingIntent pending = PendingIntent.getActivity( 288 getContext(), 0, queryIntent, PendingIntent.FLAG_ONE_SHOT); 289 290 // Now set up the bundle that will be inserted into the pending intent 291 // when it's time to do the search. We always build it here (even if empty) 292 // because the voice search activity will always need to insert "QUERY" into 293 // it anyway. 294 Bundle queryExtras = new Bundle(); 295 if (appData != null) { 296 queryExtras.putBundle(SearchManager.APP_DATA, appData); 297 } 298 299 // Now build the intent to launch the voice search. Add all necessary 300 // extras to launch the voice recognizer, and then all the necessary extras 301 // to forward the results to the searchable activity 302 Intent voiceIntent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH); 303 voiceIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 304 305 // Add all of the configuration options supplied by the searchable's metadata 306 String languageModel = getString(mSearchable.getVoiceLanguageModeId()); 307 if (languageModel == null) { 308 languageModel = RecognizerIntent.LANGUAGE_MODEL_FREE_FORM; 309 } 310 String prompt = getString(mSearchable.getVoicePromptTextId()); 311 String language = getString(mSearchable.getVoiceLanguageId()); 312 int maxResults = mSearchable.getVoiceMaxResults(); 313 if (maxResults <= 0) { 314 maxResults = 1; 315 } 316 317 voiceIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, languageModel); 318 voiceIntent.putExtra(RecognizerIntent.EXTRA_PROMPT, prompt); 319 voiceIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, language); 320 voiceIntent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, maxResults); 321 voiceIntent.putExtra(EXTRA_CALLING_PACKAGE, 322 searchActivity == null ? null : searchActivity.toShortString()); 323 324 // Add the values that configure forwarding the results 325 voiceIntent.putExtra(RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT, pending); 326 voiceIntent.putExtra(RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT_BUNDLE, queryExtras); 327 328 return voiceIntent; 329 } 330 331 public SourceResult getSuggestions(String query, int queryLimit) { 332 try { 333 Cursor cursor = getSuggestions(mContext, mSearchable, query, queryLimit); 334 if (DBG) Log.d(TAG, toString() + "[" + query + "] returned."); 335 return new CursorBackedSourceResult(query, cursor); 336 } catch (RuntimeException ex) { 337 Log.e(TAG, toString() + "[" + query + "] failed", ex); 338 return new CursorBackedSourceResult(query); 339 } 340 } 341 342 public SuggestionCursor refreshShortcut(String shortcutId, String extraData) { 343 Cursor cursor = null; 344 try { 345 cursor = getValidationCursor(mContext, mSearchable, shortcutId, extraData); 346 if (DBG) Log.d(TAG, toString() + "[" + shortcutId + "] returned."); 347 if (cursor != null && cursor.getCount() > 0) { 348 cursor.moveToFirst(); 349 } 350 return new CursorBackedSourceResult(null, cursor); 351 } catch (RuntimeException ex) { 352 Log.e(TAG, toString() + "[" + shortcutId + "] failed", ex); 353 if (cursor != null) { 354 cursor.close(); 355 } 356 // TODO: Should we delete the shortcut even if the failure is temporary? 357 return null; 358 } 359 } 360 361 private class CursorBackedSourceResult extends CursorBackedSuggestionCursor 362 implements SourceResult { 363 364 public CursorBackedSourceResult(String userQuery) { 365 this(userQuery, null); 366 } 367 368 public CursorBackedSourceResult(String userQuery, Cursor cursor) { 369 super(userQuery, cursor); 370 } 371 372 public Source getSource() { 373 return SearchableSource.this; 374 } 375 376 @Override 377 public Source getSuggestionSource() { 378 return SearchableSource.this; 379 } 380 381 public boolean isSuggestionShortcut() { 382 return false; 383 } 384 385 @Override 386 public String toString() { 387 return SearchableSource.this + "[" + getUserQuery() + "]"; 388 } 389 390 } 391 392 /** 393 * This is a copy of {@link SearchManager#getSuggestions(SearchableInfo, String)}. 394 */ 395 private static Cursor getSuggestions(Context context, SearchableInfo searchable, String query, 396 int queryLimit) { 397 if (searchable == null) { 398 return null; 399 } 400 401 String authority = searchable.getSuggestAuthority(); 402 if (authority == null) { 403 return null; 404 } 405 406 Uri.Builder uriBuilder = new Uri.Builder() 407 .scheme(ContentResolver.SCHEME_CONTENT) 408 .authority(authority); 409 410 // if content path provided, insert it now 411 final String contentPath = searchable.getSuggestPath(); 412 if (contentPath != null) { 413 uriBuilder.appendEncodedPath(contentPath); 414 } 415 416 // append standard suggestion query path 417 uriBuilder.appendPath(SearchManager.SUGGEST_URI_PATH_QUERY); 418 419 // get the query selection, may be null 420 String selection = searchable.getSuggestSelection(); 421 // inject query, either as selection args or inline 422 String[] selArgs = null; 423 if (selection != null) { // use selection if provided 424 selArgs = new String[] { query }; 425 } else { // no selection, use REST pattern 426 uriBuilder.appendPath(query); 427 } 428 429 uriBuilder.appendQueryParameter("limit", String.valueOf(queryLimit)); 430 431 Uri uri = uriBuilder.build(); 432 433 // finally, make the query 434 if (DBG) { 435 Log.d(TAG, "query(" + uri + ",null," + selection + "," 436 + Arrays.toString(selArgs) + ",null)"); 437 } 438 return context.getContentResolver().query(uri, null, selection, selArgs, null); 439 } 440 441 private static Cursor getValidationCursor(Context context, SearchableInfo searchable, 442 String shortcutId, String extraData) { 443 String authority = searchable.getSuggestAuthority(); 444 if (authority == null) { 445 return null; 446 } 447 448 Uri.Builder uriBuilder = new Uri.Builder() 449 .scheme(ContentResolver.SCHEME_CONTENT) 450 .authority(authority); 451 452 // if content path provided, insert it now 453 final String contentPath = searchable.getSuggestPath(); 454 if (contentPath != null) { 455 uriBuilder.appendEncodedPath(contentPath); 456 } 457 458 // append the shortcut path and id 459 uriBuilder.appendPath(SearchManager.SUGGEST_URI_PATH_SHORTCUT); 460 uriBuilder.appendPath(shortcutId); 461 462 Uri uri = uriBuilder 463 .appendQueryParameter(SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA, extraData) 464 .build(); 465 466 if (DBG) Log.d(TAG, "Requesting refresh " + uri); 467 // finally, make the query 468 return context.getContentResolver().query(uri, null, null, null, null); 469 } 470 471 public boolean isWebSuggestionSource() { 472 return false; 473 } 474 475 public boolean queryAfterZeroResults() { 476 return mSearchable.queryAfterZeroResults(); 477 } 478 479 public boolean shouldRewriteQueryFromData() { 480 return mSearchable.shouldRewriteQueryFromData(); 481 } 482 483 public boolean shouldRewriteQueryFromText() { 484 return mSearchable.shouldRewriteQueryFromText(); 485 } 486 487 @Override 488 public boolean equals(Object o) { 489 if (o != null && o.getClass().equals(this.getClass())) { 490 SearchableSource s = (SearchableSource) o; 491 return s.mName.equals(mName); 492 } 493 return false; 494 } 495 496 @Override 497 public int hashCode() { 498 return mName.hashCode(); 499 } 500 501 @Override 502 public String toString() { 503 return "SearchableSource{component=" + getName() + "}"; 504 } 505 506 public String getDefaultIntentAction() { 507 return mSearchable.getSuggestIntentAction(); 508 } 509 510 public String getDefaultIntentData() { 511 return mSearchable.getSuggestIntentData(); 512 } 513 514 public String getSuggestActionMsg(int keyCode) { 515 SearchableInfo.ActionKeyInfo actionKey = mSearchable.findActionKey(keyCode); 516 if (actionKey == null) return null; 517 return actionKey.getSuggestActionMsg(); 518 } 519 520 public String getSuggestActionMsgColumn(int keyCode) { 521 SearchableInfo.ActionKeyInfo actionKey = mSearchable.findActionKey(keyCode); 522 if (actionKey == null) return null; 523 return actionKey.getSuggestActionMsgColumn(); 524 } 525 526 private CharSequence getText(int id) { 527 if (id == 0) return null; 528 return mContext.getPackageManager().getText(mActivityInfo.packageName, id, 529 mActivityInfo.applicationInfo); 530 } 531 532 private String getString(int id) { 533 CharSequence text = getText(id); 534 return text == null ? null : text.toString(); 535 } 536} 537