浏览代码

Migrate contacts backup job to new background job manager API

Signed-off-by: Chris Narkiewicz <hello@ezaquarii.com>
Chris Narkiewicz 5 年之前
父节点
当前提交
500fdb919b
共有 43 个文件被更改,包括 2198 次插入299 次删除
  1. 371 0
      src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt
  2. 129 0
      src/androidTest/java/com/nextcloud/client/migrations/MigrationsDbTest.kt
  3. 68 85
      src/androidTest/java/com/nextcloud/client/migrations/MigrationsManagerTest.kt
  4. 11 4
      src/androidTest/java/com/nextcloud/client/migrations/MockSharedPreferences.kt
  5. 23 0
      src/androidTest/java/com/nextcloud/client/migrations/MockSharedPreferencesTest.kt
  6. 6 2
      src/main/java/com/nextcloud/client/account/AnonymousUser.kt
  7. 5 1
      src/main/java/com/nextcloud/client/account/RegisteredUser.kt
  8. 9 2
      src/main/java/com/nextcloud/client/account/User.kt
  9. 6 5
      src/main/java/com/nextcloud/client/di/AppModule.java
  10. 4 3
      src/main/java/com/nextcloud/client/di/ComponentsModule.java
  11. 63 2
      src/main/java/com/nextcloud/client/etm/EtmViewModel.kt
  12. 145 0
      src/main/java/com/nextcloud/client/etm/pages/EtmBackgroundJobsFragment.kt
  13. 71 0
      src/main/java/com/nextcloud/client/etm/pages/EtmMigrations.kt
  14. 25 4
      src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt
  15. 42 1
      src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt
  16. 211 6
      src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt
  17. 260 0
      src/main/java/com/nextcloud/client/jobs/ContactsBackupWork.kt
  18. 32 0
      src/main/java/com/nextcloud/client/jobs/JobInfo.kt
  19. 10 4
      src/main/java/com/nextcloud/client/jobs/JobsModule.kt
  20. 48 0
      src/main/java/com/nextcloud/client/jobs/TestJob.kt
  21. 22 0
      src/main/java/com/nextcloud/client/migrations/MigrationInfo.kt
  22. 88 16
      src/main/java/com/nextcloud/client/migrations/Migrations.kt
  23. 85 0
      src/main/java/com/nextcloud/client/migrations/MigrationsDb.kt
  24. 5 0
      src/main/java/com/nextcloud/client/migrations/MigrationsManager.kt
  25. 13 46
      src/main/java/com/nextcloud/client/migrations/MigrationsManagerImpl.kt
  26. 9 15
      src/main/java/com/owncloud/android/MainApp.java
  27. 0 1
      src/main/java/com/owncloud/android/datamodel/OCFile.java
  28. 2 2
      src/main/java/com/owncloud/android/files/BootupBroadcastReceiver.java
  29. 6 7
      src/main/java/com/owncloud/android/jobs/AccountRemovalJob.java
  30. 11 5
      src/main/java/com/owncloud/android/jobs/NCJobCreator.java
  31. 7 46
      src/main/java/com/owncloud/android/ui/activity/ContactsPreferenceActivity.java
  32. 30 31
      src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactsBackupFragment.java
  33. 9 0
      src/main/res/drawable/ic_arrow_up.xml
  34. 13 0
      src/main/res/drawable/ic_clock.xml
  35. 139 0
      src/main/res/layout/etm_background_job_list_item.xml
  36. 13 0
      src/main/res/layout/fragment_etm_background_jobs.xml
  37. 33 0
      src/main/res/layout/fragment_etm_migrations.xml
  38. 51 0
      src/main/res/menu/fragment_etm_background_jobs.xml
  39. 33 0
      src/main/res/menu/fragment_etm_migrations.xml
  40. 12 0
      src/main/res/values/strings.xml
  41. 55 2
      src/test/java/com/nextcloud/client/etm/TestEtmViewModel.kt
  42. 19 5
      src/test/java/com/nextcloud/client/jobs/BackgroundJobFactoryTest.kt
  43. 4 4
      src/test/java/com/nextcloud/client/jobs/ContentObserverWorkTest.kt

+ 371 - 0
src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt

@@ -0,0 +1,371 @@
+/*
+ * 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.jobs
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.Observer
+import androidx.test.annotation.UiThreadTest
+import androidx.work.Data
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequest
+import androidx.work.PeriodicWorkRequest
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import com.nextcloud.client.account.User
+import com.nextcloud.client.core.Clock
+import com.nhaarman.mockitokotlin2.KArgumentCaptor
+import com.nhaarman.mockitokotlin2.any
+import com.nhaarman.mockitokotlin2.argThat
+import com.nhaarman.mockitokotlin2.argumentCaptor
+import com.nhaarman.mockitokotlin2.eq
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.verify
+import com.nhaarman.mockitokotlin2.whenever
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Suite
+import org.mockito.ArgumentMatcher
+import java.util.Date
+import java.util.UUID
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+
+/**
+ * When using IDE to run enire Suite, make sure tests are run using Android Instrumentation Test
+ * runner. By default IDE runs normal JUnit - this is AS problem. One must configure the
+ * test run manually.
+ */
+@RunWith(Suite::class)
+@Suite.SuiteClasses(
+    BackgroundJobManagerTest.Manager::class,
+    BackgroundJobManagerTest.ContentObserver::class,
+    BackgroundJobManagerTest.PeriodicContactsBackup::class,
+    BackgroundJobManagerTest.ImmediateContactsBackup::class,
+    BackgroundJobManagerTest.Tags::class
+)
+class BackgroundJobManagerTest {
+
+    /**
+     * Used to help with ambiguous type inference
+     */
+    class IsOneTimeWorkRequest : ArgumentMatcher<OneTimeWorkRequest> {
+        override fun matches(argument: OneTimeWorkRequest?): Boolean = true
+    }
+
+    /**
+     * Used to help with ambiguous type inference
+     */
+    class IsPeriodicWorkRequest : ArgumentMatcher<PeriodicWorkRequest> {
+        override fun matches(argument: PeriodicWorkRequest?): Boolean = true
+    }
+
+    abstract class Fixture {
+        companion object {
+            internal const val USER_ACCOUNT_NAME = "user@nextcloud"
+            internal val TIMESTAMP = System.currentTimeMillis()
+        }
+        internal lateinit var user: User
+        internal lateinit var workManager: WorkManager
+        internal lateinit var clock: Clock
+        internal lateinit var backgroundJobManager: BackgroundJobManagerImpl
+
+        @Before
+        fun setUpFixture() {
+            user = mock()
+            whenever(user.accountName).thenReturn(USER_ACCOUNT_NAME)
+            workManager = mock()
+            clock = mock()
+            whenever(clock.currentTime).thenReturn(TIMESTAMP)
+            whenever(clock.currentDate).thenReturn(Date(TIMESTAMP))
+            backgroundJobManager = BackgroundJobManagerImpl(workManager, clock)
+        }
+
+        fun assertHasRequiredTags(tags: Set<String>, jobName: String, user: User? = null) {
+            assertTrue("""'all' tag is mandatory""", tags.contains("*"))
+            assertTrue("name tag is mandatory", tags.contains(BackgroundJobManagerImpl.formatNameTag(jobName, user)))
+            assertTrue("timestamp tag is mandatory", tags.contains(BackgroundJobManagerImpl.formatTimeTag(TIMESTAMP)))
+            if (user != null) {
+                assertTrue("user tag is mandatory", tags.contains(BackgroundJobManagerImpl.formatUserTag(user)))
+            }
+        }
+
+        fun buildWorkInfo(index: Long): WorkInfo = WorkInfo(
+            UUID.randomUUID(),
+            WorkInfo.State.RUNNING,
+            Data.Builder().build(),
+            listOf(BackgroundJobManagerImpl.formatTimeTag(1581820284000)),
+            Data.Builder().build(),
+            1
+        )
+    }
+
+    class Manager : Fixture() {
+
+        class SyncObserver<T> : Observer<T> {
+            val latch = CountDownLatch(1)
+            var value: T? = null
+            override fun onChanged(t: T) {
+                value = t
+                latch.countDown()
+            }
+
+            fun getValue(timeout: Long = 3, timeUnit: TimeUnit = TimeUnit.SECONDS): T? {
+                val result = latch.await(timeout, timeUnit)
+                if (!result) {
+                    throw TimeoutException()
+                }
+                return value
+            }
+        }
+
+        @Test
+        @UiThreadTest
+        fun get_all_job_info() {
+            // GIVEN
+            //      work manager has 2 registered workers
+            val platformWorkInfo = listOf(
+                buildWorkInfo(0),
+                buildWorkInfo(1),
+                buildWorkInfo(2)
+            )
+            val lv = MutableLiveData<List<WorkInfo>>()
+            lv.value = platformWorkInfo
+            whenever(workManager.getWorkInfosByTagLiveData(eq("*"))).thenReturn(lv)
+
+            // WHEN
+            //      job info for all jobs is requested
+            val jobs = backgroundJobManager.jobs
+
+            // THEN
+            //      live data with job info is returned
+            //      live data contains 2 job info instances
+            //      job info is sorted by timestamp from newest to oldest
+            assertNotNull(jobs)
+            val observer = SyncObserver<List<JobInfo>>()
+            jobs.observeForever(observer)
+            val jobInfo = observer.getValue()
+            assertNotNull(jobInfo)
+            assertEquals(platformWorkInfo.size, jobInfo?.size)
+            jobInfo?.let {
+                assertEquals(platformWorkInfo[2].id, it[0].id)
+                assertEquals(platformWorkInfo[1].id, it[1].id)
+                assertEquals(platformWorkInfo[0].id, it[2].id)
+            }
+        }
+
+        @Test
+        fun cancel_all_jobs() {
+            // WHEN
+            //      all jobs are cancelled
+            backgroundJobManager.cancelAllJobs()
+
+            // THEN
+            //      all jobs with * tag are cancelled
+            verify(workManager).cancelAllWorkByTag(BackgroundJobManagerImpl.TAG_ALL)
+        }
+    }
+
+    class ContentObserver : Fixture() {
+
+        private lateinit var request: OneTimeWorkRequest
+
+        @Before
+        fun setUp() {
+            val requestCaptor: KArgumentCaptor<OneTimeWorkRequest> = argumentCaptor()
+            backgroundJobManager.scheduleContentObserverJob()
+            verify(workManager).enqueueUniqueWork(
+                any(),
+                any(),
+                requestCaptor.capture()
+            )
+            assertEquals(1, requestCaptor.allValues.size)
+            request = requestCaptor.firstValue
+        }
+
+        @Test
+        fun job_is_unique() {
+            verify(workManager).enqueueUniqueWork(
+                eq(BackgroundJobManagerImpl.JOB_CONTENT_OBSERVER),
+                eq(ExistingWorkPolicy.KEEP),
+                argThat(IsOneTimeWorkRequest())
+            )
+        }
+
+        @Test
+        fun job_request_has_mandatory_tags() {
+            assertHasRequiredTags(request.tags, BackgroundJobManagerImpl.JOB_CONTENT_OBSERVER)
+        }
+    }
+
+    class PeriodicContactsBackup : Fixture() {
+        private lateinit var request: PeriodicWorkRequest
+
+        @Before
+        fun setUp() {
+            val requestCaptor: KArgumentCaptor<PeriodicWorkRequest> = argumentCaptor()
+            backgroundJobManager.schedulePeriodicContactsBackup(user)
+            verify(workManager).enqueueUniquePeriodicWork(
+                any(),
+                any(),
+                requestCaptor.capture()
+            )
+            assertEquals(1, requestCaptor.allValues.size)
+            request = requestCaptor.firstValue
+        }
+
+        @Test
+        fun job_is_unique_for_user() {
+            verify(workManager).enqueueUniquePeriodicWork(
+                eq(BackgroundJobManagerImpl.JOB_PERIODIC_CONTACTS_BACKUP),
+                eq(ExistingPeriodicWorkPolicy.KEEP),
+                argThat(IsPeriodicWorkRequest())
+            )
+        }
+
+        @Test
+        fun job_request_has_mandatory_tags() {
+            assertHasRequiredTags(request.tags, BackgroundJobManagerImpl.JOB_PERIODIC_CONTACTS_BACKUP, user)
+        }
+    }
+
+    class ImmediateContactsBackup : Fixture() {
+
+        private lateinit var workInfo: MutableLiveData<WorkInfo>
+        private lateinit var jobInfo: LiveData<JobInfo?>
+        private lateinit var request: OneTimeWorkRequest
+
+        @Before
+        fun setUp() {
+            val requestCaptor: KArgumentCaptor<OneTimeWorkRequest> = argumentCaptor()
+            workInfo = MutableLiveData()
+            whenever(workManager.getWorkInfoByIdLiveData(any())).thenReturn(workInfo)
+            jobInfo = backgroundJobManager.startImmediateContactsBackup(user)
+            verify(workManager).enqueueUniqueWork(
+                any(),
+                any(),
+                requestCaptor.capture()
+            )
+            assertEquals(1, requestCaptor.allValues.size)
+            request = requestCaptor.firstValue
+        }
+
+        @Test
+        fun job_is_unique_for_user() {
+            verify(workManager).enqueueUniqueWork(
+                eq(BackgroundJobManagerImpl.JOB_IMMEDIATE_CONTACTS_BACKUP),
+                eq(ExistingWorkPolicy.KEEP),
+                argThat(IsOneTimeWorkRequest())
+            )
+        }
+
+        @Test
+        fun job_request_has_mandatory_tags() {
+            assertHasRequiredTags(request.tags, BackgroundJobManagerImpl.JOB_IMMEDIATE_CONTACTS_BACKUP, user)
+        }
+
+        @Test
+        @UiThreadTest
+        fun job_info_is_obtained_from_work_info() {
+            // GIVEN
+            //      work info is available
+            workInfo.value = buildWorkInfo(0)
+
+            // WHEN
+            //      job info has listener
+            jobInfo.observeForever {}
+
+            // THEN
+            //      converted value is available
+            assertNotNull(jobInfo.value)
+            assertEquals(workInfo.value?.id, jobInfo.value?.id)
+        }
+    }
+
+    class Tags {
+        @Test
+        fun split_tag_key_and_value() {
+            // GIVEN
+            //      valid tag
+            //      tag has colons in value part
+            val tag = "${BackgroundJobManagerImpl.TAG_PREFIX_NAME}:value:with:colons and spaces"
+
+            // WHEN
+            //      tag is parsed
+            val parsedTag = BackgroundJobManagerImpl.parseTag(tag)
+
+            // THEN
+            //      key-value pair is returned
+            //      key is first
+            //      value with colons is second
+            assertNotNull(parsedTag)
+            assertEquals(BackgroundJobManagerImpl.TAG_PREFIX_NAME, parsedTag?.first)
+            assertEquals("value:with:colons and spaces", parsedTag?.second)
+        }
+
+        @Test
+        fun tags_with_invalid_prefixes_are_rejected() {
+            // GIVEN
+            //      tag prefix is not on allowed prefixes list
+            val tag = "invalidprefix:value"
+            BackgroundJobManagerImpl.PREFIXES.forEach {
+                assertFalse(tag.startsWith(it))
+            }
+
+            // WHEN
+            //      tag is parsed
+            val parsedTag = BackgroundJobManagerImpl.parseTag(tag)
+
+            // THEN
+            //      tag is rejected
+            assertNull(parsedTag)
+        }
+
+        @Test
+        fun strings_without_colon_are_rejected() {
+            // GIVEN
+            //      strings that are not tags
+            val tags = listOf(
+                BackgroundJobManagerImpl.TAG_ALL,
+                BackgroundJobManagerImpl.TAG_PREFIX_NAME,
+                "simplestring",
+                ""
+            )
+
+            tags.forEach {
+                // WHEN
+                //      string is parsed
+                val parsedTag = BackgroundJobManagerImpl.parseTag(it)
+
+                // THEN
+                //      tag is rejected
+                assertNull(parsedTag)
+            }
+        }
+    }
+}

