Robustly fixed issue where migration from different versions of DB data
tables was causing crashes upon grid size switching.

Bug: 304687723
Test: Verified behavior through unit test. Also manually tested that
factory resetting device -> adding shortcuts to the workspace ->
changing the grid size didn't cause any issues and resulted in normal
behavior.

Change-Id: I1945004d2c9a2d6f2b74d9597b39763ed4962e7b
diff --git a/src/com/android/launcher3/LauncherSettings.java b/src/com/android/launcher3/LauncherSettings.java
index 105d5f3..34ebaf2 100644
--- a/src/com/android/launcher3/LauncherSettings.java
+++ b/src/com/android/launcher3/LauncherSettings.java
@@ -19,8 +19,13 @@
 import android.database.sqlite.SQLiteDatabase;
 import android.provider.BaseColumns;
 
+import androidx.annotation.NonNull;
+
 import com.android.launcher3.model.data.ItemInfo;
 
+import java.util.LinkedHashMap;
+import java.util.stream.Collectors;
+
 /**
  * Settings related utilities.
  */
@@ -289,28 +294,51 @@
 
         public static void addTableToDb(SQLiteDatabase db, long myProfileId, boolean optional,
                 String tableName) {
-            String ifNotExists = optional ? " IF NOT EXISTS " : "";
-            db.execSQL("CREATE TABLE " + ifNotExists + tableName + " (" +
-                    "_id INTEGER PRIMARY KEY," +
-                    "title TEXT," +
-                    "intent TEXT," +
-                    "container INTEGER," +
-                    "screen INTEGER," +
-                    "cellX INTEGER," +
-                    "cellY INTEGER," +
-                    "spanX INTEGER," +
-                    "spanY INTEGER," +
-                    "itemType INTEGER," +
-                    "appWidgetId INTEGER NOT NULL DEFAULT -1," +
-                    "icon BLOB," +
-                    "appWidgetProvider TEXT," +
-                    "modified INTEGER NOT NULL DEFAULT 0," +
-                    "restored INTEGER NOT NULL DEFAULT 0," +
-                    "profileId INTEGER DEFAULT " + myProfileId + "," +
-                    "rank INTEGER NOT NULL DEFAULT 0," +
-                    "options INTEGER NOT NULL DEFAULT 0," +
-                    APPWIDGET_SOURCE + " INTEGER NOT NULL DEFAULT " + CONTAINER_UNKNOWN +
-                    ");");
+            db.execSQL("CREATE TABLE " + (optional ? " IF NOT EXISTS " : "") + tableName + " ("
+                    + getJoinedColumnsToTypes(myProfileId) + ");");
+        }
+
+        // LinkedHashMap maintains Order of Insertion
+        @NonNull
+        private static LinkedHashMap<String, String> getColumnsToTypes(long profileId) {
+            final LinkedHashMap<String, String> columnsToTypes = new LinkedHashMap<>();
+            columnsToTypes.put(_ID, "INTEGER PRIMARY KEY");
+            columnsToTypes.put(TITLE, "TEXT");
+            columnsToTypes.put(INTENT, "TEXT");
+            columnsToTypes.put(CONTAINER, "INTEGER");
+            columnsToTypes.put(SCREEN, "INTEGER");
+            columnsToTypes.put(CELLX, "INTEGER");
+            columnsToTypes.put(CELLY, "INTEGER");
+            columnsToTypes.put(SPANX, "INTEGER");
+            columnsToTypes.put(SPANY, "INTEGER");
+            columnsToTypes.put(ITEM_TYPE, "INTEGER");
+            columnsToTypes.put(APPWIDGET_ID, "INTEGER NOT NULL DEFAULT -1");
+            columnsToTypes.put(ICON, "BLOB");
+            columnsToTypes.put(APPWIDGET_PROVIDER, "TEXT");
+            columnsToTypes.put(MODIFIED, "INTEGER NOT NULL DEFAULT 0");
+            columnsToTypes.put(RESTORED, "INTEGER NOT NULL DEFAULT 0");
+            columnsToTypes.put(PROFILE_ID, "INTEGER DEFAULT " + profileId);
+            columnsToTypes.put(RANK, "INTEGER NOT NULL DEFAULT 0");
+            columnsToTypes.put(OPTIONS, "INTEGER NOT NULL DEFAULT 0");
+            columnsToTypes.put(APPWIDGET_SOURCE, "INTEGER NOT NULL DEFAULT -1");
+            return columnsToTypes;
+        }
+
+        private static String getJoinedColumnsToTypes(long profileId) {
+            return getColumnsToTypes(profileId)
+                    .entrySet()
+                    .stream()
+                    .map(it -> it.getKey() + " " + it.getValue())
+                    .collect(Collectors.joining(", "));
+        }
+
+        /**
+         * Returns an ordered list of columns in the Favorites table as one string, ready to use in
+         * an SQL statement.
+         */
+        @NonNull
+        public static String getColumns(long profileId) {
+            return String.join(", ", getColumnsToTypes(profileId).keySet());
         }
     }
 
