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     private class DatabaseTask extends AsyncTask<Object, Void, Object> {
179         private final String mKeyword;
180         private final DATABASE_OPERATION mOperation;
181 
DatabaseTask(String keyword, DATABASE_OPERATION operation)182         public DatabaseTask(String keyword, DATABASE_OPERATION operation) {
183             mKeyword = keyword;
184             mOperation = operation;
185         }
186 
getSortedHistoryList()187         private Cursor getSortedHistoryList() {
188             final SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
189             queryBuilder.setTables(DatabaseHelper.HISTORY_TABLE);
190 
191             return queryBuilder.query(mHelper.getReadableDatabase(), PROJECTION_HISTORY, null,
192                 null, null, null, DatabaseHelper.COLUMN_LAST_UPDATED_TIME + " DESC");
193         }
194 
addDatabaseData()195         private void addDatabaseData() {
196             final ContentValues values = new ContentValues();
197             values.put(DatabaseHelper.COLUMN_KEYWORD, mKeyword);
198             values.put(DatabaseHelper.COLUMN_LAST_UPDATED_TIME, System.currentTimeMillis());
199 
200             final long rowId = mHelper.getWritableDatabase().insert(
201                 DatabaseHelper.HISTORY_TABLE, null, values);
202             if (rowId == -1) {
203                 Log.w(TAG, "Failed to add " + mKeyword + "to database!");
204             }
205 
206             if (mListener != null) {
207                 mListener.onAddChangedListener(rowId);
208             }
209         }
210 
deleteDatabaseData()211         private void deleteDatabaseData() {
212             // We only care about the field of DatabaseHelper.COLUMN_KEYWORD for deleting
213             StringBuilder selection = new StringBuilder();
214             selection.append(DatabaseHelper.COLUMN_KEYWORD).append("=?");
215             final int numberOfRows = mHelper.getWritableDatabase().delete(
216                 DatabaseHelper.HISTORY_TABLE, selection.toString(), new String[] {
217                     mKeyword });
218             if (numberOfRows == 0) {
219                 Log.w(TAG, "Failed to delete " + mKeyword + "from database!");
220             }
221 
222             if (mListener != null) {
223                 mListener.onDeleteChangedListener(numberOfRows);
224             }
225         }
226 
updateDatabaseData()227         private void updateDatabaseData() {
228             // We just need to update the field DatabaseHelper.COLUMN_LAST_UPDATED_TIME,
229             // because we will sort by last modified when retrieving from database
230             ContentValues values = new ContentValues();
231             values.put(DatabaseHelper.COLUMN_LAST_UPDATED_TIME, System.currentTimeMillis());
232 
233             StringBuilder selection = new StringBuilder();
234             selection.append(DatabaseHelper.COLUMN_KEYWORD).append("=?");
235             final int numberOfRows = mHelper.getWritableDatabase().update(
236                 DatabaseHelper.HISTORY_TABLE, values, selection.toString(), new String[] {
237                     mKeyword });
238             if (numberOfRows == 0) {
239                 Log.w(TAG, "Failed to update " + mKeyword + "to database!");
240             }
241         }
242 
parseHistoryFromCursor(Cursor cursor)243         private void parseHistoryFromCursor(Cursor cursor) {
244             if (cursor == null) {
245                 if (DEBUG) {
246                     Log.e(TAG, "Null cursor happens when building local search history List!");
247                 }
248                 return;
249             }
250             synchronized (mLock) {
251                 mHistory.clear();
252                 try {
253                     while (cursor.moveToNext()) {
254                         mHistory.add(cursor.getString(cursor.getColumnIndex(
255                             DatabaseHelper.COLUMN_KEYWORD)));
256                     }
257                 } finally {
258                     cursor.close();
259                 }
260             }
261         }
262 
263         @Override
doInBackground(Object... params)264         protected Void doInBackground(Object... params) {
265             if (!TextUtils.isEmpty(mKeyword)) {
266                 switch (mOperation) {
267                     case ADD:
268                         addDatabaseData();
269                         break;
270                     case DELETE:
271                         deleteDatabaseData();
272                         break;
273                     case UPDATE:
274                         updateDatabaseData();
275                         break;
276                     default:
277                         break;
278                 }
279             }
280 
281             // params[0] is used to preventing reload twice when deleting over history count
282             if (params.length <= 0 || (params.length > 0 && ((Boolean)params[0]).booleanValue())) {
283                 parseHistoryFromCursor(getSortedHistoryList());
284             }
285             return null;
286         }
287 
288         @Override
onPostExecute(Object result)289         protected void onPostExecute(Object result) {
290             if (mListener != null) {
291                 mListener.onPostExecute();
292             }
293         }
294     }
295 
296     @VisibleForTesting
setDatabaseListener(DatabaseChangedListener listener)297     public void setDatabaseListener(DatabaseChangedListener listener) {
298         mListener = listener;
299     }
300 
301     interface DatabaseChangedListener {
onAddChangedListener(long longResult)302         void onAddChangedListener(long longResult);
onDeleteChangedListener(int intResult)303         void onDeleteChangedListener(int intResult);
onPostExecute()304         void onPostExecute();
305     }
306 }