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