diff --git a/src/com/android/launcher3/provider/LauncherDbUtils.java b/src/com/android/launcher3/provider/LauncherDbUtils.java
index 575551b..30958d9 100644
--- a/src/com/android/launcher3/provider/LauncherDbUtils.java
+++ b/src/com/android/launcher3/provider/LauncherDbUtils.java
@@ -16,6 +16,7 @@
 
 package com.android.launcher3.provider;
 
+import static com.android.launcher3.LauncherSettings.Favorites.getColumns;
 import static com.android.launcher3.icons.IconCache.EXTRA_SHORTCUT_BADGE_OVERRIDE_PACKAGE;
 
 import android.content.ContentValues;
@@ -48,7 +49,6 @@
  * A set of utility methods for Launcher DB used for DB updates and migration.
  */
 public class LauncherDbUtils {
-
     /**
      * Returns a string which can be used as a where clause for DB query to match the given itemId
      */
@@ -90,10 +90,12 @@
         if (fromDb != toDb) {
             toDb.execSQL("ATTACH DATABASE '" + fromDb.getPath() + "' AS from_db");
             toDb.execSQL(
-                    "INSERT INTO " + toTable + " SELECT * FROM from_db." + fromTable);
+                    "INSERT INTO " + toTable + " SELECT " + getColumns(userSerial)
+                        + " FROM from_db." + fromTable);
             toDb.execSQL("DETACH DATABASE 'from_db'");
         } else {
-            toDb.execSQL("INSERT INTO " + toTable + " SELECT * FROM " + fromTable);
+            toDb.execSQL("INSERT INTO " + toTable + " SELECT " + getColumns(userSerial) + " FROM "
+                    + fromTable);
         }
     }
 
diff --git a/tests/assets/databases/v30_workspace_items.sql b/tests/assets/databases/v30_workspace_items.sql
new file mode 100644
index 0000000..fcf5788
--- /dev/null
+++ b/tests/assets/databases/v30_workspace_items.sql
@@ -0,0 +1,5 @@
+DROP TABLE IF EXISTS 'favorites';
+CREATE TABLE favorites (_id INTEGER PRIMARY KEY,title TEXT,intent TEXT,container INTEGER,screen INTEGER,cellX INTEGER,cellY INTEGER,spanX INTEGER,spanY INTEGER,itemType INTEGER,appWidgetId INTEGER NOT NULL DEFAULT -1,iconPackage TEXT,iconResource TEXT,icon BLOB,appWidgetProvider TEXT,modified INTEGER NOT NULL DEFAULT 0,restored INTEGER NOT NULL DEFAULT 0,profileId INTEGER DEFAULT 0,rank INTEGER NOT NULL DEFAULT 0,options INTEGER NOT NULL DEFAULT 0,appWidgetSource INTEGER NOT NULL DEFAULT -1);
+INSERT INTO 'favorites' VALUES(1,'Phone','#Intent;action=android.intent.action.MAIN;category=android.intent.category.LAUNCHER;launchFlags=0x10200000;component=com.google.android.dialer/.extensions.GoogleDialtactsActivity;end',-101,0,0,0,1,1,0,-1,"iconPackage1","iconResource1",NULL,NULL,0,0,0,0,0,-1);
+INSERT INTO 'favorites' VALUES(2,'Messages','#Intent;action=android.intent.action.MAIN;category=android.intent.category.LAUNCHER;launchFlags=0x10200000;component=com.google.android.apps.messaging/.ui.ConversationListActivity;end',-101,1,1,0,1,1,0,-1,"iconPackage2","iconResource2",NULL,NULL,0,0,0,0,0,-1);
+INSERT INTO 'favorites' VALUES(3,'Play Store','#Intent;action=android.intent.action.MAIN;category=android.intent.category.LAUNCHER;launchFlags=0x10200000;component=com.android.vending/.AssetBrowserActivity;end',-101,2,2,0,1,1,0,-1,"iconPackage3","iconResource3",NULL,NULL,0,0,0,0,0,-1);
\ No newline at end of file
diff --git a/tests/src/com/android/launcher3/model/DatabaseHelperTest.kt b/tests/src/com/android/launcher3/model/DatabaseHelperTest.kt
new file mode 100644
index 0000000..c9ea421
--- /dev/null
+++ b/tests/src/com/android/launcher3/model/DatabaseHelperTest.kt
@@ -0,0 +1,79 @@
+package com.android.launcher3.model
+
+import android.database.sqlite.SQLiteDatabase
+import android.os.UserHandle
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.launcher3.LauncherSettings.Favorites.TABLE_NAME
+import com.android.launcher3.LauncherSettings.Favorites.TMP_TABLE
+import com.android.launcher3.LauncherSettings.Favorites.addTableToDb
+import com.android.launcher3.pm.UserCache
+import com.android.launcher3.provider.LauncherDbUtils
+import java.util.function.ToLongFunction
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val INSERTION_SQL = "databases/v30_workspace_items.sql"
+
+private const val ICON_PACKAGE = "iconPackage"
+private const val ICON_RESOURCE = "iconResource"
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class DatabaseHelperTest {
+
+    /**
+     * b/304687723 occurred when a return was accidentally added to a case statement in
+     * DatabaseHelper.onUpgrade, which stopped the final data migration from successfully occurring.
+     * This test loads an in-memory db from a text file containing SQL statements, and then performs
+     * the migration on the db, and verifies that the correct columns have been deleted.
+     */
+    @Test
+    fun onUpgrade_to_version_32_from_30() {
+        val context = InstrumentationRegistry.getInstrumentation().targetContext
+        val userSerialProvider =
+            ToLongFunction<UserHandle> {
+                UserCache.INSTANCE.get(context).getSerialNumberForUser(it)
+            }
+        val dbHelper = DatabaseHelper(context, null, userSerialProvider) {}
+        val db = FactitiousDbController(context, INSERTION_SQL).inMemoryDb
+
+        dbHelper.onUpgrade(db, 30, 32)
+
+        assertFalse(hasFavoritesColumn(db, ICON_PACKAGE))
+        assertFalse(hasFavoritesColumn(db, ICON_RESOURCE))
+    }
+
+    /**
+     * b/304687723 causes a crash due to copying a table with 21 columns to a table with 19 columns.
+     * This test loads an in-memory db from a text file containing SQL statements, and then copies
+     * data from the created table into a temporary one, and verifies that no exception is thrown.
+     */
+    @Test
+    fun after_migrating_from_db_v30_to_v32_copy_table() {
+        val context = InstrumentationRegistry.getInstrumentation().targetContext
+        val db = FactitiousDbController(context, INSERTION_SQL).inMemoryDb // v30 - 21 columns
+
+        addTableToDb(db, 1, true, TMP_TABLE)
+        LauncherDbUtils.copyTable(db, TABLE_NAME, db, TMP_TABLE, context)
+
+        val c1 = db.query(TABLE_NAME, null, null, null, null, null, null)
+        val c2 = db.query(TMP_TABLE, null, null, null, null, null, null)
+
+        assertEquals(21, c1.columnCount)
+        assertEquals(19, c2.columnCount)
+        assertEquals(c1.count, c2.count)
+
+        c1.close()
+        c2.close()
+    }
+
+    private fun hasFavoritesColumn(db: SQLiteDatabase, columnName: String): Boolean {
+        db.query(TABLE_NAME, null, null, null, null, null, null).use { c ->
+            return c.getColumnIndex(columnName) >= 0
+        }
+    }
+}
diff --git a/tests/src/com/android/launcher3/model/FactitiousDbController.kt b/tests/src/com/android/launcher3/model/FactitiousDbController.kt
index 664f23e..711e1d2 100644
--- a/tests/src/com/android/launcher3/model/FactitiousDbController.kt
+++ b/tests/src/com/android/launcher3/model/FactitiousDbController.kt
@@ -29,18 +29,14 @@
         "options",
         "appWidgetSource"
     )
