SearchableSource.java revision 7a9b12c46680a5b88b5d9fcbcc553d0d5b74cb63
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 final 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 mIconLoader = createIconLoader(context, searchable.getSuggestPackage()); 85 } 86 87 protected Context getContext() { 88 return mContext; 89 } 90 91 protected SearchableInfo getSearchableInfo() { 92 return mSearchable; 93 } 94 95 /** 96 * Checks if the current process can read the suggestion provider in this source. 97 */ 98 public boolean canRead() { 99 String authority = mSearchable.getSuggestAuthority(); 100 if (authority == null) { 101 Log.w(TAG, getName() + " has no searchSuggestAuthority"); 102 return false; 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 createIconLoader(Context context, String providerPackage) { 165 if (providerPackage == null) return null; 166 return new CachingIconLoader(new PackageIconLoader(context, providerPackage)); 167 } 168 169 public ComponentName getComponentName() { 170 return mSearchable.getSearchActivity(); 171 } 172 173 public int getVersionCode() { 174 return mVersionCode; 175 } 176 177 public String getName() { 178 return mName; 179 } 180 181 public Drawable getIcon(String drawableId) { 182 return mIconLoader == null ? null : mIconLoader.getIcon(drawableId); 183 } 184 185 public Uri getIconUri(String drawableId) { 186 return mIconLoader == null ? null : mIconLoader.getIconUri(drawableId); 187 } 188 189 public CharSequence getLabel() { 190 if (mLabel == null) { 191 // Load label lazily 192 mLabel = mActivityInfo.loadLabel(mContext.getPackageManager()); 193 } 194 return mLabel; 195 } 196 197 public CharSequence getHint() { 198 return getText(mSearchable.getHintId()); 199 } 200 201 public int getQueryThreshold() { 202 return mSearchable.getSuggestThreshold(); 203 } 204 205 public CharSequence getSettingsDescription() { 206 return getText(mSearchable.getSettingsDescriptionId()); 207 } 208 209 public Drawable getSourceIcon() { 210 if (mSourceIcon == null) { 211 // Load icon lazily 212 int iconRes = getSourceIconResource(); 213 PackageManager pm = mContext.getPackageManager(); 214 Drawable icon = pm.getDrawable(mActivityInfo.packageName, iconRes, 215 mActivityInfo.applicationInfo); 216 // Can't share Drawable instances, save constant state instead. 217 mSourceIcon = (icon != null) ? icon.getConstantState() : null; 218 // Optimization, return the Drawable the first time 219 return icon; 220 } 221 return (mSourceIcon != null) ? mSourceIcon.newDrawable() : null; 222 } 223 224 public Uri getSourceIconUri() { 225 int resourceId = getSourceIconResource(); 226 return Util.getResourceUri(getContext(), mActivityInfo.applicationInfo, resourceId); 227 } 228 229 private int getSourceIconResource() { 230 int icon = mActivityInfo.getIconResource(); 231 return (icon != 0) ? icon : android.R.drawable.sym_def_app_icon; 232 } 233 234 public boolean voiceSearchEnabled() { 235 return mSearchable.getVoiceSearchEnabled(); 236 } 237 238 // TODO: not all apps handle ACTION_SEARCH properly, e.g. ApplicationsProvider. 239 // Maybe we should add a flag to searchable, so that QSB can hide the search button? 240 public Intent createSearchIntent(String query, Bundle appData) { 241 Intent intent = new Intent(Intent.ACTION_SEARCH); 242 intent.setComponent(getComponentName()); 243 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 244 // We need CLEAR_TOP to avoid reusing an old task that has other activities 245 // on top of the one we want. 246 intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); 247 intent.putExtra(SearchManager.USER_QUERY, query); 248 intent.putExtra(SearchManager.QUERY, query); 249 if (appData != null) { 250 intent.putExtra(SearchManager.APP_DATA, appData); 251 } 252 return intent; 253 } 254 255 public Intent createVoiceSearchIntent(Bundle appData) { 256 if (mSearchable.getVoiceSearchLaunchWebSearch()) { 257 return WebCorpus.createVoiceWebSearchIntent(appData); 258 } else if (mSearchable.getVoiceSearchLaunchRecognizer()) { 259 return createVoiceAppSearchIntent(appData); 260 } 261 return null; 262 } 263 264 /** 265 * Create and return an Intent that can launch the voice search activity, perform a specific 266 * voice transcription, and forward the results to the searchable activity. 267 * 268 * This code is copied from SearchDialog 269 * 270 * @return A completely-configured intent ready to send to the voice search activity 271 */ 272 private Intent createVoiceAppSearchIntent(Bundle appData) { 273 ComponentName searchActivity = mSearchable.getSearchActivity(); 274 275 // create the necessary intent to set up a search-and-forward operation 276 // in the voice search system. We have to keep the bundle separate, 277 // because it becomes immutable once it enters the PendingIntent 278 Intent queryIntent = new Intent(Intent.ACTION_SEARCH); 279 queryIntent.setComponent(searchActivity); 280 PendingIntent pending = PendingIntent.getActivity( 281 getContext(), 0, queryIntent, PendingIntent.FLAG_ONE_SHOT); 282 283 // Now set up the bundle that will be inserted into the pending intent 284 // when it's time to do the search. We always build it here (even if empty) 285 // because the voice search activity will always need to insert "QUERY" into 286 // it anyway. 287 Bundle queryExtras = new Bundle(); 288 if (appData != null) { 289 queryExtras.putBundle(SearchManager.APP_DATA, appData); 290 } 291 292 // Now build the intent to launch the voice search. Add all necessary 293 // extras to launch the voice recognizer, and then all the necessary extras 294 // to forward the results to the searchable activity 295 Intent voiceIntent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH); 296 voiceIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 297 298 // Add all of the configuration options supplied by the searchable's metadata 299 String languageModel = getString(mSearchable.getVoiceLanguageModeId()); 300 if (languageModel == null) { 301 languageModel = RecognizerIntent.LANGUAGE_MODEL_FREE_FORM; 302 } 303 String prompt = getString(mSearchable.getVoicePromptTextId()); 304 String language = getString(mSearchable.getVoiceLanguageId()); 305 int maxResults = mSearchable.getVoiceMaxResults(); 306 if (maxResults <= 0) { 307 maxResults = 1; 308 } 309 310 voiceIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, languageModel); 311 voiceIntent.putExtra(RecognizerIntent.EXTRA_PROMPT, prompt); 312 voiceIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, language); 313 voiceIntent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, maxResults); 314 voiceIntent.putExtra(EXTRA_CALLING_PACKAGE, 315 searchActivity == null ? null : searchActivity.toShortString()); 316 317 // Add the values that configure forwarding the results 318 voiceIntent.putExtra(RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT, pending); 319 voiceIntent.putExtra(RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT_BUNDLE, queryExtras); 320 321 return voiceIntent; 322 } 323 324 public SourceResult getSuggestions(String query, int queryLimit) { 325 try { 326 Cursor cursor = getSuggestions(mContext, mSearchable, query, queryLimit); 327 if (DBG) Log.d(TAG, toString() + "[" + query + "] returned."); 328 return new CursorBackedSourceResult(query, cursor); 329 } catch (RuntimeException ex) { 330 Log.e(TAG, toString() + "[" + query + "] failed", ex); 331 return new CursorBackedSourceResult(query); 332 } 333 } 334 335 public SuggestionCursor refreshShortcut(String shortcutId, String extraData) { 336 Cursor cursor = null; 337 try { 338 cursor = getValidationCursor(mContext, mSearchable, shortcutId, extraData); 339 if (DBG) Log.d(TAG, toString() + "[" + shortcutId + "] returned."); 340 if (cursor != null && cursor.getCount() > 0) { 341 cursor.moveToFirst(); 342 } 343 return new CursorBackedSourceResult(null, cursor); 344 } catch (RuntimeException ex) { 345 Log.e(TAG, toString() + "[" + shortcutId + "] failed", ex); 346 if (cursor != null) { 347 cursor.close(); 348 } 349 // TODO: Should we delete the shortcut even if the failure is temporary? 350 return null; 351 } 352 } 353 354 private class CursorBackedSourceResult extends CursorBackedSuggestionCursor 355 implements SourceResult { 356 357 public CursorBackedSourceResult(String userQuery) { 358 this(userQuery, null); 359 } 360 361 public CursorBackedSourceResult(String userQuery, Cursor cursor) { 362 super(userQuery, cursor); 363 } 364 365 public Source getSource() { 366 return SearchableSource.this; 367 } 368 369 @Override 370 public Source getSuggestionSource() { 371 return SearchableSource.this; 372 } 373 374 public boolean isSuggestionShortcut() { 375 return false; 376 } 377 378 @Override 379 public String toString() { 380 return SearchableSource.this + "[" + getUserQuery() + "]"; 381 } 382 383 } 384 385 /** 386 * This is a copy of {@link SearchManager#getSuggestions(SearchableInfo, String)}. 387 */ 388 private static Cursor getSuggestions(Context context, SearchableInfo searchable, String query, 389 int queryLimit) { 390 if (searchable == null) { 391 return null; 392 } 393 394 String authority = searchable.getSuggestAuthority(); 395 if (authority == null) { 396 return null; 397 } 398 399 Uri.Builder uriBuilder = new Uri.Builder() 400 .scheme(ContentResolver.SCHEME_CONTENT) 401 .authority(authority); 402 403 // if content path provided, insert it now 404 final String contentPath = searchable.getSuggestPath(); 405 if (contentPath != null) { 406 uriBuilder.appendEncodedPath(contentPath); 407 } 408 409 // append standard suggestion query path 410 uriBuilder.appendPath(SearchManager.SUGGEST_URI_PATH_QUERY); 411 412 // get the query selection, may be null 413 String selection = searchable.getSuggestSelection(); 414 // inject query, either as selection args or inline 415 String[] selArgs = null; 416 if (selection != null) { // use selection if provided 417 selArgs = new String[] { query }; 418 } else { // no selection, use REST pattern 419 uriBuilder.appendPath(query); 420 } 421 422 uriBuilder.appendQueryParameter("limit", String.valueOf(queryLimit)); 423 424 Uri uri = uriBuilder.build(); 425 426 // finally, make the query 427 if (DBG) { 428 Log.d(TAG, "query(" + uri + ",null," + selection + "," 429 + Arrays.toString(selArgs) + ",null)"); 430 } 431 return context.getContentResolver().query(uri, null, selection, selArgs, null); 432 } 433 434 private static Cursor getValidationCursor(Context context, SearchableInfo searchable, 435 String shortcutId, String extraData) { 436 String authority = searchable.getSuggestAuthority(); 437 if (authority == null) { 438 return null; 439 } 440 441 Uri.Builder uriBuilder = new Uri.Builder() 442 .scheme(ContentResolver.SCHEME_CONTENT) 443 .authority(authority); 444 445 // if content path provided, insert it now 446 final String contentPath = searchable.getSuggestPath(); 447 if (contentPath != null) { 448 uriBuilder.appendEncodedPath(contentPath); 449 } 450 451 // append the shortcut path and id 452 uriBuilder.appendPath(SearchManager.SUGGEST_URI_PATH_SHORTCUT); 453 uriBuilder.appendPath(shortcutId); 454 455 Uri uri = uriBuilder 456 .appendQueryParameter(SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA, extraData) 457 .build(); 458 459 if (DBG) Log.d(TAG, "Requesting refresh " + uri); 460 // finally, make the query 461 return context.getContentResolver().query(uri, null, null, null, null); 462 } 463 464 public boolean isWebSuggestionSource() { 465 return false; 466 } 467 468 public boolean queryAfterZeroResults() { 469 return mSearchable.queryAfterZeroResults(); 470 } 471 472 public boolean shouldRewriteQueryFromData() { 473 return mSearchable.shouldRewriteQueryFromData(); 474 } 475 476 public boolean shouldRewriteQueryFromText() { 477 return mSearchable.shouldRewriteQueryFromText(); 478 } 479 480 @Override 481 public boolean equals(Object o) { 482 if (o != null && o.getClass().equals(this.getClass())) { 483 SearchableSource s = (SearchableSource) o; 484 return s.mName.equals(mName); 485 } 486 return false; 487 } 488 489 @Override 490 public int hashCode() { 491 return mName.hashCode(); 492 } 493 494 @Override 495 public String toString() { 496 return "SearchableSource{component=" + getName() + "}"; 497 } 498 499 public String getDefaultIntentAction() { 500 return mSearchable.getSuggestIntentAction(); 501 } 502 503 public String getDefaultIntentData() { 504 return mSearchable.getSuggestIntentData(); 505 } 506 507 private CharSequence getText(int id) { 508 if (id == 0) return null; 509 return mContext.getPackageManager().getText(mActivityInfo.packageName, id, 510 mActivityInfo.applicationInfo); 511 } 512 513 private String getString(int id) { 514 CharSequence text = getText(id); 515 return text == null ? null : text.toString(); 516 } 517} 518