1/*
2 * Copyright (C) 2008 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 android.provider;
18
19import android.content.ContentResolver;
20import android.content.ContentValues;
21import android.content.Context;
22import android.content.SearchRecentSuggestionsProvider;
23import android.net.Uri;
24import android.text.TextUtils;
25import android.util.Log;
26
27import java.util.concurrent.Semaphore;
28
29/**
30 * This is a utility class providing access to
31 * {@link android.content.SearchRecentSuggestionsProvider}.
32 *
33 * <p>Unlike some utility classes, this one must be instantiated and properly initialized, so that
34 * it can be configured to operate with the search suggestions provider that you have created.
35 *
36 * <p>Typically, you will do this in your searchable activity, each time you receive an incoming
37 * {@link android.content.Intent#ACTION_SEARCH ACTION_SEARCH} Intent.  The code to record each
38 * incoming query is as follows:
39 * <pre class="prettyprint">
40 *      SearchSuggestions suggestions = new SearchSuggestions(this,
41 *              MySuggestionsProvider.AUTHORITY, MySuggestionsProvider.MODE);
42 *      suggestions.saveRecentQuery(queryString, null);
43 * </pre>
44 *
45 * <p>For a working example, see SearchSuggestionSampleProvider and SearchQueryResults in
46 * samples/ApiDemos/app.
47 *
48 * <div class="special reference">
49 * <h3>Developer Guides</h3>
50 * <p>For information about using search suggestions in your application, read the
51 * <a href="{@docRoot}guide/topics/search/adding-recent-query-suggestions.html">Adding Recent Query
52 * Suggestions</a> developer guide.</p>
53 * </div>
54 */
55public class SearchRecentSuggestions {
56    // debugging support
57    private static final String LOG_TAG = "SearchSuggestions";
58
59    // This is a superset of all possible column names (need not all be in table)
60    private static class SuggestionColumns implements BaseColumns {
61        public static final String DISPLAY1 = "display1";
62        public static final String DISPLAY2 = "display2";
63        public static final String QUERY = "query";
64        public static final String DATE = "date";
65    }
66
67    /* if you change column order you must also change indices below */
68    /**
69     * This is the database projection that can be used to view saved queries, when
70     * configured for one-line operation.
71     */
72    public static final String[] QUERIES_PROJECTION_1LINE = new String[] {
73        SuggestionColumns._ID,
74        SuggestionColumns.DATE,
75        SuggestionColumns.QUERY,
76        SuggestionColumns.DISPLAY1,
77    };
78
79    /* if you change column order you must also change indices below */
80    /**
81     * This is the database projection that can be used to view saved queries, when
82     * configured for two-line operation.
83     */
84    public static final String[] QUERIES_PROJECTION_2LINE = new String[] {
85        SuggestionColumns._ID,
86        SuggestionColumns.DATE,
87        SuggestionColumns.QUERY,
88        SuggestionColumns.DISPLAY1,
89        SuggestionColumns.DISPLAY2,
90    };
91
92    /* these indices depend on QUERIES_PROJECTION_xxx */
93    /** Index into the provided query projections.  For use with Cursor.update methods. */
94    public static final int QUERIES_PROJECTION_DATE_INDEX = 1;
95    /** Index into the provided query projections.  For use with Cursor.update methods. */
96    public static final int QUERIES_PROJECTION_QUERY_INDEX = 2;
97    /** Index into the provided query projections.  For use with Cursor.update methods. */
98    public static final int QUERIES_PROJECTION_DISPLAY1_INDEX = 3;
99    /** Index into the provided query projections.  For use with Cursor.update methods. */
100    public static final int QUERIES_PROJECTION_DISPLAY2_INDEX = 4;  // only when 2line active
101
102    /*
103     * Set a cap on the count of items in the suggestions table, to
104     * prevent db and layout operations from dragging to a crawl. Revisit this
105     * cap when/if db/layout performance improvements are made.
106     */
107    private static final int MAX_HISTORY_COUNT = 250;
108
109    // client-provided configuration values
110    private final Context mContext;
111    private final String mAuthority;
112    private final boolean mTwoLineDisplay;
113    private final Uri mSuggestionsUri;
114
115    /** Released once per completion of async write.  Used for tests. */
116    private static final Semaphore sWritesInProgress = new Semaphore(0);
117
118    /**
119     * Although provider utility classes are typically static, this one must be constructed
120     * because it needs to be initialized using the same values that you provided in your
121     * {@link android.content.SearchRecentSuggestionsProvider}.
122     *
123     * @param authority This must match the authority that you've declared in your manifest.
124     * @param mode You can use mode flags here to determine certain functional aspects of your
125     * database.  Note, this value should not change from run to run, because when it does change,
126     * your suggestions database may be wiped.
127     *
128     * @see android.content.SearchRecentSuggestionsProvider
129     * @see android.content.SearchRecentSuggestionsProvider#setupSuggestions
130     */
131    public SearchRecentSuggestions(Context context, String authority, int mode) {
132        if (TextUtils.isEmpty(authority) ||
133                ((mode & SearchRecentSuggestionsProvider.DATABASE_MODE_QUERIES) == 0)) {
134            throw new IllegalArgumentException();
135        }
136        // unpack mode flags
137        mTwoLineDisplay = (0 != (mode & SearchRecentSuggestionsProvider.DATABASE_MODE_2LINES));
138
139        // saved values
140        mContext = context;
141        mAuthority = new String(authority);
142
143        // derived values
144        mSuggestionsUri = Uri.parse("content://" + mAuthority + "/suggestions");
145    }
146
147    /**
148     * Add a query to the recent queries list.  Returns immediately, performing the save
149     * in the background.
150     *
151     * @param queryString The string as typed by the user.  This string will be displayed as
152     * the suggestion, and if the user clicks on the suggestion, this string will be sent to your
153     * searchable activity (as a new search query).
154     * @param line2 If you have configured your recent suggestions provider with
155     * {@link android.content.SearchRecentSuggestionsProvider#DATABASE_MODE_2LINES}, you can
156     * pass a second line of text here.  It will be shown in a smaller font, below the primary
157     * suggestion.  When typing, matches in either line of text will be displayed in the list.
158     * If you did not configure two-line mode, or if a given suggestion does not have any
159     * additional text to display, you can pass null here.
160     */
161    public void saveRecentQuery(final String queryString, final String line2) {
162        if (TextUtils.isEmpty(queryString)) {
163            return;
164        }
165        if (!mTwoLineDisplay && !TextUtils.isEmpty(line2)) {
166            throw new IllegalArgumentException();
167        }
168
169        new Thread("saveRecentQuery") {
170            @Override
171            public void run() {
172                saveRecentQueryBlocking(queryString, line2);
173                sWritesInProgress.release();
174            }
175        }.start();
176    }
177
178    // Visible for testing.
179    void waitForSave() {
180        // Acquire writes semaphore until there is nothing available.
181        // This is to clean up after any previous callers to saveRecentQuery
182        // who did not also call waitForSave().
183        do {
184            sWritesInProgress.acquireUninterruptibly();
185        } while (sWritesInProgress.availablePermits() > 0);
186    }
187
188    private void saveRecentQueryBlocking(String queryString, String line2) {
189        ContentResolver cr = mContext.getContentResolver();
190        long now = System.currentTimeMillis();
191
192        // Use content resolver (not cursor) to insert/update this query
193        try {
194            ContentValues values = new ContentValues();
195            values.put(SuggestionColumns.DISPLAY1, queryString);
196            if (mTwoLineDisplay) {
197                values.put(SuggestionColumns.DISPLAY2, line2);
198            }
199            values.put(SuggestionColumns.QUERY, queryString);
200            values.put(SuggestionColumns.DATE, now);
201            cr.insert(mSuggestionsUri, values);
202        } catch (RuntimeException e) {
203            Log.e(LOG_TAG, "saveRecentQuery", e);
204        }
205
206        // Shorten the list (if it has become too long)
207        truncateHistory(cr, MAX_HISTORY_COUNT);
208    }
209
210    /**
211     * Completely delete the history.  Use this call to implement a "clear history" UI.
212     *
213     * Any application that implements search suggestions based on previous actions (such as
214     * recent queries, page/items viewed, etc.) should provide a way for the user to clear the
215     * history.  This gives the user a measure of privacy, if they do not wish for their recent
216     * searches to be replayed by other users of the device (via suggestions).
217     */
218    public void clearHistory() {
219        ContentResolver cr = mContext.getContentResolver();
220        truncateHistory(cr, 0);
221    }
222
223    /**
224     * Reduces the length of the history table, to prevent it from growing too large.
225     *
226     * @param cr Convenience copy of the content resolver.
227     * @param maxEntries Max entries to leave in the table. 0 means remove all entries.
228     */
229    protected void truncateHistory(ContentResolver cr, int maxEntries) {
230        if (maxEntries < 0) {
231            throw new IllegalArgumentException();
232        }
233
234        try {
235            // null means "delete all".  otherwise "delete but leave n newest"
236            String selection = null;
237            if (maxEntries > 0) {
238                selection = "_id IN " +
239                        "(SELECT _id FROM suggestions" +
240                        " ORDER BY " + SuggestionColumns.DATE + " DESC" +
241                        " LIMIT -1 OFFSET " + String.valueOf(maxEntries) + ")";
242            }
243            cr.delete(mSuggestionsUri, selection, null);
244        } catch (RuntimeException e) {
245            Log.e(LOG_TAG, "truncateHistory", e);
246        }
247    }
248}
249