浏览代码

Merge pull request #5546 from nextcloud/ezaquarii/migrations-manager

Migrations manager
Tobias Kaminsky 5 年之前
父节点
当前提交
581c12a867

+ 2 - 0
build.gradle

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

+ 1 - 0
detekt.yml

@@ -265,6 +265,7 @@ naming:
     functionPattern: '^([a-z$][a-zA-Z$0-9]*)|(`.*`)$'
     excludeClassPattern: '$^'
     ignoreOverridden: true
+    excludes: "**/*Test.kt"
   FunctionParameterNaming:
     active: true
     parameterPattern: '[a-z][A-Za-z0-9]*'

+ 288 - 0
src/androidTest/java/com/nextcloud/client/migrations/MigrationsManagerTest.kt

@@ -0,0 +1,288 @@
+/*
+ * 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.test.annotation.UiThreadTest
+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.assertFalse
+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
+
+class MigrationsManagerTest {
+
+    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()
+        val migrationStep3: Runnable = mock()
+        migrations = listOf(
+            Migrations.Step(0, "first migration", migrationStep1, true),
+            Migrations.Step(1, "second migration", migrationStep2, true),
+            Migrations.Step(2, "third optional migration", migrationStep3, false)
+        )
+        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)
+        val allAppliedIds = migrations.map { it.id.toString() }.toSet()
+        assertEquals(allAppliedIds, 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.findLast { it.mandatory } ?: throw IllegalStateException("Test fixture error")
+        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)
+        )
+    }
+
+    @Test
+    @UiThreadTest
+    fun optional_migration_failure_does_not_trigger_a_migration_failure() {
+        // GIVEN
+        //      pending migrations
+        //      mandatory migrations are passing
+        //      one migration is optional and fails
+        val optionalFailingMigration = migrations.first { !it.mandatory }
+        whenever(optionalFailingMigration.function.run()).thenThrow(RuntimeException())
+
+        // WHEN
+        //      migration is started
+        val startedCount = migrationsManager.startMigration()
+        asyncRunner.runOne()
+        assertEquals(migrations.size, startedCount)
+
+        // THEN
+        //      mandatory migrations are marked as applied
+        //      optional failed migration is not marked
+        //      no error
+        //      status is applied
+        //      failed migration is available during next migration
+        val appliedMigrations = migrations.filter { it.mandatory }
+            .map { it.id.toString() }
+            .toSet()
+        assertTrue("Fixture error", appliedMigrations.isNotEmpty())
+        assertEquals(appliedMigrations, migrationsDb.store.get(MigrationsManagerImpl.DB_KEY_APPLIED_MIGRATIONS))
+        assertFalse(migrationsDb.getBoolean(MigrationsManagerImpl.DB_KEY_FAILED, false))
+        assertEquals(MigrationsManager.Status.APPLIED, migrationsManager.status.value)
+    }
+}

+ 104 - 0
src/androidTest/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?
+}

+ 77 - 0
src/androidTest/java/com/nextcloud/client/migrations/MockSharedPreferencesTest.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 MockSharedPreferencesTest {
+
+    private lateinit var mock: MockSharedPreferences
+
+    @Before
+    fun setUp() {
+        mock = MockSharedPreferences()
+    }
+
+    @Test
+    fun getSetStringSet() {
+        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 getSetInt() {
+        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 getSetBoolean() {
+        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 getSetString() {
+        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"))
+    }
+}

+ 5 - 1
src/main/java/com/nextcloud/client/account/UserAccountManager.java

@@ -89,7 +89,11 @@ public interface UserAccountManager extends CurrentAccountProvider {
     boolean exists(Account account);
 
     /**
-     * Verifies that every account has userId set
+     * Verifies that every account has userId set and sets the user id if not.
+     * This migration is idempotent and can be run multiple times until
+     * all accounts are migrated.
+     *
+     * @return true if migration was successful, false if any account failed to be migrated
      */
     boolean migrateUserId();
 

+ 4 - 5
src/main/java/com/nextcloud/client/account/UserAccountManagerImpl.java

