1 /* 2 * Copyright (C) 2017 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.intelligence.search.query; 18 19 import static com.android.settings.intelligence.search.indexing.IndexDatabaseHelper.IndexColumns; 20 import static com.android.settings.intelligence.search.indexing.IndexDatabaseHelper.Tables 21 .TABLE_PREFS_INDEX; 22 23 import android.content.Context; 24 import android.database.Cursor; 25 import android.database.sqlite.SQLiteDatabase; 26 import android.support.annotation.VisibleForTesting; 27 import android.util.Log; 28 import android.util.Pair; 29 30 import com.android.settings.intelligence.nano.SettingsIntelligenceLogProto; 31 import com.android.settings.intelligence.overlay.FeatureFactory; 32 import com.android.settings.intelligence.search.SearchFeatureProvider; 33 import com.android.settings.intelligence.search.SearchResult; 34 import com.android.settings.intelligence.search.indexing.IndexDatabaseHelper; 35 import com.android.settings.intelligence.search.sitemap.SiteMapManager; 36 37 import java.util.ArrayList; 38 import java.util.Collections; 39 import java.util.Comparator; 40 import java.util.HashSet; 41 import java.util.List; 42 import java.util.Set; 43 import java.util.TreeSet; 44 import java.util.concurrent.ExecutionException; 45 import java.util.concurrent.ExecutorService; 46 import java.util.concurrent.FutureTask; 47 import java.util.concurrent.TimeUnit; 48 import java.util.concurrent.TimeoutException; 49 50 /** 51 * AsyncTask to retrieve Settings, first party app and any intent based results. 52 */ 53 public class DatabaseResultTask extends SearchQueryTask.QueryWorker { 54 55 private static final String TAG = "DatabaseResultTask"; 56 57 public static final String[] SELECT_COLUMNS = { 58 IndexColumns.DATA_TITLE, 59 IndexColumns.DATA_SUMMARY_ON, 60 IndexColumns.DATA_SUMMARY_OFF, 61 IndexColumns.CLASS_NAME, 62 IndexColumns.SCREEN_TITLE, 63 IndexColumns.ICON, 64 IndexColumns.INTENT_ACTION, 65 IndexColumns.DATA_PACKAGE, 66 IndexColumns.INTENT_TARGET_PACKAGE, 67 IndexColumns.INTENT_TARGET_CLASS, 68 IndexColumns.DATA_KEY_REF, 69 IndexColumns.PAYLOAD_TYPE, 70 IndexColumns.PAYLOAD 71 }; 72 73 public static final String[] MATCH_COLUMNS_PRIMARY = { 74 IndexColumns.DATA_TITLE, 75 IndexColumns.DATA_TITLE_NORMALIZED, 76 }; 77 78 public static final String[] MATCH_COLUMNS_SECONDARY = { 79 IndexColumns.DATA_SUMMARY_ON, 80 IndexColumns.DATA_SUMMARY_ON_NORMALIZED, 81 IndexColumns.DATA_SUMMARY_OFF, 82 IndexColumns.DATA_SUMMARY_OFF_NORMALIZED, 83 }; 84 85 public static final int QUERY_WORKER_ID = 86 SettingsIntelligenceLogProto.SettingsIntelligenceEvent.SEARCH_QUERY_DATABASE; 87 88 /** 89 * Base ranks defines the best possible rank based on what the query matches. 90 * If the query matches the prefix of the first word in the title, the best rank it can be 91 * is 1 92 * If the query matches the prefix of the other words in the title, the best rank it can be 93 * is 3 94 * If the query only matches the summary, the best rank it can be is 7 95 * If the query only matches keywords or entries, the best rank it can be is 9 96 */ 97 static final int[] BASE_RANKS = {1, 3, 7, 9}; 98 newTask(Context context, SiteMapManager siteMapManager, String query)99 public static SearchQueryTask newTask(Context context, SiteMapManager siteMapManager, 100 String query) { 101 return new SearchQueryTask(new DatabaseResultTask(context, siteMapManager, query)); 102 } 103 104 public final String[] MATCH_COLUMNS_TERTIARY = { 105 IndexColumns.DATA_KEYWORDS, 106 IndexColumns.DATA_ENTRIES 107 }; 108 109 private final CursorToSearchResultConverter mConverter; 110 private final SearchFeatureProvider mFeatureProvider; 111 DatabaseResultTask(Context context, SiteMapManager siteMapManager, String queryText)112 public DatabaseResultTask(Context context, SiteMapManager siteMapManager, String queryText) { 113 super(context, siteMapManager, queryText); 114 mConverter = new CursorToSearchResultConverter(context); 115 mFeatureProvider = FeatureFactory.get(context).searchFeatureProvider(); 116 } 117 118 @Override getQueryWorkerId()119 protected int getQueryWorkerId() { 120 return QUERY_WORKER_ID; 121 } 122 123 @Override query()124 protected List<? extends SearchResult> query() { 125 if (mQuery == null || mQuery.isEmpty()) { 126 return new ArrayList<>(); 127 } 128 // Start a Future to get search result scores. 129 FutureTask<List<Pair<String, Float>>> rankerTask = mFeatureProvider.getRankerTask( 130 mContext, mQuery); 131 132 if (rankerTask != null) { 133 ExecutorService executorService = mFeatureProvider.getExecutorService(); 134 executorService.execute(rankerTask); 135 } 136 137 final Set<SearchResult> resultSet = new HashSet<>(); 138 139 resultSet.addAll(firstWordQuery(MATCH_COLUMNS_PRIMARY, BASE_RANKS[0])); 140 resultSet.addAll(secondaryWordQuery(MATCH_COLUMNS_PRIMARY, BASE_RANKS[1])); 141 resultSet.addAll(anyWordQuery(MATCH_COLUMNS_SECONDARY, BASE_RANKS[2])); 142 resultSet.addAll(anyWordQuery(MATCH_COLUMNS_TERTIARY, BASE_RANKS[3])); 143 144 // Try to retrieve the scores in time. Otherwise use static ranking. 145 if (rankerTask != null) { 146 try { 147 final long timeoutMs = mFeatureProvider.smartSearchRankingTimeoutMs(mContext); 148 List<Pair<String, Float>> searchRankScores = rankerTask.get(timeoutMs, 149 TimeUnit.MILLISECONDS); 150 return getDynamicRankedResults(resultSet, searchRankScores); 151 } catch (TimeoutException | InterruptedException | ExecutionException e) { 152 Log.d(TAG, "Error waiting for result scores: " + e); 153 } 154 } 155 156 List<SearchResult> resultList = new ArrayList<>(resultSet); 157 Collections.sort(resultList); 158 return resultList; 159 } 160 161 // TODO (b/33577327) Retrieve all search results with a single query. 162 163 /** 164 * Creates and executes the query which matches prefixes of the first word of the given 165 * columns. 166 * 167 * @param matchColumns The columns to match on 168 * @param baseRank The highest rank achievable by these results 169 * @return A set of the matching results. 170 */ firstWordQuery(String[] matchColumns, int baseRank)171 private Set<SearchResult> firstWordQuery(String[] matchColumns, int baseRank) { 172 final String whereClause = buildSingleWordWhereClause(matchColumns); 173 final String query = mQuery + "%"; 174 final String[] selection = buildSingleWordSelection(query, matchColumns.length); 175 176 return query(whereClause, selection, baseRank); 177 } 178 179 /** 180 * Creates and executes the query which matches prefixes of the non-first words of the 181 * given columns. 182 * 183 * @param matchColumns The columns to match on 184 * @param baseRank The highest rank achievable by these results 185 * @return A set of the matching results. 186 */ secondaryWordQuery(String[] matchColumns, int baseRank)187 private Set<SearchResult> secondaryWordQuery(String[] matchColumns, int baseRank) { 188 final String whereClause = buildSingleWordWhereClause(matchColumns); 189 final String query = "% " + mQuery + "%"; 190 final String[] selection = buildSingleWordSelection(query, matchColumns.length); 191 192 return query(whereClause, selection, baseRank); 193 } 194 195 /** 196 * Creates and executes the query which matches prefixes of the any word of the given 197 * columns. 198 * 199 * @param matchColumns The columns to match on 200 * @param baseRank The highest rank achievable by these results 201 * @return A set of the matching results. 202 */ anyWordQuery(String[] matchColumns, int baseRank)203 private Set<SearchResult> anyWordQuery(String[] matchColumns, int baseRank) { 204 final String whereClause = buildTwoWordWhereClause(matchColumns); 205 final String[] selection = buildAnyWordSelection(matchColumns.length * 2); 206 207 return query(whereClause, selection, baseRank); 208 } 209 210 /** 211 * Generic method used by all of the query methods above to execute a query. 212 * 213 * @param whereClause Where clause for the SQL query which uses bindings. 214 * @param selection List of the transformed query to match each bind in the whereClause 215 * @param baseRank The highest rank achievable by these results. 216 * @return A set of the matching results. 217 */ query(String whereClause, String[] selection, int baseRank)218 private Set<SearchResult> query(String whereClause, String[] selection, int baseRank) { 219 final SQLiteDatabase database = 220 IndexDatabaseHelper.getInstance(mContext).getReadableDatabase(); 221 try (Cursor resultCursor = database.query(TABLE_PREFS_INDEX, SELECT_COLUMNS, 222 whereClause, 223 selection, null, null, null)) { 224 return mConverter.convertCursor(resultCursor, baseRank, mSiteMapManager); 225 } 226 } 227 228 /** 229 * Builds the SQLite WHERE clause that matches all matchColumns for a single query. 230 * 231 * @param matchColumns List of columns that will be used for matching. 232 * @return The constructed WHERE clause. 233 */ buildSingleWordWhereClause(String[] matchColumns)234 private static String buildSingleWordWhereClause(String[] matchColumns) { 235 StringBuilder sb = new StringBuilder(" ("); 236 final int count = matchColumns.length; 237 for (int n = 0; n < count; n++) { 238 sb.append(matchColumns[n]); 239 sb.append(" like ? "); 240 if (n < count - 1) { 241 sb.append(" OR "); 242 } 243 } 244 sb.append(") AND enabled = 1"); 245 return sb.toString(); 246 } 247 248 /** 249 * Builds the SQLite WHERE clause that matches all matchColumns to two different queries. 250 * 251 * @param matchColumns List of columns that will be used for matching. 252 * @return The constructed WHERE clause. 253 */ buildTwoWordWhereClause(String[] matchColumns)254 private static String buildTwoWordWhereClause(String[] matchColumns) { 255 StringBuilder sb = new StringBuilder(" ("); 256 final int count = matchColumns.length; 257 for (int n = 0; n < count; n++) { 258 sb.append(matchColumns[n]); 259 sb.append(" like ? OR "); 260 sb.append(matchColumns[n]); 261 sb.append(" like ?"); 262 if (n < count - 1) { 263 sb.append(" OR "); 264 } 265 } 266 sb.append(") AND enabled = 1"); 267 return sb.toString(); 268 } 269 270 /** 271 * Fills out the selection array to match the query as the prefix of a single word. 272 * 273 * @param size is the number of columns to be matched. 274 */ buildSingleWordSelection(String query, int size)275 private String[] buildSingleWordSelection(String query, int size) { 276 String[] selection = new String[size]; 277 278 for (int i = 0; i < size; i++) { 279 selection[i] = query; 280 } 281 return selection; 282 } 283 284 /** 285 * Fills out the selection array to match the query as the prefix of a word. 286 * 287 * @param size is twice the number of columns to be matched. The first match is for the 288 * 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 = mQuery + "%"; 295 final String subStringQuery = "% " + mQuery + "%"; 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 getDynamicRankedResults(Set<SearchResult> unsortedSet, final List<Pair<String, Float>> searchRankScores)304 private List<SearchResult> getDynamicRankedResults(Set<SearchResult> unsortedSet, 305 final List<Pair<String, Float>> searchRankScores) { 306 final TreeSet<SearchResult> dbResultsSortedByScores = new TreeSet<>( 307 new Comparator<SearchResult>() { 308 @Override 309 public int compare(SearchResult o1, SearchResult o2) { 310 final float score1 = getRankingScoreByKey(searchRankScores, o1.dataKey); 311 final float score2 = getRankingScoreByKey(searchRankScores, o2.dataKey); 312 if (score1 > score2) { 313 return -1; 314 } else { 315 return 1; 316 } 317 } 318 }); 319 dbResultsSortedByScores.addAll(unsortedSet); 320 321 return new ArrayList<>(dbResultsSortedByScores); 322 } 323 324 /** 325 * Looks up ranking score by key. 326 * 327 * @param key key for a single search result. 328 * @return the ranking score corresponding to the given stableId. If there is no score 329 * available for this stableId, -Float.MAX_VALUE is returned. 330 */ 331 @VisibleForTesting getRankingScoreByKey(List<Pair<String, Float>> searchRankScores, String key)332 Float getRankingScoreByKey(List<Pair<String, Float>> searchRankScores, String key) { 333 for (Pair<String, Float> rankingScore : searchRankScores) { 334 if (key.compareTo(rankingScore.first) == 0) { 335 return rankingScore.second; 336 } 337 } 338 // If key not found in the list, we assign the minimum score so it will appear at 339 // the end of the list. 340 Log.w(TAG, key + " was not in the ranking scores."); 341 return -Float.MAX_VALUE; 342 } 343 }