diff --git a/build.gradle b/build.gradle
index 47552006682c..1b94d1b29720 100644
--- a/build.gradle
+++ b/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 {
diff --git a/detekt.yml b/detekt.yml
index 1835fe1282ae..16a16e86f1f7 100644
--- a/detekt.yml
+++ b/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]*'
diff --git a/scripts/analysis/lint-results.txt b/scripts/analysis/lint-results.txt
index 2d857a44dcc9..4cb8c8d70724 100644
--- a/scripts/analysis/lint-results.txt
+++ b/scripts/analysis/lint-results.txt
@@ -1,2 +1,2 @@
DO NOT TOUCH; GENERATED BY DRONE
- Lint Report: 92 warnings
+ Lint Report: 94 warnings
diff --git a/src/androidTest/java/com/nextcloud/client/migrations/MigrationsManagerTest.kt b/src/androidTest/java/com/nextcloud/client/migrations/MigrationsManagerTest.kt
new file mode 100644
index 000000000000..6a2e59561a47
--- /dev/null
+++ b/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
+ *
+ * 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 .
+ */
+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
+
+ @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()
+ 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)
+ }
+}
diff --git a/src/androidTest/java/com/nextcloud/client/migrations/MockSharedPreferences.kt b/src/androidTest/java/com/nextcloud/client/migrations/MockSharedPreferences.kt
new file mode 100644
index 000000000000..4c1bf8b03876
--- /dev/null
+++ b/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
+ *
+ * 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 .
+ */
+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) : SharedPreferences.Editor {
+
+ val editorStore: MutableMap = 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?): 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 = 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 {
+ 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?): MutableSet? {
+ return store.getOrDefault(key, defValues) as MutableSet?
+ }
+
+ override fun registerOnSharedPreferenceChangeListener(
+ listener: SharedPreferences.OnSharedPreferenceChangeListener?
+ ) = throw UnsupportedOperationException()
+
+ override fun getString(key: String?, defValue: String?): String? = store.getOrDefault(key, defValue) as String?
+}
diff --git a/src/androidTest/java/com/nextcloud/client/migrations/MockSharedPreferencesTest.kt b/src/androidTest/java/com/nextcloud/client/migrations/MockSharedPreferencesTest.kt
new file mode 100644
index 000000000000..0598bcea8c27
--- /dev/null
+++ b/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
+ *
+ * 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 .
+ */
+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"))
+ }
+}
diff --git a/src/main/java/com/nextcloud/client/account/UserAccountManager.java b/src/main/java/com/nextcloud/client/account/UserAccountManager.java
index 4f4d35ecc689..2c3de3779ae3 100644
--- a/src/main/java/com/nextcloud/client/account/UserAccountManager.java
+++ b/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();
diff --git a/src/main/java/com/nextcloud/client/account/UserAccountManagerImpl.java b/src/main/java/com/nextcloud/client/account/UserAccountManagerImpl.java
index 49d695a2f7d5..4784b9441211 100644
--- a/src/main/java/com/nextcloud/client/account/UserAccountManagerImpl.java
+++ b/src/main/java/com/nextcloud/client/account/UserAccountManagerImpl.java
@@ -342,12 +342,11 @@ public boolean accountOwnsFile(OCFile file, Account account) {
}
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 boolean migrateUserId() {
} 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 boolean migrateUserId() {
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() {
diff --git a/src/main/java/com/nextcloud/client/appinfo/AppInfo.java b/src/main/java/com/nextcloud/client/appinfo/AppInfo.java
index e3588316576a..60d44ea4ab7d 100644
--- a/src/main/java/com/nextcloud/client/appinfo/AppInfo.java
+++ b/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);
diff --git a/src/main/java/com/nextcloud/client/appinfo/AppInfoImpl.java b/src/main/java/com/nextcloud/client/appinfo/AppInfoImpl.java
index 75aa07fd605e..5b85e091400f 100644
--- a/src/main/java/com/nextcloud/client/appinfo/AppInfoImpl.java
+++ b/src/main/java/com/nextcloud/client/appinfo/AppInfoImpl.java
@@ -33,6 +33,11 @@ public String getFormattedVersionCode() {
return Integer.toString(BuildConfig.VERSION_CODE);
}
+ @Override
+ public int getVersionCode() {
+ return BuildConfig.VERSION_CODE;
+ }
+
@Override
public boolean isDebugBuild() {
return BuildConfig.DEBUG;
diff --git a/src/main/java/com/nextcloud/client/di/AppModule.java b/src/main/java/com/nextcloud/client/di/AppModule.java
index 033199561d53..c689adef5567 100644
--- a/src/main/java/com/nextcloud/client/di/AppModule.java
+++ b/src/main/java/com/nextcloud/client/di/AppModule.java
@@ -25,6 +25,7 @@
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 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.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 @@ AudioManager audioManager(Context context) {
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());
+ }
}
diff --git a/src/main/java/com/nextcloud/client/migrations/MigrationError.kt b/src/main/java/com/nextcloud/client/migrations/MigrationError.kt
new file mode 100644
index 000000000000..fce1abca5d84
--- /dev/null
+++ b/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
+ *
+ * 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 .
+ */
+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)
+}
diff --git a/src/main/java/com/nextcloud/client/migrations/Migrations.kt b/src/main/java/com/nextcloud/client/migrations/Migrations.kt
new file mode 100644
index 000000000000..366e09fcb30e
--- /dev/null
+++ b/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
+ *
+ * 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 .
+ */
+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 = 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")
+ }
+ }
+}
diff --git a/src/main/java/com/nextcloud/client/migrations/MigrationsManager.kt b/src/main/java/com/nextcloud/client/migrations/MigrationsManager.kt
new file mode 100644
index 000000000000..1b154d093b5d
--- /dev/null
+++ b/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
+ *
+ * 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 .
+ */
+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
+
+ /**
+ * 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
+}
diff --git a/src/main/java/com/nextcloud/client/migrations/MigrationsManagerImpl.kt b/src/main/java/com/nextcloud/client/migrations/MigrationsManagerImpl.kt
new file mode 100644
index 000000000000..d29c42df56db
--- /dev/null
+++ b/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
+ *
+ * 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 .
+ */
+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
+) : 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
+
+ init {
+ this.status = MutableLiveData(Status.UNKNOWN)
+ }
+
+ fun getAppliedMigrations(): List {
+ val appliedIdsStr: Set = 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().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).value = Status.FAILED
+ return 0
+ }
+ val lastMigratedVersion = migrationsDb.getInt(DB_KEY_LAST_MIGRATED_VERSION, -1)
+ if (lastMigratedVersion >= appInfo.versionCode) {
+ (status as MutableLiveData).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).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.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).value = Status.FAILED
+ }
+
+ @MainThread
+ private fun onMigrationSuccess() {
+ migrationsDb.edit().putInt(DB_KEY_LAST_MIGRATED_VERSION, appInfo.versionCode).apply()
+ (status as MutableLiveData).value = Status.APPLIED
+ }
+}
diff --git a/src/main/java/com/nextcloud/client/migrations/Package.md b/src/main/java/com/nextcloud/client/migrations/Package.md
new file mode 100644
index 000000000000..7443659ddc35
--- /dev/null
+++ b/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.
diff --git a/src/main/java/com/owncloud/android/MainApp.java b/src/main/java/com/owncloud/android/MainApp.java
index 37ec76bc0258..8b3545c804c5 100644
--- a/src/main/java/com/owncloud/android/MainApp.java
+++ b/src/main/java/com/owncloud/android/MainApp.java
@@ -53,6 +53,7 @@
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.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 void onCreate() {
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(