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