Merge pull request #5482 from nextcloud/ezaquarii/migrate-contacts-backup-job-to-worker-api

Migrate contacts backup job to new background job manager API
This commit is contained in:
Tobias Kaminsky 2020-03-12 14:34:16 +01:00 committed by GitHub
commit e1b0b521f9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 2198 additions and 299 deletions

View file

@ -0,0 +1,371 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.jobs
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.test.annotation.UiThreadTest
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.nextcloud.client.account.User
import com.nextcloud.client.core.Clock
import com.nhaarman.mockitokotlin2.KArgumentCaptor
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.argThat
import com.nhaarman.mockitokotlin2.argumentCaptor
import com.nhaarman.mockitokotlin2.eq
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Suite
import org.mockito.ArgumentMatcher
import java.util.Date
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
/**
* When using IDE to run enire Suite, make sure tests are run using Android Instrumentation Test
* runner. By default IDE runs normal JUnit - this is AS problem. One must configure the
* test run manually.
*/
@RunWith(Suite::class)
@Suite.SuiteClasses(
BackgroundJobManagerTest.Manager::class,
BackgroundJobManagerTest.ContentObserver::class,
BackgroundJobManagerTest.PeriodicContactsBackup::class,
BackgroundJobManagerTest.ImmediateContactsBackup::class,
BackgroundJobManagerTest.Tags::class
)
class BackgroundJobManagerTest {
/**
* Used to help with ambiguous type inference
*/
class IsOneTimeWorkRequest : ArgumentMatcher<OneTimeWorkRequest> {
override fun matches(argument: OneTimeWorkRequest?): Boolean = true
}
/**
* Used to help with ambiguous type inference
*/
class IsPeriodicWorkRequest : ArgumentMatcher<PeriodicWorkRequest> {
override fun matches(argument: PeriodicWorkRequest?): Boolean = true
}
abstract class Fixture {
companion object {
internal const val USER_ACCOUNT_NAME = "user@nextcloud"
internal val TIMESTAMP = System.currentTimeMillis()
}
internal lateinit var user: User
internal lateinit var workManager: WorkManager
internal lateinit var clock: Clock
internal lateinit var backgroundJobManager: BackgroundJobManagerImpl
@Before
fun setUpFixture() {
user = mock()
whenever(user.accountName).thenReturn(USER_ACCOUNT_NAME)
workManager = mock()
clock = mock()
whenever(clock.currentTime).thenReturn(TIMESTAMP)
whenever(clock.currentDate).thenReturn(Date(TIMESTAMP))
backgroundJobManager = BackgroundJobManagerImpl(workManager, clock)
}
fun assertHasRequiredTags(tags: Set<String>, jobName: String, user: User? = null) {
assertTrue("""'all' tag is mandatory""", tags.contains("*"))
assertTrue("name tag is mandatory", tags.contains(BackgroundJobManagerImpl.formatNameTag(jobName, user)))
assertTrue("timestamp tag is mandatory", tags.contains(BackgroundJobManagerImpl.formatTimeTag(TIMESTAMP)))
if (user != null) {
assertTrue("user tag is mandatory", tags.contains(BackgroundJobManagerImpl.formatUserTag(user)))
}
}
fun buildWorkInfo(index: Long): WorkInfo = WorkInfo(
UUID.randomUUID(),
WorkInfo.State.RUNNING,
Data.Builder().build(),
listOf(BackgroundJobManagerImpl.formatTimeTag(1581820284000)),
Data.Builder().build(),
1
)
}
class Manager : Fixture() {
class SyncObserver<T> : Observer<T> {
val latch = CountDownLatch(1)
var value: T? = null
override fun onChanged(t: T) {
value = t
latch.countDown()
}
fun getValue(timeout: Long = 3, timeUnit: TimeUnit = TimeUnit.SECONDS): T? {
val result = latch.await(timeout, timeUnit)
if (!result) {
throw TimeoutException()
}
return value
}
}
@Test
@UiThreadTest
fun get_all_job_info() {
// GIVEN
// work manager has 2 registered workers
val platformWorkInfo = listOf(
buildWorkInfo(0),
buildWorkInfo(1),
buildWorkInfo(2)
)
val lv = MutableLiveData<List<WorkInfo>>()
lv.value = platformWorkInfo
whenever(workManager.getWorkInfosByTagLiveData(eq("*"))).thenReturn(lv)
// WHEN
// job info for all jobs is requested
val jobs = backgroundJobManager.jobs
// THEN
// live data with job info is returned
// live data contains 2 job info instances
// job info is sorted by timestamp from newest to oldest
assertNotNull(jobs)
val observer = SyncObserver<List<JobInfo>>()
jobs.observeForever(observer)
val jobInfo = observer.getValue()
assertNotNull(jobInfo)
assertEquals(platformWorkInfo.size, jobInfo?.size)
jobInfo?.let {
assertEquals(platformWorkInfo[2].id, it[0].id)
assertEquals(platformWorkInfo[1].id, it[1].id)
assertEquals(platformWorkInfo[0].id, it[2].id)
}
}
@Test
fun cancel_all_jobs() {
// WHEN
// all jobs are cancelled
backgroundJobManager.cancelAllJobs()
// THEN
// all jobs with * tag are cancelled
verify(workManager).cancelAllWorkByTag(BackgroundJobManagerImpl.TAG_ALL)
}
}
class ContentObserver : Fixture() {
private lateinit var request: OneTimeWorkRequest
@Before
fun setUp() {
val requestCaptor: KArgumentCaptor<OneTimeWorkRequest> = argumentCaptor()
backgroundJobManager.scheduleContentObserverJob()
verify(workManager).enqueueUniqueWork(
any(),
any(),
requestCaptor.capture()
)
assertEquals(1, requestCaptor.allValues.size)
request = requestCaptor.firstValue
}
@Test
fun job_is_unique() {
verify(workManager).enqueueUniqueWork(
eq(BackgroundJobManagerImpl.JOB_CONTENT_OBSERVER),
eq(ExistingWorkPolicy.KEEP),
argThat(IsOneTimeWorkRequest())
)
}
@Test
fun job_request_has_mandatory_tags() {
assertHasRequiredTags(request.tags, BackgroundJobManagerImpl.JOB_CONTENT_OBSERVER)
}
}
class PeriodicContactsBackup : Fixture() {
private lateinit var request: PeriodicWorkRequest
@Before
fun setUp() {
val requestCaptor: KArgumentCaptor<PeriodicWorkRequest> = argumentCaptor()
backgroundJobManager.schedulePeriodicContactsBackup(user)
verify(workManager).enqueueUniquePeriodicWork(
any(),
any(),
requestCaptor.capture()
)
assertEquals(1, requestCaptor.allValues.size)
request = requestCaptor.firstValue
}
@Test
fun job_is_unique_for_user() {
verify(workManager).enqueueUniquePeriodicWork(
eq(BackgroundJobManagerImpl.JOB_PERIODIC_CONTACTS_BACKUP),
eq(ExistingPeriodicWorkPolicy.KEEP),
argThat(IsPeriodicWorkRequest())
)
}
@Test
fun job_request_has_mandatory_tags() {
assertHasRequiredTags(request.tags, BackgroundJobManagerImpl.JOB_PERIODIC_CONTACTS_BACKUP, user)
}
}
class ImmediateContactsBackup : Fixture() {
private lateinit var workInfo: MutableLiveData<WorkInfo>
private lateinit var jobInfo: LiveData<JobInfo?>
private lateinit var request: OneTimeWorkRequest
@Before
fun setUp() {
val requestCaptor: KArgumentCaptor<OneTimeWorkRequest> = argumentCaptor()
workInfo = MutableLiveData()
whenever(workManager.getWorkInfoByIdLiveData(any())).thenReturn(workInfo)
jobInfo = backgroundJobManager.startImmediateContactsBackup(user)
verify(workManager).enqueueUniqueWork(
any(),
any(),
requestCaptor.capture()
)
assertEquals(1, requestCaptor.allValues.size)
request = requestCaptor.firstValue
}
@Test
fun job_is_unique_for_user() {
verify(workManager).enqueueUniqueWork(
eq(BackgroundJobManagerImpl.JOB_IMMEDIATE_CONTACTS_BACKUP),
eq(ExistingWorkPolicy.KEEP),
argThat(IsOneTimeWorkRequest())
)
}
@Test
fun job_request_has_mandatory_tags() {
assertHasRequiredTags(request.tags, BackgroundJobManagerImpl.JOB_IMMEDIATE_CONTACTS_BACKUP, user)
}
@Test
@UiThreadTest
fun job_info_is_obtained_from_work_info() {
// GIVEN
// work info is available
workInfo.value = buildWorkInfo(0)
// WHEN
// job info has listener
jobInfo.observeForever {}
// THEN
// converted value is available
assertNotNull(jobInfo.value)
assertEquals(workInfo.value?.id, jobInfo.value?.id)
}
}
class Tags {
@Test
fun split_tag_key_and_value() {
// GIVEN
// valid tag
// tag has colons in value part
val tag = "${BackgroundJobManagerImpl.TAG_PREFIX_NAME}:value:with:colons and spaces"
// WHEN
// tag is parsed
val parsedTag = BackgroundJobManagerImpl.parseTag(tag)
// THEN
// key-value pair is returned
// key is first
// value with colons is second
assertNotNull(parsedTag)
assertEquals(BackgroundJobManagerImpl.TAG_PREFIX_NAME, parsedTag?.first)
assertEquals("value:with:colons and spaces", parsedTag?.second)
}
@Test
fun tags_with_invalid_prefixes_are_rejected() {
// GIVEN
// tag prefix is not on allowed prefixes list
val tag = "invalidprefix:value"
BackgroundJobManagerImpl.PREFIXES.forEach {
assertFalse(tag.startsWith(it))
}
// WHEN
// tag is parsed
val parsedTag = BackgroundJobManagerImpl.parseTag(tag)
// THEN
// tag is rejected
assertNull(parsedTag)
}
@Test
fun strings_without_colon_are_rejected() {
// GIVEN
// strings that are not tags
val tags = listOf(
BackgroundJobManagerImpl.TAG_ALL,
BackgroundJobManagerImpl.TAG_PREFIX_NAME,
"simplestring",
""
)
tags.forEach {
// WHEN
// string is parsed
val parsedTag = BackgroundJobManagerImpl.parseTag(it)
// THEN
// tag is rejected
assertNull(parsedTag)
}
}
}
}

