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