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