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