@@ -342,12 +342,11 @@ public class UserAccountManagerImpl implements UserAccountManager {
     }
 
     public boolean migrateUserId() {
-        boolean success = false;
         Account[] ocAccounts = accountManager.getAccountsByType(MainApp.getAccountType(context));
         String userId;
         String displayName;
         GetUserInfoRemoteOperation remoteUserNameOperation = new GetUserInfoRemoteOperation();
-
+        int failed = 0;
         for (Account account : ocAccounts) {
             String storedUserId = accountManager.getUserData(account, com.owncloud.android.lib.common.accounts.AccountUtils.Constants.KEY_USER_ID);
 
@@ -370,10 +369,12 @@ public class UserAccountManagerImpl implements UserAccountManager {
                 } else {
                     // skip account, try it next time
                     Log_OC.e(TAG, "Error while getting username for account: " + account.name);
+                    failed++;
                     continue;
                 }
             } catch (Exception e) {
                 Log_OC.e(TAG, "Error while getting username: " + e.getMessage());
+                failed++;
                 continue;
             }
 
@@ -383,11 +384,9 @@ public class UserAccountManagerImpl implements UserAccountManager {
             accountManager.setUserData(account,
                                        com.owncloud.android.lib.common.accounts.AccountUtils.Constants.KEY_USER_ID,
                                        userId);
-
-            success = true;
         }
 
-        return success;
+        return failed == 0;
     }
 
     private String getAccountType() {

+ 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;

+ 21 - 0
src/main/java/com/nextcloud/client/di/AppModule.java

@@ -25,6 +25,7 @@ import android.app.Application;
 import android.app.NotificationManager;
 import android.content.ContentResolver;
 import android.content.Context;
+import android.content.SharedPreferences;
 import android.content.res.Resources;
 import android.os.Handler;
 import android.media.AudioManager;
@@ -32,6 +33,7 @@ import android.media.AudioManager;
 import com.nextcloud.client.account.CurrentAccountProvider;
 import com.nextcloud.client.account.UserAccountManager;
 import com.nextcloud.client.account.UserAccountManagerImpl;
+import com.nextcloud.client.appinfo.AppInfo;
 import com.nextcloud.client.core.AsyncRunner;
 import com.nextcloud.client.core.Clock;
 import com.nextcloud.client.core.ClockImpl;
@@ -41,6 +43,9 @@ import com.nextcloud.client.logger.FileLogHandler;
 import com.nextcloud.client.logger.Logger;
 import com.nextcloud.client.logger.LoggerImpl;
 import com.nextcloud.client.logger.LogsRepository;
+import com.nextcloud.client.migrations.Migrations;
+import com.nextcloud.client.migrations.MigrationsManager;
+import com.nextcloud.client.migrations.MigrationsManagerImpl;
 import com.nextcloud.client.network.ClientFactory;
 import com.owncloud.android.datamodel.ArbitraryDataProvider;
 import com.owncloud.android.datamodel.UploadsStorageManager;
@@ -172,4 +177,20 @@ class AppModule {
     EventBus eventBus() {
         return EventBus.getDefault();
     }
+
+    @Provides
+    @Singleton
+    Migrations migrations(UserAccountManager userAccountManager) {
+        return new Migrations(userAccountManager);
+    }
+
+    @Provides
+    @Singleton
+    MigrationsManager migrationsManager(Application application,
+                                        AppInfo appInfo,
+                                        AsyncRunner asyncRunner,
+                                        Migrations migrations) {
+        SharedPreferences migrationsDb = application.getSharedPreferences("migrations", Context.MODE_PRIVATE);
+        return new MigrationsManagerImpl(appInfo, migrationsDb, asyncRunner, migrations.getSteps());
+    }
 }

+ 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)
+}

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

@@ -0,0 +1,59 @@
+/*
+ * 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
+import kotlin.IllegalStateException
+
+/**
+ * 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
+     * @param mandatory If true, failing migration will cause an exception; if false, it will be skipped and repeated
+     *                  again on next startup
+     */
+    data class Step(val id: Int, val description: String, val function: Runnable, val mandatory: Boolean = true)
+
+    /**
+     * 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() }, false)
+    ).sortedBy { it.id }
+
+    fun migrateUserId() {
+        val allAccountsHaveUserId = userAccountManager.migrateUserId()
+        if (!allAccountsHaveUserId) {
+            throw IllegalStateException("Failed to set user id for all accounts")
+        }
+    }
+}

+ 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
+}

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

@@ -0,0 +1,138 @@
+/*
+ * 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()
+                addAppliedMigration(it.id)
+            } catch (t: Throwable) {
+                if (it.mandatory) {
+                    throw MigrationError(id = it.id, message = t.message ?: t.javaClass.simpleName)
+                }
+            }
+        }
+    }
+
+    @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.

+ 7 - 8
src/main/java/com/owncloud/android/MainApp.java

@@ -53,6 +53,7 @@ import com.nextcloud.client.errorhandling.ExceptionHandler;
 import com.nextcloud.client.jobs.BackgroundJobManager;
 import com.nextcloud.client.logger.LegacyLoggerAdapter;
 import com.nextcloud.client.logger.Logger;
+import com.nextcloud.client.migrations.MigrationsManager;
 import com.nextcloud.client.network.ConnectivityService;
 import com.nextcloud.client.onboarding.OnboardingService;
 import com.nextcloud.client.preferences.AppPreferences;
@@ -93,6 +94,7 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
 
@@ -170,6 +172,9 @@ public class MainApp extends MultiDexApplication implements HasAndroidInjector {
     @Inject
     EventBus eventBus;
 
+    @Inject
+    MigrationsManager migrationsManager;
+
     private PassCodeManager passCodeManager;
 
     @SuppressWarnings("unused")
@@ -261,14 +266,8 @@ public class MainApp extends MultiDexApplication implements HasAndroidInjector {
 
         registerActivityLifecycleCallbacks(new ActivityInjector());
 
-        Thread t = new Thread(() -> {
-            // best place, before any access to AccountManager or database
-            if (!preferences.isUserIdMigrated()) {
-                final boolean migrated = accountManager.migrateUserId();
-                preferences.setMigratedUserId(migrated);
-            }
-        });
-        t.start();
+        int startedMigrationsCount = migrationsManager.startMigration();
+        logger.i(TAG, String.format(Locale.US, "Started %d migrations", startedMigrationsCount));
 
         JobManager.create(this).addJobCreator(
             new NCJobCreator(