1 /*
2  * Copyright (C) 2023 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 package com.android.launcher3.model;
17 
18 import static com.android.launcher3.LauncherSettings.Favorites.addTableToDb;
19 import static com.android.launcher3.Utilities.SHOULD_SHOW_FIRST_PAGE_WIDGET;
20 import static com.android.launcher3.provider.LauncherDbUtils.dropTable;
21 
22 import android.content.ContentValues;
23 import android.content.Context;
24 import android.content.Intent;
25 import android.database.Cursor;
26 import android.database.DatabaseUtils;
27 import android.database.SQLException;
28 import android.database.sqlite.SQLiteDatabase;
29 import android.database.sqlite.SQLiteStatement;
30 import android.os.Process;
31 import android.os.UserHandle;
32 import android.provider.BaseColumns;
33 import android.text.TextUtils;
34 import android.util.Log;
35 
36 import androidx.annotation.NonNull;
37 
38 import com.android.launcher3.AutoInstallsLayout;
39 import com.android.launcher3.AutoInstallsLayout.LayoutParserCallback;
40 import com.android.launcher3.LauncherSettings;
41 import com.android.launcher3.LauncherSettings.Favorites;
42 import com.android.launcher3.Utilities;
43 import com.android.launcher3.config.FeatureFlags;
44 import com.android.launcher3.logging.FileLog;
45 import com.android.launcher3.pm.UserCache;
46 import com.android.launcher3.provider.LauncherDbUtils;
47 import com.android.launcher3.provider.LauncherDbUtils.SQLiteTransaction;
48 import com.android.launcher3.util.IntArray;
49 import com.android.launcher3.util.IntSet;
50 import com.android.launcher3.util.NoLocaleSQLiteHelper;
51 import com.android.launcher3.util.PackageManagerHelper;
52 import com.android.launcher3.util.Thunk;
53 import com.android.launcher3.widget.LauncherWidgetHolder;
54 
55 import java.io.File;
56 import java.net.URISyntaxException;
57 import java.util.Arrays;
58 import java.util.Locale;
59 import java.util.function.ToLongFunction;
60 import java.util.stream.Collectors;
61 
62 /**
63  * SqLite database for launcher home-screen model
64  * The class is subclassed in tests to create an in-memory db.
65  */
66 public class DatabaseHelper extends NoLocaleSQLiteHelper implements
67         LayoutParserCallback {
68 
69     /**
70      * Represents the schema of the database. Changes in scheme need not be backwards compatible.
71      * When increasing the scheme version, ensure that downgrade_schema.json is updated
72      */
73     public static final int SCHEMA_VERSION = 32;
74     private static final String TAG = "DatabaseHelper";
75     private static final boolean LOGD = false;
76 
77     private static final String DOWNGRADE_SCHEMA_FILE = "downgrade_schema.json";
78 
79     private final Context mContext;
80     private final ToLongFunction<UserHandle> mUserSerialProvider;
81     private final Runnable mOnEmptyDbCreateCallback;
82 
83     private int mMaxItemId = -1;
84     public boolean mHotseatRestoreTableExists;
85 
86     /**
87      * Constructor used in tests and for restore.
88      */
DatabaseHelper(Context context, String dbName, ToLongFunction<UserHandle> userSerialProvider, Runnable onEmptyDbCreateCallback)89     public DatabaseHelper(Context context, String dbName,
90             ToLongFunction<UserHandle> userSerialProvider, Runnable onEmptyDbCreateCallback) {
91         super(context, dbName, SCHEMA_VERSION);
92         mContext = context;
93         mUserSerialProvider = userSerialProvider;
94         mOnEmptyDbCreateCallback = onEmptyDbCreateCallback;
95     }
96 
initIds()97     protected void initIds() {
98         // In the case where neither onCreate nor onUpgrade gets called, we read the maxId from
99         // the DB here
100         if (mMaxItemId == -1) {
101             mMaxItemId = initializeMaxItemId(getWritableDatabase());
102         }
103     }
104 
105     @Override
onCreate(SQLiteDatabase db)106     public void onCreate(SQLiteDatabase db) {
107         if (LOGD) Log.d(TAG, "creating new launcher database");
108 
109         mMaxItemId = 1;
110 
111         addTableToDb(db, getDefaultUserSerial(), false /* optional */);
112 
113         // Fresh and clean launcher DB.
114         mMaxItemId = initializeMaxItemId(db);
115         mOnEmptyDbCreateCallback.run();
116     }
117 
onAddOrDeleteOp(SQLiteDatabase db)118     public void onAddOrDeleteOp(SQLiteDatabase db) {
119         if (mHotseatRestoreTableExists) {
120             dropTable(db, Favorites.HYBRID_HOTSEAT_BACKUP_TABLE);
121             mHotseatRestoreTableExists = false;
122         }
123     }
124 
getDefaultUserSerial()125     private long getDefaultUserSerial() {
126         return mUserSerialProvider.applyAsLong(Process.myUserHandle());
127     }
128 
129     @Override
onOpen(SQLiteDatabase db)130     public void onOpen(SQLiteDatabase db) {
131         super.onOpen(db);
132 
133         File schemaFile = mContext.getFileStreamPath(DOWNGRADE_SCHEMA_FILE);
134         if (!schemaFile.exists()) {
135             handleOneTimeDataUpgrade(db);
136         }
137         DbDowngradeHelper.updateSchemaFile(schemaFile, SCHEMA_VERSION, mContext);
138     }
139 
140     /**
141      * One-time data updated before support of onDowngrade was added. This update is backwards
142      * compatible and can safely be run multiple times.
143      * Note: No new logic should be added here after release, as the new logic might not get
144      * executed on an existing device.
145      * TODO: Move this to db upgrade path, once the downgrade path is released.
146      */
handleOneTimeDataUpgrade(SQLiteDatabase db)147     protected void handleOneTimeDataUpgrade(SQLiteDatabase db) {
148         // Remove "profile extra"
149         UserCache um = UserCache.INSTANCE.get(mContext);
150         for (UserHandle user : um.getUserProfiles()) {
151             long serial = um.getSerialNumberForUser(user);
152             String sql = "update favorites set intent = replace(intent, "
153                     + "';l.profile=" + serial + ";', ';') where itemType = 0;";
154             db.execSQL(sql);
155         }
156     }
157 
158     @Override
onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion)159     public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
160         if (LOGD) {
161             Log.d(TAG, "onUpgrade triggered: " + oldVersion);
162         }
163         switch (oldVersion) {
164             // The version cannot be lower that 12, as Launcher3 never supported a lower
165             // version of the DB.
166             case 12:
167                 // No-op
168             case 13: {
169                 try (SQLiteTransaction t = new SQLiteTransaction(db)) {
170                     // Insert new column for holding widget provider name
171                     db.execSQL("ALTER TABLE favorites ADD COLUMN appWidgetProvider TEXT;");
172                     t.commit();
173                 } catch (SQLException ex) {
174                     Log.e(TAG, ex.getMessage(), ex);
175                     // Old version remains, which means we wipe old data
176                     break;
177                 }
178             }
179             case 14: {
180                 if (!addIntegerColumn(db, Favorites.MODIFIED, 0)) {
181                     // Old version remains, which means we wipe old data
182                     break;
183                 }
184             }
185             case 15: {
186                 if (!addIntegerColumn(db, Favorites.RESTORED, 0)) {
187                     // Old version remains, which means we wipe old data
188                     break;
189                 }
190             }
191             case 16:
192                 // No-op
193             case 17:
194                 // No-op
195             case 18:
196                 // No-op
197             case 19: {
198                 // Add userId column
199                 if (!addIntegerColumn(db, Favorites.PROFILE_ID, getDefaultUserSerial())) {
200                     // Old version remains, which means we wipe old data
201                     break;
202                 }
203             }
204             case 20:
205                 if (!updateFolderItemsRank(db, true)) {
206                     break;
207                 }
208             case 21:
209                 // No-op
210             case 22: {
211                 if (!addIntegerColumn(db, Favorites.OPTIONS, 0)) {
212                     // Old version remains, which means we wipe old data
213                     break;
214                 }
215             }
216             case 23:
217                 // No-op
218             case 24:
219                 // No-op
220             case 25:
221                 convertShortcutsToLauncherActivities(db);
222             case 26:
223                 // QSB was moved to the grid. Ignore overlapping items
224             case 27: {
225                 // Update the favorites table so that the screen ids are ordered based on
226                 // workspace page rank.
227                 IntArray finalScreens = LauncherDbUtils.queryIntArray(false, db,
228                         "workspaceScreens", BaseColumns._ID, null, null, "screenRank");
229                 int[] original = finalScreens.toArray();
230                 Arrays.sort(original);
231                 String updatemap = "";
232                 for (int i = 0; i < original.length; i++) {
233                     if (finalScreens.get(i) != original[i]) {
234                         updatemap += String.format(Locale.ENGLISH, " WHEN %1$s=%2$d THEN %3$d",
235                                 Favorites.SCREEN, finalScreens.get(i), original[i]);
236                     }
237                 }
238                 if (!TextUtils.isEmpty(updatemap)) {
239                     String query = String.format(Locale.ENGLISH,
240                             "UPDATE %1$s SET %2$s=CASE %3$s ELSE %2$s END WHERE %4$s = %5$d",
241                             Favorites.TABLE_NAME, Favorites.SCREEN, updatemap,
242                             Favorites.CONTAINER, Favorites.CONTAINER_DESKTOP);
243                     db.execSQL(query);
244                 }
245                 dropTable(db, "workspaceScreens");
246             }
247             case 28: {
248                 boolean columnAdded = addIntegerColumn(
249                         db, Favorites.APPWIDGET_SOURCE, Favorites.CONTAINER_UNKNOWN);
250                 if (!columnAdded) {
251                     // Old version remains, which means we wipe old data
252                     break;
253                 }
254             }
255             case 29: {
256                 // Remove widget panel related leftover workspace items
257                 db.delete(Favorites.TABLE_NAME, Utilities.createDbSelectionQuery(
258                         Favorites.SCREEN, IntArray.wrap(-777, -778)), null);
259             }
260             case 30: {
261                 if (FeatureFlags.QSB_ON_FIRST_SCREEN
262                         && !SHOULD_SHOW_FIRST_PAGE_WIDGET) {
263                     // Clean up first row in screen 0 as it might contain junk data.
264                     Log.d(TAG, "Cleaning up first row");
265                     db.delete(Favorites.TABLE_NAME,
266                             String.format(Locale.ENGLISH,
267                                     "%1$s = %2$d AND %3$s = %4$d AND %5$s = %6$d",
268                                     Favorites.SCREEN, 0,
269                                     Favorites.CONTAINER, Favorites.CONTAINER_DESKTOP,
270                                     Favorites.CELLY, 0), null);
271                 }
272             }
273             case 31: {
274                 LauncherDbUtils.migrateLegacyShortcuts(mContext, db);
275             }
276             // Fall through
277             case 32: {
278                 // DB Upgraded successfully
279                 return;
280             }
281         }
282 
283         // DB was not upgraded
284         Log.w(TAG, "Destroying all old data.");
285         createEmptyDB(db);
286     }
287 
288     @Override
onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion)289     public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
290         try {
291             DbDowngradeHelper.parse(mContext.getFileStreamPath(DOWNGRADE_SCHEMA_FILE))
292                     .onDowngrade(db, oldVersion, newVersion);
293         } catch (Exception e) {
294             Log.d(TAG, "Unable to downgrade from: " + oldVersion + " to " + newVersion
295                     + ". Wiping database.", e);
296             createEmptyDB(db);
297         }
298     }
299 
300     /**
301      * Clears all the data for a fresh start.
302      */
createEmptyDB(SQLiteDatabase db)303     public void createEmptyDB(SQLiteDatabase db) {
304         try (SQLiteTransaction t = new SQLiteTransaction(db)) {
305             dropTable(db, Favorites.TABLE_NAME);
306             dropTable(db, "workspaceScreens");
307             onCreate(db);
308             t.commit();
309         }
310     }
311 
312     /**
313      * Removes widgets which are registered to the Launcher's host, but are not present
314      * in our model.
315      */
removeGhostWidgets(SQLiteDatabase db)316     public void removeGhostWidgets(SQLiteDatabase db) {
317         // Get all existing widget ids.
318         final LauncherWidgetHolder holder = newLauncherWidgetHolder();
319         try {
320             final int[] allWidgets;
321             try {
322                 // Although the method was defined in O, it has existed since the beginning of
323                 // time, so it might work on older platforms as well.
324                 allWidgets = holder.getAppWidgetIds();
325             } catch (IncompatibleClassChangeError e) {
326                 Log.e(TAG, "getAppWidgetIds not supported", e);
327                 return;
328             }
329             final IntSet validWidgets = IntSet.wrap(LauncherDbUtils.queryIntArray(false, db,
330                     Favorites.TABLE_NAME, Favorites.APPWIDGET_ID,
331                     "itemType=" + Favorites.ITEM_TYPE_APPWIDGET, null, null));
332             boolean isAnyWidgetRemoved = false;
333             for (int widgetId : allWidgets) {
334                 if (!validWidgets.contains(widgetId)) {
335                     try {
336                         FileLog.d(TAG, "Deleting widget not found in db: appWidgetId=" + widgetId);
337                         holder.deleteAppWidgetId(widgetId);
338                         isAnyWidgetRemoved = true;
339                     } catch (RuntimeException e) {
340                         // Ignore
341                     }
342                 }
343             }
344             if (isAnyWidgetRemoved) {
345                 final String allLauncherHostWidgetIds = Arrays.stream(allWidgets)
346                         .mapToObj(String::valueOf)
347                         .collect(Collectors.joining(",", "[", "]"));
348                 final String allValidLauncherDbWidgetIds = Arrays.stream(
349                                 validWidgets.getArray().toArray()).mapToObj(String::valueOf)
350                         .collect(Collectors.joining(",", "[", "]"));
351                 FileLog.d(TAG,
352                         "One or more widgets was removed: "
353                                 + " allLauncherHostWidgetIds=" + allLauncherHostWidgetIds
354                                 + ", allValidLauncherDbWidgetIds=" + allValidLauncherDbWidgetIds
355                 );
356             }
357         } finally {
358             holder.destroy();
359         }
360     }
361 
362     /**
363      * Replaces all shortcuts of type {@link Favorites#ITEM_TYPE_SHORTCUT} which have a valid
364      * launcher activity target with {@link Favorites#ITEM_TYPE_APPLICATION}.
365      */
366     @Thunk
convertShortcutsToLauncherActivities(SQLiteDatabase db)367     void convertShortcutsToLauncherActivities(SQLiteDatabase db) {
368         try (SQLiteTransaction t = new SQLiteTransaction(db);
369              // Only consider the primary user as other users can't have a shortcut.
370              Cursor c = db.query(Favorites.TABLE_NAME,
371                      new String[]{Favorites._ID, Favorites.INTENT},
372                      "itemType=" + Favorites.ITEM_TYPE_SHORTCUT
373                              + " AND profileId=" + getDefaultUserSerial(),
374                      null, null, null, null);
375              SQLiteStatement updateStmt = db.compileStatement("UPDATE favorites SET itemType="
376                      + Favorites.ITEM_TYPE_APPLICATION + " WHERE _id=?")
377         ) {
378             final int idIndex = c.getColumnIndexOrThrow(Favorites._ID);
379             final int intentIndex = c.getColumnIndexOrThrow(Favorites.INTENT);
380 
381             while (c.moveToNext()) {
382                 String intentDescription = c.getString(intentIndex);
383                 Intent intent;
384                 try {
385                     intent = Intent.parseUri(intentDescription, 0);
386                 } catch (URISyntaxException e) {
387                     Log.e(TAG, "Unable to parse intent", e);
388                     continue;
389                 }
390 
391                 if (!PackageManagerHelper.isLauncherAppTarget(intent)) {
392                     continue;
393                 }
394 
395                 int id = c.getInt(idIndex);
396                 updateStmt.bindLong(1, id);
397                 updateStmt.executeUpdateDelete();
398             }
399             t.commit();
400         } catch (SQLException ex) {
401             Log.w(TAG, "Error deduping shortcuts", ex);
402         }
403     }
404 
405     @Thunk
updateFolderItemsRank(SQLiteDatabase db, boolean addRankColumn)406     boolean updateFolderItemsRank(SQLiteDatabase db, boolean addRankColumn) {
407         try (SQLiteTransaction t = new SQLiteTransaction(db)) {
408             if (addRankColumn) {
409                 // Insert new column for holding rank
410                 db.execSQL("ALTER TABLE favorites ADD COLUMN rank INTEGER NOT NULL DEFAULT 0;");
411             }
412 
413             // Get a map for folder ID to folder width
414             Cursor c = db.rawQuery("SELECT container, MAX(cellX) FROM favorites"
415                             + " WHERE container IN (SELECT _id FROM favorites WHERE itemType = ?)"
416                             + " GROUP BY container;",
417                     new String[]{Integer.toString(Favorites.ITEM_TYPE_FOLDER)});
418 
419             while (c.moveToNext()) {
420                 db.execSQL("UPDATE favorites SET rank=cellX+(cellY*?) WHERE "
421                                 + "container=? AND cellX IS NOT NULL AND cellY IS NOT NULL;",
422                         new Object[]{c.getLong(1) + 1, c.getLong(0)});
423             }
424 
425             c.close();
426             t.commit();
427         } catch (SQLException ex) {
428             // Old version remains, which means we wipe old data
429             Log.e(TAG, ex.getMessage(), ex);
430             return false;
431         }
432         return true;
433     }
434 
addIntegerColumn(SQLiteDatabase db, String columnName, long defaultValue)435     private boolean addIntegerColumn(SQLiteDatabase db, String columnName, long defaultValue) {
436         try (SQLiteTransaction t = new SQLiteTransaction(db)) {
437             db.execSQL("ALTER TABLE favorites ADD COLUMN "
438                     + columnName + " INTEGER NOT NULL DEFAULT " + defaultValue + ";");
439             t.commit();
440         } catch (SQLException ex) {
441             Log.e(TAG, ex.getMessage(), ex);
442             return false;
443         }
444         return true;
445     }
446 
447     // Generates a new ID to use for an object in your database. This method should be only
448     // called from the main UI thread. As an exception, we do call it when we call the
449     // constructor from the worker thread; however, this doesn't extend until after the
450     // constructor is called, and we only pass a reference to LauncherProvider to LauncherApp
451     // after that point
452     @Override
generateNewItemId()453     public int generateNewItemId() {
454         if (mMaxItemId < 0) {
455             throw new RuntimeException("Error: max item id was not initialized");
456         }
457         mMaxItemId += 1;
458         return mMaxItemId;
459     }
460 
461     /**
462      * @return A new {@link LauncherWidgetHolder} based on the current context
463      */
464     @NonNull
newLauncherWidgetHolder()465     public LauncherWidgetHolder newLauncherWidgetHolder() {
466         return LauncherWidgetHolder.newInstance(mContext);
467     }
468 
469     @Override
insertAndCheck(SQLiteDatabase db, ContentValues values)470     public int insertAndCheck(SQLiteDatabase db, ContentValues values) {
471         return dbInsertAndCheck(db, Favorites.TABLE_NAME, values);
472     }
473 
dbInsertAndCheck(SQLiteDatabase db, String table, ContentValues values)474     public int dbInsertAndCheck(SQLiteDatabase db, String table, ContentValues values) {
475         if (values == null) {
476             throw new RuntimeException("Error: attempting to insert null values");
477         }
478         if (!values.containsKey(LauncherSettings.Favorites._ID)) {
479             throw new RuntimeException("Error: attempting to add item without specifying an id");
480         }
481         checkId(values);
482         return (int) db.insert(table, null, values);
483     }
484 
checkId(ContentValues values)485     public void checkId(ContentValues values) {
486         int id = values.getAsInteger(Favorites._ID);
487         mMaxItemId = Math.max(id, mMaxItemId);
488     }
489 
initializeMaxItemId(SQLiteDatabase db)490     private int initializeMaxItemId(SQLiteDatabase db) {
491         return getMaxId(db, "SELECT MAX(%1$s) FROM %2$s", Favorites._ID,
492                 Favorites.TABLE_NAME);
493     }
494 
495     /**
496      * Returns a new ID to use for a workspace screen in your database that is greater than all
497      * existing screen IDs
498      */
getNewScreenId()499     public int getNewScreenId() {
500         return getMaxId(getWritableDatabase(),
501                 "SELECT MAX(%1$s) FROM %2$s WHERE %3$s = %4$d AND %1$s >= 0",
502                 Favorites.SCREEN, Favorites.TABLE_NAME, Favorites.CONTAINER,
503                 Favorites.CONTAINER_DESKTOP) + 1;
504     }
505 
loadFavorites(SQLiteDatabase db, AutoInstallsLayout loader)506     public int loadFavorites(SQLiteDatabase db, AutoInstallsLayout loader) {
507         // TODO: Use multiple loaders with fall-back and transaction.
508         int count = loader.loadLayout(db);
509 
510         // Ensure that the max ids are initialized
511         mMaxItemId = initializeMaxItemId(db);
512         return count;
513     }
514 
515     /**
516      * @return the max _id in the provided table.
517      */
getMaxId(SQLiteDatabase db, String query, Object... args)518     private static int getMaxId(SQLiteDatabase db, String query, Object... args) {
519         int max = 0;
520         try (SQLiteStatement prog = db.compileStatement(
521                 String.format(Locale.ENGLISH, query, args))) {
522             max = (int) DatabaseUtils.longForQuery(prog, null);
523             if (max < 0) {
524                 throw new RuntimeException("Error: could not query max id");
525             }
526         } catch (IllegalArgumentException exception) {
527             String message = exception.getMessage();
528             if (message.contains("re-open") && message.contains("already-closed")) {
529                 // Don't crash trying to end a transaction an an already closed DB. See b/173162852.
530             } else {
531                 throw exception;
532             }
533         }
534         return max;
535     }
536 }
537