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 }