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