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