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