From c08b8c83725228746e4bbff2e61235024fa07678 Mon Sep 17 00:00:00 2001 From: KonTy <9524513+KonTy@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:35:43 -0700 Subject: [PATCH] Preserve home screen data when grid migration fails --- DEVDOC.md | 16 + build.gradle.kts | 15 + gradle/libs.versions.toml | 2 + res/values/strings.xml | 3 + .../model/GridSizeMigrationDBController.kt | 30 +- .../launcher3/model/GridSizeMigrationLogic.kt | 99 ++-- .../android/launcher3/model/LoaderTask.java | 9 + .../launcher3/model/ModelDbController.java | 92 ++-- .../launcher3/provider/LauncherDbUtils.kt | 11 +- .../launcher3/model/GridMigrationTest.kt | 9 +- .../model/GridSizeMigrationRegressionTest.kt | 485 ++++++++++++++++++ 11 files changed, 637 insertions(+), 134 deletions(-) create mode 100644 tests/unit/src/com/android/launcher3/model/GridSizeMigrationRegressionTest.kt diff --git a/DEVDOC.md b/DEVDOC.md index 1c6522a109..5ee7256df7 100644 --- a/DEVDOC.md +++ b/DEVDOC.md @@ -1,3 +1,19 @@ +## Local migration regression tests + +With JDK 21 and the Android SDK configured (`ANDROID_HOME` or `local.properties`), run: + +```sh +./gradlew testAospOmegaDebugUnitTest --tests '*GridSizeMigrationRegressionTest' +``` + +These tests live in `tests/unit/src`, outside the production `src` source root. They use +Robolectric API 35 and native, file-backed SQLite with the production migration/controller +implementation. Coverage includes SQLite rollback, retry/reopen, widget and folder preservation, +fresh database initialization, invalid/no-op migrations, and loader cancellation before defaults, +cleanup, binding, or loader-transaction commit. Application DI, logging, and the smartspace setting +are mocked; no device or launcher application startup is required. This suite does not replace +the existing device/Soong tests under `tests/src`. + ## Development Progress - [ ] Rebase on A13 diff --git a/build.gradle.kts b/build.gradle.kts index 12f2e4b029..16a0c36741 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -184,6 +184,10 @@ extensions.configure { } sourceSets { + named("test") { + java.directories.add("tests/unit/src") + kotlin.directories.add("tests/unit/src") + } named("main") { java.directories.addAll( listOf( @@ -229,6 +233,15 @@ extensions.configure { checkReleaseBuilds = false disable += listOf("MissingTranslation", "ExtraTranslation") } + + testOptions { + unitTests.isIncludeAndroidResources = true + unitTests.all { + val scratchDir = layout.buildDirectory.dir("unitTestScratch").get().asFile + it.systemProperty("java.io.tmpdir", scratchDir.absolutePath) + it.doFirst { scratchDir.mkdirs() } + } + } } protobuf { protoc { @@ -356,6 +369,8 @@ dependencies { protobuf(files("protos_overrides/")) testImplementation(libs.junit) + testImplementation(libs.mockito.core) + testImplementation(libs.robolectric) androidTestImplementation(libs.dexmaker.mockito) androidTestImplementation(libs.junit.jupiter) androidTestImplementation(libs.mockito.core) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 43881c3dbc..311e083ec4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,6 +51,7 @@ junit = "4.13.2" junit5 = "6.1.1" truth = "1.4.5" mockito = "5.23.0" +robolectric = "4.16.1" dexmaker = "1.2" androidx-annotation = "1.10.0" androidx-test = "1.3.0" @@ -137,6 +138,7 @@ dexmaker-mockito = { module = "com.google.dexmaker:dexmaker-mockito", version.re junit = { module = "junit:junit", version.ref = "junit" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit5" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } runner = { module = "com.android.support.test:runner", version.ref = "support-runner" } rules = { module = "com.android.support.test:rules", version.ref = "support-rules" } test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test" } diff --git a/res/values/strings.xml b/res/values/strings.xml index e78c2ff808..cfb039c837 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -257,6 +257,9 @@ Allows the app to change the settings and shortcuts in home. + + Couldn\'t migrate the home screen. Your saved layout has not been reset. + diff --git a/src/com/android/launcher3/model/GridSizeMigrationDBController.kt b/src/com/android/launcher3/model/GridSizeMigrationDBController.kt index e602455bbf..cdfd9f4389 100644 --- a/src/com/android/launcher3/model/GridSizeMigrationDBController.kt +++ b/src/com/android/launcher3/model/GridSizeMigrationDBController.kt @@ -115,8 +115,8 @@ object GridSizeMigrationDBController { idsInUse: List, ): Int { var newId = -1 - val c = - helper.writableDatabase.query( + helper.writableDatabase + .query( srcTableName, null, LauncherSettings.Favorites._ID + " = '" + (entry?.id ?: id) + "'", @@ -125,18 +125,20 @@ object GridSizeMigrationDBController { null, null, ) - while (c.moveToNext()) { - val values = ContentValues() - DatabaseUtils.cursorRowToContentValues(c, values) - entry?.updateContentValues(values) - ?: values.put(LauncherSettings.Favorites.CONTAINER, folderId) - do { - newId = helper.generateNewItemId() - } while (idsInUse.contains(newId)) - values.put(LauncherSettings.Favorites._ID, newId) - helper.writableDatabase.insert(destTableName, null, values) - } - c.close() + .use { c -> + check(c.moveToFirst()) { + "Missing migration entry ${entry?.id ?: id} in $srcTableName" + } + val values = ContentValues() + DatabaseUtils.cursorRowToContentValues(c, values) + entry?.updateContentValues(values) + ?: values.put(LauncherSettings.Favorites.CONTAINER, folderId) + do { + newId = helper.generateNewItemId() + } while (idsInUse.contains(newId)) + values.put(LauncherSettings.Favorites._ID, newId) + helper.writableDatabase.insertOrThrow(destTableName, null, values) + } return newId } diff --git a/src/com/android/launcher3/model/GridSizeMigrationLogic.kt b/src/com/android/launcher3/model/GridSizeMigrationLogic.kt index 61797e2403..98c0030b26 100644 --- a/src/com/android/launcher3/model/GridSizeMigrationLogic.kt +++ b/src/com/android/launcher3/model/GridSizeMigrationLogic.kt @@ -67,7 +67,6 @@ constructor( target: DatabaseHelper, source: SQLiteDatabase, isDestNewDb: Boolean, - modelDelegate: ModelDelegate, ) { if (!GridSizeMigrationDBController.needsToMigrate(srcDeviceState, destDeviceState)) { @@ -82,21 +81,20 @@ constructor( "$srcDeviceState\ndestDeviceState: $destDeviceState\nisDestNewDb: $isDestNewDb", ) - val shouldMigrateToStrtictlyTallerGrid = + val shouldMigrateToStrictlyTallerGrid = shouldMigrateToStrictlyTallerGrid(isDestNewDb, srcDeviceState, destDeviceState) - if (shouldMigrateToStrtictlyTallerGrid) { - copyTable(source, TABLE_NAME, target.writableDatabase, TABLE_NAME, context) - } else { - copyTable(source, TABLE_NAME, target.writableDatabase, TMP_TABLE, context) - } + // Stage the source before the transaction: copyTable attaches and detaches its database. + // Never replace favorites outside the transaction, even for the taller-grid shortcut. + copyTable(source, TABLE_NAME, target.writableDatabase, TMP_TABLE, context) val migrationStartTime = System.currentTimeMillis() try { SQLiteTransaction(target.writableDatabase).use { t -> // We want to add the extra row(s) to the top of the screen, so we shift the grid // down. - if (shouldMigrateToStrtictlyTallerGrid) { + if (shouldMigrateToStrictlyTallerGrid) { Log.d(TAG, "Migrating to strictly taller grid") + copyTable(t.db, TMP_TABLE, t.db, TABLE_NAME, context) if (Flags.oneGridSpecs()) { shiftWorkspaceByXCells( target.writableDatabase, @@ -104,64 +102,49 @@ constructor( TABLE_NAME, ) } - // Save current configuration, so that the migration does not run again. - destDeviceState.writeToPrefs(context) - t.commit() - - if (isOneGridMigration(srcDeviceState, destDeviceState)) { - statsLogManager.logger().log(LAUNCHER_ROW_SHIFT_ONE_GRID_MIGRATION) - } - statsLogManager.logger().log(LAUNCHER_ROW_SHIFT_GRID_MIGRATION) - - return + } else { + val srcReader = DbReader(t.db, TMP_TABLE, context) + val destReader = DbReader(t.db, TABLE_NAME, context) + + val targetSize = Point(destDeviceState.columns, destDeviceState.rows) + + // Keep destination IDs so newly added items cannot collide with them. + val idsInUse = mutableListOf() + + migrateHotseat( + srcDeviceState.numHotseat, + destDeviceState.numHotseat, + srcReader, + destReader, + target, + idsInUse, + ) + migrateWorkspace(srcReader, destReader, target, targetSize, idsInUse) } - val srcReader = DbReader(t.db, TMP_TABLE, context) - val destReader = DbReader(t.db, TABLE_NAME, context) - - val targetSize = Point(destDeviceState.columns, destDeviceState.rows) - - // Here we keep all the DB ids we have in the destination DB such that we don't - // assign - // an item that we want to add to the destination DB the same id as an already - // existing - // item. - val idsInUse = mutableListOf() - - // Migrate hotseat. - migrateHotseat( - srcDeviceState.numHotseat, - destDeviceState.numHotseat, - srcReader, - destReader, - target, - idsInUse, - ) - // Migrate workspace. - migrateWorkspace(srcReader, destReader, target, targetSize, idsInUse) - dropTable(t.db, TMP_TABLE) t.commit() - - if (isOneGridMigration(srcDeviceState, destDeviceState)) { - statsLogManager.logger().log(LAUNCHER_STANDARD_ONE_GRID_MIGRATION) - } - statsLogManager.logger().log(LAUNCHER_STANDARD_GRID_MIGRATION) } } catch (e: Exception) { FileLog.e(TAG, "Error during grid migration", e) - } finally { - Log.v( - TAG, - "Workspace migration completed in " + - (System.currentTimeMillis() - migrationStartTime), - ) - - // Save current configuration, so that the migration does not run again. - destDeviceState.writeToPrefs(context) + throw e + } - // Notify if we've migrated successfully - modelDelegate.gridMigrationComplete(srcDeviceState, destDeviceState) + Log.v( + TAG, + "Workspace migration completed in " + + (System.currentTimeMillis() - migrationStartTime), + ) + if (shouldMigrateToStrictlyTallerGrid) { + if (isOneGridMigration(srcDeviceState, destDeviceState)) { + statsLogManager.logger().log(LAUNCHER_ROW_SHIFT_ONE_GRID_MIGRATION) + } + statsLogManager.logger().log(LAUNCHER_ROW_SHIFT_GRID_MIGRATION) + } else { + if (isOneGridMigration(srcDeviceState, destDeviceState)) { + statsLogManager.logger().log(LAUNCHER_STANDARD_ONE_GRID_MIGRATION) + } + statsLogManager.logger().log(LAUNCHER_STANDARD_GRID_MIGRATION) } } diff --git a/src/com/android/launcher3/model/LoaderTask.java b/src/com/android/launcher3/model/LoaderTask.java index 7833f8daf7..fba67435b7 100644 --- a/src/com/android/launcher3/model/LoaderTask.java +++ b/src/com/android/launcher3/model/LoaderTask.java @@ -29,6 +29,7 @@ import static com.android.launcher3.model.data.AppsListData.FLAG_QUIET_MODE_ENABLED; import static com.android.launcher3.model.data.AppsListData.FLAG_WORK_PROFILE_QUIET_MODE_ENABLED; import static com.android.launcher3.model.data.ItemInfoWithIcon.FLAG_INSTALL_SESSION_ACTIVE; +import static com.android.launcher3.util.Executors.MAIN_EXECUTOR; import static com.android.launcher3.util.Executors.MODEL_EXECUTOR; import static com.android.launcher3.util.LooperExecutor.CALLER_LOADER_TASK; import static com.android.launcher3.util.PackageManagerHelper.hasShortcutsPermission; @@ -48,6 +49,7 @@ import android.os.UserHandle; import android.util.Log; import android.util.LongSparseArray; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -58,6 +60,7 @@ import com.android.launcher3.InvariantDeviceProfile; import com.android.launcher3.LauncherModel; import com.android.launcher3.LauncherPrefs; +import com.android.launcher3.R; import com.android.launcher3.Utilities; import com.android.launcher3.backuprestore.LauncherRestoreEventLogger; import com.android.launcher3.dagger.ApplicationContext; @@ -450,6 +453,12 @@ private void loadWorkspaceImpl( dbController.attemptMigrateDb(restoreEventLogger, mModelDelegate); } catch (Exception e) { FileLog.e(TAG, "Failed to migrate grid", e); + MAIN_EXECUTOR.execute(() -> Toast.makeText( + mContext, R.string.workspace_migration_failed, Toast.LENGTH_LONG).show()); + CancellationException cancelled = + new CancellationException("Workspace loading cancelled after migration failure"); + cancelled.initCause(e); + throw cancelled; } Log.d(TAG, "loadWorkspace: loading default favorites if necessary"); diff --git a/src/com/android/launcher3/model/ModelDbController.java b/src/com/android/launcher3/model/ModelDbController.java index f9518309a0..2bef38de36 100644 --- a/src/com/android/launcher3/model/ModelDbController.java +++ b/src/com/android/launcher3/model/ModelDbController.java @@ -269,41 +269,10 @@ public void refreshHotseatRestoreTable() { mOpenHelper.getReadableDatabase(), Favorites.HYBRID_HOTSEAT_BACKUP_TABLE); } - /** - * Resets the launcher DB if we should reset it. - */ - public void resetLauncherDb(@Nullable LauncherRestoreEventLogger restoreEventLogger) { - if (restoreEventLogger != null) { - sendMetricsForFailedMigration(restoreEventLogger, getDb()); - } - FileLog.d(TAG, "resetLauncherDb: Migration failed: resetting launcher database"); - createEmptyDB(); - mPrefs.putSync(getEmptyDbCreatedKey(mOpenHelper.getDatabaseName()).to(true)); - - // Write the grid state to avoid another migration - new DeviceGridState(mIdp).writeToPrefs(mContext); - } - - /** - * Determines if we should reset the DB. - */ - private boolean shouldResetDb() { - if (isThereExistingDb()) { - return true; - } - if (!isGridMigrationNecessary()) { - return false; - } - if (isCurrentDbSameAsTarget()) { - return true; - } - return false; - } - - private boolean isThereExistingDb() { + private boolean isNewDb() { if (mPrefs.get(getEmptyDbCreatedKey())) { // If we already have a new DB, ignore migration - FileLog.d(TAG, "isThereExistingDb: new DB already created, skipping migration"); + FileLog.d(TAG, "isNewDb: new DB already created, skipping migration"); return true; } return false; @@ -320,7 +289,7 @@ private boolean isGridMigrationNecessary() { private boolean isCurrentDbSameAsTarget() { String targetDbName = new DeviceGridState(mIdp).getDbFile(); if (TextUtils.equals(targetDbName, mOpenHelper.getDatabaseName())) { - FileLog.e(TAG, "isCurrentDbSameAsTarget: target db is same as current" + FileLog.d(TAG, "isCurrentDbSameAsTarget: target db is same as current" + " current db: " + mOpenHelper.getDatabaseName() + " target db: " + targetDbName); return true; @@ -329,17 +298,35 @@ private boolean isCurrentDbSameAsTarget() { } /** - * Migrates the DB. If the migration failed, it clears the DB. + * Migrates the DB, retaining the current database if migration fails. */ public void attemptMigrateDb(LauncherRestoreEventLogger restoreEventLogger, ModelDelegate modelDelegate) throws Exception { createDbIfNotExists(); - if (shouldResetDb()) { - resetLauncherDb(restoreEventLogger); + if (isNewDb()) { + try (Cursor cursor = mOpenHelper.getReadableDatabase().rawQuery( + "SELECT 1 FROM " + TABLE_NAME + " LIMIT 1", null)) { + if (cursor.moveToFirst()) { + throw new IllegalStateException( + "Refusing to initialize a database that already contains a layout"); + } + } + // Default layout initialization remains the responsibility of the loader. + new DeviceGridState(mIdp).writeToPrefs(mContext); return; } + if (!isGridMigrationNecessary()) { + return; + } + if (isCurrentDbSameAsTarget()) { + throw new IllegalStateException( + "Migration source state does not match the current database"); + } DatabaseHelper oldHelper = mOpenHelper; + DatabaseHelper targetHelper = null; + DeviceGridState srcDeviceState = new DeviceGridState(mContext); + DeviceGridState destDeviceState = new DeviceGridState(mIdp); // We save the existing db's before creating the destination db helper so we know what logic // to run in grid migration based on if that grid already existed before migration or not. @@ -348,11 +335,6 @@ public void attemptMigrateDb(LauncherRestoreEventLogger restoreEventLogger, .collect(Collectors.toList()); try { - // This is the current grid we have, given by the mContext - DeviceGridState srcDeviceState = new DeviceGridState(mContext); - // This is the state we want to migrate to that is given by the idp - DeviceGridState destDeviceState = new DeviceGridState(mIdp); - boolean isDestNewDb = !existingDBs.contains(destDeviceState.getDbFile()); boolean isAfterRestore = @@ -369,21 +351,31 @@ public void attemptMigrateDb(LauncherRestoreEventLogger restoreEventLogger, if (sourceGridMigrationOption != null && destinationGridMigrationOption != null && sourceGridMigrationOption.canMigrate(destinationGridMigrationOption, isAfterRestore)) { - mOpenHelper = createDatabaseHelper(true, new DeviceGridState(mIdp).getDbFile()); + targetHelper = createDatabaseHelper(true, destDeviceState.getDbFile()); gridSizeMigrationLogic.migrateGrid(srcDeviceState, destDeviceState, - mOpenHelper, oldHelper.getWritableDatabase(), isDestNewDb, modelDelegate); + targetHelper, oldHelper.getWritableDatabase(), isDestNewDb); } else { - FileLog.e(TAG, "Cannot migrate from source: " + srcDeviceState + throw new IllegalStateException("Cannot migrate from source: " + srcDeviceState + " to destination: " + destDeviceState); } } catch (Exception e) { - resetLauncherDb(restoreEventLogger); - throw new Exception("attemptMigrateDb: Failed to migrate grid", e); - } finally { - if (mOpenHelper != oldHelper) { - oldHelper.close(); + FileLog.e(TAG, "Grid migration failed; keeping the current database", e); + if (restoreEventLogger != null) { + sendMetricsForFailedMigration(restoreEventLogger, oldHelper.getWritableDatabase()); + } + if (targetHelper != null) { + targetHelper.close(); } + throw new Exception("attemptMigrateDb: Failed to migrate grid", e); } + + // Only publish the destination after its transaction has committed. Clearing a stale + // empty flag prevents the loader from replacing the migrated layout with defaults. + mOpenHelper = targetHelper; + clearFlagEmptyDbCreated(); + destDeviceState.writeToPrefs(mContext); + oldHelper.close(); + modelDelegate.gridMigrationComplete(srcDeviceState, destDeviceState); } /** diff --git a/src/com/android/launcher3/provider/LauncherDbUtils.kt b/src/com/android/launcher3/provider/LauncherDbUtils.kt index adfa4ffbc9..b47f7fd128 100644 --- a/src/com/android/launcher3/provider/LauncherDbUtils.kt +++ b/src/com/android/launcher3/provider/LauncherDbUtils.kt @@ -136,10 +136,13 @@ object LauncherDbUtils { if (fromDb != toDb) { toDb.run { execSQL("ATTACH DATABASE '${fromDb.path}' AS from_db") - execSQL( - "INSERT INTO $toTable SELECT ${LauncherSettings.Favorites.getColumns(userSerial)} FROM from_db.$fromTable" - ) - execSQL("DETACH DATABASE 'from_db'") + try { + execSQL( + "INSERT INTO $toTable SELECT ${LauncherSettings.Favorites.getColumns(userSerial)} FROM from_db.$fromTable" + ) + } finally { + execSQL("DETACH DATABASE 'from_db'") + } } } else { toDb.run { diff --git a/tests/src/com/android/launcher3/model/GridMigrationTest.kt b/tests/src/com/android/launcher3/model/GridMigrationTest.kt index bae4b23013..e82f202d2d 100644 --- a/tests/src/com/android/launcher3/model/GridMigrationTest.kt +++ b/tests/src/com/android/launcher3/model/GridMigrationTest.kt @@ -35,8 +35,6 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify private val phoneContext = InstrumentationRegistry.getInstrumentation().targetContext @@ -84,8 +82,6 @@ class GridMigrationTest { @Rule val setFlagsRule = SetFlagsRule(SetFlagsRule.DefaultInitValueType.DEVICE_DEFAULT) - val modelDelegate = mock() - @Before fun setup() { setFlagsRule.setFlags(true, Flags.FLAG_ONE_GRID_SPECS) @@ -99,7 +95,6 @@ class GridMigrationTest { dst.dbHelper, src.dbHelper.readableDatabase, true, - modelDelegate, ) } @@ -137,12 +132,11 @@ class GridMigrationTest { } /** - * Migrate src into dst and compare to target. This method validates 4 things: + * Migrate src into dst and compare to target. This method validates 3 things: * 1. dst has the same number of items as src after the migration, meaning, none of the items * were removed during the migration. * 2. dst is valid, meaning that none of the items overlap with each other. * 3. dst is equal to target to ensure we don't unintentionally change the migration logic. - * 4. migration notifies the complete callback. */ private fun runTest(src: GridMigrationData, dst: GridMigrationData, target: GridMigrationData) { TestUtil.runOnExecutorSync(Executors.MODEL_EXECUTOR) { @@ -152,7 +146,6 @@ class GridMigrationTest { } validateDb(dst) compare(dst, target, src) - verify(modelDelegate).gridMigrationComplete(src.gridState, dst.gridState) } } diff --git a/tests/unit/src/com/android/launcher3/model/GridSizeMigrationRegressionTest.kt b/tests/unit/src/com/android/launcher3/model/GridSizeMigrationRegressionTest.kt new file mode 100644 index 0000000000..d12946c87f --- /dev/null +++ b/tests/unit/src/com/android/launcher3/model/GridSizeMigrationRegressionTest.kt @@ -0,0 +1,485 @@ +package com.android.launcher3.model + +import android.app.Application +import android.content.ContentValues +import android.content.Context +import android.database.DatabaseUtils +import android.database.sqlite.SQLiteException +import android.os.Process +import com.android.launcher3.ConstantItem +import com.android.launcher3.EncryptionType +import com.android.launcher3.InvariantDeviceProfile +import com.android.launcher3.LauncherFiles +import com.android.launcher3.LauncherModel +import com.android.launcher3.LauncherPrefs +import com.android.launcher3.LauncherSettings.Favorites +import com.android.launcher3.Utilities +import com.android.launcher3.dagger.LauncherAppComponent +import com.android.launcher3.dagger.LauncherComponentProvider +import com.android.launcher3.backuprestore.LauncherRestoreEventLogger +import com.android.launcher3.folder.FolderNameProvider +import com.android.launcher3.homescreenfiles.HomeScreenFilesProvider +import com.android.launcher3.icons.IconCache +import com.android.launcher3.logging.StatsLogManager +import com.android.launcher3.model.data.LoaderParams +import com.android.launcher3.pm.InstallSessionHelper +import com.android.launcher3.pm.UserCache +import com.android.launcher3.provider.LauncherDbUtils +import com.android.launcher3.util.PackageManagerHelper +import com.android.launcher3.util.SettingsCache +import com.android.launcher3.widget.util.WidgetSizeHandler +import com.neoapps.neolauncher.allapps.NeoLauncherModelDelegate +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.MockedStatic +import org.mockito.Mockito.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.SQLiteMode + +/** + * Real controller, migration algorithm, preferences, and file-backed native SQLite. Launcher DI, + * logging, and the smartspace-enabled setting are isolated from application startup. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +@SQLiteMode(SQLiteMode.Mode.NATIVE) +class GridSizeMigrationRegressionTest { + private lateinit var context: Context + private lateinit var prefs: LauncherPrefs + private lateinit var users: UserCache + private lateinit var componentProvider: MockedStatic + private lateinit var utilities: MockedStatic + private lateinit var logic: GridSizeMigrationLogic + private lateinit var delegate: NeoLauncherModelDelegate + private val helpers = mutableListOf() + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + prefs = LauncherPrefs(context) + users = mock(UserCache::class.java) + `when`(users.userProfiles).thenReturn(listOf(Process.myUserHandle())) + val component = mock(LauncherAppComponent::class.java) + `when`(component.launcherPrefs).thenReturn(prefs) + `when`(component.userCache).thenReturn(users) + componentProvider = mockStatic(LauncherComponentProvider::class.java) + componentProvider.`when` { LauncherComponentProvider.get(context) } + .thenReturn(component) + utilities = mockStatic(Utilities::class.java) { invocation -> + if (invocation.method.name == "qsbOnFirstScreen") false + else invocation.callRealMethod() + } + val stats = mock(StatsLogManager::class.java, RETURNS_DEEP_STUBS) + val factory = mock(StatsLogManager.StatsLogManagerFactory::class.java) + `when`(factory.create(context)).thenReturn(stats) + logic = GridSizeMigrationLogic(context, prefs, factory) { emptySet() } + delegate = mock(NeoLauncherModelDelegate::class.java) + } + + @After + fun tearDown() { + helpers.forEach { it.close() } + utilities.close() + componentProvider.close() + } + + @Test + fun sameDatabaseIsNoOpAndPreservesWidgetIds() { + val source = helper(SOURCE) + populate(source) + state(4, 4, SOURCE).writeToPrefs(context) + val before = rows(source) + val controller = controller(4, 4, SOURCE, source) + + controller.attemptMigrateDb(null, delegate) + + assertSame(source, controller.mOpenHelper) + assertEquals(before, rows(source)) + assertEquals(SOURCE, prefs.get(LauncherPrefs.DB_FILE)) + verifyNoInteractions(delegate) + } + + @Test + fun freshDatabaseKeepsDefaultInitializationFlag() { + val controller = controller(4, 4, SOURCE) + + controller.attemptMigrateDb(null, delegate) + val source = controller.mOpenHelper + helpers += source + + assertEquals(SOURCE, source.databaseName) + assertTrue(rows(source).isEmpty()) + assertTrue(prefs.get(emptyFlag(SOURCE))) + assertEquals(SOURCE, prefs.get(LauncherPrefs.DB_FILE)) + assertEquals("4,4", prefs.get(LauncherPrefs.WORKSPACE_SIZE)) + assertEquals(4, prefs.get(LauncherPrefs.HOTSEAT_COUNT)) + verifyNoInteractions(delegate) + } + + @Test + fun staleFreshDatabaseFlagCannotReplaceExistingLayout() { + val source = helper(SOURCE) + populate(source) + state(4, 4, SOURCE).writeToPrefs(context) + prefs.putSync(emptyFlag(SOURCE).to(true)) + val before = rows(source) + val controller = controller(3, 3, TARGET, source) + + assertThrows(IllegalStateException::class.java) { + controller.attemptMigrateDb(null, delegate) + } + + assertSame(source, controller.mOpenHelper) + assertEquals(before, rows(source)) + assertEquals(SOURCE, prefs.get(LauncherPrefs.DB_FILE)) + verifyNoInteractions(delegate) + } + + @Test + fun invalidGridFailsWithoutReplacingHelperOrPublishingPreferences() { + val source = helper(SOURCE) + populate(source) + state(4, 4, SOURCE).writeToPrefs(context) + val before = rows(source) + val controller = controller(9, 9, TARGET, source) + + assertThrows(Exception::class.java) { controller.attemptMigrateDb(null, delegate) } + + assertSame(source, controller.mOpenHelper) + assertTrue(source.writableDatabase.isOpen) + assertEquals(before, rows(source)) + assertEquals(SOURCE, prefs.get(LauncherPrefs.DB_FILE)) + assertFalse(context.getDatabasePath(TARGET).exists()) + verifyNoInteractions(delegate) + } + + @Test + fun inconsistentPreferencesCannotMigrateDatabaseOntoItself() { + val current = helper(TARGET) + populate(current) + state(4, 4, SOURCE).writeToPrefs(context) + val before = rows(current) + val controller = controller(3, 3, TARGET, current) + + assertThrows(IllegalStateException::class.java) { + controller.attemptMigrateDb(null, delegate) + } + + assertSame(current, controller.mOpenHelper) + assertEquals(before, rows(current)) + assertEquals(SOURCE, prefs.get(LauncherPrefs.DB_FILE)) + verifyNoInteractions(delegate) + } + + @Test + fun sqliteFailureRollsBackTargetAndRetainsSourceAcrossRestartAndRetry() { + val source = helper(SOURCE) + populate(source) + val target = helper(TARGET) + insert(target, 90, Favorites.ITEM_TYPE_APPWIDGET, widgetId = 9090) + target.writableDatabase.execSQL( + "CREATE TRIGGER fail_widget BEFORE INSERT ON favorites " + + "WHEN NEW.itemType = ${Favorites.ITEM_TYPE_APPWIDGET} " + + "BEGIN SELECT RAISE(ABORT, 'injected write failure'); END" + ) + state(4, 4, SOURCE).writeToPrefs(context) + val sourceBefore = rows(source) + val targetBefore = rows(target) + val controller = controller(3, 3, TARGET, source) + + val error = assertThrows(Exception::class.java) { + controller.attemptMigrateDb(null, delegate) + } + assertTrue( + error.stackTraceToString(), + generateSequence(error as Throwable) { it.cause }.any { it is SQLiteException } + ) + assertSame(source, controller.mOpenHelper) + assertTrue(source.writableDatabase.isOpen) + assertEquals(sourceBefore, rows(source)) + assertEquals(targetBefore, rows(target)) + assertEquals(SOURCE, prefs.get(LauncherPrefs.DB_FILE)) + assertEquals("4,4", prefs.get(LauncherPrefs.WORKSPACE_SIZE)) + verifyNoInteractions(delegate) + + // Re-open through the production selection path, not by injecting the old helper. + source.close() + val restarted = controller(3, 3, TARGET) + restarted.query(null, null, null, null).use { + assertEquals(SOURCE, it.extras.getString(ModelDbController.EXTRA_DB_NAME)) + } + helpers += restarted.mOpenHelper + assertEquals(sourceBefore, rows(restarted.mOpenHelper)) + target.writableDatabase.execSQL("DROP TRIGGER fail_widget") + prefs.putSync(emptyFlag(TARGET).to(true)) + + restarted.attemptMigrateDb(null, delegate) + helpers += restarted.mOpenHelper + + assertEquals(TARGET, restarted.mOpenHelper.databaseName) + assertEquals(TARGET, prefs.get(LauncherPrefs.DB_FILE)) + assertEquals("3,3", prefs.get(LauncherPrefs.WORKSPACE_SIZE)) + assertEquals(3, prefs.get(LauncherPrefs.HOTSEAT_COUNT)) + assertFalse(prefs.get(emptyFlag(TARGET))) + assertMigratedLayout(restarted.mOpenHelper, 3, 3) + verify(delegate).gridMigrationComplete(any(), any()) + val afterRestart = controller(3, 3, TARGET) + afterRestart.query(null, null, null, null).use { + assertEquals(TARGET, it.extras.getString(ModelDbController.EXTRA_DB_NAME)) + } + helpers += afterRestart.mOpenHelper + assertMigratedLayout(afterRestart.mOpenHelper, 3, 3) + } + + @Test + fun successfulResizePreservesFoldersChildrenAndWidgetIds() { + val source = helper(SOURCE) + populate(source) + val before = rows(source) + state(4, 4, SOURCE).writeToPrefs(context) + val controller = controller(3, 3, TARGET, source) + + controller.attemptMigrateDb(null, delegate) + helpers += controller.mOpenHelper + + assertMigratedLayout(controller.mOpenHelper, 3, 3) + assertEquals(before, rows(source)) + assertEquals(TARGET, prefs.get(LauncherPrefs.DB_FILE)) + verify(delegate).gridMigrationComplete(any(), any()) + } + + @Test + fun tallerGridCopyPreservesFolderAndWidgetRows() { + val source = helper(SOURCE) + populate(source) + val target = helper(LauncherFiles.LAUNCHER_4_BY_6_DB) + val before = rows(source) + + logic.migrateGrid( + state(4, 4, SOURCE), + state(4, 6, LauncherFiles.LAUNCHER_4_BY_6_DB), + target, + source.writableDatabase, + true, + ) + + assertMigratedLayout(target, 4, 6) + assertEquals(before, rows(source)) + assertFalse(LauncherDbUtils.tableExists(target.writableDatabase, "favorites_tmp")) + } + + @Test + fun missingSourceRowFailsRatherThanSilentlyDroppingItem() { + val target = helper(TARGET) + val missing = DbEntry().apply { id = 12345 } + assertThrows(IllegalStateException::class.java) { + GridSizeMigrationDBController.insertEntryInDb( + target, missing, Favorites.TABLE_NAME, Favorites.TABLE_NAME, emptyList() + ) + } + } + + @Test + fun failedAttachedCopyDetachesSourceSoNextCopyCanSucceed() { + val source = helper(SOURCE) + populate(source) + val target = helper(TARGET) + val before = rows(source) + assertThrows(SQLiteException::class.java) { + LauncherDbUtils.copyTable( + source.writableDatabase, "missing_table", + target.writableDatabase, "migration_copy", context + ) + } + assertFalse(target.writableDatabase.attachedDbs.any { it.first == "from_db" }) + + LauncherDbUtils.copyTable( + source.writableDatabase, Favorites.TABLE_NAME, + target.writableDatabase, "migration_copy", context + ) + + assertEquals( + 4L, DatabaseUtils.queryNumEntries(target.writableDatabase, "migration_copy") + ) + assertEquals(before, rows(source)) + } + + @Test + fun loaderFailureSkipsDefaultsQueryModelClearGhostCleanupBindingAndCommit() { + val source = helper(SOURCE) + populate(source) + state(4, 4, SOURCE).writeToPrefs(context) + val before = rows(source) + val controller = spy(controller(9, 9, TARGET, source)) + val model = mock(LauncherModel::class.java) + `when`(model.modelDbController).thenReturn(controller) + `when`(model.modelDelegate).thenReturn(delegate) + val transaction = mock(LauncherModel.LoaderTransaction::class.java) + val bgModel = mock(BgDataModel::class.java) + val binder = mock(BaseLauncherBinder::class.java) + val sessions = mock(InstallSessionHelper::class.java) + `when`(sessions.activeSessions).thenReturn(hashMapOf()) + val homeFiles = mock(HomeScreenFilesProvider::class.java) + `when`(homeFiles.query()).thenReturn(lazy { emptyMap() }) + val idp = mock(InvariantDeviceProfile::class.java) + idp.dbFile = TARGET + val loader = LoaderTask( + context, idp, model, users, mock(PackageManagerHelper::class.java), + sessions, mock(IconCache::class.java), mock(AllAppsList::class.java), bgModel, + mock(LoaderCursor.LoaderCursorFactory::class.java), + { mock(FolderNameProvider::class.java) }, false, binder, + { mock(LauncherRestoreEventLogger::class.java) }, { emptySet() }, + mock(WidgetSizeHandler::class.java), LoaderParams(), + mock(WorkspaceItemSpaceFinder::class.java), homeFiles, + mock(FirstScreenBroadcastHelper::class.java), mock(SettingsCache::class.java), + ) + `when`(model.beginLoader(loader)).thenReturn(transaction) + + loader.run() + + verify(controller).attemptMigrateDb(any(), same(delegate)) + verify(controller, never()).loadDefaultFavoritesIfNecessary() + verify(controller, never()).query(any(), any(), any(), any()) + verify(controller, never()).removeGhostWidgets() + verify(bgModel, never()).clear() + verify(bgModel, never()).updateShortcutPinnedState(context) + verifyNoInteractions(binder) + verify(transaction, never()).commit() + verify(transaction).close() + verifyNoInteractions(delegate) + assertEquals(before, rows(source)) + assertEquals(SOURCE, prefs.get(LauncherPrefs.DB_FILE)) + } + + private fun helper(name: String) = + DatabaseHelper(context, name) {}.also { + it.initIds() + helpers += it + } + + private fun controller( + columns: Int, + rows: Int, + name: String, + source: DatabaseHelper? = null, + ): ModelDbController { + val idp = mock(InvariantDeviceProfile::class.java) + idp.numColumns = columns + idp.numRows = rows + idp.numDatabaseHotseatIcons = columns + idp.dbFile = name + return ModelDbController( + context, idp, prefs, users, mock(LayoutParserFactory::class.java), { logic } + ).also { + it.mOpenHelper = source + } + } + + private fun state(columns: Int, rows: Int, name: String) = + DeviceGridState(columns, rows, columns, InvariantDeviceProfile.TYPE_PHONE, name, 0) + + private fun emptyFlag(name: String): ConstantItem = + LauncherPrefs.backedUpItem( + "EMPTY_DATABASE_CREATED@$name", false, EncryptionType.ENCRYPTED + ) + + private fun populate(helper: DatabaseHelper) { + insert(helper, 10, Favorites.ITEM_TYPE_FOLDER) + insert(helper, 11, Favorites.ITEM_TYPE_APPLICATION, container = 10, intent = "child-one") + insert(helper, 12, Favorites.ITEM_TYPE_APPLICATION, container = 10, intent = "child-two") + insert(helper, 20, Favorites.ITEM_TYPE_APPWIDGET, x = 2, y = 2, widgetId = 4242) + } + + private fun insert( + helper: DatabaseHelper, + id: Int, + type: Int, + container: Int = Favorites.CONTAINER_DESKTOP, + x: Int = 0, + y: Int = 0, + widgetId: Int = -1, + intent: String = "item-$id", + ) { + val values = ContentValues().apply { + put(Favorites._ID, id) + put(Favorites.ITEM_TYPE, type) + put(Favorites.CONTAINER, container) + put(Favorites.SCREEN, 0) + put(Favorites.CELLX, x) + put(Favorites.CELLY, y) + put(Favorites.SPANX, if (type == Favorites.ITEM_TYPE_APPWIDGET) 2 else 1) + put(Favorites.SPANY, if (type == Favorites.ITEM_TYPE_APPWIDGET) 2 else 1) + put(Favorites.INTENT, "intent:#Intent;component=example.test/$intent;end") + put(Favorites.APPWIDGET_ID, widgetId) + put(Favorites.APPWIDGET_PROVIDER, "example.test/.Widget") + put(Favorites.PROFILE_ID, 0) + put(Favorites.RANK, if (id == 12) 1 else 0) + } + helper.writableDatabase.insertOrThrow(Favorites.TABLE_NAME, null, values) + } + + private fun rows(helper: DatabaseHelper): List> = + helper.readableDatabase.rawQuery("SELECT * FROM favorites ORDER BY _id", null).use { c -> + buildList { + while (c.moveToNext()) add((0 until c.columnCount).map { c.getString(it) }) + } + } + + private fun assertMigratedLayout(helper: DatabaseHelper, columns: Int, rows: Int) { + val db = helper.readableDatabase + assertEquals(4L, DatabaseUtils.queryNumEntries(db, Favorites.TABLE_NAME)) + val folder = DatabaseUtils.longForQuery( + db, "SELECT _id FROM favorites WHERE itemType=${Favorites.ITEM_TYPE_FOLDER}", null + ) + assertEquals( + 2L, DatabaseUtils.queryNumEntries(db, Favorites.TABLE_NAME, "container=$folder") + ) + db.rawQuery( + "SELECT intent, rank, profileId FROM favorites WHERE container=$folder ORDER BY rank", + null, + ).use { children -> + for ((rank, name) in listOf("child-one", "child-two").withIndex()) { + assertTrue(children.moveToNext()) + assertEquals("intent:#Intent;component=example.test/$name;end", children.getString(0)) + assertEquals(rank, children.getInt(1)) + assertEquals(0, children.getInt(2)) + } + assertFalse(children.moveToNext()) + } + assertEquals( + 4242L, DatabaseUtils.longForQuery( + db, "SELECT appWidgetId FROM favorites WHERE itemType=${Favorites.ITEM_TYPE_APPWIDGET}", + null + ) + ) + assertEquals( + 0L, DatabaseUtils.queryNumEntries( + db, Favorites.TABLE_NAME, + "container=${Favorites.CONTAINER_DESKTOP} AND " + + "(cellX < 0 OR cellY < 0 OR cellX + spanX > $columns OR cellY + spanY > $rows)" + ) + ) + assertEquals( + 0L, DatabaseUtils.longForQuery( + db, + "SELECT COUNT(*) FROM favorites a JOIN favorites b " + + "ON a._id < b._id AND a.screen = b.screen " + + "AND a.container = ${Favorites.CONTAINER_DESKTOP} " + + "AND b.container = ${Favorites.CONTAINER_DESKTOP} " + + "AND a.cellX < b.cellX + b.spanX AND b.cellX < a.cellX + a.spanX " + + "AND a.cellY < b.cellY + b.spanY AND b.cellY < a.cellY + a.spanY", + null, + ) + ) + } + + companion object { + private const val SOURCE = LauncherFiles.LAUNCHER_4_BY_4_DB + private const val TARGET = LauncherFiles.LAUNCHER_3_BY_3_DB + } +}