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
18package com.android.settings.search;
19
20
21import static com.android.settings.search.DatabaseResultLoader.COLUMN_INDEX_ID;
22import static com.android.settings.search.DatabaseResultLoader
23        .COLUMN_INDEX_INTENT_ACTION_TARGET_PACKAGE;
24import static com.android.settings.search.DatabaseResultLoader.COLUMN_INDEX_KEY;
25import static com.android.settings.search.DatabaseResultLoader.SELECT_COLUMNS;
26import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.CLASS_NAME;
27import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.DATA_ENTRIES;
28import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.DATA_KEYWORDS;
29import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.DATA_KEY_REF;
30import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.DATA_SUMMARY_ON;
31import static com.android.settings.search.IndexDatabaseHelper.IndexColumns
32        .DATA_SUMMARY_ON_NORMALIZED;
33import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.DATA_TITLE;
34import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.DATA_TITLE_NORMALIZED;
35import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.DOCID;
36import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.ENABLED;
37import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.ICON;
38import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.INTENT_ACTION;
39import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.INTENT_TARGET_CLASS;
40import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.INTENT_TARGET_PACKAGE;
41import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.LOCALE;
42import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.PAYLOAD;
43import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.PAYLOAD_TYPE;
44import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.SCREEN_TITLE;
45import static com.android.settings.search.IndexDatabaseHelper.IndexColumns.USER_ID;
46import static com.android.settings.search.IndexDatabaseHelper.Tables.TABLE_PREFS_INDEX;
47
48import android.content.ContentValues;
49import android.content.Context;
50import android.content.Intent;
51import android.content.pm.ResolveInfo;
52import android.database.Cursor;
53import android.database.sqlite.SQLiteDatabase;
54import android.database.sqlite.SQLiteException;
55import android.os.Build;
56import android.provider.SearchIndexablesContract;
57import android.provider.SearchIndexablesContract.SiteMapColumns;
58import android.support.annotation.VisibleForTesting;
59import android.text.TextUtils;
60import android.util.Log;
61
62import com.android.settings.overlay.FeatureFactory;
63import com.android.settings.search.indexing.IndexData;
64import com.android.settings.search.indexing.IndexDataConverter;
65import com.android.settings.search.indexing.PreIndexData;
66import com.android.settings.search.indexing.PreIndexDataCollector;
67
68import java.util.List;
69import java.util.Locale;
70import java.util.Map;
71import java.util.Set;
72
73/**
74 * Consumes the SearchIndexableProvider content providers.
75 * Updates the Resource, Raw Data and non-indexable data for Search.
76 *
77 * TODO(b/33577327) this class needs to be refactored by moving most of its methods into controllers
78 */
79public class DatabaseIndexingManager {
80
81    private static final String LOG_TAG = "DatabaseIndexingManager";
82
83    private PreIndexDataCollector mCollector;
84    private IndexDataConverter mConverter;
85
86    private Context mContext;
87
88    public DatabaseIndexingManager(Context context) {
89        mContext = context;
90    }
91
92    /**
93     * Accumulate all data and non-indexable keys from each of the content-providers.
94     * Only the first indexing for the default language gets static search results - subsequent
95     * calls will only gather non-indexable keys.
96     */
97    public void performIndexing() {
98        final long startTime = System.currentTimeMillis();
99        final Intent intent = new Intent(SearchIndexablesContract.PROVIDER_INTERFACE);
100        final List<ResolveInfo> providers =
101                mContext.getPackageManager().queryIntentContentProviders(intent, 0);
102
103        final String localeStr = Locale.getDefault().toString();
104        final String fingerprint = Build.FINGERPRINT;
105        final String providerVersionedNames =
106                IndexDatabaseHelper.buildProviderVersionedNames(providers);
107
108        final boolean isFullIndex = isFullIndex(mContext, localeStr, fingerprint,
109                providerVersionedNames);
110
111        if (isFullIndex) {
112            rebuildDatabase();
113        }
114
115        PreIndexData indexData = getIndexDataFromProviders(providers, isFullIndex);
116
117        final long updateDatabaseStartTime = System.currentTimeMillis();
118        updateDatabase(indexData, isFullIndex);
119        if (SettingsSearchIndexablesProvider.DEBUG) {
120            final long updateDatabaseTime = System.currentTimeMillis() - updateDatabaseStartTime;
121            Log.d(LOG_TAG, "performIndexing updateDatabase took time: " + updateDatabaseTime);
122        }
123
124        //TODO(63922686): Setting indexed should be a single method, not 3 separate setters.
125        IndexDatabaseHelper.setLocaleIndexed(mContext, localeStr);
126        IndexDatabaseHelper.setBuildIndexed(mContext, fingerprint);
127        IndexDatabaseHelper.setProvidersIndexed(mContext, providerVersionedNames);
128
129        if (SettingsSearchIndexablesProvider.DEBUG) {
130            final long indexingTime = System.currentTimeMillis() - startTime;
131            Log.d(LOG_TAG, "performIndexing took time: " + indexingTime
132                    + "ms. Full index? " + isFullIndex);
133        }
134    }
135
136    @VisibleForTesting
137    PreIndexData getIndexDataFromProviders(List<ResolveInfo> providers, boolean isFullIndex) {
138        if (mCollector == null) {
139            mCollector = new PreIndexDataCollector(mContext);
140        }
141        return mCollector.collectIndexableData(providers, isFullIndex);
142    }
143
144    /**
145     * Checks if the indexed data is obsolete, when either:
146     * - Device language has changed
147     * - Device has taken an OTA.
148     * In both cases, the device requires a full index.
149     *
150     * @param locale      is the default for the device
151     * @param fingerprint id for the current build.
152     * @return true if a full index should be preformed.
153     */
154    @VisibleForTesting
155    boolean isFullIndex(Context context, String locale, String fingerprint,
156            String providerVersionedNames) {
157        final boolean isLocaleIndexed = IndexDatabaseHelper.isLocaleAlreadyIndexed(context, locale);
158        final boolean isBuildIndexed = IndexDatabaseHelper.isBuildIndexed(context, fingerprint);
159        final boolean areProvidersIndexed = IndexDatabaseHelper
160                .areProvidersIndexed(context, providerVersionedNames);
161
162        return !(isLocaleIndexed && isBuildIndexed && areProvidersIndexed);
163    }
164
165    /**
166     * Drop the currently stored database, and clear the flags which mark the database as indexed.
167     */
168    private void rebuildDatabase() {
169        // Drop the database when the locale or build has changed. This eliminates rows which are
170        // dynamically inserted in the old language, or deprecated settings.
171        final SQLiteDatabase db = getWritableDatabase();
172        IndexDatabaseHelper.getInstance(mContext).reconstruct(db);
173    }
174
175    /**
176     * Adds new data to the database and verifies the correctness of the ENABLED column.
177     * First, the data to be updated and all non-indexable keys are copied locally.
178     * Then all new data to be added is inserted.
179     * Then search results are verified to have the correct value of enabled.
180     * Finally, we record that the locale has been indexed.
181     *
182     * @param needsReindexing true the database needs to be rebuilt.
183     */
184    @VisibleForTesting
185    void updateDatabase(PreIndexData preIndexData, boolean needsReindexing) {
186        final Map<String, Set<String>> nonIndexableKeys = preIndexData.nonIndexableKeys;
187
188        final SQLiteDatabase database = getWritableDatabase();
189        if (database == null) {
190            Log.w(LOG_TAG, "Cannot indexDatabase Index as I cannot get a writable database");
191            return;
192        }
193
194        try {
195            database.beginTransaction();
196
197            // Convert all Pre-index data to Index data.
198            List<IndexData> indexData = getIndexData(preIndexData);
199            insertIndexData(database, indexData);
200
201            // Only check for non-indexable key updates after initial index.
202            // Enabled state with non-indexable keys is checked when items are first inserted.
203            if (!needsReindexing) {
204                updateDataInDatabase(database, nonIndexableKeys);
205            }
206
207            database.setTransactionSuccessful();
208        } finally {
209            database.endTransaction();
210        }
211    }
212
213    @VisibleForTesting
214    List<IndexData> getIndexData(PreIndexData data) {
215        if (mConverter == null) {
216            mConverter = new IndexDataConverter(mContext);
217        }
218        return mConverter.convertPreIndexDataToIndexData(data);
219    }
220
221    /**
222     * Inserts all of the entries in {@param indexData} into the {@param database}
223     * as Search Data and as part of the Information Hierarchy.
224     */
225    @VisibleForTesting
226    void insertIndexData(SQLiteDatabase database, List<IndexData> indexData) {
227        ContentValues values;
228
229        for (IndexData dataRow : indexData) {
230            if (TextUtils.isEmpty(dataRow.normalizedTitle)) {
231                continue;
232            }
233
234            values = new ContentValues();
235            values.put(IndexDatabaseHelper.IndexColumns.DOCID, dataRow.getDocId());
236            values.put(LOCALE, dataRow.locale);
237            values.put(DATA_TITLE, dataRow.updatedTitle);
238            values.put(DATA_TITLE_NORMALIZED, dataRow.normalizedTitle);
239            values.put(DATA_SUMMARY_ON, dataRow.updatedSummaryOn);
240            values.put(DATA_SUMMARY_ON_NORMALIZED, dataRow.normalizedSummaryOn);
241            values.put(DATA_ENTRIES, dataRow.entries);
242            values.put(DATA_KEYWORDS, dataRow.spaceDelimitedKeywords);
243            values.put(CLASS_NAME, dataRow.className);
244            values.put(SCREEN_TITLE, dataRow.screenTitle);
245            values.put(INTENT_ACTION, dataRow.intentAction);
246            values.put(INTENT_TARGET_PACKAGE, dataRow.intentTargetPackage);
247            values.put(INTENT_TARGET_CLASS, dataRow.intentTargetClass);
248            values.put(ICON, dataRow.iconResId);
249            values.put(ENABLED, dataRow.enabled);
250            values.put(DATA_KEY_REF, dataRow.key);
251            values.put(USER_ID, dataRow.userId);
252            values.put(PAYLOAD_TYPE, dataRow.payloadType);
253            values.put(PAYLOAD, dataRow.payload);
254
255            database.replaceOrThrow(TABLE_PREFS_INDEX, null, values);
256
257            if (!TextUtils.isEmpty(dataRow.className)
258                    && !TextUtils.isEmpty(dataRow.childClassName)) {
259                final ContentValues siteMapPair = new ContentValues();
260                siteMapPair.put(SiteMapColumns.PARENT_CLASS, dataRow.className);
261                siteMapPair.put(SiteMapColumns.PARENT_TITLE, dataRow.screenTitle);
262                siteMapPair.put(SiteMapColumns.CHILD_CLASS, dataRow.childClassName);
263                siteMapPair.put(SiteMapColumns.CHILD_TITLE, dataRow.updatedTitle);
264
265                database.replaceOrThrow(IndexDatabaseHelper.Tables.TABLE_SITE_MAP,
266                        null /* nullColumnHack */, siteMapPair);
267            }
268        }
269    }
270
271    /**
272     * Upholds the validity of enabled data for the user.
273     * All rows which are enabled but are now flagged with non-indexable keys will become disabled.
274     * All rows which are disabled but no longer a non-indexable key will become enabled.
275     *
276     * @param database         The database to validate.
277     * @param nonIndexableKeys A map between package name and the set of non-indexable keys for it.
278     */
279    @VisibleForTesting
280    void updateDataInDatabase(SQLiteDatabase database,
281            Map<String, Set<String>> nonIndexableKeys) {
282        final String whereEnabled = ENABLED + " = 1";
283        final String whereDisabled = ENABLED + " = 0";
284
285        final Cursor enabledResults = database.query(TABLE_PREFS_INDEX, SELECT_COLUMNS,
286                whereEnabled, null, null, null, null);
287
288        final ContentValues enabledToDisabledValue = new ContentValues();
289        enabledToDisabledValue.put(ENABLED, 0);
290
291        String packageName;
292        // TODO Refactor: Move these two loops into one method.
293        while (enabledResults.moveToNext()) {
294            // Package name is the key for remote providers.
295            // If package name is null, the provider is Settings.
296            packageName = enabledResults.getString(COLUMN_INDEX_INTENT_ACTION_TARGET_PACKAGE);
297            if (packageName == null) {
298                packageName = mContext.getPackageName();
299            }
300
301            final String key = enabledResults.getString(COLUMN_INDEX_KEY);
302            final Set<String> packageKeys = nonIndexableKeys.get(packageName);
303
304            // The indexed item is set to Enabled but is now non-indexable
305            if (packageKeys != null && packageKeys.contains(key)) {
306                final String whereClause = DOCID + " = " + enabledResults.getInt(COLUMN_INDEX_ID);
307                database.update(TABLE_PREFS_INDEX, enabledToDisabledValue, whereClause, null);
308            }
309        }
310        enabledResults.close();
311
312        final Cursor disabledResults = database.query(TABLE_PREFS_INDEX, SELECT_COLUMNS,
313                whereDisabled, null, null, null, null);
314
315        final ContentValues disabledToEnabledValue = new ContentValues();
316        disabledToEnabledValue.put(ENABLED, 1);
317
318        while (disabledResults.moveToNext()) {
319            // Package name is the key for remote providers.
320            // If package name is null, the provider is Settings.
321            packageName = disabledResults.getString(COLUMN_INDEX_INTENT_ACTION_TARGET_PACKAGE);
322            if (packageName == null) {
323                packageName = mContext.getPackageName();
324            }
325
326            final String key = disabledResults.getString(COLUMN_INDEX_KEY);
327            final Set<String> packageKeys = nonIndexableKeys.get(packageName);
328
329            // The indexed item is set to Disabled but is no longer non-indexable.
330            // We do not enable keys when packageKeys is null because it means the keys came
331            // from an unrecognized package and therefore should not be surfaced as results.
332            if (packageKeys != null && !packageKeys.contains(key)) {
333                String whereClause = DOCID + " = " + disabledResults.getInt(COLUMN_INDEX_ID);
334                database.update(TABLE_PREFS_INDEX, disabledToEnabledValue, whereClause, null);
335            }
336        }
337        disabledResults.close();
338    }
339
340    private SQLiteDatabase getWritableDatabase() {
341        try {
342            return IndexDatabaseHelper.getInstance(mContext).getWritableDatabase();
343        } catch (SQLiteException e) {
344            Log.e(LOG_TAG, "Cannot open writable database", e);
345            return null;
346        }
347    }
348}