/* * 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 com.android.quicksearchbox.util.NamedTaskExecutor; import com.android.quicksearchbox.util.Util; 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.PackageInfo; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.PathPermission; import android.content.pm.ProviderInfo; import android.database.Cursor; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.speech.RecognizerIntent; import android.util.Log; import java.util.Arrays; /** * Represents a single suggestion source, e.g. Contacts. */ public class SearchableSource extends AbstractSource { private static final boolean DBG = false; 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 SearchableInfo mSearchable; private final String mName; private final ActivityInfo mActivityInfo; private final int mVersionCode; // Cached label for the activity private CharSequence mLabel = null; // Cached icon for the activity private Drawable.ConstantState mSourceIcon = null; private Uri mSuggestUriBase; public SearchableSource(Context context, SearchableInfo searchable, Handler uiThread, NamedTaskExecutor iconLoader) throws NameNotFoundException { super(context, uiThread, iconLoader); ComponentName componentName = searchable.getSearchActivity(); if (DBG) Log.d(TAG, "created Searchable for " + componentName); mSearchable = searchable; mName = componentName.flattenToShortString(); PackageManager pm = context.getPackageManager(); mActivityInfo = pm.getActivityInfo(componentName, 0); PackageInfo pkgInfo = pm.getPackageInfo(componentName.getPackageName(), 0); mVersionCode = pkgInfo.versionCode; } public SearchableInfo getSearchableInfo() { return mSearchable; } /** * Checks if the current process can read the suggestion provider in this source. */ public boolean canRead() { String authority = mSearchable.getSuggestAuthority(); if (authority == null) { // TODO: maybe we should have a way to distinguish between having suggestions // and being readable. return true; } Uri.Builder uriBuilder = new Uri.Builder() .scheme(ContentResolver.SCHEME_CONTENT) .authority(authority); // if content path provided, insert it now String contentPath = mSearchable.getSuggestPath(); if (contentPath != null) { uriBuilder.appendEncodedPath(contentPath); } // append standard suggestion query path uriBuilder.appendEncodedPath(SearchManager.SUGGEST_URI_PATH_QUERY); Uri uri = uriBuilder.build(); return canRead(uri); } /** * Checks if the current process can read the given content URI. * * TODO: Shouldn't this be a PackageManager / Context / ContentResolver method? */ private boolean canRead(Uri uri) { ProviderInfo provider = getContext().getPackageManager().resolveContentProvider( uri.getAuthority(), 0); if (provider == null) { Log.w(TAG, getName() + " has bad suggestion authority " + uri.getAuthority()); return false; } String readPermission = provider.readPermission; if (readPermission == null) { // No permission required to read anything in the content provider return true; } int pid = android.os.Process.myPid(); int uid = android.os.Process.myUid(); if (getContext().checkPermission(readPermission, pid, uid) == PackageManager.PERMISSION_GRANTED) { // We have permission to read everything in the content provider return true; } PathPermission[] pathPermissions = provider.pathPermissions; if (pathPermissions == null || pathPermissions.length == 0) { // We don't have the readPermission, and there are no pathPermissions if (DBG) Log.d(TAG, "Missing " + readPermission); return false; } String path = uri.getPath(); for (PathPermission perm : pathPermissions) { String pathReadPermission = perm.getReadPermission(); if (pathReadPermission != null && perm.match(path) && getContext().checkPermission(pathReadPermission, pid, uid) == PackageManager.PERMISSION_GRANTED) { // We have the path permission return true; } } if (DBG) Log.d(TAG, "Missing " + readPermission + " and no path permission applies"); return false; } public ComponentName getIntentComponent() { return mSearchable.getSearchActivity(); } public int getVersionCode() { return mVersionCode; } public String getName() { return mName; } @Override protected String getIconPackage() { // Get icons from the package containing the suggestion provider, if any String iconPackage = mSearchable.getSuggestPackage(); if (iconPackage != null) { return iconPackage; } else { // Fall back to the package containing the searchable activity return mSearchable.getSearchActivity().getPackageName(); } } public CharSequence getLabel() { if (mLabel == null) { // Load label lazily mLabel = mActivityInfo.loadLabel(getContext().getPackageManager()); } return mLabel; } public CharSequence getHint() { return getText(mSearchable.getHintId()); } public int getQueryThreshold() { return mSearchable.getSuggestThreshold(); } public CharSequence getSettingsDescription() { return getText(mSearchable.getSettingsDescriptionId()); } public Drawable getSourceIcon() { if (mSourceIcon == null) { Drawable icon = loadSourceIcon(); if (icon == null) { icon = getContext().getResources().getDrawable(R.drawable.corpus_icon_default); } // 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; } private Drawable loadSourceIcon() { int iconRes = getSourceIconResource(); if (iconRes == 0) return null; PackageManager pm = getContext().getPackageManager(); return pm.getDrawable(mActivityInfo.packageName, iconRes, mActivityInfo.applicationInfo); } public Uri getSourceIconUri() { int resourceId = getSourceIconResource(); if (resourceId == 0) { return Util.getResourceUri(getContext(), R.drawable.corpus_icon_default); } else { return Util.getResourceUri(getContext(), mActivityInfo.applicationInfo, resourceId); } } private int getSourceIconResource() { return mActivityInfo.getIconResource(); } public boolean voiceSearchEnabled() { return mSearchable.getVoiceSearchEnabled(); } public Intent createVoiceSearchIntent(Bundle appData) { if (mSearchable.getVoiceSearchLaunchWebSearch()) { return 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, boolean onlySource) { try { Cursor cursor = getSuggestions(getContext(), mSearchable, query, queryLimit); if (DBG) Log.d(TAG, toString() + "[" + query + "] returned."); return new CursorBackedSourceResult(this, query, cursor); } catch (RuntimeException ex) { Log.e(TAG, toString() + "[" + query + "] failed", ex); return new CursorBackedSourceResult(this, query); } } public SuggestionCursor refreshShortcut(String shortcutId, String extraData) { Cursor cursor = null; try { cursor = getValidationCursor(getContext(), mSearchable, shortcutId, extraData); if (DBG) Log.d(TAG, toString() + "[" + shortcutId + "] returned."); if (cursor != null && cursor.getCount() > 0) { cursor.moveToFirst(); } return new CursorBackedSourceResult(this, 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; } } public String getSuggestUri() { Uri uri = getSuggestUriBase(mSearchable); if (uri == null) return null; return uri.toString(); } private synchronized Uri getSuggestUriBase(SearchableInfo searchable) { if (searchable == null) { return null; } if (mSuggestUriBase == 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); mSuggestUriBase = uriBuilder.build(); } return mSuggestUriBase; } /** * This is a copy of {@link SearchManager#getSuggestions(SearchableInfo, String)}. */ private Cursor getSuggestions(Context context, SearchableInfo searchable, String query, int queryLimit) { Uri base = getSuggestUriBase(searchable); if (base == null) return null; Uri.Builder uriBuilder = base.buildUpon(); // 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)"); } Cursor c = context.getContentResolver().query(uri, null, selection, selArgs, null); if (DBG) Log.d(TAG, "Got cursor from " + mName + ": " + c); return c; } 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 int getMaxShortcuts(Config config) { return config.getMaxShortcuts(getName()); } public boolean includeInAll() { return true; } public boolean queryAfterZeroResults() { return mSearchable.queryAfterZeroResults(); } public String getDefaultIntentAction() { String action = mSearchable.getSuggestIntentAction(); if (action != null) return action; return Intent.ACTION_SEARCH; } public String getDefaultIntentData() { return mSearchable.getSuggestIntentData(); } private CharSequence getText(int id) { if (id == 0) return null; return getContext().getPackageManager().getText(mActivityInfo.packageName, id, mActivityInfo.applicationInfo); } private String getString(int id) { CharSequence text = getText(id); return text == null ? null : text.toString(); } }