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