View file

@ -0,0 +1,129 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.migrations
import android.content.Context
import android.content.SharedPreferences
import androidx.test.platform.app.InstrumentationRegistry
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.eq
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.whenever
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class MigrationsDbTest {
private lateinit var context: Context
private lateinit var store: MockSharedPreferences
private lateinit var db: MigrationsDb
@Before
fun setUp() {
context = InstrumentationRegistry.getInstrumentation().context
store = MockSharedPreferences()
assertTrue("State from previous test run found?", store.all.isEmpty())
db = MigrationsDb(store)
}
@Test
fun applied_migrations_are_returned_in_order() {
// GIVEN
// some migrations are marked as applied
// migration ids are stored in random order
val mockStore: SharedPreferences = mock()
val storedMigrationIds = LinkedHashSet<String>()
storedMigrationIds.apply {
add("3")
add("0")
add("2")
add("1")
}
whenever(mockStore.getStringSet(eq(MigrationsDb.DB_KEY_APPLIED_MIGRATIONS), any()))
.thenReturn(storedMigrationIds)
// WHEN
// applied migration ids are retrieved
val db = MigrationsDb(mockStore)
val ids = db.getAppliedMigrations()
// THEN
// returned list is sorted
assertEquals(ids, ids.sorted())
}
@Test
@Suppress("MagicNumber")
fun registering_new_applied_migration_preserves_old_ids() {
// WHEN
// some applied migrations are registered
val appliedMigrationIds = setOf("0", "1", "2")
store.edit().putStringSet(MigrationsDb.DB_KEY_APPLIED_MIGRATIONS, appliedMigrationIds).apply()
// WHEN
// new set of migration ids are registered
// some ids are added again
db.addAppliedMigration(2, 3, 4)
// THEN
// new ids are appended to set of existing ids
val expectedIds = setOf("0", "1", "2", "3", "4")
val storedIds = store.getStringSet(MigrationsDb.DB_KEY_APPLIED_MIGRATIONS, mutableSetOf())
assertEquals(expectedIds, storedIds)
}
@Test
fun failed_status_sets_status_flag_and_error_message() {
// GIVEN
// failure flag is not set
assertFalse(db.isFailed)
// WHEN
// failure status is set
val failureReason = "error message"
db.setFailed(0, failureReason)
// THEN
// failed flag is set
// error message is set
assertTrue(db.isFailed)
assertEquals(failureReason, db.failureReason)
}
@Test
fun last_migrated_version_is_set() {
// GIVEN
// last migrated version is not set
val oldVersion = db.lastMigratedVersion
assertEquals(MigrationsDb.NO_LAST_MIGRATED_VERSION, oldVersion)
// WHEN
// migrated version is set to a new value
val newVersion = 200
db.lastMigratedVersion = newVersion
// THEN
// new value is stored
assertEquals(newVersion, db.lastMigratedVersion)
}
}

View file