-private const val INSERTION_STATEMENT_FILE = "databases/workspace_items.sql"
 
-class FactitiousDbController(context: Context) : ModelDbController(context) {
+class FactitiousDbController(context: Context, insertFile: String) : ModelDbController(context) {
 
-    private val inMemoryDb: SQLiteDatabase by lazy {
+    val inMemoryDb: SQLiteDatabase by lazy {
         SQLiteDatabase.createInMemory(SQLiteDatabase.OpenParams.Builder().build()).also { db ->
             BufferedReader(
                     InputStreamReader(
-                        InstrumentationRegistry.getInstrumentation()
-                            .context
-                            .assets
-                            .open(INSERTION_STATEMENT_FILE)
+                        InstrumentationRegistry.getInstrumentation().context.assets.open(insertFile)
                     )
                 )
                 .lines()
diff --git a/tests/src/com/android/launcher3/model/LoaderTaskTest.kt b/tests/src/com/android/launcher3/model/LoaderTaskTest.kt
index 1421087..036f2d8 100644
--- a/tests/src/com/android/launcher3/model/LoaderTaskTest.kt
+++ b/tests/src/com/android/launcher3/model/LoaderTaskTest.kt
@@ -26,6 +26,8 @@
 import org.mockito.Mockito.`when`
 import org.mockito.MockitoAnnotations
 
+private const val INSERTION_STATEMENT_FILE = "databases/workspace_items.sql"
+
 @SmallTest
 @RunWith(AndroidJUnit4::class)
 class LoaderTaskTest {
@@ -55,7 +57,8 @@
         `when`(app.model).thenReturn(launcherModel)
         `when`(launcherModel.beginLoader(any(LoaderTask::class.java))).thenReturn(transaction)
         `when`(app.iconCache).thenReturn(iconCache)
-        `when`(launcherModel.modelDbController).thenReturn(FactitiousDbController(context))
+        `when`(launcherModel.modelDbController)
+            .thenReturn(FactitiousDbController(context, INSERTION_STATEMENT_FILE))
         `when`(app.invariantDeviceProfile).thenReturn(idp)
         `when`(launcherBinder.newIdleLock(any(LoaderTask::class.java))).thenReturn(idleLock)
         `when`(idleLock.awaitLocked(1000)).thenReturn(false)