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