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