Files
app_Settings/src/com/android/settings/search/DatabaseIndexingManager.java
Matthew Fritze fb772248b1 Move search querying into a single API
Settings now collects search results from a single
loader which fetches from an aggregator. This is to
facilitate the separation of search functionalitiy,
where "query" becomes a single synchronous call.
In this case, the aggregator will move to the
unbundled app and would be called on the
other end of the Query call. i.e. the new search
result loader will just call query, and unbundled
search will handle everything else.

An important implication is that the results will
be returned in a ranked order. Thus the ranking and
merging logic has been moved out of the RecyclerView
adapter (which is a good clean-up, anyway).

The SearchResultAggregator starts a Future for each
of the data sources:
- Static Results
- Installed Apps
- Input Devices
- Accessibility Services

We allow up to 500ms to collect the static results,
and then an additional 150ms for each subsequent
loader. In my quick tests, the static results take
about 20-30ms to load. The longest loader is installed
apps which takes roughly 50-60ms seconds (note that
this will be improved with dynamic result caching).

To handle the ranking in DatabaseResultLoader,
we start a Future to collect the dynamic ranking before
we start the SQL queries. When the SQL is done, we
wait the same timeout as before. Then we merge the
results, as before.

For now we have not changed how the Dynamic results
are collected, but eventually they will be a cache
of dynamic results.

Bug: 33577327
Bug: 67360547
Test: robotests
Change-Id: I91fb03f9fd059672a970f48bea21c8d655007fa3
2017-10-30 14:20:49 -07:00

409 lines
18 KiB
Java

