mirror of
https://github.com/nextcloud/android.git
synced 2024-11-29 10:49:04 +03:00
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:
commit
e1b0b521f9
43 changed files with 2198 additions and 299 deletions
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
* Nextcloud Android client application
|
||||
*
|
||||
* @author Chris Narkiewicz
|
||||
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
|
@ -22,18 +22,172 @@ package com.nextcloud.client.jobs
|
|||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.Operation
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import com.nextcloud.client.account.User
|
||||
import com.nextcloud.client.core.Clock
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
internal class BackgroundJobManagerImpl(private val workManager: WorkManager) : BackgroundJobManager {
|
||||
/**
|
||||
* Note to maintainers
|
||||
*
|
||||
* Since [androidx.work.WorkManager] is missing API to easily attach worker metadata,
|
||||
* we use tags API to attach our custom metadata.
|
||||
*
|
||||
* To create new job request, use [BackgroundJobManagerImpl.oneTimeRequestBuilder] and
|
||||
* [BackgroundJobManagerImpl.periodicRequestBuilder] calls, instead of calling
|
||||
* platform builders. Those methods will create builders pre-set with mandatory tags.
|
||||
*
|
||||
* Since Google is notoriously releasing new background job services, [androidx.work.WorkManager] API is
|
||||
* considered private implementation detail and should not be leaked through the interface, to minimize
|
||||
* potential migration cost in the future.
|
||||
*/
|
||||
@Suppress("TooManyFunctions") // we expect this implementation to have rich API
|
||||
internal class BackgroundJobManagerImpl(
|
||||
private val workManager: WorkManager,
|
||||
private val clock: Clock
|
||||
) : BackgroundJobManager {
|
||||
|
||||
companion object {
|
||||
const val TAG_CONTENT_SYNC = "content_sync"
|
||||
const val TAG_ALL = "*" // This tag allows us to retrieve list of all jobs run by Nextcloud client
|
||||
const val JOB_CONTENT_OBSERVER = "content_observer"
|
||||
const val JOB_PERIODIC_CONTACTS_BACKUP = "periodic_contacts_backup"
|
||||
const val JOB_IMMEDIATE_CONTACTS_BACKUP = "immediate_contacts_backup"
|
||||
const val JOB_TEST = "test_job"
|
||||
|
||||
const val MAX_CONTENT_TRIGGER_DELAY_MS = 1500L
|
||||
|
||||
const val TAG_PREFIX_NAME = "name"
|
||||
const val TAG_PREFIX_USER = "user"
|
||||
const val TAG_PREFIX_START_TIMESTAMP = "timestamp"
|
||||
val PREFIXES = setOf(TAG_PREFIX_NAME, TAG_PREFIX_USER, TAG_PREFIX_START_TIMESTAMP)
|
||||
const val NOT_SET_VALUE = "not set"
|
||||
const val DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES = 15L
|
||||
const val INTERVAL_SECOND = 1000L
|
||||
const val INTERVAL_MINUTE = 60L * INTERVAL_SECOND
|
||||
const val INTERVAL_HOUR = 60 * INTERVAL_MINUTE
|
||||
const val INTERVAL_24H = 24L * INTERVAL_HOUR
|
||||
|
||||
fun formatNameTag(name: String, user: User? = null): String {
|
||||
return if (user == null) {
|
||||
"$TAG_PREFIX_NAME:$name"
|
||||
} else {
|
||||
"$TAG_PREFIX_NAME:$name ${user.accountName}"
|
||||
}
|
||||
}
|
||||
fun formatUserTag(user: User): String = "$TAG_PREFIX_USER:${user.accountName}"
|
||||
fun formatTimeTag(startTimestamp: Long): String = "$TAG_PREFIX_START_TIMESTAMP:$startTimestamp"
|
||||
|
||||
fun parseTag(tag: String): Pair<String, String>? {
|
||||
val key = tag.substringBefore(":", "")
|
||||
val value = tag.substringAfter(":", "")
|
||||
return if (key in PREFIXES) {
|
||||
key to value
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun parseTimestamp(timestamp: String): Date {
|
||||
try {
|
||||
val ms = timestamp.toLong()
|
||||
return Date(ms)
|
||||
} catch (ex: NumberFormatException) {
|
||||
return Date(0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert platform [androidx.work.WorkInfo] object into application-specific [JobInfo] model.
|
||||
* Conversion extracts work metadata from tags.
|
||||
*/
|
||||
fun fromWorkInfo(info: WorkInfo?): JobInfo? {
|
||||
return if (info != null) {
|
||||
val metadata = mutableMapOf<String, String>()
|
||||
info.tags.forEach { parseTag(it)?.let { metadata[it.first] = it.second } }
|
||||
val timestamp = parseTimestamp(metadata.get(TAG_PREFIX_START_TIMESTAMP) ?: "0")
|
||||
JobInfo(
|
||||
id = info.id,
|
||||
state = info.state.toString(),
|
||||
name = metadata.get(TAG_PREFIX_NAME) ?: NOT_SET_VALUE,
|
||||
user = metadata.get(TAG_PREFIX_USER) ?: NOT_SET_VALUE,
|
||||
started = timestamp,
|
||||
progress = info.progress.getInt("progress", -1)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create [OneTimeWorkRequest.Builder] pre-set with common attributes
|
||||
*/
|
||||
private fun oneTimeRequestBuilder(
|
||||
jobClass: KClass<out ListenableWorker>,
|
||||
jobName: String,
|
||||
user: User? = null
|
||||
): OneTimeWorkRequest.Builder {
|
||||
val builder = OneTimeWorkRequest.Builder(jobClass.java)
|
||||
.addTag(TAG_ALL)
|
||||
.addTag(formatNameTag(jobName, user))
|
||||
.addTag(formatTimeTag(clock.currentTime))
|
||||
user?.let { builder.addTag(formatUserTag(it)) }
|
||||
return builder
|
||||
}
|
||||
|
||||
/**
|
||||
* Create [PeriodicWorkRequest] pre-set with common attributes
|
||||
*/
|
||||
private fun periodicRequestBuilder(
|
||||
jobClass: KClass<out ListenableWorker>,
|
||||
jobName: String,
|
||||
intervalMins: Long = DEFAULT_PERIODIC_JOB_INTERVAL_MINUTES,
|
||||
user: User? = null
|
||||
): PeriodicWorkRequest.Builder {
|
||||
val builder = PeriodicWorkRequest.Builder(jobClass.java, intervalMins, TimeUnit.MINUTES)
|
||||
.addTag(TAG_ALL)
|
||||
.addTag(formatNameTag(jobName, user))
|
||||
.addTag(formatTimeTag(clock.currentTime))
|
||||
user?.let { builder.addTag(formatUserTag(it)) }
|
||||
return builder
|
||||
}
|
||||
|
||||
private fun WorkManager.getJobInfo(id: UUID): LiveData<JobInfo?> {
|
||||
val workInfo = getWorkInfoByIdLiveData(id)
|
||||
return Transformations.map(workInfo) { fromWorkInfo(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel work using name tag with optional user scope.
|
||||
* All work instances will be cancelled.
|
||||
*/
|
||||
private fun WorkManager.cancelJob(name: String, user: User? = null): Operation {
|
||||
val tag = formatNameTag(name, user)
|
||||
return cancelAllWorkByTag(tag)
|
||||
}
|
||||
|
||||
override val jobs: LiveData<List<JobInfo>>
|
||||
get() {
|
||||
val workInfo = workManager.getWorkInfosByTagLiveData("*")
|
||||
return Transformations.map(workInfo) {
|
||||
it.map { fromWorkInfo(it) ?: JobInfo() }.sortedBy { it.started }.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
override fun scheduleContentObserverJob() {
|
||||
val constrains = Constraints.Builder()
|
||||
|
@ -44,11 +198,62 @@ internal class BackgroundJobManagerImpl(private val workManager: WorkManager) :
|
|||
.setTriggerContentMaxDelay(MAX_CONTENT_TRIGGER_DELAY_MS, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
val request = OneTimeWorkRequest.Builder(ContentObserverWork::class.java)
|
||||
val request = oneTimeRequestBuilder(ContentObserverWork::class, JOB_CONTENT_OBSERVER)
|
||||
.setConstraints(constrains)
|
||||
.addTag(TAG_CONTENT_SYNC)
|
||||
.build()
|
||||
|
||||
workManager.enqueue(request)
|
||||
workManager.enqueueUniqueWork(JOB_CONTENT_OBSERVER, ExistingWorkPolicy.KEEP, request)
|
||||
}
|
||||
|
||||
override fun schedulePeriodicContactsBackup(user: User) {
|
||||
val data = Data.Builder()
|
||||
.putString(ContactsBackupWork.ACCOUNT, user.accountName)
|
||||
.putBoolean(ContactsBackupWork.FORCE, true)
|
||||
.build()
|
||||
|
||||
val request = periodicRequestBuilder(
|
||||
ContactsBackupWork::class,
|
||||
JOB_PERIODIC_CONTACTS_BACKUP,
|
||||
INTERVAL_24H,
|
||||
user
|
||||
).setInputData(data).build()
|
||||
|
||||
workManager.enqueueUniquePeriodicWork(JOB_PERIODIC_CONTACTS_BACKUP, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||
}
|
||||
|
||||
override fun cancelPeriodicContactsBackup(user: User) {
|
||||
workManager.cancelJob(JOB_PERIODIC_CONTACTS_BACKUP, user)
|
||||
}
|
||||
|
||||
override fun startImmediateContactsBackup(user: User): LiveData<JobInfo?> {
|
||||
val data = Data.Builder()
|
||||
.putString(ContactsBackupWork.ACCOUNT, user.accountName)
|
||||
.putBoolean(ContactsBackupWork.FORCE, true)
|
||||
.build()
|
||||
|
||||
val request = oneTimeRequestBuilder(ContactsBackupWork::class, JOB_IMMEDIATE_CONTACTS_BACKUP, user)
|
||||
.setInputData(data)
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniqueWork(JOB_IMMEDIATE_CONTACTS_BACKUP, ExistingWorkPolicy.KEEP, request)
|
||||
return workManager.getJobInfo(request.id)
|
||||
}
|
||||
|
||||
override fun scheduleTestJob() {
|
||||
val request = periodicRequestBuilder(TestJob::class, JOB_TEST)
|
||||
.build()
|
||||
workManager.enqueueUniquePeriodicWork(JOB_TEST, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||
}
|
||||
|
||||
override fun cancelTestJob() {
|
||||
workManager.cancelAllWorkByTag(formatNameTag(JOB_TEST))
|
||||
}
|
||||
|
||||
override fun pruneJobs() {
|
||||
workManager.pruneWork()
|
||||
}
|
||||
|
||||
override fun cancelAllJobs() {
|
||||
workManager.cancelAllWorkByTag(TAG_ALL)
|
||||
}
|
||||
}
|
||||
|
|
260
src/main/java/com/nextcloud/client/jobs/ContactsBackupWork.kt
Normal file
260
src/main/java/com/nextcloud/client/jobs/ContactsBackupWork.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
32
src/main/java/com/nextcloud/client/jobs/JobInfo.kt
Normal file
32
src/main/java/com/nextcloud/client/jobs/JobInfo.kt
Normal 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
|
||||
)
|
|
@ -2,7 +2,7 @@
|
|||
* Nextcloud Android client application
|
||||
*
|
||||
* @author Chris Narkiewicz
|
||||
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
|
@ -23,6 +23,7 @@ import android.content.Context
|
|||
import android.content.ContextWrapper
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import com.nextcloud.client.core.Clock
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import javax.inject.Singleton
|
||||
|
@ -32,7 +33,7 @@ class JobsModule {
|
|||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun backgroundJobManager(context: Context, factory: BackgroundJobFactory): BackgroundJobManager {
|
||||
fun workManager(context: Context, factory: BackgroundJobFactory): WorkManager {
|
||||
val configuration = Configuration.Builder()
|
||||
.setWorkerFactory(factory)
|
||||
.build()
|
||||
|
@ -44,7 +45,12 @@ class JobsModule {
|
|||
}
|
||||
|
||||
WorkManager.initialize(contextWrapper, configuration)
|
||||
val wm = WorkManager.getInstance(context)
|
||||
return BackgroundJobManagerImpl(wm)
|
||||
return WorkManager.getInstance(context)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun backgroundJobManager(workManager: WorkManager, clock: Clock): BackgroundJobManager {
|
||||
return BackgroundJobManagerImpl(workManager, clock)
|
||||
}
|
||||
}
|
||||
|
|
48
src/main/java/com/nextcloud/client/jobs/TestJob.kt
Normal file
48
src/main/java/com/nextcloud/client/jobs/TestJob.kt
Normal 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()
|
||||
}
|
||||
}
|
|
@ -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)
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,7 +19,6 @@
|
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
package com.owncloud.android.datamodel;
|
||||
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
* Copyright (C) 2012 Bartek Przybylski
|
||||
* Copyright (C) 2015 ownCloud Inc.
|
||||
* Copyright (C) 2017 Mario Danic
|
||||
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
* Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License version 2,
|
||||
|
@ -73,7 +73,7 @@ public class BootupBroadcastReceiver extends BroadcastReceiver {
|
|||
powerManagementService,
|
||||
backgroundJobManager,
|
||||
clock);
|
||||
MainApp.initContactsBackup(accountManager);
|
||||
MainApp.initContactsBackup(accountManager, backgroundJobManager);
|
||||
} else {
|
||||
Log_OC.d(TAG, "Getting wrong intent: " + intent.getAction());
|
||||
}
|
||||
|
|
|
@ -6,7 +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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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() {
|
||||
|
|
9
src/main/res/drawable/ic_arrow_up.xml
Normal file
9
src/main/res/drawable/ic_arrow_up.xml
Normal 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>
|
13
src/main/res/drawable/ic_clock.xml
Normal file
13
src/main/res/drawable/ic_clock.xml
Normal 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>
|
139
src/main/res/layout/etm_background_job_list_item.xml
Normal file
139
src/main/res/layout/etm_background_job_list_item.xml
Normal 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>
|
13
src/main/res/layout/fragment_etm_background_jobs.xml
Normal file
13
src/main/res/layout/fragment_etm_background_jobs.xml
Normal 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>
|
33
src/main/res/layout/fragment_etm_migrations.xml
Normal file
33
src/main/res/layout/fragment_etm_migrations.xml
Normal 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>
|
51
src/main/res/menu/fragment_etm_background_jobs.xml
Normal file
51
src/main/res/menu/fragment_etm_background_jobs.xml
Normal 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>
|
33
src/main/res/menu/fragment_etm_migrations.xml
Normal file
33
src/main/res/menu/fragment_etm_migrations.xml
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue