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