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