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