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.quicksearchbox; 18 19 import com.android.quicksearchbox.util.Util; 20 21 import android.app.PendingIntent; 22 import android.app.SearchManager; 23 import android.app.SearchableInfo; 24 import android.content.ComponentName; 25 import android.content.ContentResolver; 26 import android.content.Context; 27 import android.content.Intent; 28 import android.content.pm.ActivityInfo; 29 import android.content.pm.PackageInfo; 30 import android.content.pm.PackageManager; 31 import android.content.pm.PathPermission; 32 import android.content.pm.ProviderInfo; 33 import android.content.pm.PackageManager.NameNotFoundException; 34 import android.database.Cursor; 35 import android.graphics.drawable.Drawable; 36 import android.net.Uri; 37 import android.os.Bundle; 38 import android.speech.RecognizerIntent; 39 import android.util.Log; 40 41 import java.util.Arrays; 42 43 /** 44 * Represents a single suggestion source, e.g. Contacts. 45 * 46 */ 47 public 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 public Intent createSearchIntent(String query, Bundle appData) { 239 return createSourceSearchIntent(getComponentName(), query, appData); 240 } 241 242 public static Intent createSourceSearchIntent(ComponentName activity, String query, 243 Bundle appData) { 244 if (activity == null) { 245 Log.w(TAG, "Tried to create search intent with no target activity"); 246 return null; 247 } 248 Intent intent = new Intent(Intent.ACTION_SEARCH); 249 intent.setComponent(activity); 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 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