| /* |
| * Copyright (C) 2017 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.settings.intelligence.search.query; |
| |
| import static com.android.settings.intelligence.search.indexing.IndexDatabaseHelper.IndexColumns; |
| import static com.android.settings.intelligence.search.indexing.IndexDatabaseHelper.Tables |
| .TABLE_PREFS_INDEX; |
| |
| import android.content.Context; |
| import android.database.Cursor; |
| import android.database.sqlite.SQLiteDatabase; |
| import androidx.annotation.VisibleForTesting; |
| import android.util.Log; |
| import android.util.Pair; |
| |
| import com.android.settings.intelligence.nano.SettingsIntelligenceLogProto; |
| import com.android.settings.intelligence.overlay.FeatureFactory; |
| import com.android.settings.intelligence.search.SearchFeatureProvider; |
| import com.android.settings.intelligence.search.SearchResult; |
| import com.android.settings.intelligence.search.indexing.IndexDatabaseHelper; |
| import com.android.settings.intelligence.search.sitemap.SiteMapManager; |
| |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.Comparator; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Set; |
| import java.util.TreeSet; |
| import java.util.concurrent.ExecutionException; |
| import java.util.concurrent.ExecutorService; |
| import java.util.concurrent.FutureTask; |
| import java.util.concurrent.TimeUnit; |
| import java.util.concurrent.TimeoutException; |
| |
| /** |
| * AsyncTask to retrieve Settings, first party app and any intent based results. |
| */ |
| public class DatabaseResultTask extends SearchQueryTask.QueryWorker { |
| |
| private static final String TAG = "DatabaseResultTask"; |
| |
| public static final String[] SELECT_COLUMNS = { |
| IndexColumns.DATA_TITLE, |
| IndexColumns.DATA_SUMMARY_ON, |
| IndexColumns.DATA_SUMMARY_OFF, |
| IndexColumns.CLASS_NAME, |
| IndexColumns.SCREEN_TITLE, |
| IndexColumns.ICON, |
| IndexColumns.INTENT_ACTION, |
| IndexColumns.DATA_PACKAGE, |
| IndexColumns.INTENT_TARGET_PACKAGE, |
| IndexColumns.INTENT_TARGET_CLASS, |
| IndexColumns.DATA_KEY_REF, |
| IndexColumns.PAYLOAD_TYPE, |
| IndexColumns.PAYLOAD |
| }; |
| |
| public static final String[] MATCH_COLUMNS_PRIMARY = { |
| IndexColumns.DATA_TITLE, |
| IndexColumns.DATA_TITLE_NORMALIZED, |
| }; |
| |
| public static final String[] MATCH_COLUMNS_SECONDARY = { |
| IndexColumns.DATA_SUMMARY_ON, |
| IndexColumns.DATA_SUMMARY_ON_NORMALIZED, |
| IndexColumns.DATA_SUMMARY_OFF, |
| IndexColumns.DATA_SUMMARY_OFF_NORMALIZED, |
| }; |
| |
| public static final int QUERY_WORKER_ID = |
| SettingsIntelligenceLogProto.SettingsIntelligenceEvent.SEARCH_QUERY_DATABASE; |
| |
| /** |
| * Base ranks defines the best possible rank based on what the query matches. |
| * If the query matches the prefix of the first word in the title, the best rank it can be |
| * is 1 |
| * If the query matches the prefix of the other words in the title, the best rank it can be |
| * is 3 |
| * If the query only matches the summary, the best rank it can be is 7 |
| * If the query only matches keywords or entries, the best rank it can be is 9 |
| */ |
| static final int[] BASE_RANKS = {1, 3, 7, 9}; |
| |
| public static SearchQueryTask newTask(Context context, SiteMapManager siteMapManager, |
| String query) { |
| return new SearchQueryTask(new DatabaseResultTask(context, siteMapManager, query)); |
| } |
| |
| public final String[] MATCH_COLUMNS_TERTIARY = { |
| IndexColumns.DATA_KEYWORDS, |
| IndexColumns.DATA_ENTRIES |
| }; |
| |
| private final CursorToSearchResultConverter mConverter; |
| private final SearchFeatureProvider mFeatureProvider; |
| |
| public DatabaseResultTask(Context context, SiteMapManager siteMapManager, String queryText) { |
| super(context, siteMapManager, queryText); |
| mConverter = new CursorToSearchResultConverter(context); |
| mFeatureProvider = FeatureFactory.get(context).searchFeatureProvider(); |
| } |
| |
| @Override |
| protected int getQueryWorkerId() { |
| return QUERY_WORKER_ID; |
| } |
| |
| @Override |
| protected List<? extends SearchResult> query() { |
| if (mQuery == null || mQuery.isEmpty()) { |
| return new ArrayList<>(); |
| } |
| // Start a Future to get search result scores. |
| FutureTask<List<Pair<String, Float>>> rankerTask = mFeatureProvider.getRankerTask( |
| mContext, mQuery); |
| |
| if (rankerTask != null) { |
| ExecutorService executorService = mFeatureProvider.getExecutorService(); |
| executorService.execute(rankerTask); |
| } |
| |
| final Set<SearchResult> resultSet = new HashSet<>(); |
| |
| resultSet.addAll(firstWordQuery(MATCH_COLUMNS_PRIMARY, BASE_RANKS[0])); |
| resultSet.addAll(secondaryWordQuery(MATCH_COLUMNS_PRIMARY, BASE_RANKS[1])); |
| resultSet.addAll(anyWordQuery(MATCH_COLUMNS_SECONDARY, BASE_RANKS[2])); |
| resultSet.addAll(anyWordQuery(MATCH_COLUMNS_TERTIARY, BASE_RANKS[3])); |
| |
| // Try to retrieve the scores in time. Otherwise use static ranking. |
| if (rankerTask != null) { |
| try { |
| final long timeoutMs = mFeatureProvider.smartSearchRankingTimeoutMs(mContext); |
| List<Pair<String, Float>> searchRankScores = rankerTask.get(timeoutMs, |
| TimeUnit.MILLISECONDS); |
| return getDynamicRankedResults(resultSet, searchRankScores); |
| } catch (TimeoutException | InterruptedException | ExecutionException e) { |
| Log.d(TAG, "Error waiting for result scores: " + e); |
| } |
| } |
| |
| List<SearchResult> resultList = new ArrayList<>(resultSet); |
| Collections.sort(resultList); |
| return resultList; |
| } |
| |
| // TODO (b/33577327) Retrieve all search results with a single query. |
| |
| /** |
| * Creates and executes the query which matches prefixes of the first word of the given |
| * columns. |
| * |
| * @param matchColumns The columns to match on |
| * @param baseRank The highest rank achievable by these results |
| * @return A set of the matching results. |
| */ |
| private Set<SearchResult> firstWordQuery(String[] matchColumns, int baseRank) { |
| final String whereClause = buildSingleWordWhereClause(matchColumns); |
| final String query = mQuery + "%"; |
| final String[] selection = buildSingleWordSelection(query, matchColumns.length); |
| |
| return query(whereClause, selection, baseRank); |
| } |
| |
| /** |
| * Creates and executes the query which matches prefixes of the non-first words of the |
| * given columns. |
| * |
| * @param matchColumns The columns to match on |
| * @param baseRank The highest rank achievable by these results |
| * @return A set of the matching results. |
| */ |
| private Set<SearchResult> secondaryWordQuery(String[] matchColumns, int baseRank) { |
| final String whereClause = buildSingleWordWhereClause(matchColumns); |
| final String query = "% " + mQuery + "%"; |
| final String[] selection = buildSingleWordSelection(query, matchColumns.length); |
| |
| return query(whereClause, selection, baseRank); |
| } |
| |
| /** |
| * Creates and executes the query which matches prefixes of the any word of the given |
| * columns. |
| * |
| * @param matchColumns The columns to match on |
| * @param baseRank The highest rank achievable by these results |
| * @return A set of the matching results. |
| */ |
| private Set<SearchResult> anyWordQuery(String[] matchColumns, int baseRank) { |
| final String whereClause = buildTwoWordWhereClause(matchColumns); |
| final String[] selection = buildAnyWordSelection(matchColumns.length * 2); |
| |
| return query(whereClause, selection, baseRank); |
| } |
| |
| /** |
| * Generic method used by all of the query methods above to execute a query. |
| * |
| * @param whereClause Where clause for the SQL query which uses bindings. |
| * @param selection List of the transformed query to match each bind in the whereClause |
| * @param baseRank The highest rank achievable by these results. |
| * @return A set of the matching results. |
| */ |
| private Set<SearchResult> query(String whereClause, String[] selection, int baseRank) { |
| final SQLiteDatabase database = |
| IndexDatabaseHelper.getInstance(mContext).getReadableDatabase(); |
| try (Cursor resultCursor = database.query(TABLE_PREFS_INDEX, SELECT_COLUMNS, |
| whereClause, |
| selection, null, null, null)) { |
| return mConverter.convertCursor(resultCursor, baseRank, mSiteMapManager); |
| } |
| } |
| |
| /** |
| * Builds the SQLite WHERE clause that matches all matchColumns for a single query. |
| * |
| * @param matchColumns List of columns that will be used for matching. |
| * @return The constructed WHERE clause. |
| */ |
| private static String buildSingleWordWhereClause(String[] matchColumns) { |
| StringBuilder sb = new StringBuilder(" ("); |
| final int count = matchColumns.length; |
| for (int n = 0; n < count; n++) { |
| sb.append(matchColumns[n]); |
| sb.append(" like ? "); |
| if (n < count - 1) { |
| sb.append(" OR "); |
| } |
| } |
| sb.append(") AND enabled = 1"); |
| return sb.toString(); |
| } |
| |
| /** |
| * Builds the SQLite WHERE clause that matches all matchColumns to two different queries. |
| * |
| * @param matchColumns List of columns that will be used for matching. |
| * @return The constructed WHERE clause. |
| */ |
| private static String buildTwoWordWhereClause(String[] matchColumns) { |
| StringBuilder sb = new StringBuilder(" ("); |
| final int count = matchColumns.length; |
| for (int n = 0; n < count; n++) { |
| sb.append(matchColumns[n]); |
| sb.append(" like ? OR "); |
| sb.append(matchColumns[n]); |
| sb.append(" like ?"); |
| if (n < count - 1) { |
| sb.append(" OR "); |
| } |
| } |
| sb.append(") AND enabled = 1"); |
| return sb.toString(); |
| } |
| |
| /** |
| * Fills out the selection array to match the query as the prefix of a single word. |
| * |
| * @param size is the number of columns to be matched. |
| */ |
| private String[] buildSingleWordSelection(String query, int size) { |
| String[] selection = new String[size]; |
| |
| for (int i = 0; i < size; i++) { |
| selection[i] = query; |
| } |
| return selection; |
| } |
| |
| /** |
| * Fills out the selection array to match the query as the prefix of a word. |
| * |
| * @param size is twice the number of columns to be matched. The first match is for the |
| * prefix |
| * of the first word in the column. The second match is for any subsequent word |
| * prefix match. |
| */ |
| private String[] buildAnyWordSelection(int size) { |
| String[] selection = new String[size]; |
| final String query = mQuery + "%"; |
| final String subStringQuery = "% " + mQuery + "%"; |
| |
| for (int i = 0; i < (size - 1); i += 2) { |
| selection[i] = query; |
| selection[i + 1] = subStringQuery; |
| } |
| return selection; |
| } |
| |
| private List<SearchResult> getDynamicRankedResults(Set<SearchResult> unsortedSet, |
| final List<Pair<String, Float>> searchRankScores) { |
| final TreeSet<SearchResult> dbResultsSortedByScores = new TreeSet<>( |
| new Comparator<SearchResult>() { |
| @Override |
| public int compare(SearchResult o1, SearchResult o2) { |
| final float score1 = getRankingScoreByKey(searchRankScores, o1.dataKey); |
| final float score2 = getRankingScoreByKey(searchRankScores, o2.dataKey); |
| if (score1 > score2) { |
| return -1; |
| } else { |
| return 1; |
| } |
| } |
| }); |
| dbResultsSortedByScores.addAll(unsortedSet); |
| |
| return new ArrayList<>(dbResultsSortedByScores); |
| } |
| |
| /** |
| * Looks up ranking score by key. |
| * |
| * @param key key for a single search result. |
| * @return the ranking score corresponding to the given stableId. If there is no score |
| * available for this stableId, -Float.MAX_VALUE is returned. |
| */ |
| @VisibleForTesting |
| Float getRankingScoreByKey(List<Pair<String, Float>> searchRankScores, String key) { |
| for (Pair<String, Float> rankingScore : searchRankScores) { |
| if (key.compareTo(rankingScore.first) == 0) { |
| return rankingScore.second; |
| } |
| } |
| // If key not found in the list, we assign the minimum score so it will appear at |
| // the end of the list. |
| Log.w(TAG, key + " was not in the ranking scores."); |
| return -Float.MAX_VALUE; |
| } |
| } |