Adding support for DB downgrade

Adding a schema file for handling DB downgrade. This schema file is part of
the backup/restore set, and hence is available on a device with lower app version.

Bug: 37257575
Change-Id: I69c8ef5f28d5209be6e6679412c7459d4eeda5d0
diff --git a/src/com/android/launcher3/LauncherProvider.java b/src/com/android/launcher3/LauncherProvider.java
index bd53b4d..c84a431 100644
--- a/src/com/android/launcher3/LauncherProvider.java
+++ b/src/com/android/launcher3/LauncherProvider.java
@@ -56,6 +56,7 @@
 import com.android.launcher3.dynamicui.ExtractionUtils;
 import com.android.launcher3.graphics.IconShapeOverride;
 import com.android.launcher3.logging.FileLog;
+import com.android.launcher3.model.DbDowngradeHelper;
 import com.android.launcher3.provider.LauncherDbUtils;
 import com.android.launcher3.provider.LauncherDbUtils.SQLiteTransaction;
 import com.android.launcher3.provider.RestoreDbTask;
@@ -64,6 +65,7 @@
 import com.android.launcher3.util.Preconditions;
 import com.android.launcher3.util.Thunk;
 
+import java.io.File;
 import java.io.FileDescriptor;
 import java.io.PrintWriter;
 import java.lang.reflect.Method;
@@ -77,18 +79,12 @@
     private static final String TAG = "LauncherProvider";
     private static final boolean LOGD = false;
 
+    private static final String DOWNGRADE_SCHEMA_FILE = "downgrade_schema.json";
+
     /**
      * Represents the schema of the database. Changes in scheme need not be backwards compatible.
      */
-    private static final int SCHEMA_VERSION = 27;
-    /**
-     * Represents the actual data. It could include additional validations and normalizations added
-     * overtime. These must be backwards compatible, else we risk breaking old devices during
-     * restore or binary version downgrade.
-     */
-    private static final int DATA_VERSION = 3;
-
-    private static final String PREF_KEY_DATA_VERISON = "provider_data_version";
+    public static final int SCHEMA_VERSION = 27;
 
     public static final String AUTHORITY = (BuildConfig.APPLICATION_ID + ".settings").intern();
 
