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