diff --git a/aconfig/launcher.aconfig b/aconfig/launcher.aconfig index 462d947a24..1ddbababd3 100644 --- a/aconfig/launcher.aconfig +++ b/aconfig/launcher.aconfig @@ -188,19 +188,21 @@ flag { } flag { - name: "grid_migration_fix" + name: "enable_grid_migration_fix" namespace: "launcher" description: "Keep items in place when migrating to a bigger grid" bug: "325286145" + is_fixed_read_only: true metadata { purpose: PURPOSE_BUGFIX } } flag { - name: "narrow_grid_restore" + name: "enable_narrow_grid_restore" namespace: "launcher" description: "Using only the most recent workspace when restoring to avoid confusion." + is_fixed_read_only: true bug: "325285743" metadata { purpose: PURPOSE_BUGFIX diff --git a/src/com/android/launcher3/InvariantDeviceProfile.java b/src/com/android/launcher3/InvariantDeviceProfile.java index 6a7c3a3a49..2fd5ebd6d0 100644 --- a/src/com/android/launcher3/InvariantDeviceProfile.java +++ b/src/com/android/launcher3/InvariantDeviceProfile.java @@ -72,6 +72,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; public class InvariantDeviceProfile { @@ -357,6 +358,15 @@ public class InvariantDeviceProfile { return displayOption.grid.name; } + /** + * @deprecated This is a temporary solution because on the backup and restore case we modify the + * IDP, this resets it. b/332974074 + */ + @Deprecated + public void reset(Context context) { + initGrid(context, getCurrentGridName(context)); + } + @VisibleForTesting public static String getDefaultGridName(Context context) { return new InvariantDeviceProfile().initGrid(context, null); @@ -575,6 +585,45 @@ public class InvariantDeviceProfile { return filteredProfiles; } + /** + * Returns the GridOption associated to the given file name or null if the fileName is not + * supported. + * Ej, launcher.db -> "normal grid", launcher_4_by_4.db -> "practical grid" + */ + public GridOption getGridOptionFromFileName(Context context, String fileName) { + return parseAllGridOptions(context).stream() + .filter(gridOption -> Objects.equals(gridOption.dbFile, fileName)) + .findFirst() + .orElse(null); + } + + /** + * Returns the name of the given size on the current device or empty string if the size is not + * supported. Ej. 4x4 -> normal, 5x4 -> practical, etc. + * (Note: the name of the grid can be different for the same grid size depending of + * the values of the InvariantDeviceProfile) + * + */ + public String getGridNameFromSize(Context context, Point size) { + return parseAllGridOptions(context).stream() + .filter(gridOption -> gridOption.numColumns == size.x + && gridOption.numRows == size.y) + .map(gridOption -> gridOption.name) + .findFirst() + .orElse(""); + } + + /** + * Returns the grid option for the given gridName on the current device (Note: the gridOption + * be different for the same gridName depending on the values of the InvariantDeviceProfile). + */ + public GridOption getGridOptionFromName(Context context, String gridName) { + return parseAllGridOptions(context).stream() + .filter(gridOption -> Objects.equals(gridOption.name, gridName)) + .findFirst() + .orElse(null); + } + /** * @return all the grid options that can be shown on the device */ diff --git a/src/com/android/launcher3/model/DeviceGridState.java b/src/com/android/launcher3/model/DeviceGridState.java index f24d1d2fff..8c68eb84ec 100644 --- a/src/com/android/launcher3/model/DeviceGridState.java +++ b/src/com/android/launcher3/model/DeviceGridState.java @@ -53,6 +53,13 @@ public class DeviceGridState implements Comparable { private final @DeviceType int mDeviceType; private final String mDbFile; + public DeviceGridState(int columns, int row, int numHotseat, int deviceType, String dbFile) { + mGridSizeString = String.format(Locale.ENGLISH, "%d,%d", columns, row); + mNumHotseat = numHotseat; + mDeviceType = deviceType; + mDbFile = dbFile; + } + public DeviceGridState(InvariantDeviceProfile idp) { mGridSizeString = String.format(Locale.ENGLISH, "%d,%d", idp.numColumns, idp.numRows); mNumHotseat = idp.numDatabaseHotseatIcons; diff --git a/src/com/android/launcher3/model/GridSizeMigrationUtil.java b/src/com/android/launcher3/model/GridSizeMigrationUtil.java index af66431d47..15190c7952 100644 --- a/src/com/android/launcher3/model/GridSizeMigrationUtil.java +++ b/src/com/android/launcher3/model/GridSizeMigrationUtil.java @@ -38,7 +38,9 @@ import android.util.ArrayMap; import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import com.android.launcher3.Flags; import com.android.launcher3.InvariantDeviceProfile; import com.android.launcher3.LauncherPrefs; import com.android.launcher3.LauncherSettings; @@ -94,6 +96,15 @@ public class GridSizeMigrationUtil { return needsToMigrate; } + @VisibleForTesting + public static List readAllEntries(SQLiteDatabase db, String tableName, + Context context) { + DbReader dbReader = new DbReader(db, tableName, context, getValidPackages(context)); + List result = dbReader.loadAllWorkspaceEntries(); + result.addAll(dbReader.loadHotseatEntries()); + return result; + } + /** * When migrating the grid, we copy the table * {@link LauncherSettings.Favorites#TABLE_NAME} from {@code source} into @@ -105,15 +116,23 @@ public class GridSizeMigrationUtil { */ public static boolean migrateGridIfNeeded( @NonNull Context context, - @NonNull InvariantDeviceProfile idp, + @NonNull DeviceGridState srcDeviceState, + @NonNull DeviceGridState destDeviceState, @NonNull DatabaseHelper target, @NonNull SQLiteDatabase source) { - - DeviceGridState srcDeviceState = new DeviceGridState(context); - DeviceGridState destDeviceState = new DeviceGridState(idp); if (!needsToMigrate(srcDeviceState, destDeviceState)) { return true; } + + if (Flags.enableGridMigrationFix() + && srcDeviceState.getColumns().equals(destDeviceState.getColumns()) + && srcDeviceState.getRows() < destDeviceState.getRows()) { + // Only use this strategy when comparing the previous grid to the new grid and the + // columns are the same and the destination has more rows + copyTable(source, TABLE_NAME, target.getWritableDatabase(), TABLE_NAME, context); + destDeviceState.writeToPrefs(context); + return true; + } copyTable(source, TABLE_NAME, target.getWritableDatabase(), TMP_TABLE, context); HashSet validPackages = getValidPackages(context); @@ -656,7 +675,7 @@ public class GridSizeMigrationUtil { } } - protected static class DbEntry extends ItemInfo implements Comparable { + public static class DbEntry extends ItemInfo implements Comparable { private String mIntent; private String mProvider; diff --git a/src/com/android/launcher3/model/ModelDbController.java b/src/com/android/launcher3/model/ModelDbController.java index 64771bdb43..7e1d40d96b 100644 --- a/src/com/android/launcher3/model/ModelDbController.java +++ b/src/com/android/launcher3/model/ModelDbController.java @@ -312,8 +312,12 @@ public class ModelDbController { mOpenHelper = (mContext instanceof SandboxContext) ? oldHelper : createDatabaseHelper(true /* forMigration */); try { - return GridSizeMigrationUtil.migrateGridIfNeeded(mContext, idp, mOpenHelper, - oldHelper.getWritableDatabase()); + // 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(idp); + return GridSizeMigrationUtil.migrateGridIfNeeded(mContext, srcDeviceState, + destDeviceState, mOpenHelper, oldHelper.getWritableDatabase()); } catch (Exception e) { FileLog.e(TAG, "Failed to migrate grid", e); return false; diff --git a/src/com/android/launcher3/provider/RestoreDbTask.java b/src/com/android/launcher3/provider/RestoreDbTask.java index 22bc13bb25..e6ce337485 100644 --- a/src/com/android/launcher3/provider/RestoreDbTask.java +++ b/src/com/android/launcher3/provider/RestoreDbTask.java @@ -50,8 +50,10 @@ import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; import androidx.annotation.WorkerThread; +import com.android.launcher3.Flags; import com.android.launcher3.InvariantDeviceProfile; import com.android.launcher3.LauncherAppState; +import com.android.launcher3.LauncherFiles; import com.android.launcher3.LauncherPrefs; import com.android.launcher3.LauncherSettings; import com.android.launcher3.LauncherSettings.Favorites; @@ -73,9 +75,11 @@ import com.android.launcher3.util.ContentWriter; import com.android.launcher3.util.IntArray; import com.android.launcher3.util.LogConfig; +import java.io.File; import java.io.InvalidObjectException; import java.util.Arrays; import java.util.Collection; +import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -121,7 +125,66 @@ public class RestoreDbTask { // executed again. LauncherPrefs.get(context).removeSync(RESTORE_DEVICE); - idp.reinitializeAfterRestore(context); + if (Flags.enableNarrowGridRestore()) { + String oldPhoneFileName = idp.dbFile; + List previousDbs = existingDbs(); + removeOldDBs(context, oldPhoneFileName); + // The idp before this contains data about the old phone, after this it becomes the idp + // of the current phone. + idp.reset(context); + trySettingPreviousGidAsCurrent(context, idp, oldPhoneFileName, previousDbs); + } else { + idp.reinitializeAfterRestore(context); + } + } + + + /** + * Try setting the gird used in the previous phone to the new one. If the current device doesn't + * support the previous grid option it will not be set. + */ + private static void trySettingPreviousGidAsCurrent(Context context, InvariantDeviceProfile idp, + String oldPhoneDbFileName, List previousDbs) { + InvariantDeviceProfile.GridOption oldPhoneGridOption = idp.getGridOptionFromFileName( + context, oldPhoneDbFileName); + // The grid option could be null if current phone doesn't support the previous db. + if (oldPhoneGridOption != null) { + /* If the user only used the default db on the previous phone and the new default db is + * bigger than or equal to the previous one, then keep the new default db */ + if (previousDbs.size() == 1 && oldPhoneGridOption.numColumns <= idp.numColumns + && oldPhoneGridOption.numRows <= idp.numRows) { + /* Keep the user in default grid */ + return; + } + /* + * Here we are setting the previous db as the current one. + */ + idp.setCurrentGrid(context, oldPhoneGridOption.name); + } + } + + /** + * Returns a list of paths of the existing launcher dbs. + */ + private static List existingDbs() { + // At this point idp.dbFile contains the name of the dbFile from the previous phone + return LauncherFiles.GRID_DB_FILES.stream() + .filter(dbName -> new File(dbName).exists()) + .toList(); + } + + /** + * Only keep the last database used on the previous device. + */ + private static void removeOldDBs(Context context, String oldPhoneDbFileName) { + // At this point idp.dbFile contains the name of the dbFile from the previous phone + LauncherFiles.GRID_DB_FILES.stream() + .filter(dbName -> !dbName.equals(oldPhoneDbFileName)) + .forEach(dbName -> { + if (context.getDatabasePath(dbName).delete()) { + FileLog.d(TAG, "Removed old grid db file: " + dbName); + } + }); } private static boolean performRestore(Context context, ModelDbController controller) { diff --git a/tests/assets/databases/BackupAndRestore/launcher.db b/tests/assets/databases/BackupAndRestore/launcher.db new file mode 100644 index 0000000000..126d166492 Binary files /dev/null and b/tests/assets/databases/BackupAndRestore/launcher.db differ diff --git a/tests/assets/databases/BackupAndRestore/launcher_3_by_3.db b/tests/assets/databases/BackupAndRestore/launcher_3_by_3.db new file mode 100644 index 0000000000..6d8cd735b7 Binary files /dev/null and b/tests/assets/databases/BackupAndRestore/launcher_3_by_3.db differ diff --git a/tests/assets/databases/BackupAndRestore/launcher_4_by_4.db b/tests/assets/databases/BackupAndRestore/launcher_4_by_4.db new file mode 100644 index 0000000000..00061dddf4 Binary files /dev/null and b/tests/assets/databases/BackupAndRestore/launcher_4_by_4.db differ diff --git a/tests/assets/databases/BackupAndRestore/launcher_4_by_5.db b/tests/assets/databases/BackupAndRestore/launcher_4_by_5.db new file mode 100644 index 0000000000..e2e65aaf07 Binary files /dev/null and b/tests/assets/databases/BackupAndRestore/launcher_4_by_5.db differ diff --git a/tests/assets/databases/GridMigrationTest/flagged_result5x5to5x8.db b/tests/assets/databases/GridMigrationTest/flagged_result5x5to5x8.db new file mode 100644 index 0000000000..8bea3ce708 Binary files /dev/null and b/tests/assets/databases/GridMigrationTest/flagged_result5x5to5x8.db differ diff --git a/tests/assets/databases/GridMigrationTest/result5x5to3x3.db b/tests/assets/databases/GridMigrationTest/result5x5to3x3.db new file mode 100644 index 0000000000..686056dc21 Binary files /dev/null and b/tests/assets/databases/GridMigrationTest/result5x5to3x3.db differ diff --git a/tests/assets/databases/GridMigrationTest/result5x5to4x7.db b/tests/assets/databases/GridMigrationTest/result5x5to4x7.db new file mode 100644 index 0000000000..cd105c5847 Binary files /dev/null and b/tests/assets/databases/GridMigrationTest/result5x5to4x7.db differ diff --git a/tests/assets/databases/GridMigrationTest/result5x5to5x8.db b/tests/assets/databases/GridMigrationTest/result5x5to5x8.db new file mode 100644 index 0000000000..4b46969950 Binary files /dev/null and b/tests/assets/databases/GridMigrationTest/result5x5to5x8.db differ diff --git a/tests/assets/databases/GridMigrationTest/test_launcher.db b/tests/assets/databases/GridMigrationTest/test_launcher.db new file mode 100644 index 0000000000..c680e95efb Binary files /dev/null and b/tests/assets/databases/GridMigrationTest/test_launcher.db differ diff --git a/tests/multivalentTests/src/com/android/launcher3/util/rule/BackAndRestoreRule.kt b/tests/multivalentTests/src/com/android/launcher3/util/rule/BackAndRestoreRule.kt new file mode 100644 index 0000000000..9ac976f822 --- /dev/null +++ b/tests/multivalentTests/src/com/android/launcher3/util/rule/BackAndRestoreRule.kt @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.launcher3.util.rule + +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import com.android.launcher3.InvariantDeviceProfile +import com.android.launcher3.LauncherPrefs +import java.io.File +import java.nio.file.Paths +import kotlin.io.path.pathString +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +/** + * Removes all launcher's DBs from the device and copies the dbs in + * assets/databases/BackupAndRestore to the device. It also set's the needed LauncherPrefs variables + * needed to kickstart a backup and restore. + */ +class BackAndRestoreRule : TestRule { + + private val phoneContext = getInstrumentation().targetContext + + private fun dbBackUp() = File(phoneContext.dataDir.path, "/databasesBackUp") + + private fun dbDirectory() = File(phoneContext.dataDir.path, "/databases") + + private fun isWorkspaceDatabase(rawFileName: String): Boolean { + val fileName = Paths.get(rawFileName).fileName.pathString + return fileName.startsWith("launcher") && fileName.endsWith(".db") + } + + fun getDatabaseFiles() = dbDirectory().listFiles().filter { isWorkspaceDatabase(it.name) } + + /** + * Setting RESTORE_DEVICE would trigger a restore next time the Launcher starts, and we remove + * the widgets and apps ids to prevent issues when loading the database. + */ + private fun setRestoreConstants() { + LauncherPrefs.get(phoneContext) + .put(LauncherPrefs.RESTORE_DEVICE.to(InvariantDeviceProfile.TYPE_MULTI_DISPLAY)) + LauncherPrefs.get(phoneContext) + .remove(LauncherPrefs.OLD_APP_WIDGET_IDS, LauncherPrefs.APP_WIDGET_IDS) + } + + private fun uploadDatabase(dbName: String) { + val file = File(File(getInstrumentation().targetContext.dataDir, "/databases"), dbName) + file.writeBytes( + getInstrumentation() + .context + .assets + .open("databases/BackupAndRestore/$dbName") + .readBytes() + ) + file.setWritable(true, false) + } + + private fun uploadDbs() { + uploadDatabase("launcher.db") + uploadDatabase("launcher_4_by_4.db") + uploadDatabase("launcher_4_by_5.db") + uploadDatabase("launcher_3_by_3.db") + } + + private fun savePreviousState() { + dbBackUp().deleteRecursively() + if (!dbDirectory().renameTo(dbBackUp())) { + throw Exception("Unable to move databases to backup directory") + } + dbDirectory().mkdir() + if (!dbDirectory().exists()) { + throw Exception("Databases directory doesn't exist") + } + } + + private fun restorePreviousState() { + dbDirectory().deleteRecursively() + if (!dbBackUp().renameTo(dbDirectory())) { + throw Exception("Unable to restore backup directory to databases directory") + } + dbBackUp().delete() + } + + fun before() { + savePreviousState() + setRestoreConstants() + uploadDbs() + } + + fun after() { + restorePreviousState() + } + + override fun apply(base: Statement?, description: Description?): Statement = + object : Statement() { + override fun evaluate() { + before() + try { + base?.evaluate() + } finally { + after() + } + } + } +} diff --git a/tests/src/com/android/launcher3/backuprestore/BackupAndRestoreDBSelectionTest.kt b/tests/src/com/android/launcher3/backuprestore/BackupAndRestoreDBSelectionTest.kt new file mode 100644 index 0000000000..479b201f61 --- /dev/null +++ b/tests/src/com/android/launcher3/backuprestore/BackupAndRestoreDBSelectionTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.launcher3.backuprestore + +import android.platform.test.flag.junit.SetFlagsRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import com.android.launcher3.Flags +import com.android.launcher3.LauncherPrefs +import com.android.launcher3.model.ModelDbController +import com.android.launcher3.util.Executors.MODEL_EXECUTOR +import com.android.launcher3.util.TestUtil +import com.android.launcher3.util.rule.BackAndRestoreRule +import com.android.launcher3.util.rule.setFlags +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Makes sure to test {@code RestoreDbTask#removeOldDBs}, we need to remove all the dbs that are not + * the last one used when we restore the device. + */ +@RunWith(AndroidJUnit4::class) +@MediumTest +class BackupAndRestoreDBSelectionTest { + + @JvmField @Rule var backAndRestoreRule = BackAndRestoreRule() + + @JvmField + @Rule + val setFlagsRule = SetFlagsRule(SetFlagsRule.DefaultInitValueType.DEVICE_DEFAULT) + + @Before + fun setUp() { + setFlagsRule.setFlags(true, Flags.FLAG_ENABLE_NARROW_GRID_RESTORE) + } + + @Test + fun oldDatabasesNotPresentAfterRestore() { + val dbController = ModelDbController(getInstrumentation().targetContext) + dbController.tryMigrateDB(null) + TestUtil.runOnExecutorSync(MODEL_EXECUTOR) { + assert(backAndRestoreRule.getDatabaseFiles().size == 1) { + "There should only be one database after restoring, the last one used. Actual databases ${backAndRestoreRule.getDatabaseFiles()}" + } + assert( + !LauncherPrefs.get(getInstrumentation().targetContext) + .has(LauncherPrefs.RESTORE_DEVICE) + ) { + "RESTORE_DEVICE shouldn't be present after a backup and restore." + } + } + } +} diff --git a/tests/src/com/android/launcher3/celllayout/board/CellLayoutBoard.java b/tests/src/com/android/launcher3/celllayout/board/CellLayoutBoard.java index 62f2259ca3..e5ad888a8c 100644 --- a/tests/src/com/android/launcher3/celllayout/board/CellLayoutBoard.java +++ b/tests/src/com/android/launcher3/celllayout/board/CellLayoutBoard.java @@ -199,6 +199,19 @@ public class CellLayoutBoard implements Comparable { return 'z'; } + /** + * Check if the given area is empty. + */ + public boolean isEmpty(int x, int y, int spanX, int spanY) { + for (int xi = x; xi < x + spanX; xi++) { + for (int yi = y; yi < y + spanY; yi++) { + if (mWidget[xi][yi] == CellType.IGNORE) continue; + if (mWidget[xi][yi] != CellType.EMPTY) return false; + } + } + return true; + } + public void addWidget(int x, int y, int spanX, int spanY, char type) { Rect rect = new Rect(x, y + spanY - 1, x + spanX - 1, y); removeOverlappingItems(rect); diff --git a/tests/src/com/android/launcher3/model/GridMigrationTest.kt b/tests/src/com/android/launcher3/model/GridMigrationTest.kt new file mode 100644 index 0000000000..15222a4fcf --- /dev/null +++ b/tests/src/com/android/launcher3/model/GridMigrationTest.kt @@ -0,0 +1,242 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.launcher3.model + +import android.platform.test.flag.junit.SetFlagsRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import com.android.launcher3.Flags +import com.android.launcher3.InvariantDeviceProfile.TYPE_PHONE +import com.android.launcher3.LauncherSettings.Favorites.TABLE_NAME +import com.android.launcher3.celllayout.board.CellLayoutBoard +import com.android.launcher3.pm.UserCache +import com.android.launcher3.util.rule.TestToPhoneFileCopier +import com.android.launcher3.util.rule.setFlags +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +private val phoneContext = InstrumentationRegistry.getInstrumentation().targetContext + +data class EntryData(val x: Int, val y: Int, val spanX: Int, val spanY: Int, val rank: Int) + +/** + * Holds the data needed to run a test in GridMigrationTest, usually we would have a src + * GridMigrationData and a dst GridMigrationData meaning the data after a migration has occurred. + * This class holds a gridState, which is the size of the grid like 5x5 (among other things). a + * dbHelper which contains the readable database and writable database used to migrate the + * databases. + * + * You can also get all the entries defined in the dbHelper database. + */ +class GridMigrationData(dbFileName: String?, val gridState: DeviceGridState) { + + val dbHelper: DatabaseHelper = + DatabaseHelper( + phoneContext, + dbFileName, + { UserCache.INSTANCE.get(phoneContext).getSerialNumberForUser(it) }, + {} + ) + + fun readEntries(): List = + GridSizeMigrationUtil.readAllEntries(dbHelper.readableDatabase, TABLE_NAME, phoneContext) +} + +/** + * Test the migration of a database from one size to another. It reads a database from the test + * assets, uploads it into the phone and migrates the database to a database in memory which is + * later compared against a database in the test assets to make sure they are identical. + */ +@SmallTest +@RunWith(AndroidJUnit4::class) +class GridMigrationTest { + private val DB_FILE = "test_launcher.db" + + @JvmField + @Rule + val setFlagsRule = SetFlagsRule(SetFlagsRule.DefaultInitValueType.DEVICE_DEFAULT) + + // Copying the src db for all tests. + @JvmField + @Rule + val fileCopier = + TestToPhoneFileCopier( + src = "databases/GridMigrationTest/$DB_FILE", + dest = "databases/$DB_FILE", + removeOnFinish = true + ) + + @Before + fun setup() { + setFlagsRule.setFlags(false, Flags.FLAG_ENABLE_GRID_MIGRATION_FIX) + } + + private fun migrate(src: GridMigrationData, dst: GridMigrationData) { + GridSizeMigrationUtil.migrateGridIfNeeded( + phoneContext, + src.gridState, + dst.gridState, + dst.dbHelper, + src.dbHelper.readableDatabase + ) + } + + /** + * Makes sure that none of the items overlaps on the result, i.e. no widget or icons share the + * same space in the db. + */ + private fun validateDb(data: GridMigrationData) { + // The array size is just a big enough number to fit all the number of workspaces + val boards = Array(100) { CellLayoutBoard(data.gridState.columns, data.gridState.rows) } + data.readEntries().forEach { + val cellLayoutBoard = boards[it.screenId] + assert(cellLayoutBoard.isEmpty(it.cellX, it.cellY, it.spanX, it.spanY)) { + "Db has overlapping items" + } + cellLayoutBoard.addWidget(it.cellX, it.cellY, it.spanX, it.spanY) + } + } + + private fun compare(dst: GridMigrationData, target: GridMigrationData) { + val sort = compareBy({ it.cellX }, { it.cellY }) + val mapF = { it: GridSizeMigrationUtil.DbEntry -> + EntryData(it.cellX, it.cellY, it.spanX, it.spanY, it.rank) + } + val entriesDst = dst.readEntries().sortedWith(sort).map(mapF) + val entriesTarget = target.readEntries().sortedWith(sort).map(mapF) + + assert(entriesDst == entriesTarget) { + "The elements on the dst database is not the same as in the target" + } + } + + /** + * 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. + */ + private fun runTest(src: GridMigrationData, dst: GridMigrationData, target: GridMigrationData) { + migrate(src, dst) + assert(src.readEntries().size == dst.readEntries().size) { + "Source db and destination db do not contain the same number of elements" + } + validateDb(dst) + compare(dst, target) + } + + @JvmField + @Rule + val result5x5to3x3 = + TestToPhoneFileCopier( + src = "databases/GridMigrationTest/result5x5to3x3.db", + dest = "databases/result5x5to3x3.db", + removeOnFinish = true + ) + + @Test + fun `5x5 to 3x3`() = + runTest( + src = GridMigrationData(DB_FILE, DeviceGridState(5, 5, 5, TYPE_PHONE, DB_FILE)), + dst = + GridMigrationData( + null, // in memory db, to download a new db change null for the filename of the + // db name to store it. Do not use existing names. + DeviceGridState(3, 3, 3, TYPE_PHONE, "") + ), + target = + GridMigrationData("result5x5to3x3.db", DeviceGridState(3, 3, 3, TYPE_PHONE, "")) + ) + + @JvmField + @Rule + val result5x5to4x7 = + TestToPhoneFileCopier( + src = "databases/GridMigrationTest/result5x5to4x7.db", + dest = "databases/result5x5to4x7.db", + removeOnFinish = true + ) + + @Test + fun `5x5 to 4x7`() = + runTest( + src = GridMigrationData(DB_FILE, DeviceGridState(5, 5, 5, TYPE_PHONE, DB_FILE)), + dst = + GridMigrationData( + null, // in memory db, to download a new db change null for the filename of the + // db name to store it. Do not use existing names. + DeviceGridState(4, 7, 4, TYPE_PHONE, "") + ), + target = + GridMigrationData("result5x5to4x7.db", DeviceGridState(4, 7, 4, TYPE_PHONE, "")) + ) + + @JvmField + @Rule + val result5x5to5x8 = + TestToPhoneFileCopier( + src = "databases/GridMigrationTest/result5x5to5x8.db", + dest = "databases/result5x5to5x8.db", + removeOnFinish = true + ) + + @Test + fun `5x5 to 5x8`() = + runTest( + src = GridMigrationData(DB_FILE, DeviceGridState(5, 5, 5, TYPE_PHONE, DB_FILE)), + dst = + GridMigrationData( + null, // in memory db, to download a new db change null for the filename of the + // db name to store it. Do not use existing names. + DeviceGridState(5, 8, 5, TYPE_PHONE, "") + ), + target = + GridMigrationData("result5x5to5x8.db", DeviceGridState(5, 8, 5, TYPE_PHONE, "")) + ) + + @JvmField + @Rule + val flaggedResult5x5to5x8 = + TestToPhoneFileCopier( + src = "databases/GridMigrationTest/flagged_result5x5to5x8.db", + dest = "databases/flagged_result5x5to5x8.db", + removeOnFinish = true + ) + + @Test + fun `flagged 5x5 to 5x8`() { + setFlagsRule.setFlags(true, Flags.FLAG_ENABLE_GRID_MIGRATION_FIX) + runTest( + src = GridMigrationData(DB_FILE, DeviceGridState(5, 5, 5, TYPE_PHONE, DB_FILE)), + dst = + GridMigrationData( + null, // in memory db, to download a new db change null for the filename of the + // db name to store it. Do not use existing names. + DeviceGridState(5, 8, 5, TYPE_PHONE, "") + ), + target = + GridMigrationData( + "flagged_result5x5to5x8.db", + DeviceGridState(5, 8, 5, TYPE_PHONE, "") + ) + ) + } +} diff --git a/tests/src/com/android/launcher3/util/rule/TestToPhoneFileCopier.kt b/tests/src/com/android/launcher3/util/rule/TestToPhoneFileCopier.kt new file mode 100644 index 0000000000..d3516d1d2d --- /dev/null +++ b/tests/src/com/android/launcher3/util/rule/TestToPhoneFileCopier.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.launcher3.util.rule + +import androidx.test.platform.app.InstrumentationRegistry +import java.io.File +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +/** Copy a file from the tests assets folder to the phone. */ +class TestToPhoneFileCopier( + val src: String, + dest: String, + private val removeOnFinish: Boolean = false +) : TestRule { + + private val dstFile = + File(InstrumentationRegistry.getInstrumentation().targetContext.dataDir, dest) + + fun getDst() = dstFile.absolutePath + + fun before() = + dstFile.writeBytes( + InstrumentationRegistry.getInstrumentation().context.assets.open(src).readBytes() + ) + + fun after() { + if (removeOnFinish) { + dstFile.delete() + } + } + + override fun apply(base: Statement, description: Description): Statement = + object : Statement() { + override fun evaluate() { + before() + try { + base.evaluate() + } finally { + after() + } + } + } +}