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