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