SearchableSource.java revision cd1e3ba5f7c3f5242345ff6f674281e3d6366e24
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 *
46 */
47public 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 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    }
85
86    protected Context getContext() {
87        return mContext;
88    }
89
90    protected SearchableInfo getSearchableInfo() {
91        return mSearchable;
92    }
93
94    /**
95     * Checks if the current process can read the suggestion provider in this source.
96     */
97    public boolean canRead() {
98        String authority = mSearchable.getSuggestAuthority();
99        if (authority == null) {
100            // TODO: maybe we should have a way to distinguish between having suggestions
101            // and being readable.
102            return true;
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 getIconLoader() {
165        if (mIconLoader == null) {
166            // Get icons from the package containing the suggestion provider, if any
167            String iconPackage = mSearchable.getSuggestPackage();
168            if (iconPackage == null) {
169                // Fall back to the package containing the searchable activity
170                iconPackage = mSearchable.getSearchActivity().getPackageName();
171            }
172            mIconLoader = new CachingIconLoader(new PackageIconLoader(mContext, iconPackage));
173        }
174        return mIconLoader;
175    }
176
177    public ComponentName getIntentComponent() {
178        return mSearchable.getSearchActivity();
179    }
180
181    public int getVersionCode() {
182        return mVersionCode;
183    }
184
185    public String getName() {
186        return mName;
187    }
188
189    public Drawable getIcon(String drawableId) {
190        return getIconLoader().getIcon(drawableId);
191    }
192
193    public Uri getIconUri(String drawableId) {
194        return getIconLoader().getIconUri(drawableId);
195    }
196
197    public CharSequence getLabel() {
198        if (mLabel == null) {
199            // Load label lazily
200            mLabel = mActivityInfo.loadLabel(mContext.getPackageManager());
201        }
202        return mLabel;
203    }
204
205    public CharSequence getHint() {
206        return getText(mSearchable.getHintId());
207    }
208
209    public int getQueryThreshold() {
210        return mSearchable.getSuggestThreshold();
211    }
212
213    public CharSequence getSettingsDescription() {
214        return getText(mSearchable.getSettingsDescriptionId());
215    }
216
217    public Drawable getSourceIcon() {
218        if (mSourceIcon == null) {
219            // Load icon lazily
220            int iconRes = getSourceIconResource();
221            PackageManager pm = mContext.getPackageManager();
222            Drawable icon = pm.getDrawable(mActivityInfo.packageName, iconRes,
223                    mActivityInfo.applicationInfo);
224            // Can't share Drawable instances, save constant state instead.
225            mSourceIcon = (icon != null) ? icon.getConstantState() : null;
226            // Optimization, return the Drawable the first time
227            return icon;
228        }
229        return (mSourceIcon != null) ? mSourceIcon.newDrawable() : null;
230    }
231
232    public Uri getSourceIconUri() {
233        int resourceId = getSourceIconResource();
234        return Util.getResourceUri(getContext(), mActivityInfo.applicationInfo, resourceId);
235    }
236
237    private int getSourceIconResource() {
238        int icon = mActivityInfo.getIconResource();
239        return (icon != 0) ? icon : android.R.drawable.sym_def_app_icon;
240    }
241
242    public boolean voiceSearchEnabled() {
243        return mSearchable.getVoiceSearchEnabled();
244    }
245
246    public Intent createSearchIntent(String query, Bundle appData) {
247        return createSourceSearchIntent(getIntentComponent(), query, appData);
248    }
249
250    public static Intent createSourceSearchIntent(ComponentName activity, String query,
251            Bundle appData) {
252        if (activity == null) {
253            Log.w(TAG, "Tried to create search intent with no target activity");
254            return null;
255        }
256        Intent intent = new Intent(Intent.ACTION_SEARCH);
257        intent.setComponent(activity);
258        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
259        // We need CLEAR_TOP to avoid reusing an old task that has other activities
260        // on top of the one we want.
261        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
262        intent.putExtra(SearchManager.USER_QUERY, query);
263        intent.putExtra(SearchManager.QUERY, query);
264        if (appData != null) {
265            intent.putExtra(SearchManager.APP_DATA, appData);
266        }
267        return intent;
268    }
269
270    public Intent createVoiceSearchIntent(Bundle appData) {
271        if (mSearchable.getVoiceSearchLaunchWebSearch()) {
272            return new VoiceSearch(mContext).createVoiceWebSearchIntent(appData);
273        } else if (mSearchable.getVoiceSearchLaunchRecognizer()) {
274            return createVoiceAppSearchIntent(appData);
275        }
276        return null;
277    }
278
279    /**
280     * Create and return an Intent that can launch the voice search activity, perform a specific
281     * voice transcription, and forward the results to the searchable activity.
282     *
283     * This code is copied from SearchDialog
284     *
285     * @return A completely-configured intent ready to send to the voice search activity
286     */
287    private Intent createVoiceAppSearchIntent(Bundle appData) {
288        ComponentName searchActivity = mSearchable.getSearchActivity();
289
290        // create the necessary intent to set up a search-and-forward operation
291        // in the voice search system.   We have to keep the bundle separate,
292        // because it becomes immutable once it enters the PendingIntent
293        Intent queryIntent = new Intent(Intent.ACTION_SEARCH);
294        queryIntent.setComponent(searchActivity);
295        PendingIntent pending = PendingIntent.getActivity(
296                getContext(), 0, queryIntent, PendingIntent.FLAG_ONE_SHOT);
297
298        // Now set up the bundle that will be inserted into the pending intent
299        // when it's time to do the search.  We always build it here (even if empty)
300        // because the voice search activity will always need to insert "QUERY" into
301        // it anyway.
302        Bundle queryExtras = new Bundle();
303        if (appData != null) {
304            queryExtras.putBundle(SearchManager.APP_DATA, appData);
305        }
306
307        // Now build the intent to launch the voice search.  Add all necessary
308        // extras to launch the voice recognizer, and then all the necessary extras
309        // to forward the results to the searchable activity
310        Intent voiceIntent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
311        voiceIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
312
313        // Add all of the configuration options supplied by the searchable's metadata
314        String languageModel = getString(mSearchable.getVoiceLanguageModeId());
315        if (languageModel == null) {
316            languageModel = RecognizerIntent.LANGUAGE_MODEL_FREE_FORM;
317        }
318        String prompt = getString(mSearchable.getVoicePromptTextId());
319        String language = getString(mSearchable.getVoiceLanguageId());
320        int maxResults = mSearchable.getVoiceMaxResults();
321        if (maxResults <= 0) {
322            maxResults = 1;
323        }
324
325        voiceIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, languageModel);
326        voiceIntent.putExtra(RecognizerIntent.EXTRA_PROMPT, prompt);
327        voiceIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, language);
328        voiceIntent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, maxResults);
329        voiceIntent.putExtra(EXTRA_CALLING_PACKAGE,
330                searchActivity == null ? null : searchActivity.toShortString());
331
332        // Add the values that configure forwarding the results
333        voiceIntent.putExtra(RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT, pending);
334        voiceIntent.putExtra(RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT_BUNDLE, queryExtras);
335
336        return voiceIntent;
337    }
338
339    public SourceResult getSuggestions(String query, int queryLimit, boolean onlySource) {
340        try {
341            Cursor cursor = getSuggestions(mContext, mSearchable, query, queryLimit);
342            if (DBG) Log.d(TAG, toString() + "[" + query + "] returned.");
343            return new CursorBackedSourceResult(query, cursor);
344        } catch (RuntimeException ex) {
345            Log.e(TAG, toString() + "[" + query + "] failed", ex);
346            return new CursorBackedSourceResult(query);
347        }
348    }
349
350    public SuggestionCursor refreshShortcut(String shortcutId, String extraData) {
351        Cursor cursor = null;
352        try {
353            cursor = getValidationCursor(mContext, mSearchable, shortcutId, extraData);
354            if (DBG) Log.d(TAG, toString() + "[" + shortcutId + "] returned.");
355            if (cursor != null && cursor.getCount() > 0) {
356                cursor.moveToFirst();
357            }
358            return new CursorBackedSourceResult(null, cursor);
359        } catch (RuntimeException ex) {
360            Log.e(TAG, toString() + "[" + shortcutId + "] failed", ex);
361            if (cursor != null) {
362                cursor.close();
363            }
364            // TODO: Should we delete the shortcut even if the failure is temporary?
365            return null;
366        }
367    }
368
369    private class CursorBackedSourceResult extends CursorBackedSuggestionCursor
370            implements SourceResult {
371
372        public CursorBackedSourceResult(String userQuery) {
373            this(userQuery, null);
374        }
375
376        public CursorBackedSourceResult(String userQuery, Cursor cursor) {
377            super(userQuery, cursor);
378        }
379
380        public Source getSource() {
381            return SearchableSource.this;
382        }
383
384        @Override
385        public Source getSuggestionSource() {
386            return SearchableSource.this;
387        }
388
389        public boolean isSuggestionShortcut() {
390            return false;
391        }
392
393        @Override
394        public String toString() {
395            return SearchableSource.this + "[" + getUserQuery() + "]";
396        }
397
398    }
399
400    /**
401     * This is a copy of {@link SearchManager#getSuggestions(SearchableInfo, String)}.
402     */
403    private static Cursor getSuggestions(Context context, SearchableInfo searchable, String query,
404            int queryLimit) {
405        if (searchable == null) {
406            return null;
407        }
408
409        String authority = searchable.getSuggestAuthority();
410        if (authority == null) {
411            return null;
412        }
413
414        Uri.Builder uriBuilder = new Uri.Builder()
415                .scheme(ContentResolver.SCHEME_CONTENT)
416                .authority(authority);
417
418        // if content path provided, insert it now
419        final String contentPath = searchable.getSuggestPath();
420        if (contentPath != null) {
421            uriBuilder.appendEncodedPath(contentPath);
422        }
423
424        // append standard suggestion query path
425        uriBuilder.appendPath(SearchManager.SUGGEST_URI_PATH_QUERY);
426
427        // get the query selection, may be null
428        String selection = searchable.getSuggestSelection();
429        // inject query, either as selection args or inline
430        String[] selArgs = null;
431        if (selection != null) {    // use selection if provided
432            selArgs = new String[] { query };
433        } else {                    // no selection, use REST pattern
434            uriBuilder.appendPath(query);
435        }
436
437        uriBuilder.appendQueryParameter("limit", String.valueOf(queryLimit));
438
439        Uri uri = uriBuilder.build();
440
441        // finally, make the query
442        if (DBG) {
443            Log.d(TAG, "query(" + uri + ",null," + selection + ","
444                    + Arrays.toString(selArgs) + ",null)");
445        }
446        return context.getContentResolver().query(uri, null, selection, selArgs, null);
447    }
448
449    private static Cursor getValidationCursor(Context context, SearchableInfo searchable,
450            String shortcutId, String extraData) {
451        String authority = searchable.getSuggestAuthority();
452        if (authority == null) {
453            return null;
454        }
455
456        Uri.Builder uriBuilder = new Uri.Builder()
457                .scheme(ContentResolver.SCHEME_CONTENT)
458                .authority(authority);
459
460        // if content path provided, insert it now
461        final String contentPath = searchable.getSuggestPath();
462        if (contentPath != null) {
463            uriBuilder.appendEncodedPath(contentPath);
464        }
465
466        // append the shortcut path and id
467        uriBuilder.appendPath(SearchManager.SUGGEST_URI_PATH_SHORTCUT);
468        uriBuilder.appendPath(shortcutId);
469
470        Uri uri = uriBuilder
471                .appendQueryParameter(SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA, extraData)
472                .build();
473
474        if (DBG) Log.d(TAG, "Requesting refresh " + uri);
475        // finally, make the query
476        return context.getContentResolver().query(uri, null, null, null, null);
477    }
478
479    public boolean isWebSuggestionSource() {
480        return false;
481    }
482
483    public boolean queryAfterZeroResults() {
484        return mSearchable.queryAfterZeroResults();
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