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