Browse Source

Migrations manager

Signed-off-by: Chris Narkiewicz <hello@ezaquarii.com>
Chris Narkiewicz 5 years ago
parent
commit
b87bf1f10e

+ 3 - 0
build.gradle

@@ -387,6 +387,9 @@ dependencies {
 //    androidJacocoAnt "org.jacoco:org.jacoco.agent:${jacocoVersion}"
 
     implementation "com.github.stateless4j:stateless4j:2.6.0"
+    testImplementation "org.robolectric:robolectric:4.3.1"
+    androidTestImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
+    androidTestImplementation "org.mockito:mockito-android:3.3.0"
 }
 
 configurations.all {

+ 104 - 0
src/androidTest/java/com/nextcloud/client/migrations/android/MockSharedPreferences.kt

@@ -0,0 +1,104 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.migrations.android
+
+import android.content.SharedPreferences
+import java.lang.UnsupportedOperationException
+import java.util.TreeMap
+
+@Suppress("TooManyFunctions")
+class MockSharedPreferences : SharedPreferences {
+
+    class MockEditor(val store: MutableMap<String?, Any?>) : SharedPreferences.Editor {
+
+        val editorStore: MutableMap<String?, Any?> = TreeMap()
+
+        override fun clear(): SharedPreferences.Editor = throw UnsupportedOperationException()
+
+        override fun putLong(key: String?, value: Long): SharedPreferences.Editor =
+            throw UnsupportedOperationException()
+
+        override fun putInt(key: String?, value: Int): SharedPreferences.Editor {
+            editorStore.put(key, value)
+            return this
+        }
+
+        override fun remove(key: String?): SharedPreferences.Editor = throw UnsupportedOperationException()
+
+        override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor {
+            editorStore.put(key, value)
+            return this
+        }
+
+        override fun putStringSet(key: String?, values: MutableSet<String>?): SharedPreferences.Editor {
+            editorStore.put(key, values?.toMutableSet())
+            return this
+        }
+
+        override fun commit(): Boolean = true
+
+        override fun putFloat(key: String?, value: Float): SharedPreferences.Editor =
+            throw UnsupportedOperationException()
+
+        override fun apply() = store.putAll(editorStore)
+
+        override fun putString(key: String?, value: String?): SharedPreferences.Editor {
+            editorStore.put(key, value)
+            return this
+        }
+    }
+
+    val store: MutableMap<String?, Any?> = TreeMap()
+
+    override fun contains(key: String?): Boolean = store.containsKey(key)
+    override fun getBoolean(key: String?, defValue: Boolean): Boolean = store.getOrDefault(key, defValue) as Boolean
+
+    override fun unregisterOnSharedPreferenceChangeListener(
+        listener: SharedPreferences.OnSharedPreferenceChangeListener?
+    ) = throw UnsupportedOperationException()
+
+    override fun getInt(key: String?, defValue: Int): Int = store.getOrDefault(key, defValue) as Int
+
+    override fun getAll(): MutableMap<String, *> {
+        throw UnsupportedOperationException()
+    }
+
+    override fun edit(): SharedPreferences.Editor {
+        return MockEditor(store)
+    }
+
+    override fun getLong(key: String?, defValue: Long): Long {
+        throw UnsupportedOperationException()
+    }
+
+    override fun getFloat(key: String?, defValue: Float): Float {
+        throw UnsupportedOperationException()
+    }
+
+    override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String>? {
+        return store.getOrDefault(key, defValues) as MutableSet<String>?
+    }
+
+    override fun registerOnSharedPreferenceChangeListener(
+        listener: SharedPreferences.OnSharedPreferenceChangeListener?
+    ) = throw UnsupportedOperationException()
+
+    override fun getString(key: String?, defValue: String?): String? = store.getOrDefault(key, defValue) as String?
+}

+ 257 - 0
src/androidTest/java/com/nextcloud/client/migrations/android/TestMigrationsManager.kt

@@ -0,0 +1,257 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.migrations.android
+
+import androidx.test.annotation.UiThreadTest
+import com.nextcloud.client.account.UserAccountManager
+import com.nextcloud.client.appinfo.AppInfo
+import com.nextcloud.client.core.ManualAsyncRunner
+import com.nextcloud.client.migrations.Migrations
+import com.nextcloud.client.migrations.MigrationsManager
+import com.nextcloud.client.migrations.MigrationsManagerImpl
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.never
+import com.nhaarman.mockitokotlin2.verify
+import com.nhaarman.mockitokotlin2.whenever
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+import java.lang.RuntimeException
+import java.util.LinkedHashSet
+
+@Suppress("FunctionNaming")
+class TestMigrationsManager {
+
+    companion object {
+        const val OLD_APP_VERSION = 41
+        const val NEW_APP_VERSION = 42
+    }
+
+    lateinit var migrations: List<Migrations.Step>
+
+    @Mock
+    lateinit var appInfo: AppInfo
+
+    lateinit var migrationsDb: MockSharedPreferences
+
+    @Mock
+    lateinit var userAccountManager: UserAccountManager
+
+    lateinit var asyncRunner: ManualAsyncRunner
+
+    internal lateinit var migrationsManager: MigrationsManagerImpl
+
+    @Before
+    fun setUp() {
+        MockitoAnnotations.initMocks(this)
+        val migrationStep1: Runnable = mock()
+        val migrationStep2: Runnable = mock()
+        migrations = listOf(
+            Migrations.Step(0, "first migration", migrationStep1),
+            Migrations.Step(1, "second migration", migrationStep2)
+        )
+        asyncRunner = ManualAsyncRunner()
+        migrationsDb = MockSharedPreferences()
+        whenever(appInfo.versionCode).thenReturn(NEW_APP_VERSION)
+        migrationsManager = MigrationsManagerImpl(
+            appInfo = appInfo,
+            migrationsDb = migrationsDb,
+            asyncRunner = asyncRunner,
+            migrations = migrations
+        )
+    }
+
+    @Test
+    fun inital_status_is_unknown() {
+        // GIVEN
+        //      migration manager has not been used yets
+
+        // THEN
+        //      status is not set
+        assertEquals(MigrationsManager.Status.UNKNOWN, migrationsManager.status.value)
+    }
+
+    @Test
+    fun applied_migrations_are_returned_in_order() {
+        // GIVEN
+        //      some migrations are marked as applied
+        //      migration ids are stored in random order
+        val storedMigrationIds = LinkedHashSet<String>()
+        storedMigrationIds.apply {
+            add("3")
+            add("0")
+            add("2")
+            add("1")
+        }
+        migrationsDb.store.put(MigrationsManagerImpl.DB_KEY_APPLIED_MIGRATIONS, storedMigrationIds)
+
+        // WHEN
+        //      applied migration ids are retrieved
+        val ids = migrationsManager.getAppliedMigrations()
+
+        // THEN
+        //      returned list is sorted
+        assertEquals(ids, ids.sorted())
+    }
+
+    @Test
+    @Suppress("MagicNumber")
+    fun registering_new_applied_migration_preserves_old_ids() {
+        // WHEN
+        //     some applied migrations are registered
+        val appliedMigrationIds = setOf("0", "1", "2")
+        migrationsDb.store.put(MigrationsManagerImpl.DB_KEY_APPLIED_MIGRATIONS, appliedMigrationIds)
+
+        // WHEN
+        //     new set of migration ids are registered
+        //      some ids are added again
+        migrationsManager.addAppliedMigration(2, 3, 4)
+
+        // THEN
+        //      new ids are appended to set of existing ids
+        val expectedIds = setOf("0", "1", "2", "3", "4")
+        assertEquals(expectedIds, migrationsDb.store.get(MigrationsManagerImpl.DB_KEY_APPLIED_MIGRATIONS))
+    }
+
+    @Test
+    @UiThreadTest
+    fun migrations_are_scheduled_on_background_thread() {
+        // GIVEN
+        //      migrations can be applied
+        assertEquals(0, migrationsManager.getAppliedMigrations().size)
+
+        // WHEN
+        //      migration is started
+        val count = migrationsManager.startMigration()
+
+        // THEN
+        //      all migrations are scheduled on background thread
+        //      single task is scheduled
+        assertEquals(migrations.size, count)
+        assertEquals(1, asyncRunner.size)
+        assertEquals(MigrationsManager.Status.RUNNING, migrationsManager.status.value)
+    }
+
+    @Test
+    @UiThreadTest
+    fun applied_migrations_are_recorded() {
+        // GIVEN
+        //      no migrations are applied yet
+        //      current app version is newer then last recorded migrated version
+        whenever(appInfo.versionCode).thenReturn(NEW_APP_VERSION)
+        migrationsDb.store.put(MigrationsManagerImpl.DB_KEY_LAST_MIGRATED_VERSION, OLD_APP_VERSION)
+
+        // WHEN
+        //      migration is run
+        whenever(userAccountManager.migrateUserId()).thenReturn(true)
+        val count = migrationsManager.startMigration()
+        assertTrue(asyncRunner.runOne())
+
+        // THEN
+        //      total migrations count is returned
+        //      migration functions are called
+        //      applied migrations are recorded
+        //      new app version code is recorded
+        assertEquals(migrations.size, count)
+        assertEquals(setOf("0", "1"), migrationsDb.store.get(MigrationsManagerImpl.DB_KEY_APPLIED_MIGRATIONS))
+        assertEquals(NEW_APP_VERSION, migrationsDb.store.get(MigrationsManagerImpl.DB_KEY_LAST_MIGRATED_VERSION))
+    }
+
+    @Test
+    @UiThreadTest
+    fun migration_error_is_recorded() {
+        // GIVEN
+        //      no migrations applied yet
+
+        // WHEN
+        //      migrations are applied
+        //      one migration throws
+        val lastMigration = migrations.last()
+        val errorMessage = "error message"
+        whenever(lastMigration.function.run()).thenThrow(RuntimeException(errorMessage))
+        migrationsManager.startMigration()
+        assertTrue(asyncRunner.runOne())
+
+        // THEN
+        //      failure is marked in the migration db
+        //      failure message is recorded
+        //      failed migration id is recorded
+        assertEquals(MigrationsManager.Status.FAILED, migrationsManager.status.value)
+        assertTrue(migrationsDb.getBoolean(MigrationsManagerImpl.DB_KEY_FAILED, false))
+        assertEquals(
+            errorMessage,
+            migrationsDb.getString(MigrationsManagerImpl.DB_KEY_FAILED_MIGRATION_ERROR_MESSAGE, "")
+        )
+        assertEquals(
+            lastMigration.id,
+            migrationsDb.getInt(MigrationsManagerImpl.DB_KEY_FAILED_MIGRATION_ID, -1)
+        )
+    }
+
+    @Test
+    @UiThreadTest
+    fun migrations_are_not_run_if_already_run_for_an_app_version() {
+        // GIVEN
+        //      migrations were already run for the current app version
+        whenever(appInfo.versionCode).thenReturn(NEW_APP_VERSION)
+        migrationsDb.store.put(MigrationsManagerImpl.DB_KEY_LAST_MIGRATED_VERSION, NEW_APP_VERSION)
+
+        // WHEN
+        //      app is migrated again
+        val migrationCount = migrationsManager.startMigration()
+
+        // THEN
+        //      migration processing is skipped entirely
+        //      status is set to applied
+        assertEquals(0, migrationCount)
+        migrations.forEach {
+            verify(it.function, never()).run()
+        }
+        assertEquals(MigrationsManager.Status.APPLIED, migrationsManager.status.value)
+    }
+
+    @Test
+    @UiThreadTest
+    fun new_app_version_is_marked_as_migrated_if_no_new_migrations_are_available() {
+        //  GIVEN
+        //      migrations were applied in previous version
+        //      new version has no new migrations
+        whenever(appInfo.versionCode).thenReturn(NEW_APP_VERSION)
+        migrationsDb.store.put(MigrationsManagerImpl.DB_KEY_LAST_MIGRATED_VERSION, OLD_APP_VERSION)
+        val applied = migrations.map { it.id.toString() }.toSet()
+        migrationsDb.store.put(MigrationsManagerImpl.DB_KEY_APPLIED_MIGRATIONS, applied)
+
+        // WHEN
+        //      migration is started
+        val startedCount = migrationsManager.startMigration()
+
+        // THEN
+        //      no new migrations are run
+        //      new version is marked as migrated
+        assertEquals(0, startedCount)
+        assertEquals(
+            NEW_APP_VERSION,
+            migrationsDb.getInt(MigrationsManagerImpl.DB_KEY_LAST_MIGRATED_VERSION, -1)
+        )
+    }
+}

+ 77 - 0
src/androidTest/java/com/nextcloud/client/migrations/android/TestMockSharedPreferences.kt

@@ -0,0 +1,77 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.migrations.android
+
+import org.junit.Before
+import org.junit.Test
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotSame
+import org.junit.Assert.assertTrue
+
+@Suppress("MagicNumber", "FunctionNaming")
+class TestMockSharedPreferences {
+
+    private lateinit var mock: MockSharedPreferences
+
+    @Before
+    fun setUp() {
+        mock = MockSharedPreferences()
+    }
+
+    @Test
+    fun get_set_string_set() {
+        val value = setOf("alpha", "bravo", "charlie")
+        mock.edit().putStringSet("key", value).apply()
+        val copy = mock.getStringSet("key", mutableSetOf())
+        assertNotSame(value, copy)
+        assertEquals(value, copy)
+    }
+
+    @Test
+    fun get_set_int() {
+        val value = 42
+        val editor = mock.edit()
+        editor.putInt("key", value)
+        assertEquals(100, mock.getInt("key", 100))
+        editor.apply()
+        assertEquals(42, mock.getInt("key", 100))
+    }
+
+    @Test
+    fun get_set_boolean() {
+        val value = true
+        val editor = mock.edit()
+        editor.putBoolean("key", value)
+        assertFalse(mock.getBoolean("key", false))
+        editor.apply()
+        assertTrue(mock.getBoolean("key", false))
+    }
+
+    @Test
+    fun get_set_string() {
+        val value = "a value"
+        val editor = mock.edit()
+        editor.putString("key", value)
+        assertEquals("default", mock.getString("key", "default"))
+        editor.apply()
+        assertEquals("a value", mock.getString("key", "default"))
+    }
+}

+ 2 - 0
src/main/java/com/nextcloud/client/account/UserAccountManager.java

@@ -90,6 +90,8 @@ public interface UserAccountManager extends CurrentAccountProvider {
 
     /**
      * Verifies that every account has userId set
+     *
+     * @return true if any account was migrated, false if no account was migrated
      */
     boolean migrateUserId();
 

+ 2 - 0
src/main/java/com/nextcloud/client/appinfo/AppInfo.java

@@ -36,6 +36,8 @@ public interface AppInfo {
      */
     String getFormattedVersionCode();
 
+    int getVersionCode();
+
     boolean isDebugBuild();
 
     String getAppVersion(Context context);

+ 5 - 0
src/main/java/com/nextcloud/client/appinfo/AppInfoImpl.java

@@ -33,6 +33,11 @@ class AppInfoImpl implements AppInfo {
         return Integer.toString(BuildConfig.VERSION_CODE);
     }
 
+    @Override
+    public int getVersionCode() {
+        return BuildConfig.VERSION_CODE;
+    }
+
     @Override
     public boolean isDebugBuild() {
         return BuildConfig.DEBUG;

+ 24 - 0
src/main/java/com/nextcloud/client/migrations/MigrationError.kt

@@ -0,0 +1,24 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.migrations
+
+class MigrationError(val id: Int, message: String, cause: Throwable?) : RuntimeException(message, cause) {
+    constructor(id: Int, message: String) : this(id, message, null)
+}

+ 52 - 0
src/main/java/com/nextcloud/client/migrations/Migrations.kt

@@ -0,0 +1,52 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.migrations
+
+import com.nextcloud.client.account.UserAccountManager
+import javax.inject.Inject
+
+/**
+ * This class collects all migration steps and provides API to supply those
+ * steps to [MigrationsManager] for execution.
+ *
+ * Note to maintainers: put all migration routines here and export collection of
+ * opaque [Runnable]s via steps property.
+ */
+class Migrations @Inject constructor(
+    private val userAccountManager: UserAccountManager
+) {
+    /**
+     * @param id Step id; id must be unique
+     * @param description Human readable migration step description
+     * @param function Migration runnable object
+     */
+    data class Step(val id: Int, val description: String, val function: Runnable)
+
+    /**
+     * List of migration steps. Those steps will be loaded and run by [MigrationsManager]
+     */
+    val steps: List<Step> = listOf(
+        Step(0, "migrate user id", Runnable { migrateUserId() })
+    ).sortedBy { it.id }
+
+    fun migrateUserId() {
+        userAccountManager.migrateUserId()
+    }
+}

+ 72 - 0
src/main/java/com/nextcloud/client/migrations/MigrationsManager.kt

@@ -0,0 +1,72 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.migrations
+
+import androidx.annotation.MainThread
+import androidx.lifecycle.LiveData
+
+/**
+ * This component allows starting and monitoring of application state migrations.
+ * Migrations are intended to upgrade any existing, persisted application state
+ * after upgrade to new version, similarly to database migrations.
+ */
+interface MigrationsManager {
+
+    enum class Status {
+        /**
+         * Application migration was not evaluated yet. This is the default
+         * state just after [android.app.Application] start
+         */
+        UNKNOWN,
+
+        /**
+         * All migrations applied successfully.
+         */
+        APPLIED,
+
+        /**
+         * Migration in progress.
+         */
+        RUNNING,
+
+        /**
+         * Migration failed. Application is in undefined state.
+         */
+        FAILED
+    }
+
+    /**
+     * Listenable migration progress.
+     */
+    val status: LiveData<Status>
+
+    /**
+     * Starts application state migration. Migrations will be run in background thread.
+     * Callers can use [status] to monitor migration progress.
+     *
+     * Although the migration process is run in background, status is updated
+     * immediately and can be accessed immediately after start.
+     *
+     * @return Number of migration steps enqueued; 0 if no migrations were started.
+     */
+    @Throws(MigrationError::class)
+    @MainThread
+    fun startMigration(): Int
+}

+ 136 - 0
src/main/java/com/nextcloud/client/migrations/MigrationsManagerImpl.kt

@@ -0,0 +1,136 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.migrations
+
+import android.content.SharedPreferences
+import androidx.annotation.MainThread
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.nextcloud.client.appinfo.AppInfo
+import com.nextcloud.client.core.AsyncRunner
+import com.nextcloud.client.migrations.MigrationsManager.Status
+import java.util.TreeSet
+
+internal class MigrationsManagerImpl(
+    private val appInfo: AppInfo,
+    private val migrationsDb: SharedPreferences,
+    private val asyncRunner: AsyncRunner,
+    private val migrations: Collection<Migrations.Step>
+) : MigrationsManager {
+
+    companion object {
+        const val DB_KEY_LAST_MIGRATED_VERSION = "last_migrated_version"
+        const val DB_KEY_APPLIED_MIGRATIONS = "applied_migrations"
+        const val DB_KEY_FAILED = "failed"
+        const val DB_KEY_FAILED_MIGRATION_ID = "failed_migration_id"
+        const val DB_KEY_FAILED_MIGRATION_ERROR_MESSAGE = "failed_migration_error"
+    }
+
+    override val status: LiveData<Status>
+
+    init {
+        this.status = MutableLiveData<Status>(Status.UNKNOWN)
+    }
+
+    fun getAppliedMigrations(): List<Int> {
+        val appliedIdsStr: Set<String> = migrationsDb.getStringSet(DB_KEY_APPLIED_MIGRATIONS, null) ?: TreeSet()
+        return appliedIdsStr.mapNotNull {
+            try {
+                it.toInt()
+            } catch (_: NumberFormatException) {
+                null
+            }
+        }.sorted()
+    }
+
+    fun addAppliedMigration(vararg migrations: Int) {
+        val oldApplied = migrationsDb.getStringSet(DB_KEY_APPLIED_MIGRATIONS, null) ?: TreeSet()
+        val newApplied = TreeSet<String>().apply {
+            addAll(oldApplied)
+            addAll(migrations.map { it.toString() })
+        }
+        migrationsDb.edit().putStringSet(DB_KEY_APPLIED_MIGRATIONS, newApplied).apply()
+    }
+
+    @Throws(MigrationError::class)
+    @Suppress("ReturnCount")
+    override fun startMigration(): Int {
+
+        if (migrationsDb.getBoolean(DB_KEY_FAILED, false)) {
+            (status as MutableLiveData<Status>).value = Status.FAILED
+            return 0
+        }
+        val lastMigratedVersion = migrationsDb.getInt(DB_KEY_LAST_MIGRATED_VERSION, -1)
+        if (lastMigratedVersion >= appInfo.versionCode) {
+            (status as MutableLiveData<Status>).value = Status.APPLIED
+            return 0
+        }
+        val applied = getAppliedMigrations()
+        val toApply = migrations.filter { !applied.contains(it.id) }
+        if (toApply.isEmpty()) {
+            onMigrationSuccess()
+            return 0
+        }
+        (status as MutableLiveData<Status>).value = Status.RUNNING
+        asyncRunner.post(
+            task = { asyncApplyMigrations(toApply) },
+            onResult = { onMigrationSuccess() },
+            onError = { onMigrationFailed(it) }
+        )
+        return toApply.size
+    }
+
+    /**
+     * This method calls all pending migrations which can execute long-blocking code.
+     * It should be run in a background thread.
+     */
+    private fun asyncApplyMigrations(migrations: Collection<Migrations.Step>) {
+        migrations.forEach {
+            @Suppress("TooGenericExceptionCaught") // migration code is free to throw anything
+            try {
+                it.function.run()
+            } catch (t: Throwable) {
+                throw MigrationError(id = it.id, message = t.message ?: t.javaClass.simpleName)
+            }
+            addAppliedMigration(it.id)
+        }
+    }
+
+    @MainThread
+    private fun onMigrationFailed(error: Throwable) {
+        val id = when (error) {
+            is MigrationError -> error.id
+            else -> -1
+        }
+        migrationsDb
+            .edit()
+            .putBoolean(DB_KEY_FAILED, true)
+            .putString(DB_KEY_FAILED_MIGRATION_ERROR_MESSAGE, error.message)
+            .putInt(DB_KEY_FAILED_MIGRATION_ID, id)
+            .apply()
+        (status as MutableLiveData<Status>).value = Status.FAILED
+    }
+
+    @MainThread
+    private fun onMigrationSuccess() {
+        migrationsDb.edit().putInt(DB_KEY_LAST_MIGRATED_VERSION, appInfo.versionCode).apply()
+        (status as MutableLiveData<Status>).value = Status.APPLIED
+    }
+}

+ 6 - 0
src/main/java/com/nextcloud/client/migrations/Package.md

@@ -0,0 +1,6 @@
+# Package com.nextcloud.client.migrations
+
+This package provides utitilies to migrate application state
+during version upgrade.
+
+Migrations are registered upon run so they can be run only once.

+ 104 - 0
src/test/java/com/nextcloud/client/migrations/MockSharedPreferences.kt

@@ -0,0 +1,104 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.migrations
+
+import android.content.SharedPreferences
+import java.lang.UnsupportedOperationException
+import java.util.TreeMap
+
+@Suppress("TooManyFunctions")
+class MockSharedPreferences : SharedPreferences {
+
+    class MockEditor(val store: MutableMap<String?, Any?>) : SharedPreferences.Editor {
+
+        val editorStore: MutableMap<String?, Any?> = TreeMap()
+
+        override fun clear(): SharedPreferences.Editor = throw UnsupportedOperationException()
+
+        override fun putLong(key: String?, value: Long): SharedPreferences.Editor =
+            throw UnsupportedOperationException()
+
+        override fun putInt(key: String?, value: Int): SharedPreferences.Editor {
+            editorStore.put(key, value)
+            return this
+        }
+
+        override fun remove(key: String?): SharedPreferences.Editor = throw UnsupportedOperationException()
+
+        override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor {
+            editorStore.put(key, value)
+            return this
+        }
+
+        override fun putStringSet(key: String?, values: MutableSet<String>?): SharedPreferences.Editor {
+            editorStore.put(key, values?.toMutableSet())
+            return this
+        }
+
+        override fun commit(): Boolean = true
+
+        override fun putFloat(key: String?, value: Float): SharedPreferences.Editor =
+            throw UnsupportedOperationException()
+
+        override fun apply() = store.putAll(editorStore)
+
+        override fun putString(key: String?, value: String?): SharedPreferences.Editor {
+            editorStore.put(key, value)
+            return this
+        }
+    }
+
+    val store: MutableMap<String?, Any?> = TreeMap()
+
+    override fun contains(key: String?): Boolean = store.containsKey(key)
+    override fun getBoolean(key: String?, defValue: Boolean): Boolean = store.getOrDefault(key, defValue) as Boolean
+
+    override fun unregisterOnSharedPreferenceChangeListener(
+        listener: SharedPreferences.OnSharedPreferenceChangeListener?
+    ) = throw UnsupportedOperationException()
+
+    override fun getInt(key: String?, defValue: Int): Int = store.getOrDefault(key, defValue) as Int
+
+    override fun getAll(): MutableMap<String, *> {
+        throw UnsupportedOperationException()
+    }
+
+    override fun edit(): SharedPreferences.Editor {
+        return MockEditor(store)
+    }
+
+    override fun getLong(key: String?, defValue: Long): Long {
+        throw UnsupportedOperationException()
+    }
+
+    override fun getFloat(key: String?, defValue: Float): Float {
+        throw UnsupportedOperationException()
+    }
+
+    override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String>? {
+        return store.getOrDefault(key, defValues) as MutableSet<String>?
+    }
+
+    override fun registerOnSharedPreferenceChangeListener(
+        listener: SharedPreferences.OnSharedPreferenceChangeListener?
+    ) = throw UnsupportedOperationException()
+
+    override fun getString(key: String?, defValue: String?): String? = store.getOrDefault(key, defValue) as String?
+}

+ 250 - 0
src/test/java/com/nextcloud/client/migrations/TestMigrationsManager.kt

@@ -0,0 +1,250 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.migrations
+
+import com.nextcloud.client.account.UserAccountManager
+import com.nextcloud.client.appinfo.AppInfo
+import com.nextcloud.client.core.ManualAsyncRunner
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.never
+import com.nhaarman.mockitokotlin2.verify
+import com.nhaarman.mockitokotlin2.whenever
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+import org.robolectric.RobolectricTestRunner
+import java.lang.RuntimeException
+import java.util.LinkedHashSet
+
+@RunWith(RobolectricTestRunner::class)
+class TestMigrationsManager {
+
+    companion object {
+        const val OLD_APP_VERSION = 41
+        const val NEW_APP_VERSION = 42
+    }
+
+    lateinit var migrations: List<Migrations.Step>
+
+    @Mock
+    lateinit var appInfo: AppInfo
+
+    lateinit var migrationsDb: MockSharedPreferences
+
+    @Mock
+    lateinit var userAccountManager: UserAccountManager
+
+    lateinit var asyncRunner: ManualAsyncRunner
+
+    internal lateinit var migrationsManager: MigrationsManagerImpl
+
+    @Before
+    fun setUp() {
+        MockitoAnnotations.initMocks(this)
+        val migrationStep1: Runnable = mock()
+        val migrationStep2: Runnable = mock()
+        migrations = listOf(
+            Migrations.Step(0, "first migration", migrationStep1),
+            Migrations.Step(1, "second migration", migrationStep2)
+        )
+        asyncRunner = ManualAsyncRunner()
+        migrationsDb = MockSharedPreferences()
+        whenever(appInfo.versionCode).thenReturn(NEW_APP_VERSION)
+        migrationsManager = MigrationsManagerImpl(
+            appInfo = appInfo,
+            migrationsDb = migrationsDb,
+            asyncRunner = asyncRunner,
+            migrations = migrations
+        )
+    }
+
+    @Test
+    fun `inital status is unknown`() {
+        // GIVEN
+        //      migration manager has not been used yets
+
+        // THEN
+        //      status is not set
+        assertEquals(MigrationsManager.Status.UNKNOWN, migrationsManager.status.value)
+    }
+
+    @Test
+    fun `applied migrations are returned in order`() {
+        // GIVEN
+        //      some migrations are marked as applied
+        //      migration ids are stored in random order
+        val storedMigrationIds = LinkedHashSet<String>()
+        storedMigrationIds.apply {
+            add("3")
+            add("0")
+            add("2")
+            add("1")
+        }
+        migrationsDb.store.put(MigrationsManagerImpl.DB_KEY_APPLIED_MIGRATIONS, storedMigrationIds)
+
+        // WHEN
+        //      applied migration ids are retrieved
+        val ids = migrationsManager.getAppliedMigrations()
+
+        // THEN
+        //      returned list is sorted
+        assertEquals(ids, ids.sorted())
+    }
+
+    @Test
+    @Suppress("MagicNumber")
+    fun `registering new applied migration preserves old ids`() {
+        // WHEN
+        //     some applied migrations are registered
+        val appliedMigrationIds = setOf("0", "1", "2")
+        migrationsDb.store.put(MigrationsManagerImpl.DB_KEY_APPLIED_MIGRATIONS, appliedMigrationIds)
+
+        // WHEN
+        //     new set of migration ids are registered
+        //      some ids are added again
+        migrationsManager.addAppliedMigration(2, 3, 4)
+
+        // THEN
+        //      new ids are appended to set of existing ids
+        val expectedIds = setOf("0", "1", "2", "3", "4")
+        assertEquals(expectedIds, migrationsDb.store.get(MigrationsManagerImpl.DB_KEY_APPLIED_MIGRATIONS))
+    }
+
+    @Test
+    fun `migrations are scheduled on background thread`() {
+        // GIVEN
+        //      migrations can be applied
+        assertEquals(0, migrationsManager.getAppliedMigrations().size)
+
+        // WHEN
+        //      migration is started
+        val count = migrationsManager.startMigration()
+
+        // THEN
+        //      all migrations are scheduled on background thread
+        //      single task is scheduled
+        assertEquals(migrations.size, count)
+        assertEquals(1, asyncRunner.size)
+        assertEquals(MigrationsManager.Status.RUNNING, migrationsManager.status.value)
+    }
+
+    @Test
+    fun `applied migrations are recorded`() {
+        // GIVEN
+        //      no migrations are applied yet
+        //      current app version is newer then last recorded migrated version
+        whenever(appInfo.versionCode).thenReturn(NEW_APP_VERSION)
+        migrationsDb.store.put(MigrationsManagerImpl.DB_KEY_LAST_MIGRATED_VERSION, OLD_APP_VERSION)
+
+        // WHEN
+        //      migration is run
+        whenever(userAccountManager.migrateUserId()).thenReturn(true)
+        val count = migrationsManager.startMigration()
+        assertTrue(asyncRunner.runOne())
+
+        // THEN
+        //      total migrations count is returned
+        //      migration functions are called
+        //      applied migrations are recorded
+        //      new app version code is recorded
+        assertEquals(migrations.size, count)
+        assertEquals(setOf("0", "1"), migrationsDb.store.get(MigrationsManagerImpl.DB_KEY_APPLIED_MIGRATIONS))
+        assertEquals(NEW_APP_VERSION, migrationsDb.store.get(MigrationsManagerImpl.DB_KEY_LAST_MIGRATED_VERSION))
+    }
+
+    @Test
+    fun `migration error is recorded`() {
+        // GIVEN
+        //      no migrations applied yet
+
+        // WHEN
+        //      migrations are applied
+        //      one migration throws
+        val lastMigration = migrations.last()
+        val errorMessage = "error message"
+        whenever(lastMigration.function.run()).thenThrow(RuntimeException(errorMessage))
+        migrationsManager.startMigration()
+        assertTrue(asyncRunner.runOne())
+
+        // THEN
+        //      failure is marked in the migration db
+        //      failure message is recorded
+        //      failed migration id is recorded
+        assertEquals(MigrationsManager.Status.FAILED, migrationsManager.status.value)
+        assertTrue(migrationsDb.getBoolean(MigrationsManagerImpl.DB_KEY_FAILED, false))
+        assertEquals(
+            errorMessage,
+            migrationsDb.getString(MigrationsManagerImpl.DB_KEY_FAILED_MIGRATION_ERROR_MESSAGE, "")
+        )
+        assertEquals(
+            lastMigration.id,
+            migrationsDb.getInt(MigrationsManagerImpl.DB_KEY_FAILED_MIGRATION_ID, -1)
+        )
+    }
+
+    @Test
+    fun `migrations are not run if already run for an app version`() {
+        // GIVEN
+        //      migrations were already run for the current app version
+        whenever(appInfo.versionCode).thenReturn(NEW_APP_VERSION)
+        migrationsDb.store.put(MigrationsManagerImpl.DB_KEY_LAST_MIGRATED_VERSION, NEW_APP_VERSION)
+
+        // WHEN
+        //      app is migrated again
+        val migrationCount = migrationsManager.startMigration()
+
+        // THEN
+        //      migration processing is skipped entirely
+        //      status is set to applied
+        assertEquals(0, migrationCount)
+        migrations.forEach {
+            verify(it.function, never()).run()
+        }
+        assertEquals(MigrationsManager.Status.APPLIED, migrationsManager.status.value)
+    }
+
+    @Test
+    fun `new app version is marked as migrated if no new migrations are available`() {
+        //  GIVEN
+        //      migrations were applied in previous version
+        //      new version has no new migrations
+        whenever(appInfo.versionCode).thenReturn(NEW_APP_VERSION)
+        migrationsDb.store.put(MigrationsManagerImpl.DB_KEY_LAST_MIGRATED_VERSION, OLD_APP_VERSION)
+        val applied = migrations.map { it.id.toString() }.toSet()
+        migrationsDb.store.put(MigrationsManagerImpl.DB_KEY_APPLIED_MIGRATIONS, applied)
+
+        // WHEN
+        //      migration is started
+        val startedCount = migrationsManager.startMigration()
+
+        // THEN
+        //      no new migrations are run
+        //      new version is marked as migrated
+        assertEquals(0, startedCount)
+        assertEquals(
+            NEW_APP_VERSION,
+            migrationsDb.getInt(MigrationsManagerImpl.DB_KEY_LAST_MIGRATED_VERSION, -1)
+        )
+    }
+}

+ 77 - 0
src/test/java/com/nextcloud/client/migrations/TestMockSharedPreferences.kt

@@ -0,0 +1,77 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.migrations
+
+import org.junit.Before
+import org.junit.Test
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotSame
+import org.junit.Assert.assertTrue
+
+@Suppress("MagicNumber")
+class TestMockSharedPreferences {
+
+    private lateinit var mock: MockSharedPreferences
+
+    @Before
+    fun setUp() {
+        mock = MockSharedPreferences()
+    }
+
+    @Test
+    fun `get set string set`() {
+        val value = setOf("alpha", "bravo", "charlie")
+        mock.edit().putStringSet("key", value).apply()
+        val copy = mock.getStringSet("key", mutableSetOf())
+        assertNotSame(value, copy)
+        assertEquals(value, copy)
+    }
+
+    @Test
+    fun `get set int`() {
+        val value = 42
+        val editor = mock.edit()
+        editor.putInt("key", value)
+        assertEquals(100, mock.getInt("key", 100))
+        editor.apply()
+        assertEquals(42, mock.getInt("key", 100))
+    }
+
+    @Test
+    fun `get set boolean`() {
+        val value = true
+        val editor = mock.edit()
+        editor.putBoolean("key", value)
+        assertFalse(mock.getBoolean("key", false))
+        editor.apply()
+        assertTrue(mock.getBoolean("key", false))
+    }
+
+    @Test
+    fun `get set string`() {
+        val value = "a value"
+        val editor = mock.edit()
+        editor.putString("key", value)
+        assertEquals("default", mock.getString("key", "default"))
+        editor.apply()
+        assertEquals("a value", mock.getString("key", "default"))
+    }
+}