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.Util; 20 21 import android.app.PendingIntent; 22 import android.app.SearchManager; 23 import android.app.SearchableInfo; 24 import android.content.ComponentName; 25 import android.content.ContentResolver; 26 import android.content.Context; 27 import android.content.Intent; 28 import android.content.pm.ActivityInfo; 29 import android.content.pm.PackageInfo; 30 import android.content.pm.PackageManager; 31 import android.content.pm.PathPermission; 32 import android.content.pm.ProviderInfo; 33 import android.content.pm.PackageManager.NameNotFoundException; 34 import android.database.Cursor; 35 import android.graphics.drawable.Drawable; 36 import android.net.Uri; 37 import android.os.Bundle; 38 import android.speech.RecognizerIntent; 39 import android.util.Log; 40 41 import java.util.Arrays; 42 43 /** 44 * Represents a single suggestion source, e.g. Contacts. 45 */ 46 public 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 Drawable icon = loadSourceIcon(); 202 if (icon == null) { 203 icon = getContext().getResources().getDrawable(R.drawable.corpus_icon_default); 204 } 205 // Can't share Drawable instances, save constant state instead. 206 mSourceIcon = (icon != null) ? icon.getConstantState() : null; 207 // Optimization, return the Drawable the first time 208 return icon; 209 } 210 return (mSourceIcon != null) ? mSourceIcon.newDrawable() : null; 211 } 212 213 private Drawable loadSourceIcon() { 214 int iconRes = getSourceIconResource(); 215 if (iconRes == 0) return null; 216 PackageManager pm = getContext().getPackageManager(); 217 return pm.getDrawable(mActivityInfo.packageName, iconRes, 218 mActivityInfo.applicationInfo); 219 } 220 221 public Uri getSourceIconUri() { 222 int resourceId = getSourceIconResource(); 223 if (resourceId == 0) { 224 return Util.getResourceUri(getContext(), R.drawable.corpus_icon_default); 225 } else { 226 return Util.getResourceUri(getContext(), mActivityInfo.applicationInfo, resourceId); 227 } 228 } 229 230 private int getSourceIconResource() { 231 return mActivityInfo.getIconResource(); 232 } 233 234 public boolean voiceSearchEnabled() { 235 return mSearchable.getVoiceSearchEnabled(); 236 } 237 238 public boolean isLocationAware() { 239 return false; 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 /** 342 * This is a copy of {@link SearchManager#getSuggestions(SearchableInfo, String)}. 343 */ 344 private static Cursor getSuggestions(Context context, SearchableInfo searchable, String query, 345 int queryLimit) { 346 if (searchable == null) { 347 return null; 348 } 349 350 String authority = searchable.getSuggestAuthority(); 351 if (authority == null) { 352 return null; 353 } 354 355 Uri.Builder uriBuilder = new Uri.Builder() 356 .scheme(ContentResolver.SCHEME_CONTENT) 357 .authority(authority); 358 359 // if content path provided, insert it now 360 final String contentPath = searchable.getSuggestPath(); 361 if (contentPath != null) { 362 uriBuilder.appendEncodedPath(contentPath); 363 } 364 365 // append standard suggestion query path 366 uriBuilder.appendPath(SearchManager.SUGGEST_URI_PATH_QUERY); 367 368 // get the query selection, may be null 369 String selection = searchable.getSuggestSelection(); 370 // inject query, either as selection args or inline 371 String[] selArgs = null; 372 if (selection != null) { // use selection if provided 373 selArgs = new String[] { query }; 374 } else { // no selection, use REST pattern 375 uriBuilder.appendPath(query); 376 } 377 378 uriBuilder.appendQueryParameter("limit", String.valueOf(queryLimit)); 379 380 Uri uri = uriBuilder.build(); 381 382 // finally, make the query 383 if (DBG) { 384 Log.d(TAG, "query(" + uri + ",null," + selection + "," 385 + Arrays.toString(selArgs) + ",null)"); 386 } 387 return context.getContentResolver().query(uri, null, selection, selArgs, null); 388 } 389 390 private static Cursor getValidationCursor(Context context, SearchableInfo searchable, 391 String shortcutId, String extraData) { 392 String authority = searchable.getSuggestAuthority(); 393 if (authority == null) { 394 return null; 395 } 396 397 Uri.Builder uriBuilder = new Uri.Builder() 398 .scheme(ContentResolver.SCHEME_CONTENT) 399 .authority(authority); 400 401 // if content path provided, insert it now 402 final String contentPath = searchable.getSuggestPath(); 403 if (contentPath != null) { 404 uriBuilder.appendEncodedPath(contentPath); 405 } 406 407 // append the shortcut path and id 408 uriBuilder.appendPath(SearchManager.SUGGEST_URI_PATH_SHORTCUT); 409 uriBuilder.appendPath(shortcutId); 410 411 Uri uri = uriBuilder 412 .appendQueryParameter(SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA, extraData) 413 .build(); 414 415 if (DBG) Log.d(TAG, "Requesting refresh " + uri); 416 // finally, make the query 417 return context.getContentResolver().query(uri, null, null, null, null); 418 } 419 420 public boolean isWebSuggestionSource() { 421 return false; 422 } 423 424 public boolean queryAfterZeroResults() { 425 return mSearchable.queryAfterZeroResults(); 426 } 427 428 public String getDefaultIntentAction() { 429 String action = mSearchable.getSuggestIntentAction(); 430 if (action != null) return action; 431 return Intent.ACTION_SEARCH; 432 } 433 434 public String getDefaultIntentData() { 435 return mSearchable.getSuggestIntentData(); 436 } 437 438 private CharSequence getText(int id) { 439 if (id == 0) return null; 440 return getContext().getPackageManager().getText(mActivityInfo.packageName, id, 441 mActivityInfo.applicationInfo); 442 } 443 444 private String getString(int id) { 445 CharSequence text = getText(id); 446 return text == null ? null : text.toString(); 447 } 448 } 449