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