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