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