diff --git a/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt b/src/androidTest/java/com/nextcloud/client/jobs/BackgroundJobManagerTest.kt new file mode 100644 index 0000000000..2c1887dba5 --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.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 { + override fun matches(argument: OneTimeWorkRequest?): Boolean = true + } + + /** + * Used to help with ambiguous type inference + */ + class IsPeriodicWorkRequest : ArgumentMatcher { + 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, 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 : Observer { + 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>() + 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>() + 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 = 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 = 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 + private lateinit var jobInfo: LiveData + private lateinit var request: OneTimeWorkRequest + + @Before + fun setUp() { + val requestCaptor: KArgumentCaptor = 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) + } + } + } +} diff --git a/src/androidTest/java/com/nextcloud/client/migrations/MigrationsDbTest.kt b/src/androidTest/java/com/nextcloud/client/migrations/MigrationsDbTest.kt new file mode 100644 index 0000000000..d94c2b6e4d --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.migrations + +import android.content.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() + 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) + } +} diff --git a/src/androidTest/java/com/nextcloud/client/migrations/MigrationsManagerTest.kt b/src/androidTest/java/com/nextcloud/client/migrations/MigrationsManagerTest.kt index 6a2e59561a..aace566fb0 100644 --- a/src/androidTest/java/com/nextcloud/client/migrations/MigrationsManagerTest.kt +++ b/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 @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() - 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) } } diff --git a/src/androidTest/java/com/nextcloud/client/migrations/MockSharedPreferences.kt b/src/androidTest/java/com/nextcloud/client/migrations/MockSharedPreferences.kt index 4c1bf8b038..8dbf30a49a 100644 --- a/src/androidTest/java/com/nextcloud/client/migrations/MockSharedPreferences.kt +++ b/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 { - throw UnsupportedOperationException() + override fun getAll(): MutableMap { + return HashMap(store) } override fun edit(): SharedPreferences.Editor { diff --git a/src/androidTest/java/com/nextcloud/client/migrations/MockSharedPreferencesTest.kt b/src/androidTest/java/com/nextcloud/client/migrations/MockSharedPreferencesTest.kt index 0598bcea8c..cd7bc7a6d0 100644 --- a/src/androidTest/java/com/nextcloud/client/migrations/MockSharedPreferencesTest.kt +++ b/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) + } } diff --git a/src/main/java/com/nextcloud/client/account/AnonymousUser.kt b/src/main/java/com/nextcloud/client/account/AnonymousUser.kt index 1682fe518d..85184a5160 100644 --- a/src/main/java/com/nextcloud/client/account/AnonymousUser.kt +++ b/src/main/java/com/nextcloud/client/account/AnonymousUser.kt @@ -2,8 +2,8 @@ * Nextcloud Android client application * * @author Chris Narkiewicz - * 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) { diff --git a/src/main/java/com/nextcloud/client/account/RegisteredUser.kt b/src/main/java/com/nextcloud/client/account/RegisteredUser.kt index 8b8ee73cdd..82eee6e340 100644 --- a/src/main/java/com/nextcloud/client/account/RegisteredUser.kt +++ b/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 diff --git a/src/main/java/com/nextcloud/client/account/User.kt b/src/main/java/com/nextcloud/client/account/User.kt index f18f26f263..6f9e73641d 100644 --- a/src/main/java/com/nextcloud/client/account/User.kt +++ b/src/main/java/com/nextcloud/client/account/User.kt @@ -2,8 +2,8 @@ * Nextcloud Android client application * * @author Chris Narkiewicz - * 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 } diff --git a/src/main/java/com/nextcloud/client/di/AppModule.java b/src/main/java/com/nextcloud/client/di/AppModule.java index c689adef55..6d50d1bbe2 100644 --- a/src/main/java/com/nextcloud/client/di/AppModule.java +++ b/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()); } } diff --git a/src/main/java/com/nextcloud/client/di/ComponentsModule.java b/src/main/java/com/nextcloud/client/di/ComponentsModule.java index 64cbf9db57..f5e73f3b1f 100644 --- a/src/main/java/com/nextcloud/client/di/ComponentsModule.java +++ b/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 + * Copyright (C) 2020 Chris Narkiewicz * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -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(); diff --git a/src/main/java/com/nextcloud/client/etm/EtmViewModel.kt b/src/main/java/com/nextcloud/client/etm/EtmViewModel.kt index 60e2b37cad..a9c1bf8e4e 100644 --- a/src/main/java/com/nextcloud/client/etm/EtmViewModel.kt +++ b/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 + * Copyright (C) 2020 Chris Narkiewicz * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -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> get() { + return backgroundJobManager.jobs + } + + val migrationsInfo: List 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() + } } diff --git a/src/main/java/com/nextcloud/client/etm/pages/EtmBackgroundJobsFragment.kt b/src/main/java/com/nextcloud/client/etm/pages/EtmBackgroundJobsFragment.kt new file mode 100644 index 0000000000..9d6542ed79 --- /dev/null +++ b/src/main/java/com/nextcloud/client/etm/pages/EtmBackgroundJobsFragment.kt @@ -0,0 +1,145 @@ +/* + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * 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 . + */ +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() { + + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val uuid = view.findViewById(R.id.etm_background_job_uuid) + val name = view.findViewById(R.id.etm_background_job_name) + val user = view.findViewById(R.id.etm_background_job_user) + val state = view.findViewById(R.id.etm_background_job_state) + val started = view.findViewById(R.id.etm_background_job_started) + val progress = view.findViewById(R.id.etm_background_job_progress) + private val progressRow = view.findViewById(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 = 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) { + adapter.backgroundJobs = backgroundJobs + } +} diff --git a/src/main/java/com/nextcloud/client/etm/pages/EtmMigrations.kt b/src/main/java/com/nextcloud/client/etm/pages/EtmMigrations.kt new file mode 100644 index 0000000000..52e9bb1b57 --- /dev/null +++ b/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() + } +} diff --git a/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt b/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt index 38a6a57c81..517f690b87 100644 --- a/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt +++ b/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 + * Copyright (C) 2020 Chris Narkiewicz * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -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, - 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 + ) + } } diff --git a/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt b/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt index c22d5ef700..f40751183e 100644 --- a/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt +++ b/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 + * Copyright (C) 2020 Chris Narkiewicz * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -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> + /** * 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 + + fun scheduleTestJob() + fun cancelTestJob() + + fun pruneJobs() + fun cancelAllJobs() } diff --git a/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index d68acf9848..df5f52bff4 100644 --- a/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/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 + * Copyright (C) 2020 Chris Narkiewicz * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -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? { + 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() + 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, + 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, + 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 { + 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> + 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 { + 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) } } diff --git a/src/main/java/com/nextcloud/client/jobs/ContactsBackupWork.kt b/src/main/java/com/nextcloud/client/jobs/ContactsBackupWork.kt new file mode 100644 index 0000000000..ddb8a55718 --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.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() + 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 = 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 + } + } + } +} diff --git a/src/main/java/com/nextcloud/client/jobs/JobInfo.kt b/src/main/java/com/nextcloud/client/jobs/JobInfo.kt new file mode 100644 index 0000000000..4a1de45f56 --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.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 +) diff --git a/src/main/java/com/nextcloud/client/jobs/JobsModule.kt b/src/main/java/com/nextcloud/client/jobs/JobsModule.kt index 027874e48a..6be8c5d505 100644 --- a/src/main/java/com/nextcloud/client/jobs/JobsModule.kt +++ b/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 + * Copyright (C) 2020 Chris Narkiewicz * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -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) } } diff --git a/src/main/java/com/nextcloud/client/jobs/TestJob.kt b/src/main/java/com/nextcloud/client/jobs/TestJob.kt new file mode 100644 index 0000000000..0cd0684cd4 --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.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() + } +} diff --git a/src/main/java/com/nextcloud/client/migrations/MigrationInfo.kt b/src/main/java/com/nextcloud/client/migrations/MigrationInfo.kt new file mode 100644 index 0000000000..c7e3b883bd --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.migrations + +data class MigrationInfo(val id: Int, val description: String, val applied: Boolean) diff --git a/src/main/java/com/nextcloud/client/migrations/Migrations.kt b/src/main/java/com/nextcloud/client/migrations/Migrations.kt index 366e09fcb3..11434ca63b 100644 --- a/src/main/java/com/nextcloud/client/migrations/Migrations.kt +++ b/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] */ - data class Step(val id: Int, val description: String, val function: Runnable, val mandatory: Boolean = true) + 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. + */ + 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 = 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") } } } diff --git a/src/main/java/com/nextcloud/client/migrations/MigrationsDb.kt b/src/main/java/com/nextcloud/client/migrations/MigrationsDb.kt new file mode 100644 index 0000000000..f864e05cc9 --- /dev/null +++ b/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 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.migrations + +import android.content.SharedPreferences +import java.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 { + val appliedIdsStr: Set = migrationsDb.getStringSet(DB_KEY_APPLIED_MIGRATIONS, null) ?: TreeSet() + return appliedIdsStr.mapNotNull { + try { + it.toInt() + } catch (_: NumberFormatException) { + null + } + }.sorted() + } + + fun addAppliedMigration(vararg migrations: Int) { + val oldApplied = migrationsDb.getStringSet(DB_KEY_APPLIED_MIGRATIONS, null) ?: TreeSet() + val newApplied = TreeSet().apply { + addAll(oldApplied) + addAll(migrations.map { it.toString() }) + } + migrationsDb.edit().putStringSet(DB_KEY_APPLIED_MIGRATIONS, newApplied).apply() + } + + 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() + } +} diff --git a/src/main/java/com/nextcloud/client/migrations/MigrationsManager.kt b/src/main/java/com/nextcloud/client/migrations/MigrationsManager.kt index 1b154d093b..2bca8c9c65 100644 --- a/src/main/java/com/nextcloud/client/migrations/MigrationsManager.kt +++ b/src/main/java/com/nextcloud/client/migrations/MigrationsManager.kt @@ -57,6 +57,11 @@ interface MigrationsManager { */ val status: LiveData + /** + * Information about all pending and applied migrations + */ + val info: List + /** * Starts application state migration. Migrations will be run in background thread. * Callers can use [status] to monitor migration progress. diff --git a/src/main/java/com/nextcloud/client/migrations/MigrationsManagerImpl.kt b/src/main/java/com/nextcloud/client/migrations/MigrationsManagerImpl.kt index d29c42df56..1c817924bf 100644 --- a/src/main/java/com/nextcloud/client/migrations/MigrationsManagerImpl.kt +++ b/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 ) : 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 = MutableLiveData(Status.UNKNOWN) - override val status: LiveData - - init { - this.status = MutableLiveData(Status.UNKNOWN) - } - - fun getAppliedMigrations(): List { - val appliedIdsStr: Set = migrationsDb.getStringSet(DB_KEY_APPLIED_MIGRATIONS, null) ?: TreeSet() - return appliedIdsStr.mapNotNull { - try { - it.toInt() - } catch (_: NumberFormatException) { - null - } - }.sorted() - } - - fun addAppliedMigration(vararg migrations: Int) { - val oldApplied = migrationsDb.getStringSet(DB_KEY_APPLIED_MIGRATIONS, null) ?: TreeSet() - val newApplied = TreeSet().apply { - addAll(oldApplied) - addAll(migrations.map { it.toString() }) + override val info: List 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).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).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).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).value = Status.APPLIED } } diff --git a/src/main/java/com/owncloud/android/MainApp.java b/src/main/java/com/owncloud/android/MainApp.java index 8b3545c804..b8b7ddbc67 100644 --- a/src/main/java/com/owncloud/android/MainApp.java +++ b/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 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); } } } diff --git a/src/main/java/com/owncloud/android/datamodel/OCFile.java b/src/main/java/com/owncloud/android/datamodel/OCFile.java index ad7ba21bd7..4570fad35d 100644 --- a/src/main/java/com/owncloud/android/datamodel/OCFile.java +++ b/src/main/java/com/owncloud/android/datamodel/OCFile.java @@ -19,7 +19,6 @@ * along with this program. If not, see . * */ - package com.owncloud.android.datamodel; diff --git a/src/main/java/com/owncloud/android/files/BootupBroadcastReceiver.java b/src/main/java/com/owncloud/android/files/BootupBroadcastReceiver.java index 311d45240a..41b477520a 100644 --- a/src/main/java/com/owncloud/android/files/BootupBroadcastReceiver.java +++ b/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 + * Copyright (C) 2020 Chris Narkiewicz * * 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()); } diff --git a/src/main/java/com/owncloud/android/jobs/AccountRemovalJob.java b/src/main/java/com/owncloud/android/jobs/AccountRemovalJob.java index 2e36c39f2d..28e8e4eede 100644 --- a/src/main/java/com/owncloud/android/jobs/AccountRemovalJob.java +++ b/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 + * Copyright (C) 2020 Chris Narkiewicz * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -21,7 +21,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ - 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, diff --git a/src/main/java/com/owncloud/android/jobs/NCJobCreator.java b/src/main/java/com/owncloud/android/jobs/NCJobCreator.java index 901e192b7e..ac36086cb2 100644 --- a/src/main/java/com/owncloud/android/jobs/NCJobCreator.java +++ b/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 + * Copyright (C) 2020 Chris Narkiewicz * *

