blob: 27bfaf926c99e952055bdb89c96afa736c19e7bc [file] [log] [blame]
/*
* Copyright (C) 2009 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.quicksearchbox;
import android.app.PendingIntent;
import android.app.SearchManager;
import android.app.SearchableInfo;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.database.Cursor;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.speech.RecognizerIntent;
import android.util.Log;
import java.util.Arrays;
/**
* Represents a single suggestion source, e.g. Contacts.
*
*/
public class SearchableSource implements Source {
private static final boolean DBG = true;
private static final String TAG = "QSB.SearchableSource";
// TODO: This should be exposed or moved to android-common, see http://b/issue?id=2440614
// The extra key used in an intent to the speech recognizer for in-app voice search.
private static final String EXTRA_CALLING_PACKAGE = "calling_package";
private final Context mContext;
private final SearchableInfo mSearchable;
private final ActivityInfo mActivityInfo;
// Cached label for the activity
private CharSequence mLabel = null;
// Cached icon for the activity
private Drawable.ConstantState mSourceIcon = null;
private final IconLoader mIconLoader;
public SearchableSource(Context context, SearchableInfo searchable)
throws NameNotFoundException {
ComponentName componentName = searchable.getSearchActivity();
mContext = context;
mSearchable = searchable;
mActivityInfo = context.getPackageManager().getActivityInfo(componentName, 0);
mIconLoader = createIconLoader(context, searchable.getSuggestPackage());
}
protected Context getContext() {
return mContext;
}
protected SearchableInfo getSearchableInfo() {
return mSearchable;
}
private IconLoader createIconLoader(Context context, String providerPackage) {
if (providerPackage == null) return null;
try {
return new CachingIconLoader(new PackageIconLoader(context, providerPackage));
} catch (PackageManager.NameNotFoundException ex) {
Log.e(TAG, "Suggestion provider package not found: " + providerPackage);
return null;
}
}
public ComponentName getComponentName() {
return mSearchable.getSearchActivity();
}
public String getFlattenedComponentName() {
return getComponentName().flattenToShortString();
}
public String getLogName() {
return getComponentName().getPackageName();
}
public Drawable getIcon(String drawableId) {
return mIconLoader == null ? null : mIconLoader.getIcon(drawableId);
}
public Uri getIconUri(String drawableId) {
return mIconLoader == null ? null : mIconLoader.getIconUri(drawableId);
}
public CharSequence getLabel() {
if (mLabel == null) {
// Load label lazily
mLabel = mActivityInfo.loadLabel(mContext.getPackageManager());
}
return mLabel;
}
public int getQueryThreshold() {
return mSearchable.getSuggestThreshold();
}
public CharSequence getSettingsDescription() {
return getText(mSearchable.getSettingsDescriptionId());
}
public Drawable getSourceIcon() {
if (mSourceIcon == null) {
// Load icon lazily
int iconRes = getSourceIconResource();
PackageManager pm = mContext.getPackageManager();
Drawable icon = pm.getDrawable(mActivityInfo.packageName, iconRes,
mActivityInfo.applicationInfo);
// Can't share Drawable instances, save constant state instead.
mSourceIcon = (icon != null) ? icon.getConstantState() : null;
// Optimization, return the Drawable the first time
return icon;
}
return (mSourceIcon != null) ? mSourceIcon.newDrawable() : null;
}
public Uri getSourceIconUri() {
int resourceId = getSourceIconResource();
return new Uri.Builder()
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
.authority(getComponentName().getPackageName())
.appendEncodedPath(String.valueOf(resourceId))
.build();
}
private int getSourceIconResource() {
int icon = mActivityInfo.getIconResource();
return (icon != 0) ? icon : android.R.drawable.sym_def_app_icon;
}
public boolean voiceSearchEnabled() {
return mSearchable.getVoiceSearchEnabled();
}
// TODO: not all apps handle ACTION_SEARCH properly, e.g. ApplicationsProvider.
// Maybe we should add a flag to searchable, so that QSB can hide the search button?
public Intent createSearchIntent(String query, Bundle appData) {
Intent intent = new Intent(Intent.ACTION_SEARCH);
intent.setComponent(getComponentName());
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
// We need CLEAR_TOP to avoid reusing an old task that has other activities
// on top of the one we want.
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
intent.putExtra(SearchManager.USER_QUERY, query);
intent.putExtra(SearchManager.QUERY, query);
if (appData != null) {
intent.putExtra(SearchManager.APP_DATA, appData);
}
return intent;
}
public Intent createVoiceSearchIntent(Bundle appData) {
if (mSearchable.getVoiceSearchLaunchWebSearch()) {
return WebCorpus.createVoiceWebSearchIntent(appData);
} else if (mSearchable.getVoiceSearchLaunchRecognizer()) {
return createVoiceAppSearchIntent(appData);
}
return null;
}
/**
* Create and return an Intent that can launch the voice search activity, perform a specific
* voice transcription, and forward the results to the searchable activity.
*
* This code is copied from SearchDialog
*
* @return A completely-configured intent ready to send to the voice search activity
*/
private Intent createVoiceAppSearchIntent(Bundle appData) {
ComponentName searchActivity = mSearchable.getSearchActivity();
// create the necessary intent to set up a search-and-forward operation
// in the voice search system. We have to keep the bundle separate,
// because it becomes immutable once it enters the PendingIntent
Intent queryIntent = new Intent(Intent.ACTION_SEARCH);
queryIntent.setComponent(searchActivity);
PendingIntent pending = PendingIntent.getActivity(
getContext(), 0, queryIntent, PendingIntent.FLAG_ONE_SHOT);
// Now set up the bundle that will be inserted into the pending intent
// when it's time to do the search. We always build it here (even if empty)
// because the voice search activity will always need to insert "QUERY" into
// it anyway.
Bundle queryExtras = new Bundle();
if (appData != null) {
queryExtras.putBundle(SearchManager.APP_DATA, appData);
}
// Now build the intent to launch the voice search. Add all necessary
// extras to launch the voice recognizer, and then all the necessary extras
// to forward the results to the searchable activity
Intent voiceIntent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
voiceIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
// Add all of the configuration options supplied by the searchable's metadata
String languageModel = getString(mSearchable.getVoiceLanguageModeId());
if (languageModel == null) {
languageModel = RecognizerIntent.LANGUAGE_MODEL_FREE_FORM;
}
String prompt = getString(mSearchable.getVoicePromptTextId());
String language = getString(mSearchable.getVoiceLanguageId());
int maxResults = mSearchable.getVoiceMaxResults();
if (maxResults <= 0) {
maxResults = 1;
}
voiceIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, languageModel);
voiceIntent.putExtra(RecognizerIntent.EXTRA_PROMPT, prompt);
voiceIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, language);
voiceIntent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, maxResults);
voiceIntent.putExtra(EXTRA_CALLING_PACKAGE,
searchActivity == null ? null : searchActivity.toShortString());
// Add the values that configure forwarding the results
voiceIntent.putExtra(RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT, pending);
voiceIntent.putExtra(RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT_BUNDLE, queryExtras);
return voiceIntent;
}
public SourceResult getSuggestions(String query, int queryLimit) {
try {
Cursor cursor = getSuggestions(mContext, mSearchable, query, queryLimit);
if (DBG) Log.d(TAG, toString() + "[" + query + "] returned.");
return new CursorBackedSourceResult(query, cursor);
} catch (RuntimeException ex) {
Log.e(TAG, toString() + "[" + query + "] failed", ex);
return new CursorBackedSourceResult(query);
}
}
public SuggestionCursor refreshShortcut(String shortcutId, String extraData) {
Cursor cursor = null;
try {
cursor = getValidationCursor(mContext, mSearchable, shortcutId, extraData);
if (DBG) Log.d(TAG, toString() + "[" + shortcutId + "] returned.");
if (cursor != null && cursor.getCount() > 0) {
cursor.moveToFirst();
}
return new CursorBackedSourceResult(null, cursor);
} catch (RuntimeException ex) {
Log.e(TAG, toString() + "[" + shortcutId + "] failed", ex);
if (cursor != null) {
cursor.close();
}
// TODO: Should we delete the shortcut even if the failure is temporary?
return null;
}
}
private class CursorBackedSourceResult extends CursorBackedSuggestionCursor
implements SourceResult {
public CursorBackedSourceResult(String userQuery) {
this(userQuery, null);
}
public CursorBackedSourceResult(String userQuery, Cursor cursor) {
super(userQuery, cursor);
}
public Source getSource() {
return SearchableSource.this;
}
@Override
public Source getSuggestionSource() {
return SearchableSource.this;
}
}
/**
* This is a copy of {@link SearchManager#getSuggestions(SearchableInfo, String)}.
*/
private static Cursor getSuggestions(Context context, SearchableInfo searchable, String query,
int queryLimit) {
if (searchable == null) {
return null;
}
String authority = searchable.getSuggestAuthority();
if (authority == null) {
return null;
}
Uri.Builder uriBuilder = new Uri.Builder()
.scheme(ContentResolver.SCHEME_CONTENT)
.authority(authority);
// if content path provided, insert it now
final String contentPath = searchable.getSuggestPath();
if (contentPath != null) {
uriBuilder.appendEncodedPath(contentPath);
}
// append standard suggestion query path
uriBuilder.appendPath(SearchManager.SUGGEST_URI_PATH_QUERY);
// get the query selection, may be null
String selection = searchable.getSuggestSelection();
// inject query, either as selection args or inline
String[] selArgs = null;
if (selection != null) { // use selection if provided
selArgs = new String[] { query };
} else { // no selection, use REST pattern
uriBuilder.appendPath(query);
}
uriBuilder.appendQueryParameter("limit", String.valueOf(queryLimit));
Uri uri = uriBuilder.build();
// finally, make the query
if (DBG) {
Log.d(TAG, "query(" + uri + ",null," + selection + ","
+ Arrays.toString(selArgs) + ",null)");
}
return context.getContentResolver().query(uri, null, selection, selArgs, null);
}
private static Cursor getValidationCursor(Context context, SearchableInfo searchable,
String shortcutId, String extraData) {
String authority = searchable.getSuggestAuthority();
if (authority == null) {
return null;
}
Uri.Builder uriBuilder = new Uri.Builder()
.scheme(ContentResolver.SCHEME_CONTENT)
.authority(authority);
// if content path provided, insert it now
final String contentPath = searchable.getSuggestPath();
if (contentPath != null) {
uriBuilder.appendEncodedPath(contentPath);
}
// append the shortcut path and id
uriBuilder.appendPath(SearchManager.SUGGEST_URI_PATH_SHORTCUT);
uriBuilder.appendPath(shortcutId);
Uri uri = uriBuilder
.appendQueryParameter(SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA, extraData)
.build();
if (DBG) Log.d(TAG, "Requesting refresh " + uri);
// finally, make the query
return context.getContentResolver().query(uri, null, null, null, null);
}
public boolean isWebSuggestionSource() {
return false;
}
public boolean queryAfterZeroResults() {
return mSearchable.queryAfterZeroResults();
}
public boolean shouldRewriteQueryFromData() {
return mSearchable.shouldRewriteQueryFromData();
}
public boolean shouldRewriteQueryFromText() {
return mSearchable.shouldRewriteQueryFromText();
}
@Override
public boolean equals(Object o) {
if (o != null && o.getClass().equals(this.getClass())) {
SearchableSource s = (SearchableSource) o;
return s.getComponentName().equals(getComponentName());
}
return false;
}
@Override
public int hashCode() {
return getComponentName().hashCode();
}
@Override
public String toString() {
return "SearchableSource{component=" + getFlattenedComponentName() + "}";
}
public String getDefaultIntentAction() {
return mSearchable.getSuggestIntentAction();
}
public String getDefaultIntentData() {
return mSearchable.getSuggestIntentData();
}
public String getSuggestActionMsg(int keyCode) {
SearchableInfo.ActionKeyInfo actionKey = mSearchable.findActionKey(keyCode);
if (actionKey == null) return null;
return actionKey.getSuggestActionMsg();
}
public String getSuggestActionMsgColumn(int keyCode) {
SearchableInfo.ActionKeyInfo actionKey = mSearchable.findActionKey(keyCode);
if (actionKey == null) return null;
return actionKey.getSuggestActionMsgColumn();
}
private CharSequence getText(int id) {
if (id == 0) return null;
return mContext.getPackageManager().getText(mActivityInfo.packageName, id,
mActivityInfo.applicationInfo);
}
private String getString(int id) {
CharSequence text = getText(id);
return text == null ? null : text.toString();
}
}