SearchableSource.java revision 93bd2e70b8b08da1ec37fd0e990dac05551d2e90
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
17package com.android.quicksearchbox;
18
19import com.android.quicksearchbox.util.Util;
20
21import android.app.PendingIntent;
22import android.app.SearchManager;
23import android.app.SearchableInfo;
24import android.content.ComponentName;
25import android.content.ContentResolver;
26import android.content.Context;
27import android.content.Intent;
28import android.content.pm.ActivityInfo;
29import android.content.pm.PackageInfo;
30import android.content.pm.PackageManager;
31import android.content.pm.PathPermission;
32import android.content.pm.ProviderInfo;
33import android.content.pm.PackageManager.NameNotFoundException;
34import android.database.Cursor;
35import android.graphics.drawable.Drawable;
36import android.net.Uri;
37import android.os.Bundle;
38import android.speech.RecognizerIntent;
39import android.util.Log;
40
41import java.util.Arrays;
42
43/**
44 * Represents a single suggestion source, e.g. Contacts.
45 */
46public 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            // Load icon lazily
202            int iconRes = getSourceIconResource();
203            PackageManager pm = getContext().getPackageManager();
204            Drawable icon = pm.getDrawable(mActivityInfo.packageName, iconRes,
205                    mActivityInfo.applicationInfo);
206            // Can't share Drawable instances, save constant state instead.
207            mSourceIcon = (icon != null) ? icon.getConstantState() : null;
208            // Optimization, return the Drawable the first time
209            return icon;
210        }
211        return (mSourceIcon != null) ? mSourceIcon.newDrawable() : null;
212    }
213
214    public Uri getSourceIconUri() {
215        int resourceId = getSourceIconResource();
216        return Util.getResourceUri(getContext(), mActivityInfo.applicationInfo, resourceId);
217    }
218
219    private int getSourceIconResource() {
220        int icon = mActivityInfo.getIconResource();
221        return (icon != 0) ? icon : android.R.drawable.sym_def_app_icon;
222    }
223
224    public boolean voiceSearchEnabled() {
225        return mSearchable.getVoiceSearchEnabled();
226    }
227
228    public boolean isLocationAware() {
229        return false;
230    }
231
232    public Intent createVoiceSearchIntent(Bundle appData) {
233        if (mSearchable.getVoiceSearchLaunchWebSearch()) {
234            return createVoiceWebSearchIntent(appData);
235        } else if (mSearchable.getVoiceSearchLaunchRecognizer()) {
236            return createVoiceAppSearchIntent(appData);
237        }
238        return null;
239    }
240
241    /**
242     * Create and return an Intent that can launch the voice search activity, perform a specific
243     * voice transcription, and forward the results to the searchable activity.
244     *
245     * This code is copied from SearchDialog
246     *
247     * @return A completely-configured intent ready to send to the voice search activity
248     */
249    private Intent createVoiceAppSearchIntent(Bundle appData) {
250        ComponentName searchActivity = mSearchable.getSearchActivity();
251
252        // create the necessary intent to set up a search-and-forward operation
253        // in the voice search system.   We have to keep the bundle separate,
254        // because it becomes immutable once it enters the PendingIntent
255        Intent queryIntent = new Intent(Intent.ACTION_SEARCH);
256        queryIntent.setComponent(searchActivity);
257        PendingIntent pending = PendingIntent.getActivity(
258                getContext(), 0, queryIntent, PendingIntent.FLAG_ONE_SHOT);
259
260        // Now set up the bundle that will be inserted into the pending intent
261        // when it's time to do the search.  We always build it here (even if empty)
262        // because the voice search activity will always need to insert "QUERY" into
263        // it anyway.
264        Bundle queryExtras = new Bundle();
265        if (appData != null) {
266            queryExtras.putBundle(SearchManager.APP_DATA, appData);
267        }
268
269        // Now build the intent to launch the voice search.  Add all necessary
270        // extras to launch the voice recognizer, and then all the necessary extras
271        // to forward the results to the searchable activity
272        Intent voiceIntent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
273        voiceIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
274
275        // Add all of the configuration options supplied by the searchable's metadata
276        String languageModel = getString(mSearchable.getVoiceLanguageModeId());
277        if (languageModel == null) {
278            languageModel = RecognizerIntent.LANGUAGE_MODEL_FREE_FORM;
279        }
280        String prompt = getString(mSearchable.getVoicePromptTextId());
281        String language = getString(mSearchable.getVoiceLanguageId());
282        int maxResults = mSearchable.getVoiceMaxResults();
283        if (maxResults <= 0) {
284            maxResults = 1;
285        }
286
287        voiceIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, languageModel);
288        voiceIntent.putExtra(RecognizerIntent.EXTRA_PROMPT, prompt);
289        voiceIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, language);
290        voiceIntent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, maxResults);
291        voiceIntent.putExtra(EXTRA_CALLING_PACKAGE,
292                searchActivity == null ? null : searchActivity.toShortString());
293
294        // Add the values that configure forwarding the results
295        voiceIntent.putExtra(RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT, pending);
296        voiceIntent.putExtra(RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT_BUNDLE, queryExtras);
297
298        return voiceIntent;
299    }
300
301    public SourceResult getSuggestions(String query, int queryLimit, boolean onlySource) {
302        try {
303            Cursor cursor = getSuggestions(getContext(), mSearchable, query, queryLimit);
304            if (DBG) Log.d(TAG, toString() + "[" + query + "] returned.");
305            return new CursorBackedSourceResult(this, query, cursor);
306        } catch (RuntimeException ex) {
307            Log.e(TAG, toString() + "[" + query + "] failed", ex);
308            return new CursorBackedSourceResult(this, query);
309        }
310    }
311
312    public SuggestionCursor refreshShortcut(String shortcutId, String extraData) {
313        Cursor cursor = null;
314        try {
315            cursor = getValidationCursor(getContext(), mSearchable, shortcutId, extraData);
316            if (DBG) Log.d(TAG, toString() + "[" + shortcutId + "] returned.");
317            if (cursor != null && cursor.getCount() > 0) {
318                cursor.moveToFirst();
319            }
320            return new CursorBackedSourceResult(this, null, cursor);
321        } catch (RuntimeException ex) {
322            Log.e(TAG, toString() + "[" + shortcutId + "] failed", ex);
323            if (cursor != null) {
324                cursor.close();
325            }
326            // TODO: Should we delete the shortcut even if the failure is temporary?
327            return null;
328        }
329    }
330
331    /**
332     * This is a copy of {@link SearchManager#getSuggestions(SearchableInfo, String)}.
333     */
334    private static Cursor getSuggestions(Context context, SearchableInfo searchable, String query,
335            int queryLimit) {
336        if (searchable == null) {
337            return null;
338        }
339
340        String authority = searchable.getSuggestAuthority();
341        if (authority == null) {
342            return null;
343        }
344
345        Uri.Builder uriBuilder = new Uri.Builder()
346                .scheme(ContentResolver.SCHEME_CONTENT)
347                .authority(authority);
348
349        // if content path provided, insert it now
350        final String contentPath = searchable.getSuggestPath();
351        if (contentPath != null) {
352            uriBuilder.appendEncodedPath(contentPath);
353        }
354
355        // append standard suggestion query path
356        uriBuilder.appendPath(SearchManager.SUGGEST_URI_PATH_QUERY);
357
358        // get the query selection, may be null
359        String selection = searchable.getSuggestSelection();
360        // inject query, either as selection args or inline
361        String[] selArgs = null;
362        if (selection != null) {    // use selection if provided
363            selArgs = new String[] { query };
364        } else {                    // no selection, use REST pattern
365            uriBuilder.appendPath(query);
366        }
367
368        uriBuilder.appendQueryParameter("limit", String.valueOf(queryLimit));
369
370        Uri uri = uriBuilder.build();
371
372        // finally, make the query
373        if (DBG) {
374            Log.d(TAG, "query(" + uri + ",null," + selection + ","
375                    + Arrays.toString(selArgs) + ",null)");
376        }
377        return context.getContentResolver().query(uri, null, selection, selArgs, null);
378    }
379
380    private static Cursor getValidationCursor(Context context, SearchableInfo searchable,
381            String shortcutId, String extraData) {
382        String authority = searchable.getSuggestAuthority();
383        if (authority == null) {
384            return null;
385        }
386
387        Uri.Builder uriBuilder = new Uri.Builder()
388                .scheme(ContentResolver.SCHEME_CONTENT)
389                .authority(authority);
390
391        // if content path provided, insert it now
392        final String contentPath = searchable.getSuggestPath();
393        if (contentPath != null) {
394            uriBuilder.appendEncodedPath(contentPath);
395        }
396
397        // append the shortcut path and id
398        uriBuilder.appendPath(SearchManager.SUGGEST_URI_PATH_SHORTCUT);
399        uriBuilder.appendPath(shortcutId);
400
401        Uri uri = uriBuilder
402                .appendQueryParameter(SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA, extraData)
403                .build();
404
405        if (DBG) Log.d(TAG, "Requesting refresh " + uri);
406        // finally, make the query
407        return context.getContentResolver().query(uri, null, null, null, null);
408    }
409
410    public boolean isWebSuggestionSource() {
411        return false;
412    }
413
414    public boolean queryAfterZeroResults() {
415        return mSearchable.queryAfterZeroResults();
416    }
417
418    public String getDefaultIntentAction() {
419        String action = mSearchable.getSuggestIntentAction();
420        if (action != null) return action;
421        return Intent.ACTION_SEARCH;
422    }
423
424    public String getDefaultIntentData() {
425        return mSearchable.getSuggestIntentData();
426    }
427
428    private CharSequence getText(int id) {
429        if (id == 0) return null;
430        return getContext().getPackageManager().getText(mActivityInfo.packageName, id,
431                mActivityInfo.applicationInfo);
432    }
433
434    private String getString(int id) {
435        CharSequence text = getText(id);
436        return text == null ? null : text.toString();
437    }
438}
439