+ 129 - 0
src/androidTest/java/com/nextcloud/client/migrations/MigrationsDbTest.kt

@@ -0,0 +1,129 @@
+/*
+ * 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.Context
+import android.content.SharedPreferences
+import androidx.test.platform.app.InstrumentationRegistry
+import com.nhaarman.mockitokotlin2.any
+import com.nhaarman.mockitokotlin2.eq
+import com.nhaarman.mockitokotlin2.mock
+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
+
+class MigrationsDbTest {
+
+    private lateinit var context: Context
+    private lateinit var store: MockSharedPreferences
+    private lateinit var db: MigrationsDb
+
+    @Before
+    fun setUp() {
+        context = InstrumentationRegistry.getInstrumentation().context
+        store = MockSharedPreferences()
+        assertTrue("State from previous test run found?", store.all.isEmpty())
+        db = MigrationsDb(store)
+    }
+
+    @Test
+    fun applied_migrations_are_returned_in_order() {
+        // GIVEN
+        //      some migrations are marked as applied
+        //      migration ids are stored in random order
+        val mockStore: SharedPreferences = mock()
+        val storedMigrationIds = LinkedHashSet<String>()
+        storedMigrationIds.apply {
+            add("3")
+            add("0")
+            add("2")
+            add("1")
+        }
+        whenever(mockStore.getStringSet(eq(MigrationsDb.DB_KEY_APPLIED_MIGRATIONS), any()))
+            .thenReturn(storedMigrationIds)
+
+        // WHEN
+        //      applied migration ids are retrieved
+        val db = MigrationsDb(mockStore)
+        val ids = db.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")
+        store.edit().putStringSet(MigrationsDb.DB_KEY_APPLIED_MIGRATIONS, appliedMigrationIds).apply()
+
+        // WHEN
+        //     new set of migration ids are registered
+        //      some ids are added again
+        db.addAppliedMigration(2, 3, 4)
+
+        // THEN
+        //      new ids are appended to set of existing ids
+        val expectedIds = setOf("0", "1", "2", "3", "4")
+        val storedIds = store.getStringSet(MigrationsDb.DB_KEY_APPLIED_MIGRATIONS, mutableSetOf())
+        assertEquals(expectedIds, storedIds)
+    }
+
+    @Test
+    fun failed_status_sets_status_flag_and_error_message() {
+        // GIVEN
+        //      failure flag is not set
+        assertFalse(db.isFailed)
+
+        // WHEN
+        //      failure status is set
+        val failureReason = "error message"
+        db.setFailed(0, failureReason)
+
+        // THEN
+        //      failed flag is set
+        //      error message is set
+        assertTrue(db.isFailed)
+        assertEquals(failureReason, db.failureReason)
+    }
+
+    @Test
+    fun last_migrated_version_is_set() {
+        // GIVEN
+        //      last migrated version is not set
+        val oldVersion = db.lastMigratedVersion
+        assertEquals(MigrationsDb.NO_LAST_MIGRATED_VERSION, oldVersion)
+
+        // WHEN
+        //      migrated version is set to a new value
+        val newVersion = 200
+        db.lastMigratedVersion = newVersion
+
+        // THEN
+        //      new value is stored
+        assertEquals(newVersion, db.lastMigratedVersion)
+    }
+}

+ 68 - 85
src/androidTest/java/com/nextcloud/client/migrations/MigrationsManagerTest.kt

@@ -20,9 +20,9 @@
 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.inOrder
 import com.nhaarman.mockitokotlin2.mock
 import com.nhaarman.mockitokotlin2.never
 import com.nhaarman.mockitokotlin2.verify
@@ -34,8 +34,6 @@ 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 {
 
@@ -44,15 +42,16 @@ class MigrationsManagerTest {
         const val NEW_APP_VERSION = 42
     }
 
+    lateinit var migrationStep1: Runnable
+    lateinit var migrationStep2: Runnable
+    lateinit var migrationStep3: Runnable
     lateinit var migrations: List<Migrations.Step>
 
     @Mock
     lateinit var appInfo: AppInfo
 
-    lateinit var migrationsDb: MockSharedPreferences
-
-    @Mock
-    lateinit var userAccountManager: UserAccountManager
+    lateinit var migrationsDbStore: MockSharedPreferences
+    lateinit var migrationsDb: MigrationsDb
 
     lateinit var asyncRunner: ManualAsyncRunner
 
@@ -61,16 +60,30 @@ class MigrationsManagerTest {
     @Before
     fun setUp() {
         MockitoAnnotations.initMocks(this)
-        val migrationStep1: Runnable = mock()
-        val migrationStep2: Runnable = mock()
-        val migrationStep3: Runnable = mock()
+        migrationStep1 = mock()
+        migrationStep2 = mock()
+        migrationStep3 = 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)
+            object : Migrations.Step(0, "first migration", true) {
+                override fun run() {
+                    migrationStep1.run()
+                }
+            },
+            object : Migrations.Step(1, "second optional migration", false) {
+                override fun run() {
+                    migrationStep2.run()
+                }
+            },
+            object : Migrations.Step(2, "third migration", true) {
+                override fun run() {
+                    migrationStep3.run()
+                }
+            }
         )
         asyncRunner = ManualAsyncRunner()
-        migrationsDb = MockSharedPreferences()
+        migrationsDbStore = MockSharedPreferences()
+        migrationsDb = MigrationsDb(migrationsDbStore)
+
         whenever(appInfo.versionCode).thenReturn(NEW_APP_VERSION)
         migrationsManager = MigrationsManagerImpl(
             appInfo = appInfo,
@@ -80,6 +93,14 @@ class MigrationsManagerTest {
         )
     }
 
+    private fun assertMigrationsRun(vararg migrationSteps: Runnable) {
+        inOrder(migrationSteps).apply {
+            migrationSteps.forEach {
+                verify(it.run())
+            }
+        }
+    }
+
     @Test
     fun inital_status_is_unknown() {
         // GIVEN
@@ -90,54 +111,12 @@ class MigrationsManagerTest {
         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)
+        assertEquals(0, migrationsDb.getAppliedMigrations().size)
 
         // WHEN
         //      migration is started
@@ -158,11 +137,10 @@ class MigrationsManagerTest {
         //      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)
+        migrationsDb.lastMigratedVersion = OLD_APP_VERSION
 
         // WHEN
         //      migration is run
-        whenever(userAccountManager.migrateUserId()).thenReturn(true)
         val count = migrationsManager.startMigration()
         assertTrue(asyncRunner.runOne())
 
@@ -172,9 +150,14 @@ class MigrationsManagerTest {
         //      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))
+        inOrder(migrationStep1, migrationStep2, migrationStep3).apply {
+            verify(migrationStep1).run()
+            verify(migrationStep2).run()
+            verify(migrationStep3).run()
+        }
+        val allAppliedIds = migrations.map { it.id }
+        assertEquals(allAppliedIds, migrationsDb.getAppliedMigrations())
+        assertEquals(NEW_APP_VERSION, migrationsDb.lastMigratedVersion)
     }
 
     @Test
@@ -182,13 +165,16 @@ class MigrationsManagerTest {
     fun migration_error_is_recorded() {
         // GIVEN
         //      no migrations applied yet
+        //      no prior failed migrations
+        assertFalse(migrationsDb.isFailed)
+        assertEquals(MigrationsDb.NO_FAILED_MIGRATION_ID, migrationsDb.failedMigrationId)
 
         // 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))
+        whenever(lastMigration.run()).thenThrow(RuntimeException(errorMessage))
         migrationsManager.startMigration()
         assertTrue(asyncRunner.runOne())
 
@@ -197,15 +183,9 @@ class MigrationsManagerTest {
         //      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)
-        )
+        assertTrue(migrationsDb.isFailed)
+        assertEquals(errorMessage, migrationsDb.failureReason)
+        assertEquals(lastMigration.id, migrationsDb.failedMigrationId)
     }
 
     @Test
@@ -214,7 +194,7 @@ class MigrationsManagerTest {
         // 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)
+        migrationsDb.lastMigratedVersion = NEW_APP_VERSION
 
         // WHEN
         //      app is migrated again
@@ -224,8 +204,8 @@ class MigrationsManagerTest {
         //      migration processing is skipped entirely
         //      status is set to applied
         assertEquals(0, migrationCount)
-        migrations.forEach {
-            verify(it.function, never()).run()
+        listOf(migrationStep1, migrationStep2, migrationStep3).forEach {
+            verify(it, never()).run()
         }
         assertEquals(MigrationsManager.Status.APPLIED, migrationsManager.status.value)
     }
@@ -237,9 +217,10 @@ class MigrationsManagerTest {
         //      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)
+        migrationsDb.lastMigratedVersion = OLD_APP_VERSION
+        migrations.forEach {
+            migrationsDb.addAppliedMigration(it.id)
+        }
 
         // WHEN
         //      migration is started
@@ -251,7 +232,7 @@ class MigrationsManagerTest {
         assertEquals(0, startedCount)
         assertEquals(
             NEW_APP_VERSION,
-            migrationsDb.getInt(MigrationsManagerImpl.DB_KEY_LAST_MIGRATED_VERSION, -1)
+            migrationsDb.lastMigratedVersion
         )
     }
 
@@ -262,8 +243,12 @@ class MigrationsManagerTest {
         //      pending migrations
         //      mandatory migrations are passing
         //      one migration is optional and fails
+        assertEquals("Fixture should provide 1 optional, failing migration",
+            1,
+            migrations.count { !it.mandatory }
+        )
         val optionalFailingMigration = migrations.first { !it.mandatory }
-        whenever(optionalFailingMigration.function.run()).thenThrow(RuntimeException())
+        whenever(optionalFailingMigration.run()).thenThrow(RuntimeException())
 
         // WHEN
         //      migration is started
@@ -277,12 +262,10 @@ class MigrationsManagerTest {
         //      no error
         //      status is applied
         //      failed migration is available during next migration
-        val appliedMigrations = migrations.filter { it.mandatory }
-            .map { it.id.toString() }
-            .toSet()
+        val appliedMigrations = migrations.filter { it.mandatory }.map { it.id }
         assertTrue("Fixture error", appliedMigrations.isNotEmpty())
-        assertEquals(appliedMigrations, migrationsDb.store.get(MigrationsManagerImpl.DB_KEY_APPLIED_MIGRATIONS))
-        assertFalse(migrationsDb.getBoolean(MigrationsManagerImpl.DB_KEY_FAILED, false))
+        assertEquals(appliedMigrations, migrationsDb.getAppliedMigrations())
+        assertFalse(migrationsDb.isFailed)
         assertEquals(MigrationsManager.Status.APPLIED, migrationsManager.status.value)
     }
 }

+ 11 - 4
src/androidTest/java/com/nextcloud/client/migrations/MockSharedPreferences.kt

@@ -23,6 +23,13 @@ import android.content.SharedPreferences
 import java.lang.UnsupportedOperationException
 import java.util.TreeMap
 
+/**
+ * This shared preferences implementation uses in-memory value store
+ * and it can be used in tests without using global, file-backed storage,
+ * improving test isolation.
+ *
+ * The implementation is not thread-safe.
+ */
 @Suppress("TooManyFunctions")
 class MockSharedPreferences : SharedPreferences {
 
@@ -33,7 +40,7 @@ class MockSharedPreferences : SharedPreferences {
         override fun clear(): SharedPreferences.Editor = throw UnsupportedOperationException()
 
         override fun putLong(key: String?, value: Long): SharedPreferences.Editor =
-            throw UnsupportedOperationException()
+            throw UnsupportedOperationException("Implement as needed")
 
         override fun putInt(key: String?, value: Int): SharedPreferences.Editor {
             editorStore.put(key, value)
@@ -55,7 +62,7 @@ class MockSharedPreferences : SharedPreferences {
         override fun commit(): Boolean = true
 
         override fun putFloat(key: String?, value: Float): SharedPreferences.Editor =
-            throw UnsupportedOperationException()
+            throw UnsupportedOperationException("Implement as needed")
 
         override fun apply() = store.putAll(editorStore)
 
@@ -76,8 +83,8 @@ class MockSharedPreferences : SharedPreferences {
 
     override fun getInt(key: String?, defValue: Int): Int = store.getOrDefault(key, defValue) as Int
 
-    override fun getAll(): MutableMap<String, *> {
-        throw UnsupportedOperationException()
+    override fun getAll(): MutableMap<String?, Any?> {
+        return HashMap(store)
     }
 
     override fun edit(): SharedPreferences.Editor {

+ 23 - 0
src/androidTest/java/com/nextcloud/client/migrations/MockSharedPreferencesTest.kt

@@ -74,4 +74,27 @@ class MockSharedPreferencesTest {
         editor.apply()
         assertEquals("a value", mock.getString("key", "default"))
     }
+
+    @Test
+    fun getAll() {
+        // GIVEN
+        //      few properties are stored in shared preferences
+        mock.edit()
+            .putInt("int", 1)
+            .putBoolean("bool", true)
+            .putString("string", "value")
+            .putStringSet("stringSet", setOf("alpha", "bravo"))
+            .apply()
+        assertEquals(4, mock.store.size)
+
+        // WHEN
+        //      all properties are retrieved
+        val all = mock.all
+
+        // THEN
+        //      returned map is a different instance
+        //      map is equal to internal storage
+        assertNotSame(all, mock.store)
+        assertEquals(all, mock.store)
+    }
 }

+ 6 - 2
src/main/java/com/nextcloud/client/account/AnonymousUser.kt

@@ -2,8 +2,8 @@
  * Nextcloud Android client application
  *
  * @author Chris Narkiewicz <hello@ezaquarii.com>
- * Copyright (C) 2019 Chris Narkiewicz
- * Copyright (C) 2019 Nextcloud GmbH
+ * Copyright (C) 2020 Chris Narkiewicz
+ * Copyright (C) 2020 Nextcloud GmbH
  *
  * 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
@@ -72,6 +72,10 @@ internal data class AnonymousUser(private val accountType: String) : User, Parce
         return user?.accountName.equals(accountName, true)
     }
 
+    override fun nameEquals(accountName: CharSequence?): Boolean {
+        return accountName?.toString().equals(this.accountType, true)
+    }
+
     override fun describeContents() = 0
 
     override fun writeToParcel(dest: Parcel, flags: Int) = with(dest) {

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

@@ -63,7 +63,11 @@ internal data class RegisteredUser(
     }
 
     override fun nameEquals(user: User?): Boolean {
-        return user?.accountName.equals(accountName, true)
+        return nameEquals(user?.accountName)
+    }
+
+    override fun nameEquals(accountName: CharSequence?): Boolean {
+        return accountName?.toString().equals(this.accountName, true)
     }
 
     override fun describeContents() = 0

+ 9 - 2
src/main/java/com/nextcloud/client/account/User.kt

@@ -2,8 +2,8 @@
  * Nextcloud Android client application
  *
  * @author Chris Narkiewicz <hello@ezaquarii.com>
- * Copyright (C) 2019 Chris Narkiewicz
- * Copyright (C) 2019 Nextcloud GmbH
+ * Copyright (C) 2020 Chris Narkiewicz
+ * Copyright (C) 2020 Nextcloud GmbH
  *
  * 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
@@ -59,4 +59,11 @@ interface User : Parcelable {
      * @return true if account names are same, false otherwise
      */
     fun nameEquals(user: User?): Boolean
+
+    /**
+     * Compare account names, case insensitive.
+     *
+     * @return true if account names are same, false otherwise
+     */
+    fun nameEquals(accountName: CharSequence?): Boolean
 }

+ 6 - 5
src/main/java/com/nextcloud/client/di/AppModule.java

@@ -27,8 +27,8 @@ 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;
+import android.os.Handler;
 
 import com.nextcloud.client.account.CurrentAccountProvider;
 import com.nextcloud.client.account.UserAccountManager;
@@ -44,6 +44,7 @@ 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.MigrationsDb;
 import com.nextcloud.client.migrations.MigrationsManager;
 import com.nextcloud.client.migrations.MigrationsManagerImpl;
 import com.nextcloud.client.network.ClientFactory;
@@ -180,17 +181,17 @@ class AppModule {
 
     @Provides
     @Singleton
-    Migrations migrations(UserAccountManager userAccountManager) {
-        return new Migrations(userAccountManager);
+    MigrationsDb migrationsDb(Application application) {
+        SharedPreferences store = application.getSharedPreferences("migrations", Context.MODE_PRIVATE);
+        return new MigrationsDb(store);
     }
 
     @Provides
     @Singleton
-    MigrationsManager migrationsManager(Application application,
+    MigrationsManager migrationsManager(MigrationsDb migrationsDb,
                                         AppInfo appInfo,
                                         AsyncRunner asyncRunner,
                                         Migrations migrations) {
-        SharedPreferences migrationsDb = application.getSharedPreferences("migrations", Context.MODE_PRIVATE);
         return new MigrationsManagerImpl(appInfo, migrationsDb, asyncRunner, migrations.getSteps());
     }
 }

+ 4 - 3
src/main/java/com/nextcloud/client/di/ComponentsModule.java

@@ -2,7 +2,7 @@
  * Nextcloud Android client application
  *
  * @author Chris Narkiewicz
- * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ * 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
@@ -74,6 +74,7 @@ import com.owncloud.android.ui.fragment.LocalFileListFragment;
 import com.owncloud.android.ui.fragment.OCFileListFragment;
 import com.owncloud.android.ui.fragment.PhotoFragment;
 import com.owncloud.android.ui.fragment.contactsbackup.ContactListFragment;
+import com.owncloud.android.ui.fragment.contactsbackup.ContactsBackupFragment;
 import com.owncloud.android.ui.preview.PreviewImageActivity;
 import com.owncloud.android.ui.preview.PreviewImageFragment;
 import com.owncloud.android.ui.preview.PreviewMediaFragment;
@@ -138,8 +139,8 @@ abstract class ComponentsModule {
     @ContributesAndroidInjector abstract FileDetailSharingFragment fileDetailSharingFragment();
     @ContributesAndroidInjector abstract ChooseTemplateDialogFragment chooseTemplateDialogFragment();
 
-    @ContributesAndroidInjector
-    abstract ChooseRichDocumentsTemplateDialogFragment chooseRichDocumentsTemplateDialogFragment();
+    @ContributesAndroidInjector abstract ChooseRichDocumentsTemplateDialogFragment chooseRichDocumentsTemplateDialogFragment();
+    @ContributesAndroidInjector abstract ContactsBackupFragment contactsBackupFragment();
     @ContributesAndroidInjector abstract PreviewImageFragment previewImageFragment();
     @ContributesAndroidInjector abstract ContactListFragment chooseContactListFragment();
     @ContributesAndroidInjector abstract PreviewMediaFragment previewMediaFragment();

+ 63 - 2
src/main/java/com/nextcloud/client/etm/EtmViewModel.kt

@@ -2,7 +2,7 @@
  * Nextcloud Android client application
  *
  * @author Chris Narkiewicz
- * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ * 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
@@ -27,7 +27,14 @@ import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
 import com.nextcloud.client.etm.pages.EtmAccountsFragment
+import com.nextcloud.client.etm.pages.EtmBackgroundJobsFragment
+import com.nextcloud.client.etm.pages.EtmMigrations
 import com.nextcloud.client.etm.pages.EtmPreferencesFragment
+import com.nextcloud.client.jobs.BackgroundJobManager
+import com.nextcloud.client.jobs.JobInfo
+import com.nextcloud.client.migrations.MigrationInfo
+import com.nextcloud.client.migrations.MigrationsDb
+import com.nextcloud.client.migrations.MigrationsManager
 import com.owncloud.android.R
 import com.owncloud.android.lib.common.accounts.AccountUtils
 import javax.inject.Inject
@@ -35,7 +42,10 @@ import javax.inject.Inject
 class EtmViewModel @Inject constructor(
     private val defaultPreferences: SharedPreferences,
     private val platformAccountManager: AccountManager,
-    private val resources: Resources
+    private val resources: Resources,
+    private val backgroundJobManager: BackgroundJobManager,
+    private val migrationsManager: MigrationsManager,
+    private val migrationsDb: MigrationsDb
 ) : ViewModel() {
 
     companion object {
@@ -47,6 +57,11 @@ class EtmViewModel @Inject constructor(
             AccountUtils.Constants.KEY_OC_VERSION,
             AccountUtils.Constants.KEY_USER_ID
         )
+
+        const val PAGE_SETTINGS = 0
+        const val PAGE_ACCOUNTS = 1
+        const val PAGE_JOBS = 2
+        const val PAGE_MIGRATIONS = 3
     }
 
     /**
@@ -66,6 +81,16 @@ class EtmViewModel @Inject constructor(
             iconRes = R.drawable.ic_user,
             titleRes = R.string.etm_accounts,
             pageClass = EtmAccountsFragment::class
+        ),
+        EtmMenuEntry(
+            iconRes = R.drawable.ic_clock,
+            titleRes = R.string.etm_background_jobs,
+            pageClass = EtmBackgroundJobsFragment::class
+        ),
+        EtmMenuEntry(
+            iconRes = R.drawable.ic_arrow_up,
+            titleRes = R.string.etm_migrations,
+            pageClass = EtmMigrations::class
         )
     )
 
@@ -86,6 +111,22 @@ class EtmViewModel @Inject constructor(
         }
     }
 
+    val backgroundJobs: LiveData<List<JobInfo>> get() {
+        return backgroundJobManager.jobs
+    }
+
+    val migrationsInfo: List<MigrationInfo> get() {
+        return migrationsManager.info
+    }
+
+    val migrationsStatus: MigrationsManager.Status get() {
+        return migrationsManager.status.value ?: MigrationsManager.Status.UNKNOWN
+    }
+
+    val lastMigratedVersion: Int get() {
+        return migrationsDb.lastMigratedVersion
+    }
+
     init {
         (currentPage as MutableLiveData).apply {
             value = null
@@ -108,4 +149,24 @@ class EtmViewModel @Inject constructor(
             false
         }
     }
+
+    fun pruneJobs() {
+        backgroundJobManager.pruneJobs()
+    }
+
+    fun cancelAllJobs() {
+        backgroundJobManager.cancelAllJobs()
+    }
+
+    fun startTestJob() {
+        backgroundJobManager.scheduleTestJob()
+    }
+
+    fun cancelTestJob() {
+        backgroundJobManager.cancelTestJob()
+    }
+
+    fun clearMigrations() {
+        migrationsDb.clearMigrations()
+    }
 }

+ 145 - 0
src/main/java/com/nextcloud/client/etm/pages/EtmBackgroundJobsFragment.kt

@@ -0,0 +1,145 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz <hello@ezaquarii.com>
+ * Copyright (C) 2020 Chris Narkiewicz
+ * Copyright (C) 2020 Nextcloud GmbH
+ *
+ * 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 <https://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.etm.pages
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.lifecycle.Observer
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.nextcloud.client.etm.EtmBaseFragment
+import com.nextcloud.client.jobs.JobInfo
+import com.owncloud.android.R
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+class EtmBackgroundJobsFragment : EtmBaseFragment() {
+
+    class Adapter(private val inflater: LayoutInflater) : RecyclerView.Adapter<Adapter.ViewHolder>() {
+
+        class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
+            val uuid = view.findViewById<TextView>(R.id.etm_background_job_uuid)
+            val name = view.findViewById<TextView>(R.id.etm_background_job_name)
+            val user = view.findViewById<TextView>(R.id.etm_background_job_user)
+            val state = view.findViewById<TextView>(R.id.etm_background_job_state)
+            val started = view.findViewById<TextView>(R.id.etm_background_job_started)
+            val progress = view.findViewById<TextView>(R.id.etm_background_job_progress)
+            private val progressRow = view.findViewById<View>(R.id.etm_background_job_progress_row)
+
+            var progressEnabled: Boolean = progressRow.visibility == View.VISIBLE
+                get() {
+                    return progressRow.visibility == View.VISIBLE
+                }
+                set(value) {
+                    field = value
+                    progressRow.visibility = if (value) {
+                        View.VISIBLE
+                    } else {
+                        View.GONE
+                    }
+                }
+        }
+
+        private val dateFormat = SimpleDateFormat("YYYY-MM-dd HH:MM:ssZ", Locale.getDefault())
+        var backgroundJobs: List<JobInfo> = emptyList()
+        set(value) {
+            field = value
+            notifyDataSetChanged()
+        }
+
+        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+            val view = inflater.inflate(R.layout.etm_background_job_list_item, parent, false)
+            return ViewHolder(view)
+        }
+
+        override fun getItemCount(): Int {
+            return backgroundJobs.size
+        }
+
+        override fun onBindViewHolder(vh: ViewHolder, position: Int) {
+            val info = backgroundJobs[position]
+            vh.uuid.text = info.id.toString()
+            vh.name.text = info.name
+            vh.user.text = info.user
+            vh.state.text = info.state
+            vh.started.text = dateFormat.format(info.started)
+            if (info.progress >= 0) {
+                vh.progressEnabled = true
+                vh.progress.text = info.progress.toString()
+            } else {
+                vh.progressEnabled = false
+            }
+        }
+    }
+
+    private lateinit var list: RecyclerView
+    private lateinit var adapter: Adapter
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setHasOptionsMenu(true)
+    }
+
+    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+        val view = inflater.inflate(R.layout.fragment_etm_background_jobs, container, false)
+        adapter = Adapter(inflater)
+        list = view.findViewById(R.id.etm_background_jobs_list)
+        list.layoutManager = LinearLayoutManager(context)
+        list.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
+        list.adapter = adapter
+        vm.backgroundJobs.observe(this, Observer { onBackgroundJobsUpdated(it) })
+        return view
+    }
+
+    override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
+        super.onCreateOptionsMenu(menu, inflater)
+        inflater.inflate(R.menu.fragment_etm_background_jobs, menu)
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem): Boolean {
+        return when (item.itemId) {
+            R.id.etm_background_jobs_cancel -> {
+                vm.cancelAllJobs(); true
+            }
+            R.id.etm_background_jobs_prune -> {
+                vm.pruneJobs(); true
+            }
+            R.id.etm_background_jobs_start_test -> {
+                vm.startTestJob(); true
+            }
+            R.id.etm_background_jobs_cancel_test -> {
+                vm.cancelTestJob(); true
+            }
+            else -> super.onOptionsItemSelected(item)
+        }
+    }
+
+    private fun onBackgroundJobsUpdated(backgroundJobs: List<JobInfo>) {
+        adapter.backgroundJobs = backgroundJobs
+    }
+}

+ 71 - 0
src/main/java/com/nextcloud/client/etm/pages/EtmMigrations.kt

@@ -0,0 +1,71 @@
+package com.nextcloud.client.etm.pages
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import com.nextcloud.client.etm.EtmBaseFragment
+import com.owncloud.android.R
+import kotlinx.android.synthetic.main.fragment_etm_migrations.*
+import java.util.Locale
+
+class EtmMigrations : EtmBaseFragment() {
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setHasOptionsMenu(true)
+    }
+
+    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+        return inflater.inflate(R.layout.fragment_etm_migrations, container, false)
+    }
+
+    override fun onResume() {
+        super.onResume()
+        showStatus()
+    }
+
+    fun showStatus() {
+        val builder = StringBuilder()
+        val status = vm.migrationsStatus.toString().toLowerCase(Locale.US)
+        builder.append("Migration status: $status\n")
+        val lastMigratedVersion = if (vm.lastMigratedVersion >= 0) {
+            vm.lastMigratedVersion.toString()
+        } else {
+            "never"
+        }
+        builder.append("Last migrated version: $lastMigratedVersion\n")
+        builder.append("Migrations:\n")
+        vm.migrationsInfo.forEach {
+            val migrationStatus = if (it.applied) {
+                "applied"
+            } else {
+                "pending"
+            }
+            builder.append(" - ${it.id} ${it.description} - $migrationStatus\n")
+        }
+        etm_migrations_text.text = builder.toString()
+    }
+
+    override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
+        super.onCreateOptionsMenu(menu, inflater)
+        inflater.inflate(R.menu.fragment_etm_migrations, menu)
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem): Boolean {
+        return when (item.itemId) {
+            R.id.etm_migrations_delete -> {
+                onDeleteMigrationsClicked(); true
+            }
+            else -> super.onOptionsItemSelected(item)
+        }
+    }
+
+    private fun onDeleteMigrationsClicked() {
+        vm.clearMigrations()
+        showStatus()
+    }
+}

+ 25 - 4
src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt

@@ -2,7 +2,7 @@
  * Nextcloud Android client application
  *
  * @author Chris Narkiewicz
- * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ * 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
@@ -21,15 +21,18 @@ package com.nextcloud.client.jobs
 
 import android.content.ContentResolver
 import android.content.Context
+import android.content.res.Resources
 import android.os.Build
 import androidx.annotation.RequiresApi
 import androidx.work.ListenableWorker
 import androidx.work.WorkerFactory
 import androidx.work.WorkerParameters
+import com.nextcloud.client.account.UserAccountManager
 import com.nextcloud.client.core.Clock
 import com.nextcloud.client.device.DeviceInfo
 import com.nextcloud.client.device.PowerManagementService
 import com.nextcloud.client.preferences.AppPreferences
+import com.owncloud.android.datamodel.ArbitraryDataProvider
 import com.owncloud.android.datamodel.SyncedFolderProvider
 import javax.inject.Inject
 import javax.inject.Provider
@@ -44,7 +47,10 @@ class BackgroundJobFactory @Inject constructor(
     private val clock: Clock,
     private val powerManagerService: PowerManagementService,
     private val backgroundJobManager: Provider<BackgroundJobManager>,
-    private val deviceInfo: DeviceInfo
+    private val deviceInfo: DeviceInfo,
+    private val accountManager: UserAccountManager,
+    private val resources: Resources,
+    private val dataProvider: ArbitraryDataProvider
 ) : WorkerFactory() {
 
     override fun createWorker(
@@ -61,12 +67,16 @@ class BackgroundJobFactory @Inject constructor(
 
         return when (workerClass) {
             ContentObserverWork::class -> createContentObserverJob(context, workerParameters, clock)
+            ContactsBackupWork::class -> createContactsBackupWork(context, workerParameters)
             else -> null // falls back to default factory
         }
     }
 
-    private fun createContentObserverJob(context: Context, workerParameters: WorkerParameters, clock: Clock):
-        ListenableWorker? {
+    private fun createContentObserverJob(
+        context: Context,
+        workerParameters: WorkerParameters,
+        clock: Clock
+    ): ListenableWorker? {
         val folderResolver = SyncedFolderProvider(contentResolver, preferences, clock)
         @RequiresApi(Build.VERSION_CODES.N)
         if (deviceInfo.apiLevel >= Build.VERSION_CODES.N) {
@@ -81,4 +91,15 @@ class BackgroundJobFactory @Inject constructor(
             return null
         }
     }
+
+    private fun createContactsBackupWork(context: Context, params: WorkerParameters): ContactsBackupWork {
+        return ContactsBackupWork(
+            context,
+            params,
+            resources,
+            dataProvider,
+            contentResolver,
+            accountManager
+        )
+    }
 }

+ 42 - 1
src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt

@@ -2,7 +2,7 @@
  * Nextcloud Android client application
  *
  * @author Chris Narkiewicz
- * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ * 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
@@ -21,6 +21,8 @@ package com.nextcloud.client.jobs
 
 import android.os.Build
 import androidx.annotation.RequiresApi
+import androidx.lifecycle.LiveData
+import com.nextcloud.client.account.User
 
 /**
  * This interface allows to control, schedule and monitor all application
@@ -28,10 +30,49 @@ import androidx.annotation.RequiresApi
  */
 interface BackgroundJobManager {
 
+    /**
+     * Information about all application background jobs.
+     */
+    val jobs: LiveData<List<JobInfo>>
+
     /**
      * Start content observer job that monitors changes in media folders
      * and launches synchronization when needed.
+     *
+     * This call is idempotent - there will be only one scheduled job
+     * regardless of number of calls.
      */
     @RequiresApi(Build.VERSION_CODES.N)
     fun scheduleContentObserverJob()
+
+    /**
+     * Schedule periodic contacts backups job. Operating system will
+     * decide when to start the job.
+     *
+     * This call is idempotent - there can be only one scheduled job
+     * at any given time.
+     *
+     * @param user User for which job will be scheduled.
+     */
+    fun schedulePeriodicContactsBackup(user: User)
+
+    /**
+     * Cancel periodic contacts backup. Existing tasks might finish, but no new
+     * invocations will occur.
+     */
+    fun cancelPeriodicContactsBackup(user: User)
+
+    /**
+     * Immediately start single contacts backup job.
+     * This job will launch independently from periodic contacts backup.
+     *
+     * @return Job info with current status, or null if job does not exist anymore
+     */
+    fun startImmediateContactsBackup(user: User): LiveData<JobInfo?>
+
+    fun scheduleTestJob()
+    fun cancelTestJob()
+
+    fun pruneJobs()
+    fun cancelAllJobs()
 }

+ 211 - 6
src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt

@@ -2,7 +2,7 @@
  * Nextcloud Android client application
  *
  * @author Chris Narkiewicz
- * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ * 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
@@ -22,18 +22,172 @@ package com.nextcloud.client.jobs
 import android.os.Build
 import android.provider.MediaStore
 import androidx.annotation.RequiresApi
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Transformations
 import androidx.work.Constraints
+import androidx.work.Data
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.ExistingWorkPolicy
+import androidx.work.ListenableWorker
 import androidx.work.OneTimeWorkRequest
+import androidx.work.Operation
+import androidx.work.PeriodicWorkRequest
+import androidx.work.WorkInfo
 import androidx.work.WorkManager
+import com.nextcloud.client.account.User
+import com.nextcloud.client.core.Clock
+import java.util.Date
+import java.util.UUID
 import java.util.concurrent.TimeUnit
+import kotlin.reflect.KClass
 
-internal class BackgroundJobManagerImpl(private val workManager: WorkManager) : BackgroundJobManager {
+/**
+ * Note to maintainers
+ *
+ * Since [androidx.work.WorkManager] is missing API to easily attach worker metadata,
+ * we use tags API to attach our custom metadata.
+ *
+ * To create new job request, use [BackgroundJobManagerImpl.oneTimeRequestBuilder] and
+ * [BackgroundJobManagerImpl.periodicRequestBuilder] calls, instead of calling
+ * platform builders. Those methods will create builders pre-set with mandatory tags.
+ *
+ * Since Google is notoriously releasing new background job services, [androidx.work.WorkManager] API is
+ * considered private implementation detail and should not be leaked through the interface, to minimize
+ * potential migration cost in the future.
+ */
+@Suppress("TooManyFunctions") // we expect this implementation to have rich API
+internal class BackgroundJobManagerImpl(
+    private val workManager: WorkManager,
+    private val clock: Clock
+) : BackgroundJobManager {
 
     companion object {
-        const val TAG_CONTENT_SYNC = "content_sync"
+        const val TAG_ALL = "*" // This tag allows us to retrieve list of all jobs run by Nextcloud client
+        const val JOB_CONTENT_OBSERVER = "content_observer"
+        const val JOB_PERIODIC_CONTACTS_BACKUP = "periodic_contacts_backup"
+        const val JOB_IMMEDIATE_CONTACTS_BACKUP = "immediate_contacts_backup"
+        const val JOB_TEST = "test_job"
+
         const val MAX_CONTENT_TRIGGER_DELAY_MS = 1500L
+
+        const val TAG_PREFIX_NAME = "name"
+        const val TAG_PREFIX_USER = "user"
+        const val TAG_PREFIX_START_TIMESTAMP = "timestamp"
+        val PREFIXES = setOf(TAG_PREFIX_NAME, TAG_PREFIX_USER, TAG_PREFIX_START_TIMESTAMP)
+        const val NOT_SET_VALUE = "not set"
+        const val DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES = 15L
+        const val INTERVAL_SECOND = 1000L
+        const val INTERVAL_MINUTE = 60L * INTERVAL_SECOND
+        const val INTERVAL_HOUR = 60 * INTERVAL_MINUTE
+        const val INTERVAL_24H = 24L * INTERVAL_HOUR
+
+        fun formatNameTag(name: String, user: User? = null): String {
+            return if (user == null) {
+                "$TAG_PREFIX_NAME:$name"
+            } else {
+                "$TAG_PREFIX_NAME:$name ${user.accountName}"
+            }
+        }
+        fun formatUserTag(user: User): String = "$TAG_PREFIX_USER:${user.accountName}"
+        fun formatTimeTag(startTimestamp: Long): String = "$TAG_PREFIX_START_TIMESTAMP:$startTimestamp"
+
+        fun parseTag(tag: String): Pair<String, String>? {
+            val key = tag.substringBefore(":", "")
+            val value = tag.substringAfter(":", "")
+            return if (key in PREFIXES) {
+                key to value
+            } else {
+                null
+            }
+        }
+
+        fun parseTimestamp(timestamp: String): Date {
+            try {
+                val ms = timestamp.toLong()
+                return Date(ms)
+            } catch (ex: NumberFormatException) {
+                return Date(0)
+            }
+        }
+
+        /**
+         * Convert platform [androidx.work.WorkInfo] object into application-specific [JobInfo] model.
+         * Conversion extracts work metadata from tags.
+         */
+        fun fromWorkInfo(info: WorkInfo?): JobInfo? {
+            return if (info != null) {
+                val metadata = mutableMapOf<String, String>()
+                info.tags.forEach { parseTag(it)?.let { metadata[it.first] = it.second } }
+                val timestamp = parseTimestamp(metadata.get(TAG_PREFIX_START_TIMESTAMP) ?: "0")
+                JobInfo(
+                    id = info.id,
+                    state = info.state.toString(),
+                    name = metadata.get(TAG_PREFIX_NAME) ?: NOT_SET_VALUE,
+                    user = metadata.get(TAG_PREFIX_USER) ?: NOT_SET_VALUE,
+                    started = timestamp,
+                    progress = info.progress.getInt("progress", -1)
+                )
+            } else {
+                null
+            }
+        }
     }
 
+    /**
+     * Create [OneTimeWorkRequest.Builder] pre-set with common attributes
+     */
+    private fun oneTimeRequestBuilder(
+        jobClass: KClass<out ListenableWorker>,
+        jobName: String,
+        user: User? = null
+    ): OneTimeWorkRequest.Builder {
+        val builder = OneTimeWorkRequest.Builder(jobClass.java)
+            .addTag(TAG_ALL)
+            .addTag(formatNameTag(jobName, user))
+            .addTag(formatTimeTag(clock.currentTime))
+        user?.let { builder.addTag(formatUserTag(it)) }
+        return builder
+    }
+
+    /**
+     * Create [PeriodicWorkRequest] pre-set with common attributes
+     */
+    private fun periodicRequestBuilder(
+        jobClass: KClass<out ListenableWorker>,
+        jobName: String,
+        intervalMins: Long = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES,
+        user: User? = null
+    ): PeriodicWorkRequest.Builder {
+        val builder = PeriodicWorkRequest.Builder(jobClass.java, intervalMins, TimeUnit.MINUTES)
+            .addTag(TAG_ALL)
+            .addTag(formatNameTag(jobName, user))
+            .addTag(formatTimeTag(clock.currentTime))
+        user?.let { builder.addTag(formatUserTag(it)) }
+        return builder
+    }
+
+    private fun WorkManager.getJobInfo(id: UUID): LiveData<JobInfo?> {
+        val workInfo = getWorkInfoByIdLiveData(id)
+        return Transformations.map(workInfo) { fromWorkInfo(it) }
+    }
+
+    /**
+     * Cancel work using name tag with optional user scope.
+     * All work instances will be cancelled.
+     */
+    private fun WorkManager.cancelJob(name: String, user: User? = null): Operation {
+        val tag = formatNameTag(name, user)
+        return cancelAllWorkByTag(tag)
+    }
+
+    override val jobs: LiveData<List<JobInfo>>
+        get() {
+            val workInfo = workManager.getWorkInfosByTagLiveData("*")
+            return Transformations.map(workInfo) {
+                it.map { fromWorkInfo(it) ?: JobInfo() }.sortedBy { it.started }.reversed()
+            }
+        }
+
     @RequiresApi(Build.VERSION_CODES.N)
     override fun scheduleContentObserverJob() {
         val constrains = Constraints.Builder()
@@ -44,11 +198,62 @@ internal class BackgroundJobManagerImpl(private val workManager: WorkManager) :
             .setTriggerContentMaxDelay(MAX_CONTENT_TRIGGER_DELAY_MS, TimeUnit.MILLISECONDS)
             .build()
 
-        val request = OneTimeWorkRequest.Builder(ContentObserverWork::class.java)
+        val request = oneTimeRequestBuilder(ContentObserverWork::class, JOB_CONTENT_OBSERVER)
             .setConstraints(constrains)
-            .addTag(TAG_CONTENT_SYNC)
             .build()
 
-        workManager.enqueue(request)
+        workManager.enqueueUniqueWork(JOB_CONTENT_OBSERVER, ExistingWorkPolicy.KEEP, request)
+    }
+
+    override fun schedulePeriodicContactsBackup(user: User) {
+        val data = Data.Builder()
+            .putString(ContactsBackupWork.ACCOUNT, user.accountName)
+            .putBoolean(ContactsBackupWork.FORCE, true)
+            .build()
+
+        val request = periodicRequestBuilder(
+            ContactsBackupWork::class,
+            JOB_PERIODIC_CONTACTS_BACKUP,
+            INTERVAL_24H,
+            user
+        ).setInputData(data).build()
+
+        workManager.enqueueUniquePeriodicWork(JOB_PERIODIC_CONTACTS_BACKUP, ExistingPeriodicWorkPolicy.KEEP, request)
+    }
+
+    override fun cancelPeriodicContactsBackup(user: User) {
+        workManager.cancelJob(JOB_PERIODIC_CONTACTS_BACKUP, user)
+    }
+
+    override fun startImmediateContactsBackup(user: User): LiveData<JobInfo?> {
+        val data = Data.Builder()
+            .putString(ContactsBackupWork.ACCOUNT, user.accountName)
+            .putBoolean(ContactsBackupWork.FORCE, true)
+            .build()
+
+        val request = oneTimeRequestBuilder(ContactsBackupWork::class, JOB_IMMEDIATE_CONTACTS_BACKUP, user)
+            .setInputData(data)
+            .build()
+
+        workManager.enqueueUniqueWork(JOB_IMMEDIATE_CONTACTS_BACKUP, ExistingWorkPolicy.KEEP, request)
+        return workManager.getJobInfo(request.id)
+    }
+
+    override fun scheduleTestJob() {
+        val request = periodicRequestBuilder(TestJob::class, JOB_TEST)
+            .build()
+        workManager.enqueueUniquePeriodicWork(JOB_TEST, ExistingPeriodicWorkPolicy.KEEP, request)
+    }
+
+    override fun cancelTestJob() {
+        workManager.cancelAllWorkByTag(formatNameTag(JOB_TEST))
+    }
+
+    override fun pruneJobs() {
+        workManager.pruneWork()
+    }
+
+    override fun cancelAllJobs() {
+        workManager.cancelAllWorkByTag(TAG_ALL)
     }
 }

+ 260 - 0
src/main/java/com/nextcloud/client/jobs/ContactsBackupWork.kt

@@ -0,0 +1,260 @@
+/*
+ * 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.jobs
+
+import android.content.ComponentName
+import android.content.ContentResolver
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.content.res.Resources
+import android.database.Cursor
+import android.net.Uri
+import android.os.IBinder
+import android.provider.ContactsContract
+import android.text.TextUtils
+import android.text.format.DateFormat
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+import com.nextcloud.client.account.User
+import com.nextcloud.client.account.UserAccountManager
+import com.owncloud.android.R
+import com.owncloud.android.datamodel.ArbitraryDataProvider
+import com.owncloud.android.datamodel.FileDataStorageManager
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.files.services.FileUploader
+import com.owncloud.android.lib.common.utils.Log_OC
+import com.owncloud.android.operations.UploadFileOperation
+import com.owncloud.android.services.OperationsService
+import com.owncloud.android.services.OperationsService.OperationsServiceBinder
+import com.owncloud.android.ui.activity.ContactsPreferenceActivity
+import ezvcard.Ezvcard
+import ezvcard.VCardVersion
+import java.io.File
+import java.io.FileWriter
+import java.io.IOException
+import java.io.InputStream
+import java.io.InputStreamReader
+import java.util.ArrayList
+import java.util.Calendar
+
+class ContactsBackupWork(
+    appContext: Context,
+    params: WorkerParameters,
+    private val resources: Resources,
+    private val arbitraryDataProvider: ArbitraryDataProvider,
+    private val contentResolver: ContentResolver,
+    private val accountManager: UserAccountManager
+) : Worker(appContext, params) {
+
+    companion object {
+        val TAG = ContactsBackupWork::class.java.simpleName
+        const val ACCOUNT = "account"
+        const val FORCE = "force"
+        const val JOB_INTERVAL_MS: Long = 24 * 60 * 60 * 1000
+        const val BUFFER_SIZE = 1024
+    }
+
+    private var operationsServiceConnection: OperationsServiceConnection? = null
+    private var operationsServiceBinder: OperationsServiceBinder? = null
+
+    @Suppress("ReturnCount") // pre-existing issue
+    override fun doWork(): Result {
+        val accountName = inputData.getString(ACCOUNT) ?: ""
+        if (TextUtils.isEmpty(accountName)) { // no account provided
+            return Result.failure()
+        }
+        val optionalUser = accountManager.getUser(accountName)
+        if (!optionalUser.isPresent) {
+            return Result.failure()
+        }
+        val user = optionalUser.get()
+        val lastExecution = arbitraryDataProvider.getLongValue(user.toPlatformAccount(),
+            ContactsPreferenceActivity.PREFERENCE_CONTACTS_LAST_BACKUP)
+        val force = inputData.getBoolean(FORCE, false)
+        if (force || lastExecution + JOB_INTERVAL_MS < Calendar.getInstance().timeInMillis) {
+            Log_OC.d(TAG, "start contacts backup job")
+            val backupFolder: String = resources.getString(R.string.contacts_backup_folder) + OCFile.PATH_SEPARATOR
+            val daysToExpire: Int = applicationContext.getResources().getInteger(R.integer.contacts_backup_expire)
+            backupContact(user, backupFolder)
+            // bind to Operations Service
+            operationsServiceConnection = OperationsServiceConnection(
+                this,
+                daysToExpire,
+                backupFolder,
+                user
+            )
+            applicationContext.bindService(
+                Intent(applicationContext, OperationsService::class.java),
+                operationsServiceConnection as OperationsServiceConnection,
+                OperationsService.BIND_AUTO_CREATE
+            )
+            // store execution date
+            arbitraryDataProvider.storeOrUpdateKeyValue(
+                user.accountName,
+                ContactsPreferenceActivity.PREFERENCE_CONTACTS_LAST_BACKUP,
+                Calendar.getInstance().timeInMillis
+            )
+        } else {
+            Log_OC.d(TAG, "last execution less than 24h ago")
+        }
+        return Result.success()
+    }
+
+    private fun backupContact(user: User, backupFolder: String) {
+        val vCard = ArrayList<String>()
+        val cursor = contentResolver.query(ContactsContract.Contacts.CONTENT_URI, null,
+            null, null, null)
+        if (cursor != null && cursor.count > 0) {
+            cursor.moveToFirst()
+            for (i in 0 until cursor.count) {
+                vCard.add(getContactFromCursor(cursor))
+                cursor.moveToNext()
+            }
+        }
+        val filename = DateFormat.format("yyyy-MM-dd_HH-mm-ss", Calendar.getInstance()).toString() + ".vcf"
+        Log_OC.d(TAG, "Storing: $filename")
+        val file = File(applicationContext.getCacheDir(), filename)
+        var fw: FileWriter? = null
+        try {
+            fw = FileWriter(file)
+            for (card in vCard) {
+                fw.write(card)
+            }
+        } catch (e: IOException) {
+            Log_OC.d(TAG, "Error ", e)
+        } finally {
+            cursor?.close()
+            if (fw != null) {
+                try {
+                    fw.close()
+                } catch (e: IOException) {
+                    Log_OC.d(TAG, "Error closing file writer ", e)
+                }
+            }
+        }
+        FileUploader.uploadNewFile(
+            applicationContext,
+            user.toPlatformAccount(),
+            file.absolutePath,
+            backupFolder + filename,
+            FileUploader.LOCAL_BEHAVIOUR_MOVE,
+            null,
+            true,
+            UploadFileOperation.CREATED_BY_USER,
+            false,
+            false,
+            FileUploader.NameCollisionPolicy.ASK_USER
+        )
+    }
+
+    private fun expireFiles(daysToExpire: Int, backupFolderString: String, account: User) { // -1 disables expiration
+        if (daysToExpire > -1) {
+            val storageManager = FileDataStorageManager(account.toPlatformAccount(),
+                applicationContext.getContentResolver())
+            val backupFolder: OCFile = storageManager.getFileByPath(backupFolderString)
+            val cal = Calendar.getInstance()
+            cal.add(Calendar.DAY_OF_YEAR, -daysToExpire)
+            val timestampToExpire = cal.timeInMillis
+            if (backupFolder != null) {
+                Log_OC.d(TAG, "expire: " + daysToExpire + " " + backupFolder.fileName)
+            }
+            val backups: List<OCFile> = storageManager.getFolderContent(backupFolder, false)
+            for (backup in backups) {
+                if (timestampToExpire > backup.modificationTimestamp) {
+                    Log_OC.d(TAG, "delete " + backup.remotePath)
+                    // delete backups
+                    val service = Intent(applicationContext, OperationsService::class.java)
+                    service.action = OperationsService.ACTION_REMOVE
+                    service.putExtra(OperationsService.EXTRA_ACCOUNT, account)
+                    service.putExtra(OperationsService.EXTRA_REMOTE_PATH, backup.remotePath)
+                    service.putExtra(OperationsService.EXTRA_REMOVE_ONLY_LOCAL, false)
+                    operationsServiceBinder!!.queueNewOperation(service)
+                }
+            }
+        }
+        operationsServiceConnection?.let {
+            applicationContext.unbindService(it)
+        }
+    }
+
+    @Suppress("NestedBlockDepth")
+    private fun getContactFromCursor(cursor: Cursor): String {
+        val lookupKey = cursor.getString(cursor.getColumnIndex(ContactsContract.Contacts.LOOKUP_KEY))
+        val uri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_VCARD_URI, lookupKey)
+        var vCard = ""
+        var inputStream: InputStream? = null
+        var inputStreamReader: InputStreamReader? = null
+        try {
+            inputStream = applicationContext.getContentResolver().openInputStream(uri)
+            val buffer = CharArray(BUFFER_SIZE)
+            val stringBuilder = StringBuilder()
+            if (inputStream != null) {
+                inputStreamReader = InputStreamReader(inputStream)
+                while (true) {
+                    val byteCount = inputStreamReader.read(buffer, 0, buffer.size)
+                    if (byteCount > 0) {
+                        stringBuilder.append(buffer, 0, byteCount)
+                    } else {
+                        break
+                    }
+                }
+            }
+            vCard = stringBuilder.toString()
+            // bump to vCard 3.0 format (min version supported by server) since Android OS exports to 2.1
+            return Ezvcard.write(Ezvcard.parse(vCard).all()).version(VCardVersion.V3_0).go()
+        } catch (e: IOException) {
+            Log_OC.d(TAG, e.message)
+        } finally {
+            try {
+                inputStream?.close()
+                inputStreamReader?.close()
+            } catch (e: IOException) {
+                Log_OC.e(TAG, "failed to close stream")
+            }
+        }
+        return vCard
+    }
+
+    /**
+     * Implements callback methods for service binding.
+     */
+    private class OperationsServiceConnection internal constructor(
+        private val worker: ContactsBackupWork,
+        private val daysToExpire: Int,
+        private val backupFolder: String,
+        private val user: User
+    ) : ServiceConnection {
+        override fun onServiceConnected(component: ComponentName, service: IBinder) {
+            Log_OC.d(TAG, "service connected")
+            if (component == ComponentName(worker.applicationContext, OperationsService::class.java)) {
+                worker.operationsServiceBinder = service as OperationsServiceBinder
+                worker.expireFiles(daysToExpire, backupFolder, user)
+            }
+        }
+
+        override fun onServiceDisconnected(component: ComponentName) {
+            Log_OC.d(TAG, "service disconnected")
+            if (component == ComponentName(worker.applicationContext, OperationsService::class.java)) {
+                worker.operationsServiceBinder = null
+            }
+        }
+    }
+}

+ 32 - 0
src/main/java/com/nextcloud/client/jobs/JobInfo.kt

@@ -0,0 +1,32 @@
+/*
+ * 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.jobs
+
+import java.util.Date
+import java.util.UUID
+
+data class JobInfo(
+    val id: UUID = UUID.fromString("00000000-0000-0000-0000-000000000000"),
+    val state: String = "",
+    val name: String = "",
+    val user: String = "",
+    val started: Date = Date(0),
+    val progress: Int = 0
+)

+ 10 - 4
src/main/java/com/nextcloud/client/jobs/JobsModule.kt

@@ -2,7 +2,7 @@
  * Nextcloud Android client application
  *
  * @author Chris Narkiewicz
- * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ * 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
@@ -23,6 +23,7 @@ import android.content.Context
 import android.content.ContextWrapper
 import androidx.work.Configuration
 import androidx.work.WorkManager
+import com.nextcloud.client.core.Clock
 import dagger.Module
 import dagger.Provides
 import javax.inject.Singleton
@@ -32,7 +33,7 @@ class JobsModule {
 
     @Provides
     @Singleton
-    fun backgroundJobManager(context: Context, factory: BackgroundJobFactory): BackgroundJobManager {
+    fun workManager(context: Context, factory: BackgroundJobFactory): WorkManager {
         val configuration = Configuration.Builder()
             .setWorkerFactory(factory)
             .build()
@@ -44,7 +45,12 @@ class JobsModule {
         }
 
         WorkManager.initialize(contextWrapper, configuration)
-        val wm = WorkManager.getInstance(context)
-        return BackgroundJobManagerImpl(wm)
+        return WorkManager.getInstance(context)
+    }
+
+    @Provides
+    @Singleton
+    fun backgroundJobManager(workManager: WorkManager, clock: Clock): BackgroundJobManager {
+        return BackgroundJobManagerImpl(workManager, clock)
     }
 }

+ 48 - 0
src/main/java/com/nextcloud/client/jobs/TestJob.kt

@@ -0,0 +1,48 @@
+/*
+ * 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.jobs
+
+import android.content.Context
+import androidx.work.Data
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+
+class TestJob(
+    appContext: Context,
+    params: WorkerParameters
+) : Worker(appContext, params) {
+
+    companion object {
+        private const val MAX_PROGRESS = 100
+        private const val DELAY_MS = 1000L
+        private const val PROGRESS_KEY = "progress"
+    }
+
+    override fun doWork(): Result {
+        for (i in 0..MAX_PROGRESS) {
+            Thread.sleep(DELAY_MS)
+            val progress = Data.Builder()
+                .putInt(PROGRESS_KEY, i)
+                .build()
+            setProgressAsync(progress)
+        }
+        return Result.success()
+    }
+}

+ 22 - 0
src/main/java/com/nextcloud/client/migrations/MigrationInfo.kt

@@ -0,0 +1,22 @@
+/*
+ * 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
+
+data class MigrationInfo(val id: Int, val description: String, val applied: Boolean)

+ 88 - 16
src/main/java/com/nextcloud/client/migrations/Migrations.kt

@@ -19,41 +19,113 @@
  */
 package com.nextcloud.client.migrations
 
+import android.os.Build
+import androidx.work.WorkManager
 import com.nextcloud.client.account.UserAccountManager
+import com.nextcloud.client.jobs.BackgroundJobManager
+import com.nextcloud.client.logger.Logger
+import com.owncloud.android.datamodel.ArbitraryDataProvider
+import com.owncloud.android.ui.activity.ContactsPreferenceActivity
 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
+    private val logger: Logger,
+    private val userAccountManager: UserAccountManager,
+    private val workManager: WorkManager,
+    private val arbitraryDataProvider: ArbitraryDataProvider,
+    private val jobManager: BackgroundJobManager
 ) {
 
+    companion object {
+        val TAG = Migrations::class.java.simpleName
+    }
+
     /**
-     * @param id Step id; id must be unique
-     * @param description Human readable migration step description
-     * @param function Migration runnable object
+     * This class wraps migration logic with some metadata with some
+     * metadata required to register and log overall migration progress.
+     *
+     * @param id Step id; id must be unique; this is verified upon registration
+     * @param description Human readable migration step descriptionsss
      * @param mandatory If true, failing migration will cause an exception; if false, it will be skipped and repeated
      *                  again on next startup
+     * @throws Exception migration logic is permitted to throw any kind of exceptions; all exceptions will be wrapped
+     * into [MigrationException]
+     */
+    abstract class Step(val id: Int, val description: String, val mandatory: Boolean = true) : Runnable
+
+    /**
+     * Migrate legacy accounts by addming user IDs. This migration can be re-tried until all accounts are
+     * successfully migrated.
      */
-    data class Step(val id: Int, val description: String, val function: Runnable, val mandatory: Boolean = true)
+    private val migrateUserId = object : Step(0, "Migrate user id", false) {
+        override fun run() {
+            val allAccountsHaveUserId = userAccountManager.migrateUserId()
+            logger.i(TAG, "$description: success = $allAccountsHaveUserId")
+            if (!allAccountsHaveUserId) {
+                throw IllegalStateException("Failed to set user id for all accounts")
+            }
+        }
+    }
+
+    /**
+     * Content observer job must be restarted to use new scheduler abstraction.
+     */
+    private val migrateContentObserverJob = object : Step(1, "Migrate content observer job", false) {
+        override fun run() {
+            val legacyWork = workManager.getWorkInfosByTag("content_sync").get()
+            legacyWork.forEach {
+                logger.i(TAG, "$description: cancelling legacy work ${it.id}")
+                workManager.cancelWorkById(it.id)
+            }
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+                jobManager.scheduleContentObserverJob()
+                logger.i(TAG, "$description: enabled")
+            } else {
+                logger.i(TAG, "$description: disabled")
+            }
+        }
+    }
+
+    /**
+     * Contacts backup job has been migrated to new job runner framework. Re-start contacts upload
+     * for all users that have it enabled.
+     *
+     * Old job is removed from source code, so we need to restart it for each user using
+     * new jobs API.
+     */
+    private val migrateContactsBackupJob = object : Step(2, "Restart contacts backup job") {
+        override fun run() {
+            val users = userAccountManager.allUsers
+            if (users.isEmpty()) {
+                logger.i(TAG, "$description: no users to migrate")
+            } else {
+                users.forEach {
+                    val backupEnabled = arbitraryDataProvider.getBooleanValue(it.accountName,
+                        ContactsPreferenceActivity.PREFERENCE_CONTACTS_AUTOMATIC_BACKUP)
+                    if (backupEnabled) {
+                        jobManager.schedulePeriodicContactsBackup(it)
+                    }
+                    logger.i(TAG, "$description: user = ${it.accountName}, backup enabled = $backupEnabled")
+                }
+            }
+        }
+    }
 
     /**
      * 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")
+        migrateUserId,
+        migrateContentObserverJob,
+        migrateContactsBackupJob
+    ).sortedBy { it.id }.apply {
+        val uniqueIds = associateBy { it.id }.size
+        if (uniqueIds != size) {
+            throw IllegalStateException("All migrations must have unique id")
         }
     }
 }

+ 85 - 0
src/main/java/com/nextcloud/client/migrations/MigrationsDb.kt

@@ -0,0 +1,85 @@
+/*
+ * 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.util.TreeSet
+
+class MigrationsDb(private val migrationsDb: SharedPreferences) {
+
+    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"
+
+        const val NO_LAST_MIGRATED_VERSION = -1
+        const val NO_FAILED_MIGRATION_ID = -1
+    }
+
+    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()
+    }
+
+    var lastMigratedVersion: Int
+        set(value) {
+            migrationsDb.edit().putInt(DB_KEY_LAST_MIGRATED_VERSION, value).apply()
+        }
+        get() {
+            return migrationsDb.getInt(DB_KEY_LAST_MIGRATED_VERSION, NO_LAST_MIGRATED_VERSION)
+        }
+
+    val isFailed: Boolean get() = migrationsDb.getBoolean(DB_KEY_FAILED, false)
+    val failureReason: String get() = migrationsDb.getString(DB_KEY_FAILED_MIGRATION_ERROR_MESSAGE, "") ?: ""
+    val failedMigrationId: Int get() = migrationsDb.getInt(DB_KEY_FAILED_MIGRATION_ID, NO_FAILED_MIGRATION_ID)
+
+    fun setFailed(id: Int, error: String) {
+        migrationsDb
+            .edit()
+            .putBoolean(DB_KEY_FAILED, true)
+            .putString(DB_KEY_FAILED_MIGRATION_ERROR_MESSAGE, error)
+            .putInt(DB_KEY_FAILED_MIGRATION_ID, id)
+            .apply()
+    }
+
+    fun clearMigrations() {
+        migrationsDb.edit()
+            .putStringSet(DB_KEY_APPLIED_MIGRATIONS, emptySet())
+            .putInt(DB_KEY_LAST_MIGRATED_VERSION, 0)
+            .apply()
+    }
+}

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

@@ -57,6 +57,11 @@ interface MigrationsManager {
      */
     val status: LiveData<Status>
 
+    /**
+     * Information about all pending and applied migrations
+     */
+    val info: List<MigrationInfo>
+
     /**
      * Starts application state migration. Migrations will be run in background thread.
      * Callers can use [status] to monitor migration progress.

+ 13 - 46
src/main/java/com/nextcloud/client/migrations/MigrationsManagerImpl.kt

@@ -19,70 +19,42 @@
  */
 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 migrationsDb: MigrationsDb,
     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()
-    }
+    override val status: LiveData<Status> = MutableLiveData<Status>(Status.UNKNOWN)
 
-    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() })
+    override val info: List<MigrationInfo> get() {
+        val applied = migrationsDb.getAppliedMigrations()
+        return migrations.map {
+            MigrationInfo(id = it.id, description = it.description, applied = applied.contains(it.id))
         }
-        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)) {
+        if (migrationsDb.isFailed) {
             (status as MutableLiveData<Status>).value = Status.FAILED
             return 0
         }
-        val lastMigratedVersion = migrationsDb.getInt(DB_KEY_LAST_MIGRATED_VERSION, -1)
-        if (lastMigratedVersion >= appInfo.versionCode) {
+        if (migrationsDb.lastMigratedVersion >= appInfo.versionCode) {
             (status as MutableLiveData<Status>).value = Status.APPLIED
             return 0
         }
-        val applied = getAppliedMigrations()
+        val applied = migrationsDb.getAppliedMigrations()
         val toApply = migrations.filter { !applied.contains(it.id) }
         if (toApply.isEmpty()) {
             onMigrationSuccess()
@@ -105,8 +77,8 @@ internal class MigrationsManagerImpl(
         migrations.forEach {
             @Suppress("TooGenericExceptionCaught") // migration code is free to throw anything
             try {
-                it.function.run()
-                addAppliedMigration(it.id)
+                it.run()
+                migrationsDb.addAppliedMigration(it.id)
             } catch (t: Throwable) {
                 if (it.mandatory) {
                     throw MigrationError(id = it.id, message = t.message ?: t.javaClass.simpleName)
@@ -121,18 +93,13 @@ internal class MigrationsManagerImpl(
             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()
+        migrationsDb.setFailed(id, error.message ?: error.javaClass.simpleName)
         (status as MutableLiveData<Status>).value = Status.FAILED
     }
 
     @MainThread
     private fun onMigrationSuccess() {
-        migrationsDb.edit().putInt(DB_KEY_LAST_MIGRATED_VERSION, appInfo.versionCode).apply()
+        migrationsDb.lastMigratedVersion = appInfo.versionCode
         (status as MutableLiveData<Status>).value = Status.APPLIED
     }
 }

+ 9 - 15
src/main/java/com/owncloud/android/MainApp.java

@@ -43,6 +43,7 @@ import android.view.WindowManager;
 
 import com.evernote.android.job.JobManager;
 import com.evernote.android.job.JobRequest;
+import com.nextcloud.client.account.User;
 import com.nextcloud.client.account.UserAccountManager;
 import com.nextcloud.client.appinfo.AppInfo;
 import com.nextcloud.client.core.Clock;
@@ -203,14 +204,6 @@ public class MainApp extends MultiDexApplication implements HasAndroidInjector {
         return powerManagementService;
     }
 
-    /**
-     * Temporary getter enabling intermediate refactoring.
-     * TODO: remove when FileSyncHelper is refactored/removed
-     */
-    public BackgroundJobManager getBackgroundJobManager() {
-        return backgroundJobManager;
-    }
-
     private String getAppProcessName() {
         String processName = "";
         if(Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
@@ -278,7 +271,8 @@ public class MainApp extends MultiDexApplication implements HasAndroidInjector {
                 connectivityService,
                 powerManagementService,
                 clock,
-                eventBus
+                eventBus,
+                backgroundJobManager
             )
         );
 
@@ -316,7 +310,7 @@ public class MainApp extends MultiDexApplication implements HasAndroidInjector {
                            powerManagementService,
                            backgroundJobManager,
                            clock);
-        initContactsBackup(accountManager);
+        initContactsBackup(accountManager, backgroundJobManager);
         notificationChannels();
 
 
@@ -386,13 +380,13 @@ public class MainApp extends MultiDexApplication implements HasAndroidInjector {
         securityKeyManager.init(this, config);
     }
 
-    public static void initContactsBackup(UserAccountManager accountManager) {
+    public static void initContactsBackup(UserAccountManager accountManager, BackgroundJobManager backgroundJobManager) {
         ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProvider(mContext.getContentResolver());
-        Account[] accounts = accountManager.getAccounts();
+        List<User> users = accountManager.getAllUsers();
+        for (User user : users) {
+            if (arbitraryDataProvider.getBooleanValue(user.toPlatformAccount(), PREFERENCE_CONTACTS_AUTOMATIC_BACKUP)) {
+                backgroundJobManager.schedulePeriodicContactsBackup(user);
 
-        for (Account account : accounts) {
-            if (arbitraryDataProvider.getBooleanValue(account, PREFERENCE_CONTACTS_AUTOMATIC_BACKUP)) {
-                ContactsPreferenceActivity.startContactBackupJob(account);
             }
         }
     }

+ 0 - 1
src/main/java/com/owncloud/android/datamodel/OCFile.java

@@ -19,7 +19,6 @@
  *   along with this program.  If not, see <http://www.gnu.org/licenses/>.
  *
  */
-
 package com.owncloud.android.datamodel;
 
 

+ 2 - 2
src/main/java/com/owncloud/android/files/BootupBroadcastReceiver.java

@@ -6,7 +6,7 @@
  * Copyright (C) 2012 Bartek Przybylski
  * Copyright (C) 2015 ownCloud Inc.
  * Copyright (C) 2017 Mario Danic
- * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ * 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 General Public License version 2,
@@ -73,7 +73,7 @@ public class BootupBroadcastReceiver extends BroadcastReceiver {
                                        powerManagementService,
                                        backgroundJobManager,
                                        clock);
-            MainApp.initContactsBackup(accountManager);
+            MainApp.initContactsBackup(accountManager, backgroundJobManager);
         } else {
             Log_OC.d(TAG, "Getting wrong intent: " + intent.getAction());
         }

+ 6 - 7
src/main/java/com/owncloud/android/jobs/AccountRemovalJob.java

@@ -6,7 +6,7 @@
  *
  * Copyright (C) 2017 Tobias Kaminsky
  * Copyright (C) 2017 Nextcloud GmbH.
- * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ * 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
@@ -21,7 +21,6 @@
  * 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.owncloud.android.jobs;
 
 import android.accounts.Account;
@@ -38,6 +37,7 @@ import com.google.gson.Gson;
 import com.nextcloud.client.account.User;
 import com.nextcloud.client.account.UserAccountManager;
 import com.nextcloud.client.core.Clock;
+import com.nextcloud.client.jobs.BackgroundJobManager;
 import com.nextcloud.client.preferences.AppPreferencesImpl;
 import com.nextcloud.java.util.Optional;
 import com.owncloud.android.MainApp;
@@ -81,15 +81,18 @@ public class AccountRemovalJob extends Job {
 
     private final UploadsStorageManager uploadsStorageManager;
     private final UserAccountManager userAccountManager;
+    private final BackgroundJobManager backgroundJobManager;
     private final Clock clock;
     private final EventBus eventBus;
 
     public AccountRemovalJob(UploadsStorageManager uploadStorageManager,
                              UserAccountManager accountManager,
+                             BackgroundJobManager backgroundJobManager,
                              Clock clock,
                              EventBus eventBus) {
         this.uploadsStorageManager = uploadStorageManager;
         this.userAccountManager = accountManager;
+        this.backgroundJobManager = backgroundJobManager;
         this.clock = clock;
         this.eventBus = eventBus;
     }
@@ -118,8 +121,7 @@ public class AccountRemovalJob extends Job {
         ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProvider(context.getContentResolver());
 
         User user = optionalUser.get();
-        // disable contact backup job
-        ContactsPreferenceActivity.cancelContactBackupJobForAccount(context, user);
+        backgroundJobManager.cancelPeriodicContactsBackup(user);
 
         final boolean userRemoved = userAccountManager.removeUser(user);
         if (userRemoved) {
@@ -134,9 +136,6 @@ public class AccountRemovalJob extends Job {
         // delete all database entries
         storageManager.deleteAllFiles();
 
-        // remove contact backup job
-        ContactsPreferenceActivity.cancelContactBackupJobForAccount(context, user);
-
         // disable daily backup
         arbitraryDataProvider.storeOrUpdateKeyValue(user.getAccountName(),
                                                     ContactsPreferenceActivity.PREFERENCE_CONTACTS_AUTOMATIC_BACKUP,

+ 11 - 5
src/main/java/com/owncloud/android/jobs/NCJobCreator.java

@@ -6,7 +6,7 @@
  *
  * Copyright (C) 2017 Mario Danic
  * Copyright (C) 2017 Nextcloud GmbH
- * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ * Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
  *
  * <p>
  * This program is free software: you can redistribute it and/or modify
@@ -31,6 +31,7 @@ import com.evernote.android.job.JobCreator;
 import com.nextcloud.client.account.UserAccountManager;
 import com.nextcloud.client.core.Clock;
 import com.nextcloud.client.device.PowerManagementService;
+import com.nextcloud.client.jobs.BackgroundJobManager;
 import com.nextcloud.client.network.ConnectivityService;
 import com.nextcloud.client.preferences.AppPreferences;
 import com.owncloud.android.datamodel.UploadsStorageManager;
@@ -53,6 +54,7 @@ public class NCJobCreator implements JobCreator {
     private final PowerManagementService powerManagementService;
     private final Clock clock;
     private final EventBus eventBus;
+    private final BackgroundJobManager backgroundJobManager;
 
     public NCJobCreator(
         Context context,
@@ -62,7 +64,8 @@ public class NCJobCreator implements JobCreator {
         ConnectivityService connectivityServices,
         PowerManagementService powerManagementService,
         Clock clock,
-        EventBus eventBus
+        EventBus eventBus,
+        BackgroundJobManager backgroundJobManager
     ) {
         this.context = context;
         this.accountManager = accountManager;
@@ -72,17 +75,20 @@ public class NCJobCreator implements JobCreator {
         this.powerManagementService = powerManagementService;
         this.clock = clock;
         this.eventBus = eventBus;
+        this.backgroundJobManager = backgroundJobManager;
     }
 
     @Override
     public Job create(@NonNull String tag) {
         switch (tag) {
-            case ContactsBackupJob.TAG:
-                return new ContactsBackupJob(accountManager);
             case ContactsImportJob.TAG:
                 return new ContactsImportJob();
             case AccountRemovalJob.TAG:
-                return new AccountRemovalJob(uploadsStorageManager, accountManager, clock, eventBus);
+                return new AccountRemovalJob(uploadsStorageManager,
+                                             accountManager,
+                                             backgroundJobManager,
+                                             clock,
+                                             eventBus);
             case FilesSyncJob.TAG:
                 return new FilesSyncJob(accountManager,
                                         preferences,

+ 7 - 46
src/main/java/com/owncloud/android/ui/activity/ContactsPreferenceActivity.java

@@ -2,8 +2,10 @@
  * Nextcloud Android client application
  *
  * @author Tobias Kaminsky
+ * @author Chris Narkiewicz <hello@ezaquarii.com>
  * Copyright (C) 2017 Tobias Kaminsky
  * Copyright (C) 2017 Nextcloud GmbH.
+ * 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
@@ -18,7 +20,6 @@
  * 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.owncloud.android.ui.activity;
 
 import android.accounts.Account;
@@ -30,6 +31,7 @@ import com.evernote.android.job.JobManager;
 import com.evernote.android.job.JobRequest;
 import com.evernote.android.job.util.support.PersistableBundleCompat;
 import com.nextcloud.client.account.User;
+import com.nextcloud.client.jobs.BackgroundJobManager;
 import com.owncloud.android.MainApp;
 import com.owncloud.android.R;
 import com.owncloud.android.datamodel.OCFile;
@@ -43,6 +45,8 @@ import org.parceler.Parcels;
 
 import java.util.Set;
 
+import javax.inject.Inject;
+
 import androidx.drawerlayout.widget.DrawerLayout;
 import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentTransaction;
@@ -58,6 +62,8 @@ public class ContactsPreferenceActivity extends FileActivity implements FileFrag
     public static final String BACKUP_TO_LIST = "BACKUP_TO_LIST";
     public static final String EXTRA_SHOW_SIDEBAR = "SHOW_SIDEBAR";
 
+    @Inject BackgroundJobManager backgroundJobManager;
+
     @Override
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
@@ -104,51 +110,6 @@ public class ContactsPreferenceActivity extends FileActivity implements FileFrag
         }
     }
 
-    public static void startContactBackupJob(Account account) {
-        Log_OC.d(TAG, "start daily contacts backup job");
-
-        PersistableBundleCompat bundle = new PersistableBundleCompat();
-        bundle.putString(ContactsBackupJob.ACCOUNT, account.name);
-
-        cancelPreviousContactBackupJobForAccount(MainApp.getAppContext(), account);
-
-        new JobRequest.Builder(ContactsBackupJob.TAG)
-                .setExtras(bundle)
-                .setPeriodic(24 * 60 * 60 * 1000)
-                .build()
-                .schedule();
-    }
-
-    public static void cancelPreviousContactBackupJobForAccount(Context context, Account account) {
-        Log_OC.d(TAG, "disabling existing contacts backup job for account: " + account.name);
-
-        JobManager jobManager = JobManager.create(context);
-        Set<JobRequest> jobs = jobManager.getAllJobRequestsForTag(ContactsBackupJob.TAG);
-
-        for (JobRequest jobRequest : jobs) {
-            PersistableBundleCompat extras = jobRequest.getExtras();
-            if (extras != null && extras.getString(ContactsBackupJob.ACCOUNT, "").equalsIgnoreCase(account.name) &&
-                    jobRequest.isPeriodic()) {
-                jobManager.cancel(jobRequest.getJobId());
-            }
-        }
-    }
-
-    public static void cancelContactBackupJobForAccount(Context context, User user) {
-        Log_OC.d(TAG, "disabling contacts backup job for account: " + user.getAccountName());
-
-        JobManager jobManager = JobManager.create(context);
-        Set<JobRequest> jobs = jobManager.getAllJobRequestsForTag(ContactsBackupJob.TAG);
-
-        for (JobRequest jobRequest : jobs) {
-            PersistableBundleCompat extras = jobRequest.getExtras();
-            if (extras.getString(ContactsBackupJob.ACCOUNT, "").equalsIgnoreCase(user.getAccountName())) {
-                jobManager.cancel(jobRequest.getJobId());
-            }
-        }
-    }
-
-
     @Override
     public void showFiles(boolean onDeviceOnly) {
         super.showFiles(onDeviceOnly);

+ 30 - 31
src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactsBackupFragment.java

@@ -36,17 +36,16 @@ import android.widget.CompoundButton;
 import android.widget.DatePicker;
 import android.widget.TextView;
 
-import com.evernote.android.job.JobRequest;
-import com.evernote.android.job.util.support.PersistableBundleCompat;
 import com.google.android.material.button.MaterialButton;
 import com.google.android.material.snackbar.Snackbar;
 import com.nextcloud.client.account.User;
+import com.nextcloud.client.di.Injectable;
+import com.nextcloud.client.jobs.BackgroundJobManager;
 import com.nextcloud.java.util.Optional;
 import com.owncloud.android.R;
 import com.owncloud.android.datamodel.ArbitraryDataProvider;
 import com.owncloud.android.datamodel.FileDataStorageManager;
 import com.owncloud.android.datamodel.OCFile;
-import com.owncloud.android.jobs.ContactsBackupJob;
 import com.owncloud.android.lib.common.operations.RemoteOperationResult;
 import com.owncloud.android.operations.RefreshFolderOperation;
 import com.owncloud.android.ui.activity.ContactsPreferenceActivity;
@@ -62,6 +61,8 @@ import java.util.Comparator;
 import java.util.Date;
 import java.util.List;
 
+import javax.inject.Inject;
+
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.appcompat.app.ActionBar;
@@ -75,7 +76,7 @@ import third_parties.daveKoeller.AlphanumComparator;
 import static com.owncloud.android.ui.activity.ContactsPreferenceActivity.PREFERENCE_CONTACTS_AUTOMATIC_BACKUP;
 import static com.owncloud.android.ui.activity.ContactsPreferenceActivity.PREFERENCE_CONTACTS_LAST_BACKUP;
 
-public class ContactsBackupFragment extends FileFragment implements DatePickerDialog.OnDateSetListener {
+public class ContactsBackupFragment extends FileFragment implements DatePickerDialog.OnDateSetListener, Injectable {
     public static final String TAG = ContactsBackupFragment.class.getSimpleName();
 
     @BindView(R.id.contacts_automatic_backup)
@@ -90,6 +91,8 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi
     @BindView(R.id.contacts_backup_now)
     public MaterialButton backupNow;
 
+    @Inject BackgroundJobManager backgroundJobManager;
+
     private Date selectedDate;
     private boolean calendarPickerOpen;
 
@@ -322,40 +325,36 @@ public class ContactsBackupFragment extends FileFragment implements DatePickerDi
     }
 
     private void startContactsBackupJob() {
-        final ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity();
-
-        PersistableBundleCompat bundle = new PersistableBundleCompat();
-        bundle.putString(ContactsBackupJob.ACCOUNT, contactsPreferenceActivity.getAccount().name);
-        bundle.putBoolean(ContactsBackupJob.FORCE, true);
-
-        new JobRequest.Builder(ContactsBackupJob.TAG)
-                .setExtras(bundle)
-                .startNow()
-                .setUpdateCurrent(false)
-                .build()
-                .schedule();
-
-        DisplayUtils.showSnackMessage(getView().findViewById(R.id.contacts_linear_layout),
-                R.string.contacts_preferences_backup_scheduled);
+        ContactsPreferenceActivity activity = (ContactsPreferenceActivity)getActivity();
+        if (activity != null) {
+            Optional<User> optionalUser = activity.getUser();
+            if (optionalUser.isPresent()) {
+                backgroundJobManager.startImmediateContactsBackup(optionalUser.get());
+                DisplayUtils.showSnackMessage(getView().findViewById(R.id.contacts_linear_layout),
+                                              R.string.contacts_preferences_backup_scheduled);
+            }
+        }
     }
 
-    private void setAutomaticBackup(final boolean bool) {
-
-        final ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity();
+    private void setAutomaticBackup(final boolean enabled) {
 
-        if (bool) {
-            ContactsPreferenceActivity.startContactBackupJob(contactsPreferenceActivity.getAccount());
+        final ContactsPreferenceActivity activity = (ContactsPreferenceActivity) getActivity();
+        if (activity == null) {
+            return;
+        }
+        Optional<User> optionalUser = activity.getUser();
+        if (!optionalUser.isPresent()) {
+            return;
+        }
+        User user = optionalUser.get();
+        if (enabled) {
+            backgroundJobManager.schedulePeriodicContactsBackup(user);
         } else {
-            Optional<User> user = contactsPreferenceActivity.getUser();
-
-            if (user.isPresent()) {
-                ContactsPreferenceActivity.cancelContactBackupJobForAccount(contactsPreferenceActivity,
-                                                                            user.get());
-            }
+            backgroundJobManager.cancelPeriodicContactsBackup(user);
         }
 
         arbitraryDataProvider.storeOrUpdateKeyValue(account.name, PREFERENCE_CONTACTS_AUTOMATIC_BACKUP,
-                String.valueOf(bool));
+                String.valueOf(enabled));
     }
 
     private boolean checkAndAskForContactsReadPermission() {

+ 9 - 0
src/main/res/drawable/ic_arrow_up.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:viewportWidth="24.0"
+        android:viewportHeight="24.0">
+    <path
+        android:fillColor="#FFF"
+        android:pathData="M4,12l1.41,1.41L11,7.83V20h2V7.83l5.58,5.59L20,12l-8,-8 -8,8z"/>
+</vector>

+ 13 - 0
src/main/res/drawable/ic_clock.xml

@@ -0,0 +1,13 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:viewportWidth="24.0"
+        android:viewportHeight="24.0"
+        android:tint="#757575">
+    <path
+        android:fillColor="#757575"
+        android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z"/>
+    <path
+        android:fillColor="#757575"
+        android:pathData="M12.5,7H11v6l5.25,3.15 0.75,-1.23 -4.5,-2.67z"/>
+</vector>

+ 139 - 0
src/main/res/layout/etm_background_job_list_item.xml

@@ -0,0 +1,139 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    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/>.
+-->
+<TableLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:padding="8dp"
+    android:stretchColumns="1">
+
+    <TableRow
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginEnd="20dp"
+            android:text="@string/etm_background_job_uuid" />
+
+        <TextView
+            android:id="@+id/etm_background_job_uuid"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            tools:text="d7edb387-0b61-4e4e-a728-ffab3055d700" />
+    </TableRow>
+
+    <TableRow
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginEnd="20dp"
+            android:text="@string/etm_background_job_name"
+            app:layout_constraintStart_toStartOf="@+id/etm_background_task_id_label"
+            app:layout_constraintTop_toBottomOf="@+id/etm_background_task_id_label" />
+
+        <TextView
+            android:id="@+id/etm_background_job_name"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            tools:text="job name" />
+
+    </TableRow>
+
+    <TableRow
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginEnd="20dp"
+            android:text="@string/etm_background_job_user" />
+
+        <TextView
+            android:id="@+id/etm_background_job_user"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            tools:text="user@nextcloud.com" />
+
+    </TableRow>
+
+    <TableRow
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginEnd="20dp"
+            android:text="@string/etm_background_job_state" />
+
+        <TextView
+            android:id="@+id/etm_background_job_state"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            tools:text="ENQUEUED" />
+
+    </TableRow>
+
+    <TableRow
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginEnd="20dp"
+            android:text="@string/etm_background_job_started" />
+
+        <TextView
+            android:id="@+id/etm_background_job_started"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            tools:text="2020-02-15T20:53:15Z" />
+
+    </TableRow>
+
+    <TableRow
+        android:id="@+id/etm_background_job_progress_row"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginEnd="20dp"
+            android:text="@string/etm_background_job_progress" />
+
+        <TextView
+            android:id="@+id/etm_background_job_progress"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            tools:text="50%" />
+
+    </TableRow>
+
+</TableLayout>

+ 13 - 0
src/main/res/layout/fragment_etm_background_jobs.xml

@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".etm.pages.EtmBackgroundJobsFragment">
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/etm_background_jobs_list"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"/>
+
+</FrameLayout>

+ 33 - 0
src/main/res/layout/fragment_etm_migrations.xml

@@ -0,0 +1,33 @@
+<!--
+    Nextcloud Android client application
+
+    @author Chris Narkiewicz
+    Copyright (C) 2019 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/>.
+-->
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context="com.nextcloud.client.etm.pages.EtmAccountsFragment">
+
+    <TextView
+        android:id="@+id/etm_migrations_text"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:padding="@dimen/standard_padding"
+        android:scrollbars="vertical"/>
+
+</FrameLayout>

+ 51 - 0
src/main/res/menu/fragment_etm_background_jobs.xml

@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    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/>.
+-->
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    tools:ignore="AppCompatResource">
+
+    <item
+        android:id="@+id/etm_background_jobs_cancel"
+        android:title="@string/etm_background_jobs_cancel_all"
+        app:showAsAction="never"
+        android:showAsAction="never" />
+
+    <item
+        android:id="@+id/etm_background_jobs_prune"
+        android:title="@string/etm_background_jobs_prune"
+        app:showAsAction="never"
+        android:showAsAction="never" />
+
+
+    <item
+        android:id="@+id/etm_background_jobs_start_test"
+        android:title="@string/etm_background_jobs_start_test_job"
+        app:showAsAction="never"
+        android:showAsAction="never" />
+
+    <item
+        android:id="@+id/etm_background_jobs_cancel_test"
+        android:title="@string/etm_background_jobs_stop_test_job"
+        app:showAsAction="never"
+        android:showAsAction="never" />
+
+</menu>

+ 33 - 0
src/main/res/menu/fragment_etm_migrations.xml

@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    Nextcloud Android client application
+
+    @author Chris Narkiewicz
+    Copyright (C) 2019 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/>.
+-->
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    tools:ignore="AppCompatResource">
+
+    <item
+        android:id="@+id/etm_migrations_delete"
+        android:title="@string/common_delete"
+        app:showAsAction="ifRoom"
+        android:showAsAction="ifRoom"
+        android:icon="@drawable/ic_delete" />
+
+</menu>

+ 12 - 0
src/main/res/values/strings.xml

@@ -884,6 +884,18 @@
     <string name="etm_title">Engineering Test Mode</string>
     <string name="etm_accounts">Accounts</string>
     <string name="etm_preferences">Preferences</string>
+    <string name="etm_background_jobs">Background jobs</string>
+    <string name="etm_background_jobs_cancel_all">Cancel all jobs</string>
+    <string name="etm_background_jobs_prune">Prune inactive jobs</string>
+    <string name="etm_background_jobs_start_test_job">Start test job</string>
+    <string name="etm_background_jobs_stop_test_job">Stop test job</string>
+    <string name="etm_background_job_uuid">UUID</string>
+    <string name="etm_background_job_name">Job name</string>
+    <string name="etm_background_job_user">@string/user_icon</string>
+    <string name="etm_background_job_state">State</string>
+    <string name="etm_background_job_started">Started</string>
+    <string name="etm_background_job_progress">Progress</string>
+    <string name="etm_migrations">Migrations (app upgrade)</string>
 
     <string name="logs_status_loading">Loading…</string>
     <string name="logs_status_filtered">Logs: %1$d kB, query matched %2$d / %3$d in %4$d ms</string>

+ 55 - 2
src/test/java/com/nextcloud/client/etm/TestEtmViewModel.kt

@@ -23,10 +23,17 @@ import android.accounts.AccountManager
 import android.content.SharedPreferences
 import android.content.res.Resources
 import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import androidx.lifecycle.LiveData
 import androidx.lifecycle.Observer
+import com.nextcloud.client.etm.pages.EtmBackgroundJobsFragment
+import com.nextcloud.client.jobs.BackgroundJobManager
+import com.nextcloud.client.jobs.JobInfo
+import com.nextcloud.client.migrations.MigrationsDb
+import com.nextcloud.client.migrations.MigrationsManager
 import com.nhaarman.mockitokotlin2.any
 import com.nhaarman.mockitokotlin2.anyOrNull
 import com.nhaarman.mockitokotlin2.eq
+import com.nhaarman.mockitokotlin2.inOrder
 import com.nhaarman.mockitokotlin2.mock
 import com.nhaarman.mockitokotlin2.never
 import com.nhaarman.mockitokotlin2.reset
@@ -38,6 +45,7 @@ import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertNotNull
 import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
 import org.junit.Assert.assertTrue
 import org.junit.Before
 import org.junit.Rule
@@ -48,7 +56,8 @@ import org.junit.runners.Suite
 @RunWith(Suite::class)
 @Suite.SuiteClasses(
     TestEtmViewModel.MainPage::class,
-    TestEtmViewModel.PreferencesPage::class
+    TestEtmViewModel.PreferencesPage::class,
+    TestEtmViewModel.BackgroundJobsPage::class
 )
 class TestEtmViewModel {
 
@@ -61,14 +70,27 @@ class TestEtmViewModel {
         protected lateinit var sharedPreferences: SharedPreferences
         protected lateinit var vm: EtmViewModel
         protected lateinit var resources: Resources
+        protected lateinit var backgroundJobManager: BackgroundJobManager
+        protected lateinit var migrationsManager: MigrationsManager
+        protected lateinit var migrationsDb: MigrationsDb
 
         @Before
         fun setUpBase() {
             sharedPreferences = mock()
             platformAccountManager = mock()
             resources = mock()
+            backgroundJobManager = mock()
+            migrationsManager = mock()
+            migrationsDb = mock()
             whenever(resources.getString(any())).thenReturn("mock-account-type")
-            vm = EtmViewModel(sharedPreferences, platformAccountManager, resources)
+            vm = EtmViewModel(
+                sharedPreferences,
+                platformAccountManager,
+                resources,
+                backgroundJobManager,
+                migrationsManager,
+                migrationsDb
+            )
         }
     }
 
@@ -207,4 +229,35 @@ class TestEtmViewModel {
             assertEquals("false", prefs["key3"])
         }
     }
+
+    internal class BackgroundJobsPage : Base() {
+        @Before
+        fun setUp() {
+            vm.onPageSelected(EtmViewModel.PAGE_JOBS)
+            assertEquals(EtmBackgroundJobsFragment::class, vm.currentPage.value?.pageClass)
+        }
+
+        @Test
+        fun `prune jobs action is delegated to job manager`() {
+            vm.pruneJobs()
+            verify(backgroundJobManager).pruneJobs()
+        }
+
+        @Test
+        fun `start stop test job actions are delegated to job manager`() {
+            vm.startTestJob()
+            vm.cancelTestJob()
+            inOrder(backgroundJobManager).apply {
+                verify(backgroundJobManager).scheduleTestJob()
+                verify(backgroundJobManager).cancelTestJob()
+            }
+        }
+
+        @Test
+        fun `job info is taken from job manager`() {
+            val jobInfo: LiveData<List<JobInfo>> = mock()
+            whenever(backgroundJobManager.jobs).thenReturn(jobInfo)
+            assertSame(jobInfo, vm.backgroundJobs)
+        }
+    }
 }

+ 19 - 5
src/test/java/com/nextcloud/client/jobs/BackgroundJobFactoryTest.kt

@@ -2,7 +2,7 @@
  * Nextcloud Android client application
  *
  * @author Chris Narkiewicz
- * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ * 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
@@ -17,18 +17,20 @@
  * 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.jobs
 
 import android.content.ContentResolver
 import android.content.Context
+import android.content.res.Resources
 import android.os.Build
 import androidx.work.WorkerParameters
+import com.nextcloud.client.account.UserAccountManager
 import com.nextcloud.client.core.Clock
 import com.nextcloud.client.device.DeviceInfo
 import com.nextcloud.client.device.PowerManagementService
 import com.nextcloud.client.preferences.AppPreferences
 import com.nhaarman.mockitokotlin2.whenever
+import com.owncloud.android.datamodel.ArbitraryDataProvider
 import org.junit.Assert.assertNotNull
 import org.junit.Assert.assertNull
 import org.junit.Before
@@ -63,6 +65,15 @@ class BackgroundJobFactoryTest {
     @Mock
     private lateinit var clock: Clock
 
+    @Mock
+    private lateinit var accountManager: UserAccountManager
+
+    @Mock
+    private lateinit var resources: Resources
+
+    @Mock
+    private lateinit var dataProvider: ArbitraryDataProvider
+
     private lateinit var factory: BackgroundJobFactory
 
     @Before
@@ -74,12 +85,15 @@ class BackgroundJobFactoryTest {
             clock,
             powerManagementService,
             Provider { backgroundJobManager },
-            deviceInfo
+            deviceInfo,
+            accountManager,
+            resources,
+            dataProvider
         )
     }
 
     @Test
-    fun `worker is created on api level 24+`() {
+    fun worker_is_created_on_api_level_24() {
         // GIVEN
         //      api level is > 24
         //      content URI trigger is supported
@@ -95,7 +109,7 @@ class BackgroundJobFactoryTest {
     }
 
     @Test
-    fun `worker is not created below api level 24`() {
+    fun worker_is_not_created_below_api_level_24() {
         // GIVEN
         //      api level is < 24
         //      content URI trigger is not supported

+ 4 - 4
src/test/java/com/nextcloud/client/jobs/ContentObserverWorkTest.kt

@@ -68,7 +68,7 @@ class ContentObserverWorkTest {
     }
 
     @Test
-    fun `job reschedules self after each run unconditionally`() {
+    fun job_reschedules_self_after_each_run_unconditionally() {
         // GIVEN
         //      nothing to sync
         whenever(params.triggeredContentUris).thenReturn(emptyList())
@@ -84,7 +84,7 @@ class ContentObserverWorkTest {
 
     @Test
     @Ignore("TODO: needs further refactoring")
-    fun `sync is triggered`() {
+    fun sync_is_triggered() {
         // GIVEN
         //      power saving is disabled
         //      some folders are configured for syncing
@@ -102,7 +102,7 @@ class ContentObserverWorkTest {
 
     @Test
     @Ignore("TODO: needs further refactoring")
-    fun `sync is not triggered under power saving mode`() {
+    fun sync_is_not_triggered_under_power_saving_mode() {
         // GIVEN
         //      power saving is enabled
         //      some folders are configured for syncing
@@ -120,7 +120,7 @@ class ContentObserverWorkTest {
 
     @Test
     @Ignore("TODO: needs further refactoring")
-    fun `sync is not triggered if no folder are synced`() {
+    fun sync_is_not_triggered_if_no_folder_are_synced() {
         // GIVEN
         //      power saving is disabled
         //      no folders configured for syncing