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.provider.LauncherDbUtils.dropTable; 20 21 import android.content.ContentValues; 22 import android.content.Context; 23 import android.content.Intent; 24 import android.database.Cursor; 25 import android.database.DatabaseUtils; 26 import android.database.SQLException; 27 import android.database.sqlite.SQLiteDatabase; 28 import android.database.sqlite.SQLiteStatement; 29 import android.os.Process; 30 import android.os.UserHandle; 31 import android.provider.BaseColumns; 32 import android.text.TextUtils; 33 import android.util.Log; 34 35 import androidx.annotation.NonNull; 36 37 import com.android.launcher3.AutoInstallsLayout; 38 import com.android.launcher3.AutoInstallsLayout.LayoutParserCallback; 39 import com.android.launcher3.LauncherSettings; 40 import com.android.launcher3.LauncherSettings.Favorites; 41 import com.android.launcher3.Utilities; 42 import com.android.launcher3.config.FeatureFlags; 43 import com.android.launcher3.logging.FileLog; 44 import com.android.launcher3.pm.UserCache; 45 import com.android.launcher3.provider.LauncherDbUtils; 46 import com.android.launcher3.provider.LauncherDbUtils.SQLiteTransaction; 47 import com.android.launcher3.util.IntArray; 48 import com.android.launcher3.util.IntSet; 49 import com.android.launcher3.util.NoLocaleSQLiteHelper; 50 import com.android.launcher3.util.PackageManagerHelper; 51 import com.android.launcher3.util.Thunk; 52 import com.android.launcher3.widget.LauncherWidgetHolder; 53 54 import java.io.File; 55 import java.net.URISyntaxException; 56 import java.util.Arrays; 57 import java.util.Locale; 58 import java.util.function.ToLongFunction; 59 import java.util.stream.Collectors; 60 61 /** 62 * SqLite database for launcher home-screen model 63 * The class is subclassed in tests to create an in-memory db. 64 */ 65 public class DatabaseHelper extends NoLocaleSQLiteHelper implements 66 LayoutParserCallback { 67 68 /** 69 * Represents the schema of the database. Changes in scheme need not be backwards compatible. 70 * When increasing the scheme version, ensure that downgrade_schema.json is updated 71 */ 72 public static final int SCHEMA_VERSION = 32; 73 private static final String TAG = "DatabaseHelper"; 74 private static final boolean LOGD = false; 75 76 private static final String DOWNGRADE_SCHEMA_FILE = "downgrade_schema.json"; 77 78 private final Context mContext; 79 private final ToLongFunction<UserHandle> mUserSerialProvider; 80 private final Runnable mOnEmptyDbCreateCallback; 81 82 private int mMaxItemId = -1; 83 public boolean mHotseatRestoreTableExists; 84 85 /** 86 * Constructor used in tests and for restore. 87 */ DatabaseHelper(Context context, String dbName, ToLongFunction<UserHandle> userSerialProvider, Runnable onEmptyDbCreateCallback)88 public DatabaseHelper(Context context, String dbName, 89 ToLongFunction<UserHandle> userSerialProvider, Runnable onEmptyDbCreateCallback) { 90 super(context, dbName, SCHEMA_VERSION); 91 mContext = context; 92 mUserSerialProvider = userSerialProvider; 93 mOnEmptyDbCreateCallback = onEmptyDbCreateCallback; 94 } 95 initIds()96 protected void initIds() { 97 // In the case where neither onCreate nor onUpgrade gets called, we read the maxId from 98 // the DB here 99 if (mMaxItemId == -1) { 100 mMaxItemId = initializeMaxItemId(getWritableDatabase()); 101 } 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 = 1; 109 110 addTableToDb(db, getDefaultUserSerial(), false /* optional */); 111 112 // Fresh and clean launcher DB. 113 mMaxItemId = 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 // Clean up first row in screen 0 as it might contain junk data. 262 Log.d(TAG, "Cleaning up first row"); 263 db.delete(Favorites.TABLE_NAME, 264 String.format(Locale.ENGLISH, 265 "%1$s = %2$d AND %3$s = %4$d AND %5$s = %6$d", 266 Favorites.SCREEN, 0, 267 Favorites.CONTAINER, Favorites.CONTAINER_DESKTOP, 268 Favorites.CELLY, 0), null); 269 } 270 return; 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 invalid widget " + widgetId); 336 holder.deleteAppWidgetId(widgetId); 337 isAnyWidgetRemoved = true; 338 } catch (RuntimeException e) { 339 // Ignore 340 } 341 } 342 } 343 if (isAnyWidgetRemoved) { 344 final String allWidgetsIds = Arrays.stream(allWidgets).mapToObj(String::valueOf) 345 .collect(Collectors.joining(",", "[", "]")); 346 final String validWidgetsIds = Arrays.stream( 347 validWidgets.getArray().toArray()).mapToObj(String::valueOf) 348 .collect(Collectors.joining(",", "[", "]")); 349 FileLog.d(TAG, 350 "One or more widgets was removed. db_path=" + db.getPath() 351 + " allWidgetsIds=" + allWidgetsIds 352 + ", validWidgetsIds=" + validWidgetsIds); 353 } 354 } finally { 355 holder.destroy(); 356 } 357 } 358 359 /** 360 * Replaces all shortcuts of type {@link Favorites#ITEM_TYPE_SHORTCUT} which have a valid 361 * launcher activity target with {@link Favorites#ITEM_TYPE_APPLICATION}. 362 */ 363 @Thunk convertShortcutsToLauncherActivities(SQLiteDatabase db)364 void convertShortcutsToLauncherActivities(SQLiteDatabase db) { 365 try (SQLiteTransaction t = new SQLiteTransaction(db); 366 // Only consider the primary user as other users can't have a shortcut. 367 Cursor c = db.query(Favorites.TABLE_NAME, 368 new String[]{Favorites._ID, Favorites.INTENT}, 369 "itemType=" + Favorites.ITEM_TYPE_SHORTCUT 370 + " AND profileId=" + getDefaultUserSerial(), 371 null, null, null, null); 372 SQLiteStatement updateStmt = db.compileStatement("UPDATE favorites SET itemType=" 373 + Favorites.ITEM_TYPE_APPLICATION + " WHERE _id=?") 374 ) { 375 final int idIndex = c.getColumnIndexOrThrow(Favorites._ID); 376 final int intentIndex = c.getColumnIndexOrThrow(Favorites.INTENT); 377 378 while (c.moveToNext()) { 379 String intentDescription = c.getString(intentIndex); 380 Intent intent; 381 try { 382 intent = Intent.parseUri(intentDescription, 0); 383 } catch (URISyntaxException e) { 384 Log.e(TAG, "Unable to parse intent", e); 385 continue; 386 } 387 388 if (!PackageManagerHelper.isLauncherAppTarget(intent)) { 389 continue; 390 } 391 392 int id = c.getInt(idIndex); 393 updateStmt.bindLong(1, id); 394 updateStmt.executeUpdateDelete(); 395 } 396 t.commit(); 397 } catch (SQLException ex) { 398 Log.w(TAG, "Error deduping shortcuts", ex); 399 } 400 } 401 402 @Thunk updateFolderItemsRank(SQLiteDatabase db, boolean addRankColumn)403 boolean updateFolderItemsRank(SQLiteDatabase db, boolean addRankColumn) { 404 try (SQLiteTransaction t = new SQLiteTransaction(db)) { 405 if (addRankColumn) { 406 // Insert new column for holding rank 407 db.execSQL("ALTER TABLE favorites ADD COLUMN rank INTEGER NOT NULL DEFAULT 0;"); 408 } 409 410 // Get a map for folder ID to folder width 411 Cursor c = db.rawQuery("SELECT container, MAX(cellX) FROM favorites" 412 + " WHERE container IN (SELECT _id FROM favorites WHERE itemType = ?)" 413 + " GROUP BY container;", 414 new String[]{Integer.toString(Favorites.ITEM_TYPE_FOLDER)}); 415 416 while (c.moveToNext()) { 417 db.execSQL("UPDATE favorites SET rank=cellX+(cellY*?) WHERE " 418 + "container=? AND cellX IS NOT NULL AND cellY IS NOT NULL;", 419 new Object[]{c.getLong(1) + 1, c.getLong(0)}); 420 } 421 422 c.close(); 423 t.commit(); 424 } catch (SQLException ex) { 425 // Old version remains, which means we wipe old data 426 Log.e(TAG, ex.getMessage(), ex); 427 return false; 428 } 429 return true; 430 } 431 addIntegerColumn(SQLiteDatabase db, String columnName, long defaultValue)432 private boolean addIntegerColumn(SQLiteDatabase db, String columnName, long defaultValue) { 433 try (SQLiteTransaction t = new SQLiteTransaction(db)) { 434 db.execSQL("ALTER TABLE favorites ADD COLUMN " 435 + columnName + " INTEGER NOT NULL DEFAULT " + defaultValue + ";"); 436 t.commit(); 437 } catch (SQLException ex) { 438 Log.e(TAG, ex.getMessage(), ex); 439 return false; 440 } 441 return true; 442 } 443 444 // Generates a new ID to use for an object in your database. This method should be only 445 // called from the main UI thread. As an exception, we do call it when we call the 446 // constructor from the worker thread; however, this doesn't extend until after the 447 // constructor is called, and we only pass a reference to LauncherProvider to LauncherApp 448 // after that point 449 @Override generateNewItemId()450 public int generateNewItemId() { 451 if (mMaxItemId < 0) { 452 throw new RuntimeException("Error: max item id was not initialized"); 453 } 454 mMaxItemId += 1; 455 return mMaxItemId; 456 } 457 458 /** 459 * @return A new {@link LauncherWidgetHolder} based on the current context 460 */ 461 @NonNull newLauncherWidgetHolder()462 public LauncherWidgetHolder newLauncherWidgetHolder() { 463 return LauncherWidgetHolder.newInstance(mContext); 464 } 465 466 @Override insertAndCheck(SQLiteDatabase db, ContentValues values)467 public int insertAndCheck(SQLiteDatabase db, ContentValues values) { 468 return dbInsertAndCheck(db, Favorites.TABLE_NAME, values); 469 } 470 dbInsertAndCheck(SQLiteDatabase db, String table, ContentValues values)471 public int dbInsertAndCheck(SQLiteDatabase db, String table, ContentValues values) { 472 if (values == null) { 473 throw new RuntimeException("Error: attempting to insert null values"); 474 } 475 if (!values.containsKey(LauncherSettings.Favorites._ID)) { 476 throw new RuntimeException("Error: attempting to add item without specifying an id"); 477 } 478 checkId(values); 479 return (int) db.insert(table, null, values); 480 } 481 checkId(ContentValues values)482 public void checkId(ContentValues values) { 483 int id = values.getAsInteger(Favorites._ID); 484 mMaxItemId = Math.max(id, mMaxItemId); 485 } 486 initializeMaxItemId(SQLiteDatabase db)487 private int initializeMaxItemId(SQLiteDatabase db) { 488 return getMaxId(db, "SELECT MAX(%1$s) FROM %2$s", Favorites._ID, 489 Favorites.TABLE_NAME); 490 } 491 492 /** 493 * Returns a new ID to use for a workspace screen in your database that is greater than all 494 * existing screen IDs 495 */ getNewScreenId()496 public int getNewScreenId() { 497 return getMaxId(getWritableDatabase(), 498 "SELECT MAX(%1$s) FROM %2$s WHERE %3$s = %4$d AND %1$s >= 0", 499 Favorites.SCREEN, Favorites.TABLE_NAME, Favorites.CONTAINER, 500 Favorites.CONTAINER_DESKTOP) + 1; 501 } 502 loadFavorites(SQLiteDatabase db, AutoInstallsLayout loader)503 public int loadFavorites(SQLiteDatabase db, AutoInstallsLayout loader) { 504 // TODO: Use multiple loaders with fall-back and transaction. 505 int count = loader.loadLayout(db, new IntArray()); 506 507 // Ensure that the max ids are initialized 508 mMaxItemId = initializeMaxItemId(db); 509 return count; 510 } 511 512 /** 513 * @return the max _id in the provided table. 514 */ getMaxId(SQLiteDatabase db, String query, Object... args)515 private static int getMaxId(SQLiteDatabase db, String query, Object... args) { 516 int max = 0; 517 try (SQLiteStatement prog = db.compileStatement( 518 String.format(Locale.ENGLISH, query, args))) { 519 max = (int) DatabaseUtils.longForQuery(prog, null); 520 if (max < 0) { 521 throw new RuntimeException("Error: could not query max id"); 522 } 523 } catch (IllegalArgumentException exception) { 524 String message = exception.getMessage(); 525 if (message.contains("re-open") && message.contains("already-closed")) { 526 // Don't crash trying to end a transaction an an already closed DB. See b/173162852. 527 } else { 528 throw exception; 529 } 530 } 531 return max; 532 } 533 } 534