@ -20,9 +20,9 @@
package com.nextcloud.client.migrations
import androidx.test.annotation.UiThreadTest
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.appinfo.AppInfo
import com.nextcloud.client.core.ManualAsyncRunner
import com.nhaarman.mockitokotlin2.inOrder
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.never
import com.nhaarman.mockitokotlin2.verify
@ -34,8 +34,6 @@ import org.junit.Before
import org.junit.Test
import org.mockito.Mock
import org.mockito.MockitoAnnotations
import java.lang.RuntimeException
import java.util.LinkedHashSet
class MigrationsManagerTest {
@ -44,15 +42,16 @@ class MigrationsManagerTest {
const val NEW_APP_VERSION = 42
}
lateinit var migrationStep1: Runnable
lateinit var migrationStep2: Runnable
lateinit var migrationStep3: Runnable
lateinit var migrations: List<Migrations.Step>
@Mock
lateinit var appInfo: AppInfo
lateinit var migrationsDb: MockSharedPreferences
@Mock
lateinit var userAccountManager: UserAccountManager
lateinit var migrationsDbStore: MockSharedPreferences
lateinit var migrationsDb: MigrationsDb
lateinit var asyncRunner: ManualAsyncRunner
@ -61,16 +60,30 @@ class MigrationsManagerTest {
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
val migrationStep1: Runnable = mock()
val migrationStep2: Runnable = mock()
val migrationStep3: Runnable = mock()
migrationStep1 = mock()
migrationStep2 = mock()
migrationStep3 = mock()
migrations = listOf(
Migrations.Step(0, "first migration", migrationStep1, true),
Migrations.Step(1, "second migration", migrationStep2, true),
Migrations.Step(2, "third optional migration", migrationStep3, false)
object : Migrations.Step(0, "first migration", true) {
override fun run() {
migrationStep1.run()
}
},
object : Migrations.Step(1, "second optional migration", false) {
override fun run() {
migrationStep2.run()
}
},
object : Migrations.Step(2, "third migration", true) {
override fun run() {
migrationStep3.run()
}
}
)
asyncRunner = ManualAsyncRunner()
migrationsDb = MockSharedPreferences()
migrationsDbStore = MockSharedPreferences()
migrationsDb = MigrationsDb(migrationsDbStore)
whenever(appInfo.versionCode).thenReturn(NEW_APP_VERSION)
migrationsManager = MigrationsManagerImpl(
appInfo = appInfo,
@ -80,6 +93,14 @@ class MigrationsManagerTest {
)
}
private fun assertMigrationsRun(vararg migrationSteps: Runnable) {
inOrder(migrationSteps).apply {
migrationSteps.forEach {
verify(it.run())
}
}
}
@Test
fun inital_status_is_unknown() {
// GIVEN
@ -90,54 +111,12 @@ class MigrationsManagerTest {
assertEquals(MigrationsManager.Status.UNKNOWN, migrationsManager.status.value)
}
@Test
fun applied_migrations_are_returned_in_order() {
// GIVEN
// some migrations are marked as applied
// migration ids are stored in random order
val storedMigrationIds = LinkedHashSet<String>()
storedMigrationIds.apply {
add("3")
add("0")
add("2")
add("1")
}
migrationsDb.store.put(MigrationsManagerImpl.DB_KEY_APPLIED_MIGRATIONS, storedMigrationIds)
// WHEN
// applied migration ids are retrieved
val ids = migrationsManager.getAppliedMigrations()
// THEN
// returned list is sorted
assertEquals(ids, ids.sorted())
}
@Test
@Suppress("MagicNumber")
fun registering_new_applied_migration_preserves_old_ids() {
// WHEN
// some applied migrations are registered
val appliedMigrationIds = setOf("0", "1", "2")
migrationsDb.store.put(MigrationsManagerImpl.DB_KEY_APPLIED_MIGRATIONS, appliedMigrationIds)
// WHEN
// new set of migration ids are registered
// some ids are added again
migrationsManager.addAppliedMigration(2, 3, 4)
// THEN
// new ids are appended to set of existing ids
val expectedIds = setOf("0", "1", "2", "3", "4")
assertEquals(expectedIds, migrationsDb.store.get(MigrationsManagerImpl.DB_KEY_APPLIED_MIGRATIONS))
}
@Test
@UiThreadTest
fun migrations_are_scheduled_on_background_thread() {
// GIVEN
// migrations can be applied
assertEquals(0, migrationsManager.getAppliedMigrations().size)
assertEquals(0, migrationsDb.getAppliedMigrations().size)
// WHEN
// migration is started
@ -158,11 +137,10 @@ class MigrationsManagerTest {
// no migrations are applied yet
// current app version is newer then last recorded migrated version
whenever(appInfo.versionCode).thenReturn(NEW_APP_VERSION)
migrationsDb.store.put(MigrationsManagerImpl.DB_KEY_LAST_MIGRATED_VERSION, OLD_APP_VERSION)
migrationsDb.lastMigratedVersion = OLD_APP_VERSION
// WHEN
// migration is run
whenever(userAccountManager.migrateUserId()).thenReturn(true)
val count = migrationsManager.startMigration()
assertTrue(asyncRunner.runOne())
@ -172,9 +150,14 @@ class MigrationsManagerTest {
// applied migrations are recorded
// new app version code is recorded
assertEquals(migrations.size, count)
val allAppliedIds = migrations.map { it.id.toString() }.toSet()
assertEquals(allAppliedIds, migrationsDb.store.get(MigrationsManagerImpl.DB_KEY_APPLIED_MIGRATIONS))
assertEquals(NEW_APP_VERSION, migrationsDb.store.get(MigrationsManagerImpl.DB_KEY_LAST_MIGRATED_VERSION))
inOrder(migrationStep1, migrationStep2, migrationStep3).apply {
verify(migrationStep1).run()
verify(migrationStep2).run()
verify(migrationStep3).run()
}
val allAppliedIds = migrations.map { it.id }
assertEquals(allAppliedIds, migrationsDb.getAppliedMigrations())
assertEquals(NEW_APP_VERSION, migrationsDb.lastMigratedVersion)
}
@Test
@ -182,13 +165,16 @@ class MigrationsManagerTest {
fun migration_error_is_recorded() {
// GIVEN
// no migrations applied yet
// no prior failed migrations
assertFalse(migrationsDb.isFailed)
assertEquals(MigrationsDb.NO_FAILED_MIGRATION_ID, migrationsDb.failedMigrationId)
// WHEN
// migrations are applied
// one migration throws
val lastMigration = migrations.findLast { it.mandatory } ?: throw IllegalStateException("Test fixture error")
val errorMessage = "error message"
whenever(lastMigration.function.run()).thenThrow(RuntimeException(errorMessage))
whenever(lastMigration.run()).thenThrow(RuntimeException(errorMessage))
migrationsManager.startMigration()
assertTrue(asyncRunner.runOne())
@ -197,15 +183,9 @@ class MigrationsManagerTest {
// failure message is recorded
// failed migration id is recorded
assertEquals(MigrationsManager.Status.FAILED, migrationsManager.status.value)
assertTrue(migrationsDb.getBoolean(MigrationsManagerImpl.DB_KEY_FAILED, false))
assertEquals(
errorMessage,
migrationsDb.getString(MigrationsManagerImpl.DB_KEY_FAILED_MIGRATION_ERROR_MESSAGE, "")
)
assertEquals(
lastMigration.id,
migrationsDb.getInt(MigrationsManagerImpl.DB_KEY_FAILED_MIGRATION_ID, -1)
)
assertTrue(migrationsDb.isFailed)
assertEquals(errorMessage, migrationsDb.failureReason)
assertEquals(lastMigration.id, migrationsDb.failedMigrationId)
}
@Test
@ -214,7 +194,7 @@ class MigrationsManagerTest {
// GIVEN
// migrations were already run for the current app version
whenever(appInfo.versionCode).thenReturn(NEW_APP_VERSION)
migrationsDb.store.put(MigrationsManagerImpl.DB_KEY_LAST_MIGRATED_VERSION, NEW_APP_VERSION)
migrationsDb.lastMigratedVersion = NEW_APP_VERSION
// WHEN
// app is migrated again
@ -224,8 +204,8 @@ class MigrationsManagerTest {
// migration processing is skipped entirely
// status is set to applied
assertEquals(0, migrationCount)
migrations.forEach {
verify(it.function, never()).run()
listOf(migrationStep1, migrationStep2, migrationStep3).forEach {
verify(it, never()).run()
}
assertEquals(MigrationsManager.Status.APPLIED, migrationsManager.status.value)
}
@ -237,9 +217,10 @@ class MigrationsManagerTest {
// migrations were applied in previous version
// new version has no new migrations
whenever(appInfo.versionCode).thenReturn(NEW_APP_VERSION)
migrationsDb.store.put(MigrationsManagerImpl.DB_KEY_LAST_MIGRATED_VERSION, OLD_APP_VERSION)
val applied = migrations.map { it.id.toString() }.toSet()
migrationsDb.store.put(MigrationsManagerImpl.DB_KEY_APPLIED_MIGRATIONS, applied)
migrationsDb.lastMigratedVersion = OLD_APP_VERSION
migrations.forEach {
migrationsDb.addAppliedMigration(it.id)
}
// WHEN
// migration is started
@ -251,7 +232,7 @@ class MigrationsManagerTest {
assertEquals(0, startedCount)
assertEquals(
NEW_APP_VERSION,
migrationsDb.getInt(MigrationsManagerImpl.DB_KEY_LAST_MIGRATED_VERSION, -1)
migrationsDb.lastMigratedVersion
)
}
@ -262,8 +243,12 @@ class MigrationsManagerTest {
// pending migrations
// mandatory migrations are passing
// one migration is optional and fails
assertEquals("Fixture should provide 1 optional, failing migration",
1,
migrations.count { !it.mandatory }
)
val optionalFailingMigration = migrations.first { !it.mandatory }
whenever(optionalFailingMigration.function.run()).thenThrow(RuntimeException())
whenever(optionalFailingMigration.run()).thenThrow(RuntimeException())
// WHEN
// migration is started
@ -277,12 +262,10 @@ class MigrationsManagerTest {
// no error
// status is applied
// failed migration is available during next migration
val appliedMigrations = migrations.filter { it.mandatory }
.map { it.id.toString() }
.toSet()
val appliedMigrations = migrations.filter { it.mandatory }.map { it.id }
assertTrue("Fixture error", appliedMigrations.isNotEmpty())
assertEquals(appliedMigrations, migrationsDb.store.get(MigrationsManagerImpl.DB_KEY_APPLIED_MIGRATIONS))
assertFalse(migrationsDb.getBoolean(MigrationsManagerImpl.DB_KEY_FAILED, false))
assertEquals(appliedMigrations, migrationsDb.getAppliedMigrations())
assertFalse(migrationsDb.isFailed)
assertEquals(MigrationsManager.Status.APPLIED, migrationsManager.status.value)
}
}

View file

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

View file

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

View file

@ -2,8 +2,8 @@
* Nextcloud Android client application
*
* @author Chris Narkiewicz <hello@ezaquarii.com>
* Copyright (C) 2019 Chris Narkiewicz
* Copyright (C) 2019 Nextcloud GmbH
* Copyright (C) 2020 Chris Narkiewicz
* Copyright (C) 2020 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@ -72,6 +72,10 @@ internal data class AnonymousUser(private val accountType: String) : User, Parce
return user?.accountName.equals(accountName, true)
}
override fun nameEquals(accountName: CharSequence?): Boolean {
return accountName?.toString().equals(this.accountType, true)
}
override fun describeContents() = 0
override fun writeToParcel(dest: Parcel, flags: Int) = with(dest) {

View file

@ -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

View file

@ -2,8 +2,8 @@
* Nextcloud Android client application
*
* @author Chris Narkiewicz <hello@ezaquarii.com>
* Copyright (C) 2019 Chris Narkiewicz
* Copyright (C) 2019 Nextcloud GmbH
* Copyright (C) 2020 Chris Narkiewicz
* Copyright (C) 2020 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@ -59,4 +59,11 @@ interface User : Parcelable {
* @return true if account names are same, false otherwise
*/
fun nameEquals(user: User?): Boolean
/**
* Compare account names, case insensitive.
*
* @return true if account names are same, false otherwise
*/
fun nameEquals(accountName: CharSequence?): Boolean
}

View file

@ -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());
}
}

View file

@ -2,7 +2,7 @@
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@ -74,6 +74,7 @@ import com.owncloud.android.ui.fragment.LocalFileListFragment;
import com.owncloud.android.ui.fragment.OCFileListFragment;
import com.owncloud.android.ui.fragment.PhotoFragment;
import com.owncloud.android.ui.fragment.contactsbackup.ContactListFragment;
import com.owncloud.android.ui.fragment.contactsbackup.ContactsBackupFragment;
import com.owncloud.android.ui.preview.PreviewImageActivity;
import com.owncloud.android.ui.preview.PreviewImageFragment;
import com.owncloud.android.ui.preview.PreviewMediaFragment;
@ -138,8 +139,8 @@ abstract class ComponentsModule {
@ContributesAndroidInjector abstract FileDetailSharingFragment fileDetailSharingFragment();
@ContributesAndroidInjector abstract ChooseTemplateDialogFragment chooseTemplateDialogFragment();
@ContributesAndroidInjector
abstract ChooseRichDocumentsTemplateDialogFragment chooseRichDocumentsTemplateDialogFragment();
@ContributesAndroidInjector abstract ChooseRichDocumentsTemplateDialogFragment chooseRichDocumentsTemplateDialogFragment();
@ContributesAndroidInjector abstract ContactsBackupFragment contactsBackupFragment();
@ContributesAndroidInjector abstract PreviewImageFragment previewImageFragment();
@ContributesAndroidInjector abstract ContactListFragment chooseContactListFragment();
@ContributesAndroidInjector abstract PreviewMediaFragment previewMediaFragment();

View file

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

View file

@ -0,0 +1,145 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz <hello@ezaquarii.com>
* Copyright (C) 2020 Chris Narkiewicz
* Copyright (C) 2020 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.etm.pages
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.nextcloud.client.etm.EtmBaseFragment
import com.nextcloud.client.jobs.JobInfo
import com.owncloud.android.R
import java.text.SimpleDateFormat
import java.util.Locale
class EtmBackgroundJobsFragment : EtmBaseFragment() {
class Adapter(private val inflater: LayoutInflater) : RecyclerView.Adapter<Adapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val uuid = view.findViewById<TextView>(R.id.etm_background_job_uuid)
val name = view.findViewById<TextView>(R.id.etm_background_job_name)
val user = view.findViewById<TextView>(R.id.etm_background_job_user)
val state = view.findViewById<TextView>(R.id.etm_background_job_state)
val started = view.findViewById<TextView>(R.id.etm_background_job_started)
val progress = view.findViewById<TextView>(R.id.etm_background_job_progress)
private val progressRow = view.findViewById<View>(R.id.etm_background_job_progress_row)
var progressEnabled: Boolean = progressRow.visibility == View.VISIBLE
get() {
return progressRow.visibility == View.VISIBLE
}
set(value) {
field = value
progressRow.visibility = if (value) {
View.VISIBLE
} else {
View.GONE
}
}
}
private val dateFormat = SimpleDateFormat("YYYY-MM-dd HH:MM:ssZ", Locale.getDefault())
var backgroundJobs: List<JobInfo> = emptyList()
set(value) {
field = value
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = inflater.inflate(R.layout.etm_background_job_list_item, parent, false)
return ViewHolder(view)
}
override fun getItemCount(): Int {
return backgroundJobs.size
}
override fun onBindViewHolder(vh: ViewHolder, position: Int) {
val info = backgroundJobs[position]
vh.uuid.text = info.id.toString()
vh.name.text = info.name
vh.user.text = info.user
vh.state.text = info.state
vh.started.text = dateFormat.format(info.started)
if (info.progress >= 0) {
vh.progressEnabled = true
vh.progress.text = info.progress.toString()
} else {
vh.progressEnabled = false
}
}
}
private lateinit var list: RecyclerView
private lateinit var adapter: Adapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_etm_background_jobs, container, false)
adapter = Adapter(inflater)
list = view.findViewById(R.id.etm_background_jobs_list)
list.layoutManager = LinearLayoutManager(context)
list.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
list.adapter = adapter
vm.backgroundJobs.observe(this, Observer { onBackgroundJobsUpdated(it) })
return view
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.fragment_etm_background_jobs, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.etm_background_jobs_cancel -> {
vm.cancelAllJobs(); true
}
R.id.etm_background_jobs_prune -> {
vm.pruneJobs(); true
}
R.id.etm_background_jobs_start_test -> {
vm.startTestJob(); true
}
R.id.etm_background_jobs_cancel_test -> {
vm.cancelTestJob(); true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun onBackgroundJobsUpdated(backgroundJobs: List<JobInfo>) {
adapter.backgroundJobs = backgroundJobs
}
}

View file

@ -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()
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,260 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.jobs
import android.content.ComponentName
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.res.Resources
import android.database.Cursor
import android.net.Uri
import android.os.IBinder
import android.provider.ContactsContract
import android.text.TextUtils
import android.text.format.DateFormat
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.nextcloud.client.account.User
import com.nextcloud.client.account.UserAccountManager
import com.owncloud.android.R
import com.owncloud.android.datamodel.ArbitraryDataProvider
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.files.services.FileUploader
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.operations.UploadFileOperation
import com.owncloud.android.services.OperationsService
import com.owncloud.android.services.OperationsService.OperationsServiceBinder
import com.owncloud.android.ui.activity.ContactsPreferenceActivity
import ezvcard.Ezvcard
import ezvcard.VCardVersion
import java.io.File
import java.io.FileWriter
import java.io.IOException
import java.io.InputStream
import java.io.InputStreamReader
import java.util.ArrayList
import java.util.Calendar
class ContactsBackupWork(
appContext: Context,
params: WorkerParameters,
private val resources: Resources,
private val arbitraryDataProvider: ArbitraryDataProvider,
private val contentResolver: ContentResolver,
private val accountManager: UserAccountManager
) : Worker(appContext, params) {
companion object {
val TAG = ContactsBackupWork::class.java.simpleName
const val ACCOUNT = "account"
const val FORCE = "force"
const val JOB_INTERVAL_MS: Long = 24 * 60 * 60 * 1000
const val BUFFER_SIZE = 1024
}
private var operationsServiceConnection: OperationsServiceConnection? = null
private var operationsServiceBinder: OperationsServiceBinder? = null
@Suppress("ReturnCount") // pre-existing issue
override fun doWork(): Result {
val accountName = inputData.getString(ACCOUNT) ?: ""
if (TextUtils.isEmpty(accountName)) { // no account provided
return Result.failure()
}
val optionalUser = accountManager.getUser(accountName)
if (!optionalUser.isPresent) {
return Result.failure()
}
val user = optionalUser.get()
val lastExecution = arbitraryDataProvider.getLongValue(user.toPlatformAccount(),
ContactsPreferenceActivity.PREFERENCE_CONTACTS_LAST_BACKUP)
val force = inputData.getBoolean(FORCE, false)
if (force || lastExecution + JOB_INTERVAL_MS < Calendar.getInstance().timeInMillis) {
Log_OC.d(TAG, "start contacts backup job")
val backupFolder: String = resources.getString(R.string.contacts_backup_folder) + OCFile.PATH_SEPARATOR
val daysToExpire: Int = applicationContext.getResources().getInteger(R.integer.contacts_backup_expire)
backupContact(user, backupFolder)
// bind to Operations Service
operationsServiceConnection = OperationsServiceConnection(
this,
daysToExpire,
backupFolder,
user
)
applicationContext.bindService(
Intent(applicationContext, OperationsService::class.java),
operationsServiceConnection as OperationsServiceConnection,
OperationsService.BIND_AUTO_CREATE
)
// store execution date
arbitraryDataProvider.storeOrUpdateKeyValue(
user.accountName,
ContactsPreferenceActivity.PREFERENCE_CONTACTS_LAST_BACKUP,
Calendar.getInstance().timeInMillis
)
} else {
Log_OC.d(TAG, "last execution less than 24h ago")
}
return Result.success()
}
private fun backupContact(user: User, backupFolder: String) {
val vCard = ArrayList<String>()
val cursor = contentResolver.query(ContactsContract.Contacts.CONTENT_URI, null,
null, null, null)
if (cursor != null && cursor.count > 0) {
cursor.moveToFirst()
for (i in 0 until cursor.count) {
vCard.add(getContactFromCursor(cursor))
cursor.moveToNext()
}
}
val filename = DateFormat.format("yyyy-MM-dd_HH-mm-ss", Calendar.getInstance()).toString() + ".vcf"
Log_OC.d(TAG, "Storing: $filename")
val file = File(applicationContext.getCacheDir(), filename)
var fw: FileWriter? = null
try {
fw = FileWriter(file)
for (card in vCard) {
fw.write(card)
}
} catch (e: IOException) {
Log_OC.d(TAG, "Error ", e)
} finally {
cursor?.close()
if (fw != null) {
try {
fw.close()
} catch (e: IOException) {
Log_OC.d(TAG, "Error closing file writer ", e)
}
}
}
FileUploader.uploadNewFile(
applicationContext,
user.toPlatformAccount(),
file.absolutePath,
backupFolder + filename,
FileUploader.LOCAL_BEHAVIOUR_MOVE,
null,
true,
UploadFileOperation.CREATED_BY_USER,
false,
false,
FileUploader.NameCollisionPolicy.ASK_USER
)
}
private fun expireFiles(daysToExpire: Int, backupFolderString: String, account: User) { // -1 disables expiration
if (daysToExpire > -1) {
val storageManager = FileDataStorageManager(account.toPlatformAccount(),
applicationContext.getContentResolver())
val backupFolder: OCFile = storageManager.getFileByPath(backupFolderString)
val cal = Calendar.getInstance()
cal.add(Calendar.DAY_OF_YEAR, -daysToExpire)
val timestampToExpire = cal.timeInMillis
if (backupFolder != null) {
Log_OC.d(TAG, "expire: " + daysToExpire + " " + backupFolder.fileName)
}
val backups: List<OCFile> = storageManager.getFolderContent(backupFolder, false)
for (backup in backups) {
if (timestampToExpire > backup.modificationTimestamp) {
Log_OC.d(TAG, "delete " + backup.remotePath)
// delete backups
val service = Intent(applicationContext, OperationsService::class.java)
service.action = OperationsService.ACTION_REMOVE
service.putExtra(OperationsService.EXTRA_ACCOUNT, account)
service.putExtra(OperationsService.EXTRA_REMOTE_PATH, backup.remotePath)
service.putExtra(OperationsService.EXTRA_REMOVE_ONLY_LOCAL, false)
operationsServiceBinder!!.queueNewOperation(service)
}
}
}
operationsServiceConnection?.let {
applicationContext.unbindService(it)
}
}
@Suppress("NestedBlockDepth")
private fun getContactFromCursor(cursor: Cursor): String {
val lookupKey = cursor.getString(cursor.getColumnIndex(ContactsContract.Contacts.LOOKUP_KEY))
val uri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_VCARD_URI, lookupKey)
var vCard = ""
var inputStream: InputStream? = null
var inputStreamReader: InputStreamReader? = null
try {
inputStream = applicationContext.getContentResolver().openInputStream(uri)
val buffer = CharArray(BUFFER_SIZE)
val stringBuilder = StringBuilder()
if (inputStream != null) {
inputStreamReader = InputStreamReader(inputStream)
while (true) {
val byteCount = inputStreamReader.read(buffer, 0, buffer.size)
if (byteCount > 0) {
stringBuilder.append(buffer, 0, byteCount)
} else {
break
}
}
}
vCard = stringBuilder.toString()
// bump to vCard 3.0 format (min version supported by server) since Android OS exports to 2.1
return Ezvcard.write(Ezvcard.parse(vCard).all()).version(VCardVersion.V3_0).go()
} catch (e: IOException) {
Log_OC.d(TAG, e.message)
} finally {
try {
inputStream?.close()
inputStreamReader?.close()
} catch (e: IOException) {
Log_OC.e(TAG, "failed to close stream")
}
}
return vCard
}
/**
* Implements callback methods for service binding.
*/
private class OperationsServiceConnection internal constructor(
private val worker: ContactsBackupWork,
private val daysToExpire: Int,
private val backupFolder: String,
private val user: User
) : ServiceConnection {
override fun onServiceConnected(component: ComponentName, service: IBinder) {
Log_OC.d(TAG, "service connected")
if (component == ComponentName(worker.applicationContext, OperationsService::class.java)) {
worker.operationsServiceBinder = service as OperationsServiceBinder
worker.expireFiles(daysToExpire, backupFolder, user)
}
}
override fun onServiceDisconnected(component: ComponentName) {
Log_OC.d(TAG, "service disconnected")
if (component == ComponentName(worker.applicationContext, OperationsService::class.java)) {
worker.operationsServiceBinder = null
}
}
}
}

View file

@ -0,0 +1,32 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.jobs
import java.util.Date
import java.util.UUID
data class JobInfo(
val id: UUID = UUID.fromString("00000000-0000-0000-0000-000000000000"),
val state: String = "",
val name: String = "",
val user: String = "",
val started: Date = Date(0),
val progress: Int = 0
)

View file

@ -2,7 +2,7 @@
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@ -23,6 +23,7 @@ import android.content.Context
import android.content.ContextWrapper
import androidx.work.Configuration
import androidx.work.WorkManager
import com.nextcloud.client.core.Clock
import dagger.Module
import dagger.Provides
import javax.inject.Singleton
@ -32,7 +33,7 @@ class JobsModule {
@Provides
@Singleton
fun backgroundJobManager(context: Context, factory: BackgroundJobFactory): BackgroundJobManager {
fun workManager(context: Context, factory: BackgroundJobFactory): WorkManager {
val configuration = Configuration.Builder()
.setWorkerFactory(factory)
.build()
@ -44,7 +45,12 @@ class JobsModule {
}
WorkManager.initialize(contextWrapper, configuration)
val wm = WorkManager.getInstance(context)
return BackgroundJobManagerImpl(wm)
return WorkManager.getInstance(context)
}
@Provides
@Singleton
fun backgroundJobManager(workManager: WorkManager, clock: Clock): BackgroundJobManager {
return BackgroundJobManagerImpl(workManager, clock)
}
}

View file

@ -0,0 +1,48 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.jobs
import android.content.Context
import androidx.work.Data
import androidx.work.Worker
import androidx.work.WorkerParameters
class TestJob(
appContext: Context,
params: WorkerParameters
) : Worker(appContext, params) {
companion object {
private const val MAX_PROGRESS = 100
private const val DELAY_MS = 1000L
private const val PROGRESS_KEY = "progress"
}
override fun doWork(): Result {
for (i in 0..MAX_PROGRESS) {
Thread.sleep(DELAY_MS)
val progress = Data.Builder()
.putInt(PROGRESS_KEY, i)
.build()
setProgressAsync(progress)
}
return Result.success()
}
}

View file

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

View file

@ -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<Step> = listOf(
Step(0, "migrate user id", Runnable { migrateUserId() }, false)
).sortedBy { it.id }
fun migrateUserId() {
val allAccountsHaveUserId = userAccountManager.migrateUserId()
if (!allAccountsHaveUserId) {
throw IllegalStateException("Failed to set user id for all accounts")
migrateUserId,
migrateContentObserverJob,
migrateContactsBackupJob
).sortedBy { it.id }.apply {
val uniqueIds = associateBy { it.id }.size
if (uniqueIds != size) {
throw IllegalStateException("All migrations must have unique id")
}
}
}

View file

@ -0,0 +1,85 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.migrations
import android.content.SharedPreferences
import java.util.TreeSet
class MigrationsDb(private val migrationsDb: SharedPreferences) {
companion object {
const val DB_KEY_LAST_MIGRATED_VERSION = "last_migrated_version"
const val DB_KEY_APPLIED_MIGRATIONS = "applied_migrations"
const val DB_KEY_FAILED = "failed"
const val DB_KEY_FAILED_MIGRATION_ID = "failed_migration_id"
const val DB_KEY_FAILED_MIGRATION_ERROR_MESSAGE = "failed_migration_error"
const val NO_LAST_MIGRATED_VERSION = -1
const val NO_FAILED_MIGRATION_ID = -1
}
fun getAppliedMigrations(): List<Int> {
val appliedIdsStr: Set<String> = migrationsDb.getStringSet(DB_KEY_APPLIED_MIGRATIONS, null) ?: TreeSet()
return appliedIdsStr.mapNotNull {
try {
it.toInt()
} catch (_: NumberFormatException) {
null
}
}.sorted()
}
fun addAppliedMigration(vararg migrations: Int) {
val oldApplied = migrationsDb.getStringSet(DB_KEY_APPLIED_MIGRATIONS, null) ?: TreeSet()
val newApplied = TreeSet<String>().apply {
addAll(oldApplied)
addAll(migrations.map { it.toString() })
}
migrationsDb.edit().putStringSet(DB_KEY_APPLIED_MIGRATIONS, newApplied).apply()
}
var lastMigratedVersion: Int
set(value) {
migrationsDb.edit().putInt(DB_KEY_LAST_MIGRATED_VERSION, value).apply()
}
get() {
return migrationsDb.getInt(DB_KEY_LAST_MIGRATED_VERSION, NO_LAST_MIGRATED_VERSION)
}
val isFailed: Boolean get() = migrationsDb.getBoolean(DB_KEY_FAILED, false)
val failureReason: String get() = migrationsDb.getString(DB_KEY_FAILED_MIGRATION_ERROR_MESSAGE, "") ?: ""
val failedMigrationId: Int get() = migrationsDb.getInt(DB_KEY_FAILED_MIGRATION_ID, NO_FAILED_MIGRATION_ID)
fun setFailed(id: Int, error: String) {
migrationsDb
.edit()
.putBoolean(DB_KEY_FAILED, true)
.putString(DB_KEY_FAILED_MIGRATION_ERROR_MESSAGE, error)
.putInt(DB_KEY_FAILED_MIGRATION_ID, id)
.apply()
}
fun clearMigrations() {
migrationsDb.edit()
.putStringSet(DB_KEY_APPLIED_MIGRATIONS, emptySet())
.putInt(DB_KEY_LAST_MIGRATED_VERSION, 0)
.apply()
}
}

View file

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

View file

@ -19,70 +19,42 @@
*/
package com.nextcloud.client.migrations
import android.content.SharedPreferences
import androidx.annotation.MainThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.nextcloud.client.appinfo.AppInfo
import com.nextcloud.client.core.AsyncRunner
import com.nextcloud.client.migrations.MigrationsManager.Status
import java.util.TreeSet
internal class MigrationsManagerImpl(
private val appInfo: AppInfo,
private val migrationsDb: SharedPreferences,
private val migrationsDb: MigrationsDb,
private val asyncRunner: AsyncRunner,
private val migrations: Collection<Migrations.Step>
) : MigrationsManager {
companion object {
const val DB_KEY_LAST_MIGRATED_VERSION = "last_migrated_version"
const val DB_KEY_APPLIED_MIGRATIONS = "applied_migrations"
const val DB_KEY_FAILED = "failed"
const val DB_KEY_FAILED_MIGRATION_ID = "failed_migration_id"
const val DB_KEY_FAILED_MIGRATION_ERROR_MESSAGE = "failed_migration_error"
}
override val status: LiveData<Status> = MutableLiveData<Status>(Status.UNKNOWN)
override val status: LiveData<Status>
init {
this.status = MutableLiveData<Status>(Status.UNKNOWN)
}
fun getAppliedMigrations(): List<Int> {
val appliedIdsStr: Set<String> = migrationsDb.getStringSet(DB_KEY_APPLIED_MIGRATIONS, null) ?: TreeSet()
return appliedIdsStr.mapNotNull {
try {
it.toInt()
} catch (_: NumberFormatException) {
null
}
}.sorted()
}
fun addAppliedMigration(vararg migrations: Int) {
val oldApplied = migrationsDb.getStringSet(DB_KEY_APPLIED_MIGRATIONS, null) ?: TreeSet()
val newApplied = TreeSet<String>().apply {
addAll(oldApplied)
addAll(migrations.map { it.toString() })
override val info: List<MigrationInfo> get() {
val applied = migrationsDb.getAppliedMigrations()
return migrations.map {
MigrationInfo(id = it.id, description = it.description, applied = applied.contains(it.id))
}
migrationsDb.edit().putStringSet(DB_KEY_APPLIED_MIGRATIONS, newApplied).apply()
}
@Throws(MigrationError::class)
@Suppress("ReturnCount")
override fun startMigration(): Int {
if (migrationsDb.getBoolean(DB_KEY_FAILED, false)) {
if (migrationsDb.isFailed) {
(status as MutableLiveData<Status>).value = Status.FAILED
return 0
}
val lastMigratedVersion = migrationsDb.getInt(DB_KEY_LAST_MIGRATED_VERSION, -1)
if (lastMigratedVersion >= appInfo.versionCode) {
if (migrationsDb.lastMigratedVersion >= appInfo.versionCode) {
(status as MutableLiveData<Status>).value = Status.APPLIED
return 0
}
val applied = getAppliedMigrations()
val applied = migrationsDb.getAppliedMigrations()
val toApply = migrations.filter { !applied.contains(it.id) }
if (toApply.isEmpty()) {
onMigrationSuccess()
@ -105,8 +77,8 @@ internal class MigrationsManagerImpl(
migrations.forEach {
@Suppress("TooGenericExceptionCaught") // migration code is free to throw anything
try {
it.function.run()
addAppliedMigration(it.id)
it.run()
migrationsDb.addAppliedMigration(it.id)
} catch (t: Throwable) {
if (it.mandatory) {
throw MigrationError(id = it.id, message = t.message ?: t.javaClass.simpleName)
@ -121,18 +93,13 @@ internal class MigrationsManagerImpl(
is MigrationError -> error.id
else -> -1
}
migrationsDb
.edit()
.putBoolean(DB_KEY_FAILED, true)
.putString(DB_KEY_FAILED_MIGRATION_ERROR_MESSAGE, error.message)
.putInt(DB_KEY_FAILED_MIGRATION_ID, id)
.apply()
migrationsDb.setFailed(id, error.message ?: error.javaClass.simpleName)
(status as MutableLiveData<Status>).value = Status.FAILED
}
@MainThread
private fun onMigrationSuccess() {
migrationsDb.edit().putInt(DB_KEY_LAST_MIGRATED_VERSION, appInfo.versionCode).apply()
migrationsDb.lastMigratedVersion = appInfo.versionCode
(status as MutableLiveData<Status>).value = Status.APPLIED
}
}

View file

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

View file

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

View file

@ -6,7 +6,7 @@
* Copyright (C) 2012 Bartek Przybylski
* Copyright (C) 2015 ownCloud Inc.
* Copyright (C) 2017 Mario Danic
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
@ -73,7 +73,7 @@ public class BootupBroadcastReceiver extends BroadcastReceiver {
powerManagementService,
backgroundJobManager,
clock);
MainApp.initContactsBackup(accountManager);
MainApp.initContactsBackup(accountManager, backgroundJobManager);
} else {
Log_OC.d(TAG, "Getting wrong intent: " + intent.getAction());
}

View file

@ -6,7 +6,7 @@
*
* Copyright (C) 2017 Tobias Kaminsky
* Copyright (C) 2017 Nextcloud GmbH.
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@ -21,7 +21,6 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.owncloud.android.jobs;
import android.accounts.Account;
@ -38,6 +37,7 @@ import com.google.gson.Gson;
import com.nextcloud.client.account.User;
import com.nextcloud.client.account.UserAccountManager;
import com.nextcloud.client.core.Clock;
import com.nextcloud.client.jobs.BackgroundJobManager;
import com.nextcloud.client.preferences.AppPreferencesImpl;
import com.nextcloud.java.util.Optional;
import com.owncloud.android.MainApp;
@ -81,15 +81,18 @@ public class AccountRemovalJob extends Job {
private final UploadsStorageManager uploadsStorageManager;
private final UserAccountManager userAccountManager;
private final BackgroundJobManager backgroundJobManager;
private final Clock clock;
private final EventBus eventBus;
public AccountRemovalJob(UploadsStorageManager uploadStorageManager,
UserAccountManager accountManager,
BackgroundJobManager backgroundJobManager,
Clock clock,
EventBus eventBus) {
this.uploadsStorageManager = uploadStorageManager;
this.userAccountManager = accountManager;
this.backgroundJobManager = backgroundJobManager;
this.clock = clock;
this.eventBus = eventBus;
}
@ -118,8 +121,7 @@ public class AccountRemovalJob extends Job {
ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProvider(context.getContentResolver());
User user = optionalUser.get();
// disable contact backup job
ContactsPreferenceActivity.cancelContactBackupJobForAccount(context, user);
backgroundJobManager.cancelPeriodicContactsBackup(user);
final boolean userRemoved = userAccountManager.removeUser(user);
if (userRemoved) {
@ -134,9 +136,6 @@ public class AccountRemovalJob extends Job {
// delete all database entries
storageManager.deleteAllFiles();
// remove contact backup job
ContactsPreferenceActivity.cancelContactBackupJobForAccount(context, user);
// disable daily backup
arbitraryDataProvider.storeOrUpdateKeyValue(user.getAccountName(),
ContactsPreferenceActivity.PREFERENCE_CONTACTS_AUTOMATIC_BACKUP,

View file

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

View file

@ -2,8 +2,10 @@
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* @author Chris Narkiewicz <hello@ezaquarii.com>
* Copyright (C) 2017 Tobias Kaminsky
* Copyright (C) 2017 Nextcloud GmbH.
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@ -18,7 +20,6 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.owncloud.android.ui.activity;
import android.accounts.Account;
@ -30,6 +31,7 @@ import com.evernote.android.job.JobManager;
import com.evernote.android.job.JobRequest;
import com.evernote.android.job.util.support.PersistableBundleCompat;
import com.nextcloud.client.account.User;
import com.nextcloud.client.jobs.BackgroundJobManager;
import com.owncloud.android.MainApp;
import com.owncloud.android.R;
import com.owncloud.android.datamodel.OCFile;
@ -43,6 +45,8 @@ import org.parceler.Parcels;
import java.util.Set;
import javax.inject.Inject;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
@ -58,6 +62,8 @@ public class ContactsPreferenceActivity extends FileActivity implements FileFrag
public static final String BACKUP_TO_LIST = "BACKUP_TO_LIST";
public static final String EXTRA_SHOW_SIDEBAR = "SHOW_SIDEBAR";
@Inject BackgroundJobManager backgroundJobManager;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@ -104,51 +110,6 @@ public class ContactsPreferenceActivity extends FileActivity implements FileFrag
}
}
public static void startContactBackupJob(Account account) {
Log_OC.d(TAG, "start daily contacts backup job");
PersistableBundleCompat bundle = new PersistableBundleCompat();
bundle.putString(ContactsBackupJob.ACCOUNT, account.name);
cancelPreviousContactBackupJobForAccount(MainApp.getAppContext(), account);
new JobRequest.Builder(ContactsBackupJob.TAG)
.setExtras(bundle)
.setPeriodic(24 * 60 * 60 * 1000)
.build()
.schedule();
}
public static void cancelPreviousContactBackupJobForAccount(Context context, Account account) {
Log_OC.d(TAG, "disabling existing contacts backup job for account: " + account.name);
JobManager jobManager = JobManager.create(context);
Set<JobRequest> jobs = jobManager.getAllJobRequestsForTag(ContactsBackupJob.TAG);
for (JobRequest jobRequest : jobs) {
PersistableBundleCompat extras = jobRequest.getExtras();
if (extras != null && extras.getString(ContactsBackupJob.ACCOUNT, "").equalsIgnoreCase(account.name) &&
jobRequest.isPeriodic()) {
jobManager.cancel(jobRequest.getJobId());
}
}
}
public static void cancelContactBackupJobForAccount(Context context, User user) {
Log_OC.d(TAG, "disabling contacts backup job for account: " + user.getAccountName());
JobManager jobManager = JobManager.create(context);
Set<JobRequest> jobs = jobManager.getAllJobRequestsForTag(ContactsBackupJob.TAG);
for (JobRequest jobRequest : jobs) {
PersistableBundleCompat extras = jobRequest.getExtras();
if (extras.getString(ContactsBackupJob.ACCOUNT, "").equalsIgnoreCase(user.getAccountName())) {
jobManager.cancel(jobRequest.getJobId());
}
}
}
@Override
public void showFiles(boolean onDeviceOnly) {
super.showFiles(onDeviceOnly);

View file

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

View file

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

View file

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

View file

@ -0,0 +1,139 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Nextcloud Android client application
@author Chris Narkiewicz
Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<TableLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp"
android:stretchColumns="1">
<TableRow
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="20dp"
android:text="@string/etm_background_job_uuid" />
<TextView
android:id="@+id/etm_background_job_uuid"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="d7edb387-0b61-4e4e-a728-ffab3055d700" />
</TableRow>
<TableRow
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="20dp"
android:text="@string/etm_background_job_name"
app:layout_constraintStart_toStartOf="@+id/etm_background_task_id_label"
app:layout_constraintTop_toBottomOf="@+id/etm_background_task_id_label" />
<TextView
android:id="@+id/etm_background_job_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="job name" />
</TableRow>
<TableRow
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="20dp"
android:text="@string/etm_background_job_user" />
<TextView
android:id="@+id/etm_background_job_user"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="user@nextcloud.com" />
</TableRow>
<TableRow
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="20dp"
android:text="@string/etm_background_job_state" />
<TextView
android:id="@+id/etm_background_job_state"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="ENQUEUED" />
</TableRow>
<TableRow
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="20dp"
android:text="@string/etm_background_job_started" />
<TextView
android:id="@+id/etm_background_job_started"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="2020-02-15T20:53:15Z" />
</TableRow>
<TableRow
android:id="@+id/etm_background_job_progress_row"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="20dp"
android:text="@string/etm_background_job_progress" />
<TextView
android:id="@+id/etm_background_job_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="50%" />
</TableRow>
</TableLayout>

View file

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

View file

@ -0,0 +1,33 @@
<!--
Nextcloud Android client application
@author Chris Narkiewicz
Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.nextcloud.client.etm.pages.EtmAccountsFragment">
<TextView
android:id="@+id/etm_migrations_text"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="@dimen/standard_padding"
android:scrollbars="vertical"/>
</FrameLayout>

View file

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Nextcloud Android client application
@author Chris Narkiewicz
Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="AppCompatResource">
<item
android:id="@+id/etm_background_jobs_cancel"
android:title="@string/etm_background_jobs_cancel_all"
app:showAsAction="never"
android:showAsAction="never" />
<item
android:id="@+id/etm_background_jobs_prune"
android:title="@string/etm_background_jobs_prune"
app:showAsAction="never"
android:showAsAction="never" />
<item
android:id="@+id/etm_background_jobs_start_test"
android:title="@string/etm_background_jobs_start_test_job"
app:showAsAction="never"
android:showAsAction="never" />
<item
android:id="@+id/etm_background_jobs_cancel_test"
android:title="@string/etm_background_jobs_stop_test_job"
app:showAsAction="never"
android:showAsAction="never" />
</menu>

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Nextcloud Android client application
@author Chris Narkiewicz
Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="AppCompatResource">
<item
android:id="@+id/etm_migrations_delete"
android:title="@string/common_delete"
app:showAsAction="ifRoom"
android:showAsAction="ifRoom"
android:icon="@drawable/ic_delete" />
</menu>

View file

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

View file

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

View file

@ -2,7 +2,7 @@
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@ -17,18 +17,20 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.jobs
import android.content.ContentResolver
import android.content.Context
import android.content.res.Resources
import android.os.Build
import androidx.work.WorkerParameters
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.core.Clock
import com.nextcloud.client.device.DeviceInfo
import com.nextcloud.client.device.PowerManagementService
import com.nextcloud.client.preferences.AppPreferences
import com.nhaarman.mockitokotlin2.whenever
import com.owncloud.android.datamodel.ArbitraryDataProvider
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
@ -63,6 +65,15 @@ class BackgroundJobFactoryTest {
@Mock
private lateinit var clock: Clock
@Mock
private lateinit var accountManager: UserAccountManager
@Mock
private lateinit var resources: Resources
@Mock
private lateinit var dataProvider: ArbitraryDataProvider
private lateinit var factory: BackgroundJobFactory
@Before
@ -74,12 +85,15 @@ class BackgroundJobFactoryTest {
clock,
powerManagementService,
Provider { backgroundJobManager },
deviceInfo
deviceInfo,
accountManager,
resources,
dataProvider
)
}
@Test
fun `worker is created on api level 24+`() {
fun worker_is_created_on_api_level_24() {
// GIVEN
// api level is > 24
// content URI trigger is supported
@ -95,7 +109,7 @@ class BackgroundJobFactoryTest {
}
@Test
fun `worker is not created below api level 24`() {
fun worker_is_not_created_below_api_level_24() {
// GIVEN
// api level is < 24
// content URI trigger is not supported

View file

@ -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