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