1 /*
2  * Copyright (C) 2019 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.documentsui.queries;
18 
19 import static com.android.documentsui.base.SharedMinimal.DEBUG;
20 
21 import android.content.ContentValues;
22 import android.content.Context;
23 import android.database.Cursor;
24 import android.database.sqlite.SQLiteDatabase;
25 import android.database.sqlite.SQLiteOpenHelper;
26 import android.database.sqlite.SQLiteQueryBuilder;
27 import android.os.AsyncTask;
28 import android.text.TextUtils;
29 import android.util.Log;
30 
31 import androidx.annotation.GuardedBy;
32 import androidx.annotation.Nullable;
33 import androidx.annotation.VisibleForTesting;
34 
35 import com.android.documentsui.R;
36 
37 import java.util.ArrayList;
38 import java.util.Collections;
39 import java.util.List;
40 
41 /**
42  * A manager used to manage search history data.
43  */
44 public class SearchHistoryManager {
45 
46     private static final String TAG = "SearchHistoryManager";
47 
48     private static final String[] PROJECTION_HISTORY = new String[]{
49             DatabaseHelper.COLUMN_KEYWORD, DatabaseHelper.COLUMN_LAST_UPDATED_TIME
50     };
51 
52     private static SearchHistoryManager sManager;
53     private final DatabaseHelper mHelper;
54     private final int mLimitedHistoryCount;
55     @GuardedBy("mLock")
56     private final List<String> mHistory = Collections.synchronizedList(new ArrayList<>());
57     private final Object mLock = new Object();
58     private DatabaseChangedListener mListener;
59 
60     private enum DATABASE_OPERATION {
61         QUERY, ADD, DELETE, UPDATE
62     }
63 
SearchHistoryManager(Context context)64     private SearchHistoryManager(Context context) {
65         mHelper = new DatabaseHelper(context);
66         mLimitedHistoryCount = context.getResources().getInteger(
67             R.integer.config_maximum_search_history);
68     }
69 
70     /**
71      * Get the singleton instance of SearchHistoryManager.
72      *
73      * @return the singleton instance, guaranteed not null
74      */
getInstance(Context context)75     public static SearchHistoryManager getInstance(Context context) {
76         synchronized (SearchHistoryManager.class) {
77             if (sManager == null) {
78                 sManager = new SearchHistoryManager(context);
79                 sManager.new DatabaseTask(null, DATABASE_OPERATION.QUERY).executeOnExecutor(
80                     AsyncTask.SERIAL_EXECUTOR);
81             }
82             return sManager;
83         }
84     }
85 
86     private static class DatabaseHelper extends SQLiteOpenHelper {
87 
88         private static final int DATABASE_VERSION = 1;
89         private static final String COLUMN_KEYWORD = "keyword";
90         private static final String COLUMN_LAST_UPDATED_TIME = "last_updated_time";
91         private static final String HISTORY_DATABASE = "search_history.db";
92         private static final String HISTORY_TABLE = "search_history";
93 
DatabaseHelper(Context context)94         private DatabaseHelper(Context context) {
95             super(context, HISTORY_DATABASE, null, DATABASE_VERSION);
96         }
97 
98         @Override
onCreate(SQLiteDatabase db)99         public void onCreate(SQLiteDatabase db) {
100             db.execSQL("CREATE TABLE " + HISTORY_TABLE + " (" + COLUMN_KEYWORD + " TEXT NOT NULL, "
101                 + COLUMN_LAST_UPDATED_TIME + " INTEGER)");
102         }
103 
104         @Override
onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion)105         public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
106             //TODO: Doing database backup/restore data migration or upgrade with b/121987495
107 
108             if (DEBUG) {
109                 Log.w(TAG, "Upgrading database..., Old version = " + oldVersion
110                     + ", New version = " + newVersion);
111             }
112             db.execSQL("DROP TABLE IF EXISTS " + HISTORY_TABLE);
113             onCreate(db);
114         }
115     }
116 
117     /**
118      * Get search history list with/without filter text.
119      * @param filter the filter text
120      * @return a list of search history
121      */
getHistoryList(@ullable String filter)122     public List<String> getHistoryList(@Nullable String filter) {
123         synchronized (mLock) {
124             if (!TextUtils.isEmpty(filter)) {
125                 final List<String> filterKeyword = Collections.synchronizedList(new ArrayList<>());
126                 final String keyword = filter;
127                 for (String history : mHistory) {
128                     if (history.contains(keyword)) {
129                         filterKeyword.add(history);
130                     }
131                 }
132                 return filterKeyword;
133             } else {
134                 return Collections.synchronizedList(new ArrayList<>(mHistory));
135             }
136         }
137     }
138 
139     /**
140      * Add search keyword text to list.
141      * @param keyword the text to be added
142      */
addHistory(String keyword)143     public void addHistory(String keyword) {
144         synchronized (mLock) {
145             if (mHistory.remove(keyword)) {
146                 mHistory.add(0, keyword);
147                 new DatabaseTask(keyword, DATABASE_OPERATION.UPDATE).executeOnExecutor(
148                     AsyncTask.SERIAL_EXECUTOR);
149             } else {
150                 if (mHistory.size() >= mLimitedHistoryCount) {
151                     new DatabaseTask(mHistory.remove(mHistory.size() - 1),
152                         DATABASE_OPERATION.DELETE).executeOnExecutor(AsyncTask.SERIAL_EXECUTOR,
153                         Boolean.FALSE);
154 
155                     Log.w(TAG, "Over search history count !! keyword = " + keyword
156                         + "has been deleted");
157                 }
158                 mHistory.add(0, keyword);
159                 new DatabaseTask(keyword, DATABASE_OPERATION.ADD).executeOnExecutor(
160                     AsyncTask.SERIAL_EXECUTOR);
161             }
162         }
163     }
164 
165     /**
166      * Delete search keyword text from list.
167      * @param keyword the text to be deleted
168      */
deleteHistory(String keyword)169     public void deleteHistory(String keyword) {
170         synchronized (mLock) {
171             if (mHistory.remove(keyword)) {
172                 new DatabaseTask(keyword, DATABASE_OPERATION.DELETE).executeOnExecutor(
173                     AsyncTask.SERIAL_EXECUTOR);
174             }
175         }
176     }
177 
178     /**
179      * Closes the database.
180      */
closeDatabase()181     public void closeDatabase() {
182         mHelper.close();
183     }
184 
185     private class DatabaseTask extends AsyncTask<Object, Void, Object> {
186         private final String mKeyword;
187         private final DATABASE_OPERATION mOperation;
188 
DatabaseTask(String keyword, DATABASE_OPERATION operation)189         public DatabaseTask(String keyword, DATABASE_OPERATION operation) {
190             mKeyword = keyword;
191             mOperation = operation;
192         }
193 
getSortedHistoryList()194         private Cursor getSortedHistoryList() {
195             final SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
196             queryBuilder.setTables(DatabaseHelper.HISTORY_TABLE);
197 
198             return queryBuilder.query(mHelper.getReadableDatabase(), PROJECTION_HISTORY, null,
199                 null, null, null, DatabaseHelper.COLUMN_LAST_UPDATED_TIME + " DESC");
200         }
201 
addDatabaseData()202         private void addDatabaseData() {
203             final ContentValues values = new ContentValues();
204             values.put(DatabaseHelper.COLUMN_KEYWORD, mKeyword);
205             values.put(DatabaseHelper.COLUMN_LAST_UPDATED_TIME, System.currentTimeMillis());
206 
207             final long rowId = mHelper.getWritableDatabase().insert(
208                 DatabaseHelper.HISTORY_TABLE, null, values);
209             if (rowId == -1) {
210                 Log.w(TAG, "Failed to add " + mKeyword + "to database!");
211             }
212 
213             if (mListener != null) {
214                 mListener.onAddChangedListener(rowId);
215             }
216         }
217 
deleteDatabaseData()218         private void deleteDatabaseData() {
219             // We only care about the field of DatabaseHelper.COLUMN_KEYWORD for deleting
220             StringBuilder selection = new StringBuilder();
221             selection.append(DatabaseHelper.COLUMN_KEYWORD).append("=?");
222             final int numberOfRows = mHelper.getWritableDatabase().delete(
223                 DatabaseHelper.HISTORY_TABLE, selection.toString(), new String[] {
224                     mKeyword });
225             if (numberOfRows == 0) {
226                 Log.w(TAG, "Failed to delete " + mKeyword + "from database!");
227             }
228 
229             if (mListener != null) {
230                 mListener.onDeleteChangedListener(numberOfRows);
231             }
232         }
233 
updateDatabaseData()234         private void updateDatabaseData() {
235             // We just need to update the field DatabaseHelper.COLUMN_LAST_UPDATED_TIME,
236             // because we will sort by last modified when retrieving from database
237             ContentValues values = new ContentValues();
238             values.put(DatabaseHelper.COLUMN_LAST_UPDATED_TIME, System.currentTimeMillis());
239 
240             StringBuilder selection = new StringBuilder();
241             selection.append(DatabaseHelper.COLUMN_KEYWORD).append("=?");
242             final int numberOfRows = mHelper.getWritableDatabase().update(
243                 DatabaseHelper.HISTORY_TABLE, values, selection.toString(), new String[] {
244                     mKeyword });
245             if (numberOfRows == 0) {
246                 Log.w(TAG, "Failed to update " + mKeyword + "to database!");
247             }
248         }
249 
parseHistoryFromCursor(Cursor cursor)250         private void parseHistoryFromCursor(Cursor cursor) {
251             if (cursor == null) {
252                 if (DEBUG) {
253                     Log.e(TAG, "Null cursor happens when building local search history List!");
254                 }
255                 return;
256             }
257             synchronized (mLock) {
258                 mHistory.clear();
259                 try {
260                     while (cursor.moveToNext()) {
261                         mHistory.add(cursor.getString(cursor.getColumnIndex(
262                             DatabaseHelper.COLUMN_KEYWORD)));
263                     }
264                 } finally {
265                     cursor.close();
266                 }
267             }
268         }
269 
270         @Override
doInBackground(Object... params)271         protected Void doInBackground(Object... params) {
272             if (!TextUtils.isEmpty(mKeyword)) {
273                 switch (mOperation) {
274                     case ADD:
275                         addDatabaseData();
276                         break;
277                     case DELETE:
278                         deleteDatabaseData();
279                         break;
280                     case UPDATE:
281                         updateDatabaseData();
282                         break;
283                     default:
284                         break;
285                 }
286             }
287 
288             // params[0] is used to preventing reload twice when deleting over history count
289             if (params.length <= 0 || (params.length > 0 && ((Boolean)params[0]).booleanValue())) {
290                 parseHistoryFromCursor(getSortedHistoryList());
291             }
292             return null;
293         }
294 
295         @Override
onPostExecute(Object result)296         protected void onPostExecute(Object result) {
297             if (mListener != null) {
298                 mListener.onPostExecute();
299             }
300         }
301     }
302 
303     @VisibleForTesting
setDatabaseListener(DatabaseChangedListener listener)304     public void setDatabaseListener(DatabaseChangedListener listener) {
305         mListener = listener;
306     }
307 
308     interface DatabaseChangedListener {
onAddChangedListener(long longResult)309         void onAddChangedListener(long longResult);
onDeleteChangedListener(int intResult)310         void onDeleteChangedListener(int intResult);
onPostExecute()311         void onPostExecute();
312     }
313 }