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 androidx.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.DATA_AUTHORITY, 67 IndexColumns.INTENT_TARGET_PACKAGE, 68 IndexColumns.INTENT_TARGET_CLASS, 69 IndexColumns.DATA_KEY_REF, 70 IndexColumns.PAYLOAD_TYPE, 71 IndexColumns.PAYLOAD 72 }; 73 74 public static final String[] MATCH_COLUMNS_PRIMARY = { 75 IndexColumns.DATA_TITLE, 76 IndexColumns.DATA_TITLE_NORMALIZED, 77 }; 78 79 public static final String[] MATCH_COLUMNS_SECONDARY = { 80 IndexColumns.DATA_SUMMARY_ON, 81 IndexColumns.DATA_SUMMARY_ON_NORMALIZED, 82 IndexColumns.DATA_SUMMARY_OFF, 83 IndexColumns.DATA_SUMMARY_OFF_NORMALIZED, 84 }; 85 86 public static final int QUERY_WORKER_ID = 87 SettingsIntelligenceLogProto.SettingsIntelligenceEvent.SEARCH_QUERY_DATABASE; 88 89 /** 90 * Base ranks defines the best possible rank based on what the query matches. 91 * If the query matches the prefix of the first word in the title, the best rank it can be 92 * is 1 93 * If the query matches the prefix of the other words in the title, the best rank it can be 94 * is 3 95 * If the query only matches the summary, the best rank it can be is 7 96 * If the query only matches keywords or entries, the best rank it can be is 9 97 */ 98 static final int[] BASE_RANKS = {1, 3, 7, 9}; 99 newTask(Context context, SiteMapManager siteMapManager, String query)100 public static SearchQueryTask newTask(Context context, SiteMapManager siteMapManager, 101 String query) { 102 return new SearchQueryTask(new DatabaseResultTask(context, siteMapManager, query)); 103 } 104 105 public final String[] MATCH_COLUMNS_TERTIARY = { 106 IndexColumns.DATA_KEYWORDS, 107 IndexColumns.DATA_ENTRIES 108 }; 109 110 private final CursorToSearchResultConverter mConverter; 111 private final SearchFeatureProvider mFeatureProvider; 112 DatabaseResultTask(Context context, SiteMapManager siteMapManager, String queryText)113 public DatabaseResultTask(Context context, SiteMapManager siteMapManager, String queryText) { 114 super(context, siteMapManager, queryText); 115 mConverter = new CursorToSearchResultConverter(context); 116 mFeatureProvider = FeatureFactory.get(context).searchFeatureProvider(); 117 } 118 119 @Override getQueryWorkerId()120 protected int getQueryWorkerId() { 121 return QUERY_WORKER_ID; 122 } 123 124 @Override query()125 protected List<? extends SearchResult> query() { 126 if (mQuery == null || mQuery.isEmpty()) { 127 return new ArrayList<>(); 128 } 129 // Start a Future to get search result scores. 130 FutureTask<List<Pair<String, Float>>> rankerTask = mFeatureProvider.getRankerTask( 131 mContext, mQuery); 132 133 if (rankerTask != null) { 134 ExecutorService executorService = mFeatureProvider.getExecutorService(); 135 executorService.execute(rankerTask); 136 } 137 138 final Set<SearchResult> resultSet = new HashSet<>(); 139 140 resultSet.addAll(firstWordQuery(MATCH_COLUMNS_PRIMARY, BASE_RANKS[0])); 141 resultSet.addAll(secondaryWordQuery(MATCH_COLUMNS_PRIMARY, BASE_RANKS[1])); 142 resultSet.addAll(anyWordQuery(MATCH_COLUMNS_SECONDARY, BASE_RANKS[2])); 143 resultSet.addAll(anyWordQuery(MATCH_COLUMNS_TERTIARY, BASE_RANKS[3])); 144 145 // Try to retrieve the scores in time. Otherwise use static ranking. 146 if (rankerTask != null) { 147 try { 148 final long timeoutMs = mFeatureProvider.smartSearchRankingTimeoutMs(mContext); 149 List<Pair<String, Float>> searchRankScores = rankerTask.get(timeoutMs, 150 TimeUnit.MILLISECONDS); 151 return getDynamicRankedResults(resultSet, searchRankScores); 152 } catch (TimeoutException | InterruptedException | ExecutionException e) { 153 Log.d(TAG, "Error waiting for result scores: " + e); 154 } 155 } 156 157 List<SearchResult> resultList = new ArrayList<>(resultSet); 158 Collections.sort(resultList); 159 return resultList; 160 } 161 162 // TODO (b/33577327) Retrieve all search results with a single query. 163 164 /** 165 * Creates and executes the query which matches prefixes of the first word of the given 166 * columns. 167 * 168 * @param matchColumns The columns to match on 169 * @param baseRank The highest rank achievable by these results 170 * @return A set of the matching results. 171 */ firstWordQuery(String[] matchColumns, int baseRank)172 private Set<SearchResult> firstWordQuery(String[] matchColumns, int baseRank) { 173 final String whereClause = buildSingleWordWhereClause(matchColumns); 174 final String query = mQuery + "%"; 175 final String[] selection = buildSingleWordSelection(query, matchColumns.length); 176 177 return query(whereClause, selection, baseRank); 178 } 179 180 /** 181 * Creates and executes the query which matches prefixes of the non-first words of the 182 * given columns. 183 * 184 * @param matchColumns The columns to match on 185 * @param baseRank The highest rank achievable by these results 186 * @return A set of the matching results. 187 */ secondaryWordQuery(String[] matchColumns, int baseRank)188 private Set<SearchResult> secondaryWordQuery(String[] matchColumns, int baseRank) { 189 final String whereClause = buildSingleWordWhereClause(matchColumns); 190 final String query = "% " + mQuery + "%"; 191 final String[] selection = buildSingleWordSelection(query, matchColumns.length); 192 193 return query(whereClause, selection, baseRank); 194 } 195 196 /** 197 * Creates and executes the query which matches prefixes of the any word of the given 198 * columns. 199 * 200 * @param matchColumns The columns to match on 201 * @param baseRank The highest rank achievable by these results 202 * @return A set of the matching results. 203 */ anyWordQuery(String[] matchColumns, int baseRank)204 private Set<SearchResult> anyWordQuery(String[] matchColumns, int baseRank) { 205 final String whereClause = buildTwoWordWhereClause(matchColumns); 206 final String[] selection = buildAnyWordSelection(matchColumns.length * 2); 207 208 return query(whereClause, selection, baseRank); 209 } 210 211 /** 212 * Generic method used by all of the query methods above to execute a query. 213 * 214 * @param whereClause Where clause for the SQL query which uses bindings. 215 * @param selection List of the transformed query to match each bind in the whereClause 216 * @param baseRank The highest rank achievable by these results. 217 * @return A set of the matching results. 218 */ query(String whereClause, String[] selection, int baseRank)219 private Set<SearchResult> query(String whereClause, String[] selection, int baseRank) { 220 final SQLiteDatabase database = 221 IndexDatabaseHelper.getInstance(mContext).getReadableDatabase(); 222 try (Cursor resultCursor = database.query(TABLE_PREFS_INDEX, SELECT_COLUMNS, 223 whereClause, 224 selection, null, null, null)) { 225 return mConverter.convertCursor(resultCursor, baseRank, mSiteMapManager); 226 } 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 289 * prefix 290 * of the first word in the column. The second match is for any subsequent word 291 * prefix match. 292 */ buildAnyWordSelection(int size)293 private String[] buildAnyWordSelection(int size) { 294 String[] selection = new String[size]; 295 final String query = mQuery + "%"; 296 final String subStringQuery = "% " + mQuery + "%"; 297 298 for (int i = 0; i < (size - 1); i += 2) { 299 selection[i] = query; 300 selection[i + 1] = subStringQuery; 301 } 302 return selection; 303 } 304 getDynamicRankedResults(Set<SearchResult> unsortedSet, final List<Pair<String, Float>> searchRankScores)305 private List<SearchResult> getDynamicRankedResults(Set<SearchResult> unsortedSet, 306 final List<Pair<String, Float>> searchRankScores) { 307 final TreeSet<SearchResult> dbResultsSortedByScores = new TreeSet<>( 308 new Comparator<SearchResult>() { 309 @Override 310 public int compare(SearchResult o1, SearchResult o2) { 311 final float score1 = getRankingScoreByKey(searchRankScores, o1.dataKey); 312 final float score2 = getRankingScoreByKey(searchRankScores, o2.dataKey); 313 if (score1 > score2) { 314 return -1; 315 } else { 316 return 1; 317 } 318 } 319 }); 320 dbResultsSortedByScores.addAll(unsortedSet); 321 322 return new ArrayList<>(dbResultsSortedByScores); 323 } 324 325 /** 326 * Looks up ranking score by key. 327 * 328 * @param key key for a single search result. 329 * @return the ranking score corresponding to the given stableId. If there is no score 330 * available for this stableId, -Float.MAX_VALUE is returned. 331 */ 332 @VisibleForTesting getRankingScoreByKey(List<Pair<String, Float>> searchRankScores, String key)333 Float getRankingScoreByKey(List<Pair<String, Float>> searchRankScores, String key) { 334 for (Pair<String, Float> rankingScore : searchRankScores) { 335 if (key.compareTo(rankingScore.first) == 0) { 336 return rankingScore.second; 337 } 338 } 339 // If key not found in the list, we assign the minimum score so it will appear at 340 // the end of the list. 341 Log.w(TAG, key + " was not in the ranking scores."); 342 return -Float.MAX_VALUE; 343 } 344 }