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