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