* 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, diff --git a/src/main/java/com/owncloud/android/ui/activity/ContactsPreferenceActivity.java b/src/main/java/com/owncloud/android/ui/activity/ContactsPreferenceActivity.java index 277fdca593..5e12b187a0 100644 --- a/src/main/java/com/owncloud/android/ui/activity/ContactsPreferenceActivity.java +++ b/src/main/java/com/owncloud/android/ui/activity/ContactsPreferenceActivity.java @@ -2,8 +2,10 @@ * Nextcloud Android client application * * @author Tobias Kaminsky + * @author Chris Narkiewicz * Copyright (C) 2017 Tobias Kaminsky * Copyright (C) 2017 Nextcloud GmbH. + * Copyright (C) 2020 Chris Narkiewicz * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -18,7 +20,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ - 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 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 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); diff --git a/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactsBackupFragment.java b/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactsBackupFragment.java index 6d2a60ed3e..a81ca19291 100644 --- a/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/ContactsBackupFragment.java +++ b/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 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) { + private void setAutomaticBackup(final boolean enabled) { - final ContactsPreferenceActivity contactsPreferenceActivity = (ContactsPreferenceActivity) getActivity(); - - if (bool) { - ContactsPreferenceActivity.startContactBackupJob(contactsPreferenceActivity.getAccount()); + final ContactsPreferenceActivity activity = (ContactsPreferenceActivity) getActivity(); + if (activity == null) { + return; + } + Optional optionalUser = activity.getUser(); + if (!optionalUser.isPresent()) { + return; + } + User user = optionalUser.get(); + if (enabled) { + backgroundJobManager.schedulePeriodicContactsBackup(user); } else { - Optional 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() { diff --git a/src/main/res/drawable/ic_arrow_up.xml b/src/main/res/drawable/ic_arrow_up.xml new file mode 100644 index 0000000000..5f07add222 --- /dev/null +++ b/src/main/res/drawable/ic_arrow_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/main/res/drawable/ic_clock.xml b/src/main/res/drawable/ic_clock.xml new file mode 100644 index 0000000000..2db391e331 --- /dev/null +++ b/src/main/res/drawable/ic_clock.xml @@ -0,0 +1,13 @@ + + + + diff --git a/src/main/res/layout/etm_background_job_list_item.xml b/src/main/res/layout/etm_background_job_list_item.xml new file mode 100644 index 0000000000..1cd78552eb --- /dev/null +++ b/src/main/res/layout/etm_background_job_list_item.xml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/res/layout/fragment_etm_background_jobs.xml b/src/main/res/layout/fragment_etm_background_jobs.xml new file mode 100644 index 0000000000..0cfa7bf013 --- /dev/null +++ b/src/main/res/layout/fragment_etm_background_jobs.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/src/main/res/layout/fragment_etm_migrations.xml b/src/main/res/layout/fragment_etm_migrations.xml new file mode 100644 index 0000000000..45f66c3c2c --- /dev/null +++ b/src/main/res/layout/fragment_etm_migrations.xml @@ -0,0 +1,33 @@ + + + + + + diff --git a/src/main/res/menu/fragment_etm_background_jobs.xml b/src/main/res/menu/fragment_etm_background_jobs.xml new file mode 100644 index 0000000000..dd41353595 --- /dev/null +++ b/src/main/res/menu/fragment_etm_background_jobs.xml @@ -0,0 +1,51 @@ + + +

+ + + + + + + + + + + diff --git a/src/main/res/menu/fragment_etm_migrations.xml b/src/main/res/menu/fragment_etm_migrations.xml new file mode 100644 index 0000000000..af9b75073d --- /dev/null +++ b/src/main/res/menu/fragment_etm_migrations.xml @@ -0,0 +1,33 @@ + + + + + + + diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 1f350e8d5b..cab9560c4f 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -884,6 +884,18 @@ Engineering Test Mode Accounts Preferences + Background jobs + Cancel all jobs + Prune inactive jobs + Start test job + Stop test job + UUID + Job name + @string/user_icon + State + Started + Progress + Migrations (app upgrade) Loading… Logs: %1$d kB, query matched %2$d / %3$d in %4$d ms diff --git a/src/test/java/com/nextcloud/client/etm/TestEtmViewModel.kt b/src/test/java/com/nextcloud/client/etm/TestEtmViewModel.kt index 5423a9ca16..397318f2c3 100644 --- a/src/test/java/com/nextcloud/client/etm/TestEtmViewModel.kt +++ b/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> = mock() + whenever(backgroundJobManager.jobs).thenReturn(jobInfo) + assertSame(jobInfo, vm.backgroundJobs) + } + } } diff --git a/src/test/java/com/nextcloud/client/jobs/BackgroundJobFactoryTest.kt b/src/test/java/com/nextcloud/client/jobs/BackgroundJobFactoryTest.kt index 23a5f88eed..8295b9535d 100644 --- a/src/test/java/com/nextcloud/client/jobs/BackgroundJobFactoryTest.kt +++ b/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 + * Copyright (C) 2020 Chris Narkiewicz * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -17,18 +17,20 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ - 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 diff --git a/src/test/java/com/nextcloud/client/jobs/ContentObserverWorkTest.kt b/src/test/java/com/nextcloud/client/jobs/ContentObserverWorkTest.kt index 16aab6f811..efd2f529ee 100644 --- a/src/test/java/com/nextcloud/client/jobs/ContentObserverWorkTest.kt +++ b/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