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