1 /*
2  * Copyright (C) 2016 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 
17 package com.android.settings.search2;
18 
19 import android.content.Context;
20 import android.database.Cursor;
21 import android.database.sqlite.SQLiteDatabase;
22 
23 import android.support.annotation.VisibleForTesting;
24 import android.text.TextUtils;
25 import com.android.settings.dashboard.SiteMapManager;
26 import com.android.settings.overlay.FeatureFactory;
27 import com.android.settings.search.IndexDatabaseHelper;
28 import com.android.settings.utils.AsyncLoader;
29 
30 import java.util.ArrayList;
31 import java.util.List;
32 
33 import static com.android.settings.search.IndexDatabaseHelper.IndexColumns;
34 import static com.android.settings.search.IndexDatabaseHelper.Tables.TABLE_PREFS_INDEX;
35 
36 /**
37  * AsyncTask to retrieve Settings, First party app and any intent based results.
38  */
39 public class DatabaseResultLoader extends AsyncLoader<List<? extends SearchResult>> {
40     private static final String LOG = "DatabaseResultLoader";
41 
42     /* These indices are used to match the columns of the this loader's SELECT statement.
43      These are not necessarily the same order nor similar coverage as the schema defined in
44      IndexDatabaseHelper */
45     static final int COLUMN_INDEX_ID = 0;
46     static final int COLUMN_INDEX_TITLE = 1;
47     static final int COLUMN_INDEX_SUMMARY_ON = 2;
48     static final int COLUMN_INDEX_SUMMARY_OFF = 3;
49     static final int COLUMN_INDEX_CLASS_NAME = 4;
50     static final int COLUMN_INDEX_SCREEN_TITLE = 5;
51     static final int COLUMN_INDEX_ICON = 6;
52     static final int COLUMN_INDEX_INTENT_ACTION = 7;
53     static final int COLUMN_INDEX_INTENT_ACTION_TARGET_PACKAGE = 8;
54     static final int COLUMN_INDEX_INTENT_ACTION_TARGET_CLASS = 9;
55     static final int COLUMN_INDEX_KEY = 10;
56     static final int COLUMN_INDEX_PAYLOAD_TYPE = 11;
57     static final int COLUMN_INDEX_PAYLOAD = 12;
58 
59     public static final String[] SELECT_COLUMNS = {
60             IndexColumns.DOCID,
61             IndexColumns.DATA_TITLE,
62             IndexColumns.DATA_SUMMARY_ON,
63             IndexColumns.DATA_SUMMARY_OFF,
64             IndexColumns.CLASS_NAME,
65             IndexColumns.SCREEN_TITLE,
66             IndexColumns.ICON,
67             IndexColumns.INTENT_ACTION,
68             IndexColumns.INTENT_TARGET_PACKAGE,
69             IndexColumns.INTENT_TARGET_CLASS,
70             IndexColumns.DATA_KEY_REF,
71             IndexColumns.PAYLOAD_TYPE,
72             IndexColumns.PAYLOAD
73     };
74 
75     public static final String[] MATCH_COLUMNS_PRIMARY = {
76             IndexColumns.DATA_TITLE,
77             IndexColumns.DATA_TITLE_NORMALIZED,
78     };
79 
80     public static final String[] MATCH_COLUMNS_SECONDARY = {
81             IndexColumns.DATA_SUMMARY_ON,
82             IndexColumns.DATA_SUMMARY_ON_NORMALIZED,
83             IndexColumns.DATA_SUMMARY_OFF,
84             IndexColumns.DATA_SUMMARY_OFF_NORMALIZED,
85     };
86 
87     public static final String[] MATCH_COLUMNS_TERTIARY = {
88             IndexColumns.DATA_KEYWORDS,
89             IndexColumns.DATA_ENTRIES
90     };
91 
92     /**
93      * Base ranks defines the best possible rank based on what the query matches.
94      * If the query matches the prefix of the first word in the title, the best rank it can be is 1
95      * If the query matches the prefix of the other words in the title, the best rank it can be is 3
96      * If the query only matches the summary, the best rank it can be is 7
97      * If the query only matches keywords or entries, the best rank it can be is 9
98      *
99      */
100     public static final int[] BASE_RANKS = {1, 3, 7, 9};
101 
102     private final String mQueryText;
103     private final Context mContext;
104     private final CursorToSearchResultConverter mConverter;
105     private final SiteMapManager mSiteMapManager;
106 
DatabaseResultLoader(Context context, String queryText, SiteMapManager mapManager)107     public DatabaseResultLoader(Context context, String queryText, SiteMapManager mapManager) {
108         super(context);
109         mSiteMapManager = mapManager;
110         mContext = context;
111         mQueryText = cleanQuery(queryText);
112         mConverter = new CursorToSearchResultConverter(context, mQueryText);
113     }
114 
115     @Override
onDiscardResult(List<? extends SearchResult> result)116     protected void onDiscardResult(List<? extends SearchResult> result) {
117         // TODO Search
118     }
119 
120     @Override
loadInBackground()121     public List<? extends SearchResult> loadInBackground() {
122         if (mQueryText == null || mQueryText.isEmpty()) {
123             return null;
124         }
125 
126         final List<SearchResult> primaryFirstWordResults;
127         final List<SearchResult> primaryMidWordResults;
128         final List<SearchResult> secondaryResults;
129         final List<SearchResult> tertiaryResults;
130 
131         primaryFirstWordResults = firstWordQuery(MATCH_COLUMNS_PRIMARY, BASE_RANKS[0]);
132         primaryMidWordResults = secondaryWordQuery(MATCH_COLUMNS_PRIMARY, BASE_RANKS[1]);
133         secondaryResults = anyWordQuery(MATCH_COLUMNS_SECONDARY, BASE_RANKS[2]);
134         tertiaryResults = anyWordQuery(MATCH_COLUMNS_TERTIARY, BASE_RANKS[3]);
135 
136         final List<SearchResult> results = new ArrayList<>(
137                 primaryFirstWordResults.size()
138                 + primaryMidWordResults.size()
139                 + secondaryResults.size()
140                 + tertiaryResults.size());
141 
142         results.addAll(primaryFirstWordResults);
143         results.addAll(primaryMidWordResults);
144         results.addAll(secondaryResults);
145         results.addAll(tertiaryResults);
146 
147         return removeDuplicates(results);
148     }
149 
150     @Override
onCancelLoad()151     protected boolean onCancelLoad() {
152         // TODO
153         return super.onCancelLoad();
154     }
155 
156     /**
157      * A generic method to make the query suitable for searching the database.
158      *
159      * @return the cleaned query string
160      */
cleanQuery(String query)161     private static String cleanQuery(String query) {
162         if (TextUtils.isEmpty(query)) {
163             return null;
164         }
165         return query.trim();
166     }
167 
168     /**
169      * Creates and executes the query which matches prefixes of the first word of the given columns.
170      *
171      * @param matchColumns The columns to match on
172      * @param baseRank The highest rank achievable by these results
173      * @return A list of the matching results.
174      */
firstWordQuery(String[] matchColumns, int baseRank)175     private List<SearchResult> firstWordQuery(String[] matchColumns, int baseRank) {
176         final String whereClause = buildSingleWordWhereClause(matchColumns);
177         final String query = mQueryText + "%";
178         final String[] selection = buildSingleWordSelection(query, matchColumns.length);
179 
180         return query(whereClause, selection, baseRank);
181     }
182 
183     /**
184      * Creates and executes the query which matches prefixes of the non-first words of the
185      * given columns.
186      *
187      * @param matchColumns The columns to match on
188      * @param baseRank The highest rank achievable by these results
189      * @return A list of the matching results.
190      */
secondaryWordQuery(String[] matchColumns, int baseRank)191     private List<SearchResult> secondaryWordQuery(String[] matchColumns, int baseRank) {
192         final String whereClause = buildSingleWordWhereClause(matchColumns);
193         final String query = "% " + mQueryText + "%";
194         final String[] selection = buildSingleWordSelection(query, matchColumns.length);
195 
196         return query(whereClause, selection, baseRank);
197     }
198 
199     /**
200      * Creates and executes the query which matches prefixes of the any word of the given columns.
201      *
202      * @param matchColumns The columns to match on
203      * @param baseRank The highest rank achievable by these results
204      * @return A list of the matching results.
205      */
anyWordQuery(String[] matchColumns, int baseRank)206     private List<SearchResult> anyWordQuery(String[] matchColumns, int baseRank) {
207         final String whereClause = buildTwoWordWhereClause(matchColumns);
208         final String[] selection = buildAnyWordSelection(matchColumns.length * 2);
209 
210         return query(whereClause, selection, baseRank);
211     }
212 
213     /**
214      * Generic method used by all of the query methods above to execute a query.
215      *
216      * @param whereClause Where clause for the SQL query which uses bindings.
217      * @param selection List of the transformed query to match each bind in the whereClause
218      * @param baseRank The highest rank achievable by these results.
219      * @return A list of the matching results.
220      */
query(String whereClause, String[] selection, int baseRank)221     private List<SearchResult> query(String whereClause, String[] selection, int baseRank) {
222         final SQLiteDatabase database = IndexDatabaseHelper.getInstance(mContext)
223                 .getReadableDatabase();
224         final Cursor resultCursor = database.query(TABLE_PREFS_INDEX, SELECT_COLUMNS, whereClause,
225                 selection, null, null, null);
226         return mConverter.convertCursor(mSiteMapManager, resultCursor, baseRank);
227     }
228 
229     /**
230      * Builds the SQLite WHERE clause that matches all matchColumns for a single query.
231      *
232      * @param matchColumns List of columns that will be used for matching.
233      * @return The constructed WHERE clause.
234      */
buildSingleWordWhereClause(String[] matchColumns)235     private static String buildSingleWordWhereClause(String[] matchColumns) {
236         StringBuilder sb = new StringBuilder(" (");
237         final int count = matchColumns.length;
238         for (int n = 0; n < count; n++) {
239             sb.append(matchColumns[n]);
240             sb.append(" like ? ");
241             if (n < count - 1) {
242                 sb.append(" OR ");
243             }
244         }
245         sb.append(") AND enabled = 1");
246         return sb.toString();
247     }
248 
249     /**
250      * Builds the SQLite WHERE clause that matches all matchColumns to two different queries.
251      *
252      * @param matchColumns List of columns that will be used for matching.
253      * @return The constructed WHERE clause.
254      */
buildTwoWordWhereClause(String[] matchColumns)255     private static String buildTwoWordWhereClause(String[] matchColumns) {
256         StringBuilder sb = new StringBuilder(" (");
257         final int count = matchColumns.length;
258         for (int n = 0; n < count; n++) {
259             sb.append(matchColumns[n]);
260             sb.append(" like ? OR ");
261             sb.append(matchColumns[n]);
262             sb.append(" like ?");
263             if (n < count - 1) {
264                 sb.append(" OR ");
265             }
266         }
267         sb.append(") AND enabled = 1");
268         return sb.toString();
269     }
270 
271     /**
272      * Fills out the selection array to match the query as the prefix of a single word.
273      *
274      * @param size is the number of columns to be matched.
275      */
buildSingleWordSelection(String query, int size)276     private String[] buildSingleWordSelection(String query, int size) {
277         String[] selection = new String[size];
278 
279         for(int i = 0; i < size; i ++) {
280             selection[i] = query;
281         }
282         return selection;
283     }
284 
285     /**
286      * Fills out the selection array to match the query as the prefix of a word.
287      *
288      * @param size is twice the number of columns to be matched. The first match is for the prefix
289      *             of the first word in the column. The second match is for any subsequent word
290      *             prefix match.
291      */
buildAnyWordSelection(int size)292     private String[] buildAnyWordSelection(int size) {
293         String[] selection = new String[size];
294         final String query = mQueryText + "%";
295         final String subStringQuery = "% " + mQueryText + "%";
296 
297         for(int i = 0; i < (size - 1); i += 2) {
298             selection[i] = query;
299             selection[i + 1] = subStringQuery;
300         }
301         return selection;
302     }
303 
304     /**
305      * Goes through the list of search results and verifies that none of the results are duplicates.
306      * A duplicate is quantified by a result with the same Title and the same non-empty Summary.
307      *
308      * The method walks through the results starting with the highest priority result. It removes
309      * the duplicates by doing the first rule that applies below:
310      * - If a result is inline, remove the intent result.
311      * - Remove the lower rank item.
312      * @param results A list of results with potential duplicates
313      * @return The list of results with duplicates removed.
314      */
315     @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
removeDuplicates(List<SearchResult> results)316     List<SearchResult> removeDuplicates(List<SearchResult> results) {
317         SearchResult primaryResult, secondaryResult;
318 
319         // We accept the O(n^2) solution because the number of results is small.
320         for (int i = results.size() - 1; i >= 0; i--) {
321             secondaryResult = results.get(i);
322 
323             for (int j = i - 1; j >= 0; j--) {
324                 primaryResult = results.get(j);
325                 if (areDuplicateResults(primaryResult, secondaryResult)) {
326                     if (primaryResult.viewType != ResultPayload.PayloadType.INTENT) {
327                         // Case where both payloads are inline
328                         results.remove(i);
329                         break;
330                     } else if (secondaryResult.viewType != ResultPayload.PayloadType.INTENT) {
331                         // Case where only second result is inline.
332                         results.remove(j);
333                         i--; // shift the top index to reflect the lower element being removed
334                     } else {
335                         // Case where both payloads are intent.
336                         results.remove(i);
337                         break;
338                     }
339                 }
340             }
341         }
342         return results;
343     }
344 
345     /**
346      * @return True when the two {@link SearchResult SearchResults} have the same title, and the same
347      * non-empty summary.
348      */
areDuplicateResults(SearchResult primary, SearchResult secondary)349     private boolean areDuplicateResults(SearchResult primary, SearchResult secondary) {
350         return TextUtils.equals(primary.title, secondary.title)
351                 && TextUtils.equals(primary.summary, secondary.summary)
352                 && !TextUtils.isEmpty(primary.summary);
353     }
354 }