@@ -703,47 +699,30 @@
         @Override
         public void onOpen(SQLiteDatabase db) {
             super.onOpen(db);
-            SharedPreferences prefs = mContext
-                    .getSharedPreferences(LauncherFiles.DEVICE_PREFERENCES_KEY, 0);
-            int oldVersion = prefs.getInt(PREF_KEY_DATA_VERISON, 0);
-            if (oldVersion != DATA_VERSION) {
-                // Only run the data upgrade path for an existing db.
-                if (!Utilities.getPrefs(mContext).getBoolean(EMPTY_DATABASE_CREATED, false)) {
-                    try (SQLiteTransaction t = new SQLiteTransaction(db)) {
-                        onDataUpgrade(db, oldVersion);
-                        t.commit();
-                    } catch (Exception e) {
-                        Log.d(TAG, "Error updating data version, ignoring", e);
-                        return;
-                    }
-                }
-                prefs.edit().putInt(PREF_KEY_DATA_VERISON, DATA_VERSION).apply();
+
+            File schemaFile = mContext.getFileStreamPath(DOWNGRADE_SCHEMA_FILE);
+            if (!schemaFile.exists()) {
+                handleOneTimeDataUpgrade(db);
             }
+            DbDowngradeHelper.updateSchemaFile(schemaFile, SCHEMA_VERSION, mContext,
+                    R.raw.downgrade_schema);
         }
 
         /**
-         * Called when the data is updated as part of app update. It can be called multiple times
-         * with old version, even though it had been run before. The changes made here must be
-         * backwards compatible, else we risk breaking old devices during restore or binary
-         * version downgrade.
+         * One-time data updated before support of onDowngrade was added. This update is backwards
+         * compatible and can safely be run multiple times.
+         * Note: No new logic should be added here after release, as the new logic might not get
+         * executed on an existing device.
+         * TODO: Move this to db upgrade path, once the downgrade path is released.
          */
-        protected void onDataUpgrade(SQLiteDatabase db, int oldVersion) {
-            switch (oldVersion) {
-                case 0:
-                case 1: {
-                    // Remove "profile extra"
-                    UserManagerCompat um = UserManagerCompat.getInstance(mContext);
-                    for (UserHandle user : um.getUserProfiles()) {
-                        long serial = um.getSerialNumberForUser(user);
-                        String sql = "update favorites set intent = replace(intent, "
-                                + "';l.profile=" + serial + ";', ';') where itemType = 0;";
-                        db.execSQL(sql);
-                    }
-                }
-                case 2:
-                case 3:
-                    // data updated
-                    return;
+        protected void handleOneTimeDataUpgrade(SQLiteDatabase db) {
+            // Remove "profile extra"
+            UserManagerCompat um = UserManagerCompat.getInstance(mContext);
+            for (UserHandle user : um.getUserProfiles()) {
+                long serial = um.getSerialNumberForUser(user);
+                String sql = "update favorites set intent = replace(intent, "
+                        + "';l.profile=" + serial + ";', ';') where itemType = 0;";
+                db.execSQL(sql);
             }
         }
 
@@ -850,15 +829,14 @@
 
         @Override
         public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
-            if (oldVersion == 28 && newVersion == 27) {
-                // TODO: remove this check. This is only applicable for internal development/testing
-                // and for any released version of Launcher.
-                return;
+            try {
+                DbDowngradeHelper.parse(mContext.getFileStreamPath(DOWNGRADE_SCHEMA_FILE))
+                        .onDowngrade(db, oldVersion, newVersion);
+            } catch (Exception e) {
+                Log.d(TAG, "Unable to downgrade from: " + oldVersion + " to " + newVersion +
+                        ". Wiping databse.", e);
+                createEmptyDB(db);
             }
-            // This shouldn't happen -- throw our hands up in the air and start over.
-            Log.w(TAG, "Database version downgrade from: " + oldVersion + " to " + newVersion +
-                    ". Wiping databse.");
-            createEmptyDB(db);
         }
 
         /**
diff --git a/src/com/android/launcher3/model/DbDowngradeHelper.java b/src/com/android/launcher3/model/DbDowngradeHelper.java
new file mode 100644
index 0000000..cd86b72
--- /dev/null
+++ b/src/com/android/launcher3/model/DbDowngradeHelper.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2017 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.content.Context;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
+import android.util.Log;
+import android.util.SparseArray;
+
+import com.android.launcher3.provider.LauncherDbUtils.SQLiteTransaction;
+import com.android.launcher3.util.IOUtils;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+
+/**
+ * Utility class to handle DB downgrade
+ */
+public class DbDowngradeHelper {
+
+    private static final String TAG = "DbDowngradeHelper";
+
+    private static final String KEY_VERSION = "version";
+    private static final String KEY_DOWNGRADE_TO = "downgrade_to_";
+
+    private final SparseArray<String[]> mStatements = new SparseArray<>();
+    public final int version;
+
+    private DbDowngradeHelper(int version) {
+        this.version = version;
+    }
+
+    public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+        ArrayList<String> allCommands = new ArrayList<>();
+
+        for (int i = oldVersion - 1; i >= newVersion; i--) {
+            String[] commands = mStatements.get(i);
+            if (commands == null) {
+                throw new SQLiteException("Downgrade path not supported to version " + i);
+            }
+            Collections.addAll(allCommands, commands);
+        }
+
+        try (SQLiteTransaction t = new SQLiteTransaction(db)) {
+            for (String sql : allCommands) {
+                db.execSQL(sql);
+            }
+            t.commit();
+        }
+    }
+
+    public static DbDowngradeHelper parse(File file) throws JSONException, IOException {
+        JSONObject obj = new JSONObject(new String(IOUtils.toByteArray(file)));
+        DbDowngradeHelper helper = new DbDowngradeHelper(obj.getInt(KEY_VERSION));
+        for (int version = helper.version - 1; version > 0; version--) {
+            if (obj.has(KEY_DOWNGRADE_TO + version)) {
+                JSONArray statements = obj.getJSONArray(KEY_DOWNGRADE_TO + version);
+                String[] parsed = new String[statements.length()];
+                for (int i = 0; i < parsed.length; i++) {
+                    parsed[i] = statements.getString(i);
+                }
+                helper.mStatements.put(version, parsed);
+            }
+        }
+        return helper;
+    }
+
+    public static void updateSchemaFile(File schemaFile, int expectedVersion,
+            Context context, int schemaResId) {
+        try {
+            if (DbDowngradeHelper.parse(schemaFile).version >= expectedVersion) {
+                return;
+            }
+        } catch (Exception e) {
+            // Schema error
+        }
+
+        // Write the updated schema
+        try (FileOutputStream fos = new FileOutputStream(schemaFile);
+            InputStream in = context.getResources().openRawResource(schemaResId)) {
+            IOUtils.copy(in, fos);
+        } catch (IOException e) {
+            Log.e(TAG, "Error writing schema file", e);
+        }
+    }
+}
diff --git a/src/com/android/launcher3/provider/LauncherDbUtils.java b/src/com/android/launcher3/provider/LauncherDbUtils.java
index 6325040..74373d3 100644
--- a/src/com/android/launcher3/provider/LauncherDbUtils.java
+++ b/src/com/android/launcher3/provider/LauncherDbUtils.java
@@ -52,6 +52,7 @@
 
             if (screenIds.isEmpty()) {
                 // No update needed
+                t.commit();
                 return true;
             }
             if (screenIds.get(0) != 0) {
@@ -71,6 +72,7 @@
             if (DatabaseUtils.queryNumEntries(db, Favorites.TABLE_NAME,
                     "container = -100 and screen = 0 and cellY = 0") == 0) {
                 // First row is empty, no need to migrate.
+                t.commit();
                 return true;
             }
 
diff --git a/src/com/android/launcher3/util/IOUtils.java b/src/com/android/launcher3/util/IOUtils.java
new file mode 100644
index 0000000..77c21fe
--- /dev/null
+++ b/src/com/android/launcher3/util/IOUtils.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2017 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;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Supports various IO utility functions
+ */
+public class IOUtils {
+
+    private static final int BUF_SIZE = 0x1000; // 4K
+
+    public static byte[] toByteArray(File file) throws IOException {
+        try (InputStream in = new FileInputStream(file)) {
+            return toByteArray(in);
+        }
+    }
+
+    public static byte[] toByteArray(InputStream in) throws IOException {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        copy(in, out);
+        return out.toByteArray();
+    }
+
+    public static long copy(InputStream from, OutputStream to) throws IOException {
+        byte[] buf = new byte[BUF_SIZE];
+        long total = 0;
+        int r;
+        while ((r = from.read(buf)) != -1) {
+            to.write(buf, 0, r);
+            total += r;
+        }
+        return total;
+    }
+}