/*
* 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.search;
import static com.android.settings.search.CursorToSearchResultConverter.COLUMN_INDEX_ID;
import static com.android.settings.search.CursorToSearchResultConverter
.COLUMN_INDEX_INTENT_ACTION_TARGET_PACKAGE;
import static com.android.settings.search.CursorToSearchResultConverter.COLUMN_INDEX_KEY;
import static com.android.settings.search.DatabaseResultLoader.SELECT_COLUMNS;
import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.DOCID;
import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.CLASS_NAME;
import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.DATA_ENTRIES;
import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.DATA_KEYWORDS;
import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.DATA_KEY_REF;
import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.DATA_SUMMARY_ON;
import static com.android.settings.search.IndexDatabaseHelper.IndexColumns
.DATA_SUMMARY_ON_NORMALIZED;
import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.DATA_TITLE;
import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.DATA_TITLE_NORMALIZED;
import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.ENABLED;
import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.ICON;
import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.INTENT_ACTION;
import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.INTENT_TARGET_CLASS;
import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.INTENT_TARGET_PACKAGE;
import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.LOCALE;
import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.PAYLOAD;
import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.PAYLOAD_TYPE;
import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.SCREEN_TITLE;
import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.USER_ID;
import static com.android.settings.search.IndexDatabaseHelper.Tables.TABLE_PREFS_INDEX;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ResolveInfo;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.os.AsyncTask;
import android.os.Build;
import android.provider.SearchIndexablesContract;
import android.support.annotation.VisibleForTesting;
import android.text.TextUtils;
import android.util.Log;
import com.android.settings.overlay.FeatureFactory;
import com.android.settings.search.indexing.IndexData;
import com.android.settings.search.indexing.IndexDataConverter;
import com.android.settings.search.indexing.PreIndexData;
import com.android.settings.search.indexing.PreIndexDataCollector;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Consumes the SearchIndexableProvider content providers.
* Updates the Resource, Raw Data and non-indexable data for Search.
*
* TODO(b/33577327) this class needs to be refactored by moving most of its methods into controllers
*/
public class DatabaseIndexingManager {
private static final String LOG_TAG = "DatabaseIndexingManager";
private static final String METRICS_ACTION_SETTINGS_ASYNC_INDEX =
"search_asynchronous_indexing";
public static final String FIELD_NAME_SEARCH_INDEX_DATA_PROVIDER =
"SEARCH_INDEX_DATA_PROVIDER";
@VisibleForTesting
final AtomicBoolean mIsIndexingComplete = new AtomicBoolean(false);
private PreIndexDataCollector mCollector;
private IndexDataConverter mConverter;
private Context mContext;
public DatabaseIndexingManager(Context context) {
mContext = context;
}
public boolean isIndexingComplete() {
return mIsIndexingComplete.get();
}
public void indexDatabase(IndexingCallback callback) {
IndexingTask task = new IndexingTask(callback);
task.execute();
}
/**
* Accumulate all data and non-indexable keys from each of the content-providers.
* Only the first indexing for the default language gets static search results - subsequent
* calls will only gather non-indexable keys.
*/
public void performIndexing() {
final long startTime = System.currentTimeMillis();
final Intent intent = new Intent(SearchIndexablesContract.PROVIDER_INTERFACE);
final List<ResolveInfo> providers =
mContext.getPackageManager().queryIntentContentProviders(intent, 0);
final String localeStr = Locale.getDefault().toString();
final String fingerprint = Build.FINGERPRINT;
final String providerVersionedNames =
IndexDatabaseHelper.buildProviderVersionedNames(providers);
final boolean isFullIndex = isFullIndex(mContext, localeStr, fingerprint,
providerVersionedNames);
if (isFullIndex) {
rebuildDatabase();
}
PreIndexData indexData = getIndexDataFromProviders(providers, isFullIndex);
final long updateDatabaseStartTime = System.currentTimeMillis();
updateDatabase(indexData, isFullIndex);
if (SettingsSearchIndexablesProvider.DEBUG) {
final long updateDatabaseTime = System.currentTimeMillis() - updateDatabaseStartTime;
Log.d(LOG_TAG, "performIndexing updateDatabase took time: " + updateDatabaseTime);
}
//TODO(63922686): Setting indexed should be a single method, not 3 separate setters.
IndexDatabaseHelper.setLocaleIndexed(mContext, localeStr);
IndexDatabaseHelper.setBuildIndexed(mContext, fingerprint);
IndexDatabaseHelper.setProvidersIndexed(mContext, providerVersionedNames);
if (SettingsSearchIndexablesProvider.DEBUG) {
final long indexingTime = System.currentTimeMillis() - startTime;
Log.d(LOG_TAG, "performIndexing took time: " + indexingTime
+ "ms. Full index? " + isFullIndex);
}
}
@VisibleForTesting
PreIndexData getIndexDataFromProviders(List<ResolveInfo> providers, boolean isFullIndex) {
if (mCollector == null) {
mCollector = new PreIndexDataCollector(mContext);
}
return mCollector.collectIndexableData(providers, isFullIndex);
}
/**
* Checks if the indexed data is obsolete, when either:
* - Device language has changed
* - Device has taken an OTA.
* In both cases, the device requires a full index.
*
* @param locale is the default for the device
* @param fingerprint id for the current build.
* @return true if a full index should be preformed.
*/
@VisibleForTesting
boolean isFullIndex(Context context, String locale, String fingerprint,
String providerVersionedNames) {
final boolean isLocaleIndexed = IndexDatabaseHelper.isLocaleAlreadyIndexed(context, locale);
final boolean isBuildIndexed = IndexDatabaseHelper.isBuildIndexed(context, fingerprint);
final boolean areProvidersIndexed = IndexDatabaseHelper
.areProvidersIndexed(context, providerVersionedNames);
return !(isLocaleIndexed && isBuildIndexed && areProvidersIndexed);
}
/**
* Drop the currently stored database, and clear the flags which mark the database as indexed.
*/
private void rebuildDatabase() {
// Drop the database when the locale or build has changed. This eliminates rows which are
// dynamically inserted in the old language, or deprecated settings.
final SQLiteDatabase db = getWritableDatabase();
IndexDatabaseHelper.getInstance(mContext).reconstruct(db);
}
/**
* Adds new data to the database and verifies the correctness of the ENABLED column.
* First, the data to be updated and all non-indexable keys are copied locally.
* Then all new data to be added is inserted.
* Then search results are verified to have the correct value of enabled.
* Finally, we record that the locale has been indexed.
*
* @param needsReindexing true the database needs to be rebuilt.
*/
@VisibleForTesting
void updateDatabase(PreIndexData preIndexData, boolean needsReindexing) {
final Map<String, Set<String>> nonIndexableKeys = preIndexData.nonIndexableKeys;
final SQLiteDatabase database = getWritableDatabase();
if (database == null) {
Log.w(LOG_TAG, "Cannot indexDatabase Index as I cannot get a writable database");
return;
}
try {
database.beginTransaction();
// Convert all Pre-index data to Index data.
List<IndexData> indexData = getIndexData(preIndexData);
insertIndexData(database, indexData);
// Only check for non-indexable key updates after initial index.
// Enabled state with non-indexable keys is checked when items are first inserted.
if (!needsReindexing) {
updateDataInDatabase(database, nonIndexableKeys);
}
database.setTransactionSuccessful();
} finally {
database.endTransaction();
}
}
@VisibleForTesting
List<IndexData> getIndexData(PreIndexData data) {
if (mConverter == null) {
mConverter = new IndexDataConverter(mContext);
}
return mConverter.convertPreIndexDataToIndexData(data);
}
/**
* Inserts all of the entries in {@param indexData} into the {@param database}
* as Search Data and as part of the Information Hierarchy.
*/
@VisibleForTesting
void insertIndexData(SQLiteDatabase database, List<IndexData> indexData) {
ContentValues values;
for (IndexData dataRow : indexData) {
if (TextUtils.isEmpty(dataRow.normalizedTitle)) {
continue;
}
values = new ContentValues();
values.put(IndexDatabaseHelper.IndexColumns.DOCID, dataRow.getDocId());
values.put(LOCALE, dataRow.locale);
values.put(DATA_TITLE, dataRow.updatedTitle);
values.put(DATA_TITLE_NORMALIZED, dataRow.normalizedTitle);
values.put(DATA_SUMMARY_ON, dataRow.updatedSummaryOn);
values.put(DATA_SUMMARY_ON_NORMALIZED, dataRow.normalizedSummaryOn);
values.put(DATA_ENTRIES, dataRow.entries);
values.put(DATA_KEYWORDS, dataRow.spaceDelimitedKeywords);
values.put(CLASS_NAME, dataRow.className);
values.put(SCREEN_TITLE, dataRow.screenTitle);
values.put(INTENT_ACTION, dataRow.intentAction);
values.put(INTENT_TARGET_PACKAGE, dataRow.intentTargetPackage);
values.put(INTENT_TARGET_CLASS, dataRow.intentTargetClass);
values.put(ICON, dataRow.iconResId);
values.put(ENABLED, dataRow.enabled);
values.put(DATA_KEY_REF, dataRow.key);
values.put(USER_ID, dataRow.userId);
values.put(PAYLOAD_TYPE, dataRow.payloadType);
values.put(PAYLOAD, dataRow.payload);
database.replaceOrThrow(TABLE_PREFS_INDEX, null, values);
if (!TextUtils.isEmpty(dataRow.className)
&& !TextUtils.isEmpty(dataRow.childClassName)) {
ContentValues siteMapPair = new ContentValues();
final int pairDocId = Objects.hash(dataRow.className, dataRow.childClassName);
siteMapPair.put(IndexDatabaseHelper.SiteMapColumns.DOCID, pairDocId);
siteMapPair.put(IndexDatabaseHelper.SiteMapColumns.PARENT_CLASS,
dataRow.className);
siteMapPair.put(IndexDatabaseHelper.SiteMapColumns.PARENT_TITLE,
dataRow.screenTitle);
siteMapPair.put(IndexDatabaseHelper.SiteMapColumns.CHILD_CLASS,
dataRow.childClassName);
siteMapPair.put(IndexDatabaseHelper.SiteMapColumns.CHILD_TITLE,
dataRow.updatedTitle);
database.replaceOrThrow(IndexDatabaseHelper.Tables.TABLE_SITE_MAP,
null /* nullColumnHack */, siteMapPair);
}
}
}
/**
* Upholds the validity of enabled data for the user.
* All rows which are enabled but are now flagged with non-indexable keys will become disabled.
* All rows which are disabled but no longer a non-indexable key will become enabled.
*
* @param database The database to validate.
* @param nonIndexableKeys A map between package name and the set of non-indexable keys for it.
*/
@VisibleForTesting
void updateDataInDatabase(SQLiteDatabase database,
Map<String, Set<String>> nonIndexableKeys) {
final String whereEnabled = ENABLED + " = 1";
final String whereDisabled = ENABLED + " = 0";
final Cursor enabledResults = database.query(TABLE_PREFS_INDEX, SELECT_COLUMNS,
whereEnabled, null, null, null, null);
final ContentValues enabledToDisabledValue = new ContentValues();
enabledToDisabledValue.put(ENABLED, 0);
String packageName;
// TODO Refactor: Move these two loops into one method.
while (enabledResults.moveToNext()) {
// Package name is the key for remote providers.
// If package name is null, the provider is Settings.
packageName = enabledResults.getString(COLUMN_INDEX_INTENT_ACTION_TARGET_PACKAGE);
if (packageName == null) {
packageName = mContext.getPackageName();
}
final String key = enabledResults.getString(COLUMN_INDEX_KEY);
final Set<String> packageKeys = nonIndexableKeys.get(packageName);
// The indexed item is set to Enabled but is now non-indexable
if (packageKeys != null && packageKeys.contains(key)) {
final String whereClause = DOCID + " = " + enabledResults.getInt(COLUMN_INDEX_ID);
database.update(TABLE_PREFS_INDEX, enabledToDisabledValue, whereClause, null);
}
}
enabledResults.close();
final Cursor disabledResults = database.query(TABLE_PREFS_INDEX, SELECT_COLUMNS,
whereDisabled, null, null, null, null);
final ContentValues disabledToEnabledValue = new ContentValues();
disabledToEnabledValue.put(ENABLED, 1);
while (disabledResults.moveToNext()) {
// Package name is the key for remote providers.
// If package name is null, the provider is Settings.
packageName = disabledResults.getString(COLUMN_INDEX_INTENT_ACTION_TARGET_PACKAGE);
if (packageName == null) {
packageName = mContext.getPackageName();
}
final String key = disabledResults.getString(COLUMN_INDEX_KEY);
final Set<String> packageKeys = nonIndexableKeys.get(packageName);
// The indexed item is set to Disabled but is no longer non-indexable.
// We do not enable keys when packageKeys is null because it means the keys came
// from an unrecognized package and therefore should not be surfaced as results.
if (packageKeys != null && !packageKeys.contains(key)) {
String whereClause = DOCID + " = " + disabledResults.getInt(COLUMN_INDEX_ID);
database.update(TABLE_PREFS_INDEX, disabledToEnabledValue, whereClause, null);
}
}
disabledResults.close();
}
private SQLiteDatabase getWritableDatabase() {
try {
return IndexDatabaseHelper.getInstance(mContext).getWritableDatabase();
} catch (SQLiteException e) {
Log.e(LOG_TAG, "Cannot open writable database", e);
return null;
}
}
public class IndexingTask extends AsyncTask<Void, Void, Void> {
@VisibleForTesting
IndexingCallback mCallback;
private long mIndexStartTime;
public IndexingTask(IndexingCallback callback) {
mCallback = callback;
}
@Override
protected void onPreExecute() {
mIndexStartTime = System.currentTimeMillis();
mIsIndexingComplete.set(false);
}
@Override
protected Void doInBackground(Void... voids) {
performIndexing();
return null;
}
@Override
protected void onPostExecute(Void aVoid) {
int indexingTime = (int) (System.currentTimeMillis() - mIndexStartTime);
FeatureFactory.getFactory(mContext).getMetricsFeatureProvider()
.histogram(mContext, METRICS_ACTION_SETTINGS_ASYNC_INDEX, indexingTime);
mIsIndexingComplete.set(true);
if (mCallback != null) {
mCallback.onIndexingFinished();
}
}
}
}