Workmanager scheduled sync (bitfireAT/davx5#216)

Closes bitfireAT/davx5#14

* extract WorkManager util functions and kdoc
* add PeriodicSyncWorker
* refactor and some kdoc
* use PeriodicSyncWorker and add todos
* allow SyncAdapter arguments to be passed to SyncWorker and have re-synchronization use SyncWorker
* handle sync cancellation in AccountSetting migration
* handle sync cancellation when account is renamed
* remove sync frameworks global sync setting and sync state awareness for the GUI
* Observe sync worker status in AccountListFragment
* Create setup for stepwise refactor of the sync adapters. This should keep the app from failing to build.
* Create new abstract BaseSyncer class for generic sync code. Adapt consumers and tests to use the new class.
* Move calender sync code to new CalenderSyncer class which is independent from the sync framework
* In CalendarSyncAdapterService pass sync requests from Sync Adapter Framework to SyncWorker
* Use CalenderSyncer in SyncWorker
* Move contacts sync code to new ContactsSyncer class.
* Move address book sync code to new AddressBookSyncer class
* Move jtx sync code to new JtxSyncer class
* Move tasks sync code to new TaskSyncer class
* Remove duplicate code in inherited sync adapters.
* Remove refactoring helper interface, duplicate generic sync code and some linting.
* Adapt tests for new Syncer class and move to the new package
* Remove remaining duplicate code in SyncAdapterService, add todos and edit kdoc.
* Move all the single line sync adapter services into one file.
* Remove concurrent sync runner code and its test, as we now use WorkManagers one time work requests.
* Remove SAF manual sync flag usage where unnecessary.
* Drop ability to prioritise collections for sync, as not used and hindering removal of sync adapter arguments.
* Pass simple string array to SyncWorker instead of bundle to simplify code.
* Restructure work query code.
* Get debug info from account settings and WorkManager.
* Write tests for PeriodicSyncWorker
* Test account creation will set a default sync interval for CardDAV and CalDAV
* Throw an exception if accountManager returns null for sync interval value
* Do proper interval check and add tests for AccountSettings
* Use work manager query to determine whether work is in a specific state
* [WIP] Add test to check that task provider is configured correctly on account creation
* Edit test checking that task sync is configured correctly on account creation with/without installed task app(s), by mocking TaskUtil
* Edit test such that it does not require a flaky flag
* Remove periodic sync when tasks app is uninstalled
* Bring back content triggered syncs
* Rename enqueueSyncWorker method to enqueue only for clarity
* Enable SyncAdapterFramework to cancel running SyncWorker
* Add test ensuring that SyncWorker.onStopped() interrupts the running sync thread
* Add retry policy sync on soft errors
* Check users sync conditions before enqueueing SyncWorker
* Add test for whether user sync conditions are treated correctly and kdoc
* Rename ambiguous shorthand "SAF" to "SyncFramework", as SAF usually means StorageAccessFramework
* Migration: Disable sync framework periodic syncs when interval is changed for specified authority
* Add Workers info to debug info
* Use WorkInfo.runAttemptCount to fail work after 20 soft errors
* Notify user if retry limit for soft errors has been reached
* Remove left over concurrency sync run tests prevention
* Migration: Continue to remove periodic sync framework syncs until user migration to PeriodicSyncWorker syncs is complete
* Kdoc and small changes
* Migration: Change to hard migration strategy
* Drop repairSyncIntervals method in favor of hard migration strategy
* Improve debug info of workers
* Remove sync framework periodic syncs, created by enabling content triggered syncs
* Change minimum sync interval to 15 min; minor other changes
* Fix tests
* Implement requested changes and update kdoc
* Add network connectivity restrictions to PeriodicSyncWorker
* Minor changes
* Move back sync classes to syncadapter package for now (can be separated later)
* Add KDoc
* Changes from review
* Rename test methods
* Add back global sync status warning

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
This commit is contained in:
Sunik Kupfer 2023-05-03 10:20:15 +02:00 committed by Ricki Hirner
parent 299fb27af4
commit f9d6bb153c
52 changed files with 2067 additions and 1480 deletions

View file

@ -162,6 +162,7 @@ dependencies {
androidTestImplementation "com.google.dagger:hilt-android-testing:${versions.hilt}"
kaptAndroidTest "com.google.dagger:hilt-android-compiler:${versions.hilt}"
androidTestImplementation "androidx.arch.core:core-testing:2.2.0"
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'

View file

@ -4,33 +4,69 @@
package at.bitfire.davdroid
import android.app.Application
import android.content.Context
import androidx.test.platform.app.InstrumentationRegistry
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.work.WorkInfo
import androidx.work.WorkManager
import at.bitfire.davdroid.log.Logger
import com.google.common.util.concurrent.ListenableFuture
import androidx.work.WorkQuery
import org.jetbrains.annotations.TestOnly
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
object TestUtils {
val targetApplication by lazy { InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application }
@TestOnly
fun workScheduledOrRunning(context: Context, workerName: String): Boolean =
workInStates(context, workerName, listOf(
WorkInfo.State.ENQUEUED,
WorkInfo.State.RUNNING
))
fun workScheduledOrRunning(context: Context, workerName: String): Boolean {
val future: ListenableFuture<List<WorkInfo>> = WorkManager.getInstance(context).getWorkInfosForUniqueWork(workerName)
val workInfoList: List<WorkInfo>
try {
workInfoList = future.get()
} catch (e: Exception) {
Logger.log.severe("Failed to retrieve work info list for worker $workerName", )
return false
@TestOnly
fun workScheduledOrRunningOrSuccessful(context: Context, workerName: String): Boolean =
workInStates(context, workerName, listOf(
WorkInfo.State.ENQUEUED,
WorkInfo.State.RUNNING,
WorkInfo.State.SUCCEEDED
))
@TestOnly
fun workInStates(context: Context, workerName: String, states: List<WorkInfo.State>): Boolean =
WorkManager.getInstance(context).getWorkInfos(WorkQuery.Builder
.fromUniqueWorkNames(listOf(workerName))
.addStates(states)
.build()
).get().isNotEmpty()
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
@TestOnly
fun <T> LiveData<T>.getOrAwaitValue(
time: Long = 2,
timeUnit: TimeUnit = TimeUnit.SECONDS
): T {
var data: T? = null
val latch = CountDownLatch(1)
val observer = object : Observer<T> {
override fun onChanged(value: T) {
data = value
latch.countDown()
this@getOrAwaitValue.removeObserver(this)
}
}
for (workInfo in workInfoList) {
val state = workInfo.state
if (state == WorkInfo.State.RUNNING || state == WorkInfo.State.ENQUEUED)
return true
this.observeForever(observer)
// Don't wait indefinitely if the LiveData is not set.
if (!latch.await(time, timeUnit)) {
throw TimeoutException("LiveData value was never set.")
}
return false
@Suppress("UNCHECKED_CAST")
return data as T
}
}

View file

@ -6,6 +6,7 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.accounts.AccountManager
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.R
import dagger.hilt.android.testing.HiltAndroidRule
@ -18,7 +19,7 @@ import org.junit.Test
@HiltAndroidTest
class LocalAddressBookTest {
@get:Rule()
@get:Rule
val hiltRule = HiltAndroidRule(this)
val context = InstrumentationRegistry.getInstrumentation().targetContext

View file

@ -7,17 +7,27 @@ package at.bitfire.davdroid.settings
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.os.Build
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.util.Log
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.syncadapter.AccountUtils
import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker
import at.bitfire.davdroid.syncadapter.SyncManagerTest
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.ical4android.TaskProvider
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.After
import org.junit.Assert.*
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@ -30,22 +40,47 @@ class AccountSettingsTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
var instantTaskExecutorRule = InstantTaskExecutorRule()
@Inject
lateinit var settingsManager: SettingsManager
val context = InstrumentationRegistry.getInstrumentation().targetContext
private val context = InstrumentationRegistry.getInstrumentation().targetContext
val account = Account("Test Account", context.getString(R.string.account_type))
val account = Account(javaClass.canonicalName, SyncManagerTest.context.getString(R.string.account_type))
val fakeCredentials = Credentials("test", "test")
val authorities = listOf(
context.getString(R.string.address_books_authority),
CalendarContract.AUTHORITY,
TaskProvider.ProviderName.JtxBoard.authority,
TaskProvider.ProviderName.OpenTasks.authority,
TaskProvider.ProviderName.TasksOrg.authority
)
@Before
fun setUp() {
hiltRule.inject()
assertTrue(AccountUtils.createAccount(context, account, AccountSettings.initialUserData(fakeCredentials)))
assertTrue(AccountUtils.createAccount(
context,
account,
AccountSettings.initialUserData(fakeCredentials)
))
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0)
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
// However, we need notification channels for the ongoing work notifications.
NotificationUtils.createChannels(context)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
}
@After
@ -58,7 +93,8 @@ class AccountSettingsTest {
@Test
fun testSyncIntervals() {
val settings = AccountSettings(context, account)
val presetIntervals = context.resources.getStringArray(R.array.settings_sync_interval_seconds)
val presetIntervals =
context.resources.getStringArray(R.array.settings_sync_interval_seconds)
.map { it.toLong() }
.filter { it != AccountSettings.SYNC_INTERVAL_MANUALLY }
for (interval in presetIntervals) {
@ -68,21 +104,41 @@ class AccountSettingsTest {
}
@Test
fun testSyncIntervals_IsNotSyncable() {
fun testSyncIntervals_Syncable() {
val settings = AccountSettings(context, account)
val interval = 15*60L // 15 min
val result = settings.setSyncInterval(ContactsContract.AUTHORITY, interval)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) // below Android 7, Android returns true for whatever reason
assertFalse(result)
val result = settings.setSyncInterval(CalendarContract.AUTHORITY, interval)
assertTrue(result)
}
@Test
@Test(expected = IllegalArgumentException::class)
fun testSyncIntervals_TooShort() {
val settings = AccountSettings(context, account)
val interval = 60L // 1 min is not supported by Android
val result = settings.setSyncInterval(CalendarContract.AUTHORITY, interval)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) // below Android 7, Android returns true for whatever reason
assertFalse(result)
settings.setSyncInterval(CalendarContract.AUTHORITY, interval)
}
@Test
fun testSyncIntervals_activatesPeriodicSyncWorker() {
val settings = AccountSettings(context, account)
val interval = 15*60L
for (authority in authorities) {
ContentResolver.setIsSyncable(account, authority, 1)
assertTrue(settings.setSyncInterval(authority, interval))
assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, PeriodicSyncWorker.workerName(account, authority)))
assertEquals(interval, settings.getSyncInterval(authority))
}
}
@Test
fun testSyncIntervals_disablesPeriodicSyncWorker() {
val settings = AccountSettings(context, account)
val interval = AccountSettings.SYNC_INTERVAL_MANUALLY // -1
for (authority in authorities) {
ContentResolver.setIsSyncable(account, authority, 1)
assertTrue(settings.setSyncInterval(authority, interval))
assertFalse(TestUtils.workScheduledOrRunningOrSuccessful(context, PeriodicSyncWorker.workerName(account, authority)))
assertEquals(AccountSettings.SYNC_INTERVAL_MANUALLY, settings.getSyncInterval(authority))
}
}
}

View file

@ -0,0 +1,212 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.setup
import android.accounts.AccountManager
import android.content.ContentResolver
import android.content.Context
import android.provider.CalendarContract
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TestUtils.getOrAwaitValue
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.resource.TaskUtils
import at.bitfire.davdroid.servicedetection.DavResourceFinder
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.ical4android.TaskProvider
import at.bitfire.vcard4android.GroupMethod
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.*
import org.junit.*
import org.junit.Assert.*
import javax.inject.Inject
@HiltAndroidTest
class AccountDetailsFragmentTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@get:Rule
var instantTaskExecutorRule = InstantTaskExecutorRule() // required for TestUtils: LiveData.getOrAwaitValue()
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var settingsManager: SettingsManager
private val targetContext: Context = InstrumentationRegistry.getInstrumentation().targetContext
private val fakeCredentials = Credentials("test", "test")
@Before
fun setUp() {
hiltRule.inject()
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
// However, we need notification channels for the ongoing work notifications.
NotificationUtils.createChannels(targetContext)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(targetContext, config)
}
@After
fun tearDown() {
// Remove accounts created by tests
val am = AccountManager.get(targetContext)
val accounts = am.getAccountsByType(targetContext.getString(R.string.account_type))
for (account in accounts) {
am.removeAccountExplicitly(account)
}
}
@Test
fun testModel_CreateAccount_configuresContactsAndCalendars() {
val accountName = "MyAccountName"
val emptyServiceInfo = DavResourceFinder.Configuration.ServiceInfo()
val config = DavResourceFinder.Configuration(emptyServiceInfo, emptyServiceInfo, false, "")
// Create account -> should also set sync interval in settings
val accountCreated = AccountDetailsFragment.Model(targetContext, db, settingsManager)
.createAccount(accountName, fakeCredentials, config, GroupMethod.GROUP_VCARDS)
assertTrue(accountCreated.getOrAwaitValue(5))
// Get the created account
val account = AccountManager.get(targetContext)
.getAccountsByType(targetContext.getString(R.string.account_type))
.first { account -> account.name == accountName }
for (authority in listOf(
targetContext.getString(R.string.address_books_authority),
CalendarContract.AUTHORITY,
)) {
// Check isSyncable was set
assertEquals(1, ContentResolver.getIsSyncable(account, authority))
// Check default sync interval was set for
// [AccountSettings.KEY_SYNC_INTERVAL_ADDRESSBOOKS],
// [AccountSettings.KEY_SYNC_INTERVAL_CALENDARS]
assertEquals(
settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL),
AccountSettings(targetContext, account).getSyncInterval(authority)
)
}
}
@Test
@RequiresApi(28) // for mockkObject
fun testModel_CreateAccount_configuresCalendarsWithTasks() {
for (provider in listOf(
TaskProvider.ProviderName.JtxBoard,
TaskProvider.ProviderName.OpenTasks,
TaskProvider.ProviderName.TasksOrg
)) {
val accountName = "testAccount-$provider"
val emptyServiceInfo = DavResourceFinder.Configuration.ServiceInfo()
val config = DavResourceFinder.Configuration(emptyServiceInfo, emptyServiceInfo, false, "")
// Mock TaskUtils currentProvider method, pretending that one of the task apps is installed :)
mockkObject(TaskUtils)
every { TaskUtils.currentProvider(targetContext) } returns provider
assertEquals(provider, TaskUtils.currentProvider(targetContext))
// Create account -> should also set tasks sync interval in settings
val accountCreated =
AccountDetailsFragment.Model(targetContext, db, settingsManager)
.createAccount(accountName, fakeCredentials, config, GroupMethod.GROUP_VCARDS)
assertTrue(accountCreated.getOrAwaitValue(5))
// Get the created account
val account = AccountManager.get(targetContext)
.getAccountsByType(targetContext.getString(R.string.account_type))
.first { account -> account.name == accountName }
// Calendar: Check isSyncable and default interval are set correctly
assertEquals(1, ContentResolver.getIsSyncable(account, CalendarContract.AUTHORITY))
assertEquals(
settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL),
AccountSettings(targetContext, account).getSyncInterval(CalendarContract.AUTHORITY)
)
// Tasks: Check isSyncable and default sync interval were set
assertEquals(1, ContentResolver.getIsSyncable(account, provider.authority))
assertEquals(
settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL),
AccountSettings(targetContext, account).getSyncInterval(provider.authority)
)
}
}
@Test
@RequiresApi(28)
fun testModel_CreateAccount_configuresCalendarsWithoutTasks() {
val accountName = "testAccount"
val emptyServiceInfo = DavResourceFinder.Configuration.ServiceInfo()
val config = DavResourceFinder.Configuration(emptyServiceInfo, emptyServiceInfo, false, "")
// Mock TaskUtils currentProvider method, pretending that no task app is installed
mockkObject(TaskUtils)
every { TaskUtils.currentProvider(targetContext) } returns null
assertEquals(null, TaskUtils.currentProvider(targetContext))
// Mock static ContentResolver calls
// TODO: Should not be needed, see below
mockkStatic(ContentResolver::class)
every { ContentResolver.setIsSyncable(any(), any(), any()) } returns Unit
every { ContentResolver.getIsSyncable(any(), any()) } returns 1
// Create account -> should also set tasks sync interval in settings
val accountCreated = AccountDetailsFragment.Model(targetContext, db, settingsManager)
.createAccount(accountName, fakeCredentials, config, GroupMethod.GROUP_VCARDS)
assertTrue(accountCreated.getOrAwaitValue(5))
// Get the created account
val account = AccountManager.get(targetContext)
.getAccountsByType(targetContext.getString(R.string.account_type))
.first { account -> account.name == accountName }
val accountSettings = AccountSettings(targetContext, account)
// Calendar: Check automatic sync is enabled and default interval are set correctly
assertEquals(1, ContentResolver.getIsSyncable(account, CalendarContract.AUTHORITY))
assertEquals(
settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL),
accountSettings.getSyncInterval(CalendarContract.AUTHORITY)
)
// Tasks: Check isSyncable state is unknown (=-1) and sync interval is "unset" (=null)
for (authority in listOf(
TaskProvider.ProviderName.JtxBoard.authority,
TaskProvider.ProviderName.OpenTasks.authority,
TaskProvider.ProviderName.TasksOrg.authority
)) {
// Until below is fixed, just verify the method for enabling sync did not get called
verify(exactly = 0) { ContentResolver.setIsSyncable(account, authority, 1) }
// TODO: Flaky, returns 1, although it should not. It only returns -1 if the test is run
// alone, on a blank emulator and if it's the first test run.
// This seems to have to do with the previous calls to ContentResolver by other tests.
// Finding a a way of resetting the ContentResolver before each test is run should
// solve the issue.
//assertEquals(-1, ContentResolver.getIsSyncable(account, authority))
//assertNull(accountSettings.getSyncInterval(authority)) // Depends on above
}
}
}

View file

@ -0,0 +1,126 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.util.Log
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.testing.TestWorkerBuilder
import androidx.work.testing.WorkManagerTestInitHelper
import androidx.work.workDataOf
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.TestUtils.workScheduledOrRunning
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.ical4android.TaskProvider
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.Executors
@HiltAndroidTest
class PeriodicSyncWorkerTest {
companion object {
val context = InstrumentationRegistry.getInstrumentation().targetContext
private val accountManager = AccountManager.get(context)
private val account = Account("Test Account", context.getString(R.string.account_type))
private val fakeCredentials = Credentials("test", "test")
@BeforeClass
@JvmStatic
fun setUp() {
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
// However, we need notification channels for the ongoing work notifications.
NotificationUtils.createChannels(context)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
assertTrue(AccountUtils.createAccount(context, account, AccountSettings.initialUserData(fakeCredentials)))
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1)
}
@AfterClass
@JvmStatic
fun removeAccount() {
accountManager.removeAccountExplicitly(account)
}
}
private val executor = Executors.newSingleThreadExecutor()
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Before
fun inject() {
hiltRule.inject()
}
@Test
fun enable_enqueuesPeriodicWorker() {
PeriodicSyncWorker.enable(context, account, CalendarContract.AUTHORITY, 60)
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
assertTrue(workScheduledOrRunning(context, workerName))
}
@Test
fun disable_removesPeriodicWorker() {
PeriodicSyncWorker.enable(context, account, CalendarContract.AUTHORITY, 60)
PeriodicSyncWorker.disable(context, account, CalendarContract.AUTHORITY)
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
assertFalse(workScheduledOrRunning(context, workerName))
}
@Test
fun doWork_immediatelyEnqueuesSyncWorkerForGivenAuthority() {
val authorities = listOf(
context.getString(R.string.address_books_authority),
CalendarContract.AUTHORITY,
ContactsContract.AUTHORITY,
TaskProvider.ProviderName.JtxBoard.authority,
TaskProvider.ProviderName.OpenTasks.authority,
TaskProvider.ProviderName.TasksOrg.authority
)
for (authority in authorities) {
val inputData = workDataOf(
PeriodicSyncWorker.ARG_AUTHORITY to authority,
PeriodicSyncWorker.ARG_ACCOUNT_NAME to account.name,
PeriodicSyncWorker.ARG_ACCOUNT_TYPE to account.type
)
// Run PeriodicSyncWorker as TestWorker
TestWorkerBuilder<PeriodicSyncWorker>(context, executor, inputData).build().doWork()
// Check the PeriodicSyncWorker enqueued the right SyncWorker
assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context,
SyncWorker.workerName(account, authority)
))
}
}
}

View file

@ -1,35 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.syncadapter
import android.os.Bundle
import androidx.test.filters.SmallTest
import at.bitfire.davdroid.syncadapter.SyncAdapterService.SyncAdapter
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class SyncAdapterServiceTest {
@Test
@SmallTest
fun testPriorityCollections() {
val extras = Bundle()
assertTrue(SyncAdapter.priorityCollections(extras).isEmpty())
extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "")
assertTrue(SyncAdapter.priorityCollections(extras).isEmpty())
extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "123")
assertArrayEquals(longArrayOf(123), SyncAdapter.priorityCollections(extras).toLongArray())
extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, ",x,")
assertTrue(SyncAdapter.priorityCollections(extras).isEmpty())
extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "1,2,3")
assertArrayEquals(longArrayOf(1,2,3), SyncAdapter.priorityCollections(extras).toLongArray())
}
}

View file

@ -1,151 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
@HiltAndroidTest
class SyncAdapterTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var settingsManager: SettingsManager
val context by lazy { InstrumentationRegistry.getInstrumentation().context }
val targetContext by lazy { InstrumentationRegistry.getInstrumentation().targetContext }
/** use our WebDAV provider as a mock provider because it's our own and we don't need any permissions for it */
val mockAuthority = targetContext.getString(R.string.webdav_authority)
val mockProvider = context.contentResolver.acquireContentProviderClient(mockAuthority)!!
val account = Account("test", "com.example.test")
@Inject lateinit var db: AppDatabase
lateinit var syncAdapter: TestSyncAdapter
@Before
fun setUp() {
hiltRule.inject()
syncAdapter = TestSyncAdapter(targetContext)
}
@Test
fun testPriorityCollections() {
assertTrue(SyncAdapterService.SyncAdapter.priorityCollections(Bundle()).isEmpty())
assertArrayEquals(arrayOf(1L,2L), SyncAdapterService.SyncAdapter.priorityCollections(Bundle(1).apply {
putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "1,error,2")
}).toTypedArray())
}
@Test
fun testOnPerformSync_allowsSequentialSyncs() {
for (i in 0 until 5)
syncAdapter.onPerformSync(account, Bundle(), mockAuthority, mockProvider, SyncResult())
assertEquals(5, syncAdapter.syncCalled.get())
}
@Test
fun testOnPerformSync_allowsSimultaneousSyncs() {
val extras = Bundle(1)
extras.putLong(TestSyncAdapter.EXTRA_WAIT, 100) // sync takes 100 ms
val syncThreads = mutableListOf<Thread>()
for (i in 0 until 100) { // within 100 ms, at least 2 threads should be created and run simultaneously
syncThreads += Thread({
syncAdapter.onPerformSync(account, extras, "$mockAuthority-$i", mockProvider, SyncResult())
}).apply {
start()
}
}
// wait for all threads
syncThreads.forEach { it.join() }
assertEquals(100, syncAdapter.syncCalled.get())
}
@Test
fun testOnPerformSync_preventsDuplicateSyncs() {
val extras = Bundle(1)
extras.putLong(TestSyncAdapter.EXTRA_WAIT, 500) // sync takes 500 ms
val syncThreads = mutableListOf<Thread>()
for (i in 0 until 100) { // creating 100 threads should be possible within in 500 ms
syncThreads += Thread({
syncAdapter.onPerformSync(account, extras, mockAuthority, mockProvider, SyncResult())
}).apply {
start()
}
}
// wait for all threads
syncThreads.forEach { it.join() }
assertEquals(1, syncAdapter.syncCalled.get())
}
@Test
fun testOnPerformSync_runsSyncAndSetsClassLoader() {
syncAdapter.onPerformSync(account, Bundle(), mockAuthority, mockProvider, SyncResult())
// check whether onPerformSync() actually calls sync()
assertEquals(1, syncAdapter.syncCalled.get())
// check whether contextClassLoader is set
assertEquals(targetContext.classLoader, Thread.currentThread().contextClassLoader)
}
class TestSyncAdapter(context: Context): SyncAdapterService.SyncAdapter(context) {
companion object {
/**
* How long the sync() method shall wait
*/
val EXTRA_WAIT = "waitMillis"
}
val syncCalled = AtomicInteger()
override fun sync(
account: Account,
extras: Bundle,
authority: String,
httpClient: Lazy<HttpClient>,
provider: ContentProviderClient,
syncResult: SyncResult
) {
val wait = extras.getLong(EXTRA_WAIT)
Thread.sleep(wait)
syncCalled.incrementAndGet()
}
}
}

View file

@ -6,10 +6,14 @@ package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.hilt.work.HiltWorkerFactory
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.dav4jvm.PropStat
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.Response.HrefRelation
@ -20,6 +24,7 @@ import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.NotificationUtils
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.Protocol
@ -34,17 +39,6 @@ import javax.inject.Inject
@HiltAndroidTest
class SyncManagerTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var settingsManager: SettingsManager
@Before
fun inject() {
hiltRule.inject()
}
companion object {
val context = InstrumentationRegistry.getInstrumentation().targetContext
@ -67,13 +61,42 @@ class SyncManagerTest {
}
@get:Rule
val hiltRule = HiltAndroidRule(this)
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
@Inject
lateinit var settingsManager: SettingsManager
@Inject
lateinit var workerFactory: HiltWorkerFactory
val server = MockWebServer()
@Before
fun inject() {
hiltRule.inject()
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
// However, we need notification channels for the ongoing work notifications.
NotificationUtils.createChannels(context)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
}
private fun syncManager(collection: LocalTestCollection) =
TestSyncManager(
context,
account,
Bundle(),
arrayOf(),
"TestAuthority",
HttpClient.Builder().build(),
SyncResult(),

View file

@ -7,42 +7,55 @@ package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.content.Context
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.util.Log
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.testing.TestWorkerBuilder
import androidx.work.testing.WorkManagerTestInitHelper
import androidx.work.workDataOf
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TestUtils.workScheduledOrRunning
import at.bitfire.davdroid.TestUtils.workScheduledOrRunningOrSuccessful
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.NotificationUtils
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import org.junit.After
import org.junit.Assert.*
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.Rule
import org.junit.Test
import java.util.concurrent.Executors
@HiltAndroidTest
class SyncWorkerTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
val context = InstrumentationRegistry.getInstrumentation().targetContext
private val accountManager = AccountManager.get(context)
private val account = Account("Test Account", context.getString(R.string.account_type))
private val fakeCredentials = Credentials("test", "test")
private val executor = Executors.newSingleThreadExecutor()
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Before
fun inject() {
hiltRule.inject()
}
@Before
fun setUp() {
hiltRule.inject()
assertTrue(AccountUtils.createAccount(context, account, AccountSettings.initialUserData(fakeCredentials)))
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0)
@ -63,21 +76,81 @@ class SyncWorkerTest {
accountManager.removeAccountExplicitly(account)
}
@Test
fun testRequestSync_enqueuesWorker() {
SyncWorker.requestSync(context, account, CalendarContract.AUTHORITY)
fun testEnqueue_enqueuesWorker() {
SyncWorker.enqueue(context, account, CalendarContract.AUTHORITY)
val workerName = SyncWorker.workerName(account, CalendarContract.AUTHORITY)
assertTrue(workScheduledOrRunning(context, workerName))
assertTrue(workScheduledOrRunningOrSuccessful(context, workerName))
}
@Test
fun testStopSync_stopsWorker() {
SyncWorker.requestSync(context, account, CalendarContract.AUTHORITY)
SyncWorker.stopSync(context, account, CalendarContract.AUTHORITY)
val workerName = SyncWorker.workerName(account, CalendarContract.AUTHORITY)
assertFalse(workScheduledOrRunning(context, workerName))
fun testWifiConditionsMet_withoutWifi() {
val accountSettings = mockk<AccountSettings>()
every { accountSettings.getSyncWifiOnly() } returns false
assertTrue(SyncWorker.wifiConditionsMet(context, accountSettings))
}
@Test
fun testWifiConditionsMet_anyWifi_wifiEnabled() {
val accountSettings = AccountSettings(context, account)
accountSettings.setSyncWiFiOnly(true)
// here we could test whether stopping the work really interrupts the sync thread
mockkObject(SyncWorker.Companion)
every { SyncWorker.Companion.wifiAvailable(any()) } returns true
every { SyncWorker.Companion.correctWifiSsid(any(), any()) } returns true
assertTrue(SyncWorker.wifiConditionsMet(context, accountSettings))
}
@Test
fun testWifiConditionsMet_anyWifi_wifiDisabled() {
val accountSettings = AccountSettings(context, account)
accountSettings.setSyncWiFiOnly(true)
mockkObject(SyncWorker.Companion)
every { SyncWorker.Companion.wifiAvailable(any()) } returns false
every { SyncWorker.Companion.correctWifiSsid(any(), any()) } returns true
assertFalse(SyncWorker.wifiConditionsMet(context, accountSettings))
}
@Test
fun testWifiConditionsMet_correctWifiSsid() {
// TODO: Write test
}
@Test
fun testWifiConditionsMet_wrongWifiSsid() {
// TODO: Write test
}
@Test
fun testOnStopped_interruptsSyncThread() {
val authority = CalendarContract.AUTHORITY
val inputData = workDataOf(
SyncWorker.ARG_AUTHORITY to authority,
SyncWorker.ARG_ACCOUNT_NAME to account.name,
SyncWorker.ARG_ACCOUNT_TYPE to account.type
)
// Create SyncWorker as TestWorker
val testSyncWorker = TestWorkerBuilder<SyncWorker>(context, executor, inputData).build()
assertNull(testSyncWorker.syncThread)
// Run SyncWorker and assert sync thread is alive
testSyncWorker.doWork()
assertNotNull(testSyncWorker.syncThread)
assertTrue(testSyncWorker.syncThread!!.isAlive)
assertFalse(testSyncWorker.syncThread!!.isInterrupted) // Sync running
// Stop SyncWorker and assert sync thread was interrupted
testSyncWorker.onStopped()
assertNotNull(testSyncWorker.syncThread)
assertTrue(testSyncWorker.syncThread!!.isAlive)
assertTrue(testSyncWorker.syncThread!!.isInterrupted) // Sync thread interrupted
}
}

View file

@ -0,0 +1,73 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.R
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger
@HiltAndroidTest
class SyncerTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
val context = InstrumentationRegistry.getInstrumentation().targetContext
/** use our WebDAV provider as a mock provider because it's our own and we don't need any permissions for it */
val mockAuthority = context.getString(R.string.webdav_authority)
val mockProvider = context.contentResolver!!.acquireContentProviderClient(mockAuthority)!!
val account = Account(javaClass.canonicalName, context.getString(R.string.account_type))
@Before
fun setUp() {
hiltRule.inject()
}
@Test
fun testOnPerformSync_runsSyncAndSetsClassLoader() {
val syncer = TestSyncer(context)
syncer.onPerformSync(account, arrayOf(), mockAuthority, mockProvider, SyncResult())
// check whether onPerformSync() actually calls sync()
assertEquals(1, syncer.syncCalled.get())
// check whether contextClassLoader is set
assertEquals(context.classLoader, Thread.currentThread().contextClassLoader)
}
class TestSyncer(context: Context) : Syncer(context) {
val syncCalled = AtomicInteger()
override fun sync(
account: Account,
extras: Array<String>,
authority: String,
httpClient: Lazy<HttpClient>,
provider: ContentProviderClient,
syncResult: SyncResult
) {
Thread.sleep(1000)
syncCalled.incrementAndGet()
}
}
}

View file

@ -7,7 +7,6 @@ package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
@ -26,7 +25,7 @@ import org.junit.Assert.assertEquals
class TestSyncManager(
context: Context,
account: Account,
extras: Bundle,
extras: Array<String>,
authority: String,
httpClient: HttpClient,
syncResult: SyncResult,

View file

@ -13,7 +13,6 @@ import androidx.core.graphics.drawable.toBitmap
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.syncadapter.AccountsUpdatedListener
import at.bitfire.davdroid.syncadapter.SyncUtils
import at.bitfire.davdroid.ui.DebugInfoActivity
@ -89,9 +88,6 @@ class App: Application(), Thread.UncaughtExceptionHandler, Configuration.Provide
// create/update app shortcuts
UiUtils.updateShortcuts(this)
// check/repair sync intervals
AccountSettings.repairSyncIntervals(this)
}
}

View file

@ -20,7 +20,6 @@ import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.syncadapter.AccountUtils
import at.bitfire.davdroid.syncadapter.SyncUtils.removePeriodicSyncs
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.vcard4android.*
import java.io.ByteArrayOutputStream
@ -63,7 +62,7 @@ open class LocalAddressBook(
throw IllegalStateException("Couldn't create address book account")
val addressBook = LocalAddressBook(context, account, provider)
addressBook.updateSyncSettings()
addressBook.updateSyncFrameworkSettings()
// initialize Contacts Provider Settings
val values = ContentValues(2)
@ -230,7 +229,7 @@ open class LocalAddressBook(
fun update(info: Collection, forceReadOnly: Boolean) {
val newAccountName = accountName(mainAccount, info)
if (account.name != newAccountName && Build.VERSION.SDK_INT >= 21) {
if (account.name != newAccountName) {
// no need to re-assign contacts to new account, because they will be deleted by contacts provider in any case
val accountManager = AccountManager.get(context)
val future = accountManager.renameAccount(account, newAccountName, null, null)
@ -261,7 +260,7 @@ open class LocalAddressBook(
}
// make sure it will still be synchronized when contacts are updated
updateSyncSettings()
updateSyncFrameworkSettings()
}
fun delete() {
@ -277,17 +276,23 @@ open class LocalAddressBook(
/**
* Updates the sync framework settings for this address book:
*
* - Contacts sync of this address book account shall be possible isSyncable = 1
* - When a contact is changed, a sync shall be initiated (ContactsSyncAdapter) -> syncAutomatically = true
* - However, we don't want a periodic (ContactsSyncAdapter) sync for this address book
* because contact synchronization is handled by AddressBooksSyncAdapter
* (which has its own periodic sync according to the account's contacts sync interval). */
fun updateSyncSettings() {
* - Contacts sync of this address book account shall be possible -> isSyncable = 1
* - When a contact is changed, a sync shall be initiated -> syncAutomatically = true
* - Remove unwanted sync framework periodic syncs created by setSyncAutomatically, as
* we use PeriodicSyncWorker for scheduled syncs
*/
fun updateSyncFrameworkSettings() {
// Enable sync-ability
if (ContentResolver.getIsSyncable(account, ContactsContract.AUTHORITY) != 1)
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1)
// Enable content trigger
if (!ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY))
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
removePeriodicSyncs(account, ContactsContract.AUTHORITY)
// Remove periodic syncs (setSyncAutomatically also creates periodic syncs, which we don't want)
for (periodicSync in ContentResolver.getPeriodicSyncs(account, ContactsContract.AUTHORITY))
ContentResolver.removePeriodicSync(periodicSync.account, periodicSync.authority, periodicSync.extras)
}

View file

@ -329,7 +329,7 @@ class RefreshCollectionsWorker @AssistedInject constructor(
try {
DavResource(httpClient, homeSetUrl).propfind(1, *DAV_COLLECTION_PROPERTIES) { response, relation ->
// NB: This callback may be called multiple times ([MultiResponseCallback])
// Note: This callback may be called multiple times ([MultiResponseCallback])
if (!response.isSuccess())
return@propfind

View file

@ -6,10 +6,7 @@ package at.bitfire.davdroid.settings
import android.accounts.Account
import android.accounts.AccountManager
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.*
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.Parcel
@ -31,6 +28,7 @@ import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalTask
import at.bitfire.davdroid.resource.TaskUtils
import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker
import at.bitfire.davdroid.syncadapter.SyncUtils
import at.bitfire.davdroid.util.closeCompat
import at.bitfire.ical4android.AndroidCalendar
@ -79,7 +77,7 @@ class AccountSettings(
companion object {
const val CURRENT_VERSION = 13
const val CURRENT_VERSION = 14
const val KEY_SETTINGS_VERSION = "version"
const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks"
@ -148,53 +146,6 @@ class AccountSettings(
return bundle
}
fun repairSyncIntervals(context: Context) {
val addressBooksAuthority = context.getString(R.string.address_books_authority)
val taskAuthority = TaskUtils.currentProvider(context)?.authority
val am = AccountManager.get(context)
for (account in am.getAccountsByType(context.getString(R.string.account_type)))
try {
val settings = AccountSettings(context, account)
// repair address book sync
settings.getSavedAddressbooksSyncInterval()?.let { shouldBe ->
val authority = addressBooksAuthority
val current = settings.getSyncInterval(authority)
if (current != shouldBe) {
Logger.log.warning("${account.name}: $authority sync interval should be $shouldBe but is $current -> setting to $shouldBe")
if (!settings.setSyncInterval(authority, shouldBe))
Logger.log.warning("${account.name}: repairing/setting the sync interval for $authority failed")
}
}
// repair calendar sync
settings.getSavedCalendarsSyncInterval()?.let { shouldBe ->
val authority = CalendarContract.AUTHORITY
val current = settings.getSyncInterval(authority)
if (current != shouldBe) {
Logger.log.warning("${account.name}: $authority sync interval should be $shouldBe but is $current -> setting to $shouldBe")
if (!settings.setSyncInterval(authority, shouldBe))
Logger.log.warning("${account.name}: repairing/setting the sync interval for $authority failed")
}
}
if (taskAuthority != null)
// repair calendar sync
settings.getSavedTasksSyncInterval()?.let { shouldBe ->
val authority = taskAuthority
val current = settings.getSyncInterval(authority)
if (current != shouldBe) {
Logger.log.warning("${account.name}: $authority sync interval should be $shouldBe but is $current -> setting to $shouldBe")
if (!settings.setSyncInterval(authority, shouldBe))
Logger.log.warning("${account.name}: repairing/setting the sync interval for $authority failed")
}
}
} catch (ignored: InvalidAccountException) {
// account doesn't exist (anymore)
}
}
}
@ -250,81 +201,125 @@ class AccountSettings(
// sync. settings
/**
* Gets the currently set sync interval for this account in seconds.
*
* @param authority authority to check (for instance: [CalendarContract.AUTHORITY]])
* @return sync interval in seconds; *[SYNC_INTERVAL_MANUALLY]* if manual sync; *null* if not set
*/
fun getSyncInterval(authority: String): Long? {
if (ContentResolver.getIsSyncable(account, authority) <= 0)
return null
return if (ContentResolver.getSyncAutomatically(account, authority))
ContentResolver.getPeriodicSyncs(account, authority).firstOrNull()?.period ?: SYNC_INTERVAL_MANUALLY
else
SYNC_INTERVAL_MANUALLY
val key = when {
authority == context.getString(R.string.address_books_authority) ->
KEY_SYNC_INTERVAL_ADDRESSBOOKS
authority == CalendarContract.AUTHORITY ->
KEY_SYNC_INTERVAL_CALENDARS
TaskProvider.ProviderName.values().any { it.authority == authority } ->
KEY_SYNC_INTERVAL_TASKS
else -> throw IllegalArgumentException("Authority does not exist: $authority")
}
return accountManager.getUserData(account, key)?.toLong()
}
/**
* Sets the sync interval and enables/disables automatic sync for the given account and authority.
* Sets the sync interval and en- or disables periodic sync for the given account and authority.
* Does *not* call [ContentResolver.setIsSyncable].
*
* This method blocks until the settings have arrived in the sync framework, so it should not
* be called from the UI thread.
* This method blocks until a worker as been created and enqueued (sync active) or removed
* (sync disabled), so it should not be called from the UI thread.
*
* @param authority sync authority (like [CalendarContract.AUTHORITY])
* @param seconds if [SYNC_INTERVAL_MANUALLY]: automatic sync will be disabled;
* otherwise: automatic sync will be enabled and set to the given number of seconds
* otherwise ( 15 min): automatic sync will be enabled and set to the given number of seconds
*
* @return whether the sync interval was successfully set
* @throws IllegalArgumentException when [seconds] is not [SYNC_INTERVAL_MANUALLY] but less than 15 min
*/
@WorkerThread
fun setSyncInterval(authority: String, seconds: Long): Boolean {
/* Ugly hack: because there is no callback for when the sync status/interval has been
updated, we need to make this call blocking. */
val setInterval: () -> Boolean =
if (seconds == SYNC_INTERVAL_MANUALLY) {
{
Logger.log.fine("Disabling automatic sync of $account/$authority")
ContentResolver.setSyncAutomatically(account, authority, false)
if (seconds != SYNC_INTERVAL_MANUALLY && seconds < 60*15)
throw IllegalArgumentException("<15 min is not supported by Android")
/* return */ !ContentResolver.getSyncAutomatically(account, authority)
}
} else {
{
Logger.log.fine("Setting automatic sync of $account/$authority to $seconds seconds")
ContentResolver.setSyncAutomatically(account, authority, true)
ContentResolver.addPeriodicSync(account, authority, Bundle(), seconds)
/* return */ ContentResolver.getSyncAutomatically(account, authority) &&
ContentResolver.getPeriodicSyncs(account, authority).firstOrNull()?.period == seconds
}
}
// try up to 10 times with 100 ms pause
var success = false
for (idxTry in 0 until 10) {
success = setInterval()
if (success)
break
Thread.sleep(100)
}
if (!success)
val onlyManually = seconds == SYNC_INTERVAL_MANUALLY
try {
if (onlyManually) {
Logger.log.fine("Disabling periodic sync of $account/$authority")
PeriodicSyncWorker.disable(context, account, authority)
} else {
Logger.log.fine("Setting periodic sync of $account/$authority to $seconds seconds")
PeriodicSyncWorker.enable(context, account, authority, seconds)
}.result.get() // On operation (enable/disable) failure exception is thrown
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Failed to set sync interval of $account/$authority to $seconds seconds", e)
return false
// store sync interval in account settings (used when the provider is switched)
when {
authority == context.getString(R.string.address_books_authority) ->
accountManager.setUserData(account, KEY_SYNC_INTERVAL_ADDRESSBOOKS, seconds.toString())
authority == CalendarContract.AUTHORITY ->
accountManager.setUserData(account, KEY_SYNC_INTERVAL_CALENDARS, seconds.toString())
TaskProvider.ProviderName.values().any { it.authority == authority } ->
accountManager.setUserData(account, KEY_SYNC_INTERVAL_TASKS, seconds.toString())
}
// Also enable/disable content change triggered syncs (SyncFramework automatic sync).
// We could make this a separate user adjustable setting later on.
setSyncOnContentChange(authority, !onlyManually)
// Store (user defined) sync interval in account settings. Used when the provider is
// switched or re-installed
val key = when {
authority == context.getString(R.string.address_books_authority) ->
KEY_SYNC_INTERVAL_ADDRESSBOOKS
authority == CalendarContract.AUTHORITY ->
KEY_SYNC_INTERVAL_CALENDARS
TaskProvider.ProviderName.values().any { it.authority == authority } ->
KEY_SYNC_INTERVAL_TASKS
else ->
throw IllegalArgumentException("Sync interval not applicable to authority $authority")
}
accountManager.setUserData(account, key, seconds.toString())
return true
}
fun getSavedAddressbooksSyncInterval() = accountManager.getUserData(account, KEY_SYNC_INTERVAL_ADDRESSBOOKS)?.toLong()
fun getSavedCalendarsSyncInterval() = accountManager.getUserData(account, KEY_SYNC_INTERVAL_CALENDARS)?.toLong()
/**
* Enables/disables sync adapter automatic sync (content triggered sync) for the given
* account and authority. Does *not* call [ContentResolver.setIsSyncable].
*
* We use the sync adapter framework only for the trigger, actual syncing is implemented
* with WorkManager. The trigger comes in through SyncAdapterService.
*
* This method blocks until the sync-on-content-change has been enabled or disabled, so it
* should not be called from the UI thread.
*
* @param enable *true* enables automatic sync; *false* disables it
* @param authority sync authority (like [CalendarContract.AUTHORITY])
* @return whether the content triggered sync was enabled successfully
*/
@WorkerThread
fun setSyncOnContentChange(authority: String, enable: Boolean): Boolean {
// Enable content change triggers (sync adapter framework)
val setContentTrigger: () -> Boolean =
/* Ugly hack: because there is no callback for when the sync status/interval has been
updated, we need to make this call blocking. */
if (enable) {{
Logger.log.fine("Enabling content-triggered sync of $account/$authority")
ContentResolver.setSyncAutomatically(account, authority, true) // enables content triggers
// Remove unwanted sync framework periodic syncs created by setSyncAutomatically
for (periodicSync in ContentResolver.getPeriodicSyncs(account, authority))
ContentResolver.removePeriodicSync(periodicSync.account, periodicSync.authority, periodicSync.extras)
/* return */ ContentResolver.getSyncAutomatically(account, authority)
}} else {{
Logger.log.fine("Disabling content-triggered sync of $account/$authority")
ContentResolver.setSyncAutomatically(account, authority, false) // disables content triggers
/* return */ !ContentResolver.getSyncAutomatically(account, authority)
}}
// try up to 10 times with 100 ms pause
for (idxTry in 0 until 10) {
if (setContentTrigger())
// successfully set
return true
Thread.sleep(100)
}
return false
}
fun getSavedTasksSyncInterval() = accountManager.getUserData(account, KEY_SYNC_INTERVAL_TASKS)?.toLong()
fun getSyncWifiOnly() =
@ -467,6 +462,62 @@ class AccountSettings(
}
}
/**
* Disables all sync adapter periodic syncs for every authority. Then enables
* corresponding PeriodicSyncWorkers
*/
@Suppress("unused","FunctionName")
fun update_13_14() {
val authorities = listOf(
context.getString(R.string.address_books_authority),
CalendarContract.AUTHORITY,
TaskProvider.ProviderName.JtxBoard.authority,
TaskProvider.ProviderName.OpenTasks.authority,
TaskProvider.ProviderName.TasksOrg.authority
)
for (authority in authorities) {
// Enable PeriodicSyncWorker (WorkManager), with known intervals
v14_enableWorkManager(authority)
// Disable periodic syncs (sync adapter framework)
v14_disableSyncFramework(authority)
}
}
private fun v14_enableWorkManager(authority: String) {
getSyncInterval(authority)?.let { syncInterval ->
if (!setSyncInterval(authority, syncInterval))
Logger.log.severe("Failed to enable PeriodicSyncWorker for $authority")
}
}
private fun v14_disableSyncFramework(authority: String) {
// Cancel potentially running sync
ContentResolver.cancelSync(account, authority)
// Disable periodic syncs (sync adapter framework)
val disable: () -> Boolean = {
/* Ugly hack: because there is no callback for when the sync status/interval has been
updated, we need to make this call blocking. */
val syncs = ContentResolver.getPeriodicSyncs(account, authority)
for (sync in syncs) {
Logger.log.fine("Disabling sync framework periodic syncs of $account/$authority")
ContentResolver.removePeriodicSync(sync.account, sync.authority, sync.extras)
}
/* return */
!syncs.all { sync ->
ContentResolver.getPeriodicSyncs(sync.account, sync.authority).isEmpty()
}
}
// try up to 10 times with 100 ms pause
var success = false
for (idxTry in 0 until 10) {
success = disable()
if (success)
break
Thread.sleep(100)
}
if (!success)
Logger.log.severe("Failed to disable sync framework periodic syncs for $authority")
}
@Suppress("unused","FunctionName")
/**

View file

@ -0,0 +1,124 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.syncadapter
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.content.pm.PackageManager
import android.provider.ContactsContract
import androidx.core.content.ContextCompat
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.util.closeCompat
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.util.logging.Level
/**
* Sync logic for address books
*/
class AddressBookSyncer(context: Context): Syncer(context) {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AddressBooksSyncAdapterEntryPoint {
fun settingsManager(): SettingsManager
}
val entryPoint = EntryPointAccessors.fromApplication(context, AddressBooksSyncAdapterEntryPoint::class.java)
val settingsManager = entryPoint.settingsManager()
override fun sync(
account: Account,
extras: Array<String>,
authority: String,
httpClient: Lazy<HttpClient>,
provider: ContentProviderClient,
syncResult: SyncResult
) {
try {
if (updateLocalAddressBooks(account, syncResult))
for (addressBookAccount in LocalAddressBook.findAll(context, null, account).map { it.account }) {
Logger.log.log(Level.INFO, "Running sync for address book", addressBookAccount)
SyncWorker.enqueue(context, addressBookAccount, ContactsContract.AUTHORITY)
}
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync address books", e)
}
Logger.log.info("Address book sync complete")
}
private fun updateLocalAddressBooks(account: Account, syncResult: SyncResult): Boolean {
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV)
val remoteAddressBooks = mutableMapOf<HttpUrl, Collection>()
if (service != null)
for (collection in db.collectionDao().getByServiceAndSync(service.id))
remoteAddressBooks[collection.url] = collection
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
if (remoteAddressBooks.isEmpty())
Logger.log.info("No contacts permission, but no address book selected for synchronization")
else
Logger.log.warning("No contacts permission, but address books are selected for synchronization")
return false
}
val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
try {
if (contactsProvider == null) {
Logger.log.severe("Couldn't access contacts provider")
syncResult.databaseError = true
return false
}
val forceAllReadOnly = settingsManager.getBoolean(Settings.FORCE_READ_ONLY_ADDRESSBOOKS)
// delete/update local address books
for (addressBook in LocalAddressBook.findAll(context, contactsProvider, account)) {
val url = addressBook.url.toHttpUrl()
val info = remoteAddressBooks[url]
if (info == null) {
Logger.log.log(Level.INFO, "Deleting obsolete local address book", url)
addressBook.delete()
} else {
// remote CollectionInfo found for this local collection, update data
try {
Logger.log.log(Level.FINE, "Updating local address book $url", info)
addressBook.update(info, forceAllReadOnly)
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't rename address book account", e)
}
// we already have a local address book for this remote collection, don't take into consideration anymore
remoteAddressBooks -= url
}
}
// create new local address books
for ((_, info) in remoteAddressBooks) {
Logger.log.log(Level.INFO, "Adding local address book", info)
LocalAddressBook.create(context, contactsProvider, account, info, forceAllReadOnly)
}
} finally {
contactsProvider?.closeCompat()
}
return true
}
}

View file

@ -1,131 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.syncadapter
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.Context
import android.content.SyncResult
import android.content.pm.PackageManager
import android.os.Bundle
import android.provider.ContactsContract
import androidx.core.content.ContextCompat
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.util.closeCompat
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.util.logging.Level
class AddressBooksSyncAdapterService : SyncAdapterService() {
override fun syncAdapter() = AddressBooksSyncAdapter(this)
class AddressBooksSyncAdapter(context: Context) : SyncAdapter(context) {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AddressBooksSyncAdapterEntryPoint {
fun settingsManager(): SettingsManager
}
val entryPoint = EntryPointAccessors.fromApplication(context, AddressBooksSyncAdapterEntryPoint::class.java)
val settingsManager = entryPoint.settingsManager()
override fun sync(account: Account, extras: Bundle, authority: String, httpClient: Lazy<HttpClient>, provider: ContentProviderClient, syncResult: SyncResult) {
try {
val accountSettings = AccountSettings(context, account)
/* don't run sync if
- sync conditions (e.g. "sync only in WiFi") are not met AND
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
*/
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
return
if (updateLocalAddressBooks(account, syncResult))
for (addressBookAccount in LocalAddressBook.findAll(context, null, account).map { it.account }) {
Logger.log.log(Level.INFO, "Running sync for address book", addressBookAccount)
SyncWorker.requestSync(context, addressBookAccount, ContactsContract.AUTHORITY)
}
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync address books", e)
}
Logger.log.info("Address book sync complete")
}
private fun updateLocalAddressBooks(account: Account, syncResult: SyncResult): Boolean {
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV)
val remoteAddressBooks = mutableMapOf<HttpUrl, Collection>()
if (service != null)
for (collection in db.collectionDao().getByServiceAndSync(service.id))
remoteAddressBooks[collection.url] = collection
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
if (remoteAddressBooks.isEmpty())
Logger.log.info("No contacts permission, but no address book selected for synchronization")
else
Logger.log.warning("No contacts permission, but address books are selected for synchronization")
return false
}
val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
try {
if (contactsProvider == null) {
Logger.log.severe("Couldn't access contacts provider")
syncResult.databaseError = true
return false
}
val forceAllReadOnly = settingsManager.getBoolean(Settings.FORCE_READ_ONLY_ADDRESSBOOKS)
// delete/update local address books
for (addressBook in LocalAddressBook.findAll(context, contactsProvider, account)) {
val url = addressBook.url.toHttpUrl()
val info = remoteAddressBooks[url]
if (info == null) {
Logger.log.log(Level.INFO, "Deleting obsolete local address book", url)
addressBook.delete()
} else {
// remote CollectionInfo found for this local collection, update data
try {
Logger.log.log(Level.FINE, "Updating local address book $url", info)
addressBook.update(info, forceAllReadOnly)
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't rename address book account", e)
}
// we already have a local address book for this remote collection, don't take into consideration anymore
remoteAddressBooks -= url
}
}
// create new local address books
for ((_, info) in remoteAddressBooks) {
Logger.log.log(Level.INFO, "Adding local address book", info)
LocalAddressBook.create(context, contactsProvider, account, info, forceAllReadOnly)
}
} finally {
contactsProvider?.closeCompat()
}
return true
}
}
}

View file

@ -7,7 +7,6 @@ package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import at.bitfire.dav4jvm.DavCalendar
import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
@ -46,7 +45,7 @@ class CalendarSyncManager(
context: Context,
account: Account,
accountSettings: AccountSettings,
extras: Bundle,
extras: Array<String>,
httpClient: HttpClient,
authority: String,
syncResult: SyncResult,

View file

@ -0,0 +1,91 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.provider.CalendarContract
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.ical4android.AndroidCalendar
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.util.logging.Level
/**
* Sync logic for calendars
*/
class CalendarSyncer(context: Context): Syncer(context) {
override fun sync(
account: Account,
extras: Array<String>,
authority: String,
httpClient: Lazy<HttpClient>,
provider: ContentProviderClient,
syncResult: SyncResult
) {
try {
val accountSettings = AccountSettings(context, account)
if (accountSettings.getEventColors())
AndroidCalendar.insertColors(provider, account)
else
AndroidCalendar.removeColors(provider, account)
updateLocalCalendars(provider, account, accountSettings)
val calendars = AndroidCalendar
.find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null)
for (calendar in calendars) {
Logger.log.info("Synchronizing calendar #${calendar.id}, URL: ${calendar.name}")
CalendarSyncManager(context, account, accountSettings, extras, httpClient.value, authority, syncResult, calendar).performSync()
}
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync calendars", e)
}
Logger.log.info("Calendar sync complete")
}
private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) {
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)
val remoteCalendars = mutableMapOf<HttpUrl, Collection>()
if (service != null)
for (collection in db.collectionDao().getSyncCalendars(service.id)) {
remoteCalendars[collection.url] = collection
}
// delete/update local calendars
val updateColors = settings.getManageCalendarColors()
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null))
calendar.name?.let {
val url = it.toHttpUrl()
val info = remoteCalendars[url]
if (info == null) {
Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url)
calendar.delete()
} else {
// remote CollectionInfo found for this local collection, update data
Logger.log.log(Level.FINE, "Updating local calendar $url", info)
calendar.update(info, updateColors)
// we already have a local calendar for this remote collection, don't take into consideration anymore
remoteCalendars -= url
}
}
// create new local calendars
for ((_, info) in remoteCalendars) {
Logger.log.log(Level.INFO, "Adding local calendar", info)
LocalCalendar.create(account, provider, info)
}
}
}

View file

@ -1,104 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import android.provider.CalendarContract
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.ical4android.AndroidCalendar
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.util.logging.Level
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.set
class CalendarsSyncAdapterService: SyncAdapterService() {
override fun syncAdapter() = CalendarsSyncAdapter(this)
class CalendarsSyncAdapter(context: Context) : SyncAdapter(context) {
override fun sync(account: Account, extras: Bundle, authority: String, httpClient: Lazy<HttpClient>, provider: ContentProviderClient, syncResult: SyncResult) {
try {
val accountSettings = AccountSettings(context, account)
/* don't run sync if
- sync conditions (e.g. "sync only in WiFi") are not met AND
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
*/
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
return
if (accountSettings.getEventColors())
AndroidCalendar.insertColors(provider, account)
else
AndroidCalendar.removeColors(provider, account)
updateLocalCalendars(provider, account, accountSettings)
val priorityCalendars = priorityCollections(extras)
val calendars = AndroidCalendar
.find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null)
.sortedByDescending { priorityCalendars.contains(it.id) }
for (calendar in calendars) {
Logger.log.info("Synchronizing calendar #${calendar.id}, URL: ${calendar.name}")
CalendarSyncManager(context, account, accountSettings, extras, httpClient.value, authority, syncResult, calendar).let {
it.performSync()
}
}
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync calendars", e)
}
Logger.log.info("Calendar sync complete")
}
private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) {
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)
val remoteCalendars = mutableMapOf<HttpUrl, Collection>()
if (service != null)
for (collection in db.collectionDao().getSyncCalendars(service.id)) {
remoteCalendars[collection.url] = collection
}
// delete/update local calendars
val updateColors = settings.getManageCalendarColors()
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null))
calendar.name?.let {
val url = it.toHttpUrl()
val info = remoteCalendars[url]
if (info == null) {
Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url)
calendar.delete()
} else {
// remote CollectionInfo found for this local collection, update data
Logger.log.log(Level.FINE, "Updating local calendar $url", info)
calendar.update(info, updateColors)
// we already have a local calendar for this remote collection, don't take into consideration anymore
remoteCalendars -= url
}
}
// create new local calendars
for ((_, info) in remoteCalendars) {
Logger.log.log(Level.INFO, "Adding local calendar", info)
LocalCalendar.create(account, provider, info)
}
}
}
}

View file

@ -0,0 +1,64 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.provider.ContactsContract
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.settings.AccountSettings
import java.util.logging.Level
/**
* Sync logic for contacts
*/
class ContactSyncer(context: Context): Syncer(context) {
companion object {
const val PREVIOUS_GROUP_METHOD = "previous_group_method"
}
override fun sync(
account: Account,
extras: Array<String>,
authority: String,
httpClient: Lazy<HttpClient>,
provider: ContentProviderClient,
syncResult: SyncResult
) {
try {
val accountSettings = AccountSettings(context, account)
val addressBook = LocalAddressBook(context, account, provider)
// handle group method change
val groupMethod = accountSettings.getGroupMethod().name
accountSettings.accountManager.getUserData(account, PREVIOUS_GROUP_METHOD)?.let { previousGroupMethod ->
if (previousGroupMethod != groupMethod) {
Logger.log.info("Group method changed, deleting all local contacts/groups")
// delete all local contacts and groups so that they will be downloaded again
provider.delete(addressBook.syncAdapterURI(ContactsContract.RawContacts.CONTENT_URI), null, null)
provider.delete(addressBook.syncAdapterURI(ContactsContract.Groups.CONTENT_URI), null, null)
// reset sync state
addressBook.syncState = null
}
}
accountSettings.accountManager.setUserData(account, PREVIOUS_GROUP_METHOD, groupMethod)
Logger.log.info("Synchronizing address book: ${addressBook.url}")
Logger.log.info("Taking settings from: ${addressBook.mainAccount}")
ContactsSyncManager(context, account, accountSettings, httpClient.value, extras, authority, syncResult, provider, addressBook).performSync()
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync contacts", e)
}
Logger.log.info("Contacts sync complete")
}
}

View file

@ -1,73 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import android.provider.ContactsContract
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.settings.AccountSettings
import java.util.logging.Level
class ContactsSyncAdapterService: SyncAdapterService() {
companion object {
const val PREVIOUS_GROUP_METHOD = "previous_group_method"
}
override fun syncAdapter() = ContactsSyncAdapter(this)
class ContactsSyncAdapter(context: Context) : SyncAdapter(context) {
override fun sync(account: Account, extras: Bundle, authority: String, httpClient: Lazy<HttpClient>, provider: ContentProviderClient, syncResult: SyncResult) {
try {
val accountSettings = AccountSettings(context, account)
val addressBook = LocalAddressBook(context, account, provider)
// handle group method change
val groupMethod = accountSettings.getGroupMethod().name
accountSettings.accountManager.getUserData(account, PREVIOUS_GROUP_METHOD)?.let { previousGroupMethod ->
if (previousGroupMethod != groupMethod) {
Logger.log.info("Group method changed, deleting all local contacts/groups")
// delete all local contacts and groups so that they will be downloaded again
provider.delete(addressBook.syncAdapterURI(ContactsContract.RawContacts.CONTENT_URI), null, null)
provider.delete(addressBook.syncAdapterURI(ContactsContract.Groups.CONTENT_URI), null, null)
// reset sync state
addressBook.syncState = null
}
}
accountSettings.accountManager.setUserData(account, PREVIOUS_GROUP_METHOD, groupMethod)
/* don't run sync if
- sync conditions (e.g. "sync only in WiFi") are not met AND
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
*/
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
return
Logger.log.info("Synchronizing address book: ${addressBook.url}")
Logger.log.info("Taking settings from: ${addressBook.mainAccount}")
ContactsSyncManager(context, account, accountSettings, httpClient.value, extras, authority, syncResult, provider, addressBook).let {
it.performSync()
}
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync contacts", e)
}
Logger.log.info("Contacts sync complete")
}
}
}

View file

@ -10,7 +10,6 @@ import android.content.ContentResolver
import android.content.Context
import android.content.SyncResult
import android.os.Build
import android.os.Bundle
import at.bitfire.dav4jvm.DavAddressBook
import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
@ -83,7 +82,7 @@ class ContactsSyncManager(
account: Account,
accountSettings: AccountSettings,
httpClient: HttpClient,
extras: Bundle,
extras: Array<String>,
authority: String,
syncResult: SyncResult,
val provider: ContentProviderClient,
@ -114,7 +113,7 @@ class ContactsSyncManager(
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
val reallyDirty = localCollection.verifyDirty()
val deleted = localCollection.findDeleted().size
if (extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD) && reallyDirty == 0 && deleted == 0) {
if (extras.contains(ContentResolver.SYNC_EXTRAS_UPLOAD) && reallyDirty == 0 && deleted == 0) {
Logger.log.info("This sync was called to up-sync dirty/deleted contacts, but no contacts have been changed")
return false
}
@ -216,7 +215,7 @@ class ContactsSyncManager(
groupStrategy.beforeUploadDirty()
// generate UID/file name for newly created contacts
var superModified = super.uploadDirty()
val superModified = super.uploadDirty()
// return true when any operation returned true
return modified or superModified

View file

@ -1,113 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.Context
import android.content.SyncResult
import android.os.Build
import android.os.Bundle
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalJtxCollection
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.ical4android.JtxCollection
import at.bitfire.ical4android.TaskProvider
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.util.logging.Level
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.set
class JtxSyncAdapterService: SyncAdapterService() {
override fun syncAdapter() = JtxSyncAdapter(this)
class JtxSyncAdapter(context: Context) : SyncAdapter(context) {
override fun sync(account: Account, extras: Bundle, authority: String, httpClient: Lazy<HttpClient>, provider: ContentProviderClient, syncResult: SyncResult) {
try {
// check whether jtx Board is new enough
TaskProvider.checkVersion(context, TaskProvider.ProviderName.JtxBoard)
// make sure account can be seen by task provider
if (Build.VERSION.SDK_INT >= 26) {
/* Warning: If setAccountVisibility is called, Android 12 broadcasts the
AccountManager.LOGIN_ACCOUNTS_CHANGED_ACTION Intent. This cancels running syncs
and starts them again! So make sure setAccountVisibility is only called when necessary. */
val am = AccountManager.get(context)
if (am.getAccountVisibility(account, TaskProvider.ProviderName.JtxBoard.packageName) != AccountManager.VISIBILITY_VISIBLE)
am.setAccountVisibility(account, TaskProvider.ProviderName.JtxBoard.packageName, AccountManager.VISIBILITY_VISIBLE)
}
/* don't run sync if
- sync conditions (e.g. "sync only in WiFi") are not met AND
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
*/
val accountSettings = AccountSettings(context, account)
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
return
// sync list of collections
updateLocalCollections(account, provider)
// sync contents of collections
val collections = JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null)
for (collection in collections) {
Logger.log.info("Synchronizing $collection")
JtxSyncManager(context, account, accountSettings, extras, httpClient.value, authority, syncResult, collection).let {
it.performSync()
}
}
} catch (e: TaskProvider.ProviderTooOldException) {
SyncUtils.notifyProviderTooOld(context, e)
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync jtx collections", e)
}
Logger.log.info("jtx sync complete")
}
private fun updateLocalCollections(account: Account, client: ContentProviderClient) {
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)
val remoteCollections = mutableMapOf<HttpUrl, Collection>()
if (service != null)
for (collection in db.collectionDao().getSyncJtxCollections(service.id))
remoteCollections[collection.url] = collection
for (jtxCollection in JtxCollection.find(account, client, context, LocalJtxCollection.Factory, null, null))
jtxCollection.url?.let { strUrl ->
val url = strUrl.toHttpUrl()
val info = remoteCollections[url]
if (info == null) {
Logger.log.fine("Deleting obsolete local collection $url")
jtxCollection.delete()
} else {
// remote CollectionInfo found for this local collection, update data
Logger.log.log(Level.FINE, "Updating local collection $url", info)
val owner = info.ownerId?.let { db.principalDao().get(it) }
jtxCollection.updateCollection(info, owner)
// we already have a local task list for this remote collection, don't take into consideration anymore
remoteCollections -= url
}
}
// create new local collections
for ((_,info) in remoteCollections) {
Logger.log.log(Level.INFO, "Adding local collections", info)
val owner = info.ownerId?.let { db.principalDao().get(it) }
LocalJtxCollection.create(account, client, info, owner)
}
}
}
}

View file

@ -7,7 +7,6 @@ package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import at.bitfire.dav4jvm.DavCalendar
import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
@ -38,7 +37,7 @@ class JtxSyncManager(
context: Context,
account: Account,
accountSettings: AccountSettings,
extras: Bundle,
extras: Array<String>,
httpClient: HttpClient,
authority: String,
syncResult: SyncResult,

View file

@ -0,0 +1,104 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.os.Build
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalJtxCollection
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.ical4android.JtxCollection
import at.bitfire.ical4android.TaskProvider
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.util.logging.Level
/**
* Sync logic for jtx board
*/
class JtxSyncer(context: Context): Syncer(context) {
override fun sync(
account: Account,
extras: Array<String>,
authority: String,
httpClient: Lazy<HttpClient>,
provider: ContentProviderClient,
syncResult: SyncResult
) {
try {
// check whether jtx Board is new enough
TaskProvider.checkVersion(context, TaskProvider.ProviderName.JtxBoard)
// make sure account can be seen by task provider
if (Build.VERSION.SDK_INT >= 26) {
/* Warning: If setAccountVisibility is called, Android 12 broadcasts the
AccountManager.LOGIN_ACCOUNTS_CHANGED_ACTION Intent. This cancels running syncs
and starts them again! So make sure setAccountVisibility is only called when necessary. */
val am = AccountManager.get(context)
if (am.getAccountVisibility(account, TaskProvider.ProviderName.JtxBoard.packageName) != AccountManager.VISIBILITY_VISIBLE)
am.setAccountVisibility(account, TaskProvider.ProviderName.JtxBoard.packageName, AccountManager.VISIBILITY_VISIBLE)
}
val accountSettings = AccountSettings(context, account)
// sync list of collections
updateLocalCollections(account, provider)
// sync contents of collections
val collections = JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null)
for (collection in collections) {
Logger.log.info("Synchronizing $collection")
JtxSyncManager(context, account, accountSettings, extras, httpClient.value, authority, syncResult, collection).performSync()
}
} catch (e: TaskProvider.ProviderTooOldException) {
SyncUtils.notifyProviderTooOld(context, e)
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync jtx collections", e)
}
Logger.log.info("jtx sync complete")
}
private fun updateLocalCollections(account: Account, client: ContentProviderClient) {
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)
val remoteCollections = mutableMapOf<HttpUrl, Collection>()
if (service != null)
for (collection in db.collectionDao().getSyncJtxCollections(service.id))
remoteCollections[collection.url] = collection
for (jtxCollection in JtxCollection.find(account, client, context, LocalJtxCollection.Factory, null, null))
jtxCollection.url?.let { strUrl ->
val url = strUrl.toHttpUrl()
val info = remoteCollections[url]
if (info == null) {
Logger.log.fine("Deleting obsolete local collection $url")
jtxCollection.delete()
} else {
// remote CollectionInfo found for this local collection, update data
Logger.log.log(Level.FINE, "Updating local collection $url", info)
val owner = info.ownerId?.let { db.principalDao().get(it) }
jtxCollection.updateCollection(info, owner)
// we already have a local task list for this remote collection, don't take into consideration anymore
remoteCollections -= url
}
}
// create new local collections
for ((_,info) in remoteCollections) {
Logger.log.log(Level.INFO, "Adding local collections", info)
val owner = info.ownerId?.let { db.principalDao().get(it) }
LocalJtxCollection.create(account, client, info, owner)
}
}
}

View file

@ -1,7 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.syncadapter
class OpenTasksSyncAdapterService: TasksSyncAdapterService()

View file

@ -0,0 +1,134 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.Context
import android.provider.CalendarContract
import androidx.hilt.work.HiltWorker
import androidx.work.*
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.AccountSettings
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import java.util.concurrent.TimeUnit
/**
* Handles scheduled sync requests.
*
* Enqueues immediate [SyncWorker] syncs at the appropriate moment. This will prevent the actual
* sync code from running twice simultaneously (for manual and scheduled sync).
*
* For each account there will be multiple dedicated workers running for each authority.
*/
@HiltWorker
class PeriodicSyncWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters
) : Worker(appContext, workerParams) {
companion object {
private const val WORKER_TAG = "periodic-sync"
// Worker input parameters
internal const val ARG_ACCOUNT_NAME = "accountName"
internal const val ARG_ACCOUNT_TYPE = "accountType"
internal const val ARG_AUTHORITY = "authority"
/**
* Name of this worker.
* Used to distinguish between other work processes. A worker names are unique. There can
* never be two running workers with the same name.
*/
fun workerName(account: Account, authority: String): String =
"$WORKER_TAG $authority ${account.type}/${account.name}"
/**
* Activate scheduled synchronization of an account with a specific authority.
*
* @param account account to sync
* @param authority authority to sync (for instance: [CalendarContract.AUTHORITY]])
* @param interval interval between recurring syncs in seconds
* @return operation object to check when and whether activation was successful
*/
fun enable(context: Context, account: Account, authority: String, interval: Long): Operation {
val arguments = Data.Builder()
.putString(ARG_AUTHORITY, authority)
.putString(ARG_ACCOUNT_NAME, account.name)
.putString(ARG_ACCOUNT_TYPE, account.type)
.build()
val constraints = Constraints.Builder()
.setRequiredNetworkType(
if (AccountSettings(context, account).getSyncWifiOnly())
NetworkType.UNMETERED
else
NetworkType.CONNECTED
).build()
val workRequest = PeriodicWorkRequestBuilder<PeriodicSyncWorker>(interval, TimeUnit.SECONDS)
.addTag(WORKER_TAG)
.setInputData(arguments)
.setConstraints(constraints)
.build()
return WorkManager.getInstance(context).enqueueUniquePeriodicWork(
workerName(account, authority),
// if a periodic sync exists already, we want to update it with the new interval
// and/or new required network type (applies on next iteration of periodic worker)
ExistingPeriodicWorkPolicy.UPDATE,
workRequest
)
}
/**
* Disables scheduled synchronization of an account for a specific authority.
*
* @param account account to sync
* @param authority authority to sync (for instance: [CalendarContract.AUTHORITY]])
* @return operation object to check process state of work cancellation
*/
fun disable(context: Context, account: Account, authority: String): Operation =
WorkManager.getInstance(context)
.cancelUniqueWork(workerName(account, authority))
/**
* Finds out whether the [PeriodicSyncWorker] is currently enqueued or running
*
* @param account account to check
* @param authority authority to check (for instance: [CalendarContract.AUTHORITY]])
* @return boolean whether the [PeriodicSyncWorker] is running or enqueued
*/
fun isEnabled(context: Context, account: Account, authority: String): Boolean =
WorkManager.getInstance(context)
.getWorkInfos(
WorkQuery.Builder
.fromUniqueWorkNames(listOf(workerName(account, authority)))
.addStates(listOf(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING))
.build()
).get()
.isNotEmpty()
}
override fun doWork(): Result {
val account = Account(
inputData.getString(ARG_ACCOUNT_NAME) ?: throw IllegalArgumentException("$ARG_ACCOUNT_NAME required"),
inputData.getString(ARG_ACCOUNT_TYPE) ?: throw IllegalArgumentException("$ARG_ACCOUNT_TYPE required")
)
val authority = inputData.getString(ARG_AUTHORITY) ?: throw IllegalArgumentException("$ARG_AUTHORITY required")
Logger.log.info("Running periodic sync worker: account=$account, authority=$authority")
val accountSettings = AccountSettings(applicationContext, account)
if (!SyncWorker.wifiConditionsMet(applicationContext, accountSettings)) {
Logger.log.info("Sync conditions not met. Won't run sync.")
return Result.failure()
}
// Just request immediate sync
Logger.log.info("Requesting immediate sync")
SyncWorker.enqueue(applicationContext, account, authority)
return Result.success()
}
}

View file

@ -1,215 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.app.Service
import android.content.*
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.wifi.WifiManager
import android.os.Bundle
import androidx.core.content.getSystemService
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.account.WifiPermissionsActivity
import at.bitfire.davdroid.util.ConcurrentUtils
import at.bitfire.davdroid.util.PermissionUtils
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import java.util.*
import java.util.logging.Level
abstract class SyncAdapterService: Service() {
companion object {
/**
* Specifies an list of IDs which are requested to be synchronized before
* the other collections. For instance, if some calendars of a CalDAV
* account are visible in the calendar app and others are hidden, the visible calendars can
* be synchronized first, so that the "Refresh" action in the calendar app is more responsive.
*
* Extra type: String (comma-separated list of IDs)
*
* In case of calendar sync, the extra value is a list of Android calendar IDs.
* In case of task sync, the extra value is an a list of OpenTask task list IDs.
*/
const val SYNC_EXTRAS_PRIORITY_COLLECTIONS = "priority_collections"
/**
* Requests a re-synchronization of all entries. For instance, if this extra is
* set for a calendar sync, all remote events will be listed and checked for remote
* changes again.
*
* Useful if settings which modify the remote resource list (like the CalDAV setting
* "sync events n days in the past") have been changed.
*/
const val SYNC_EXTRAS_RESYNC = "resync"
/**
* Requests a full re-synchronization of all entries. For instance, if this extra is
* set for an address book sync, all contacts will be downloaded again and updated in the
* local storage.
*
* Useful if settings which modify parsing/local behavior have been changed.
*/
const val SYNC_EXTRAS_FULL_RESYNC = "full_resync"
}
protected abstract fun syncAdapter(): AbstractThreadedSyncAdapter
override fun onBind(intent: Intent?) = syncAdapter().syncAdapterBinder!!
/**
* Base class for our sync adapters. Guarantees that
*
* 1. not more than one sync adapter per account and authority is running at a time,
* 2. `Thread.currentThread().contextClassLoader` is set to the current context's class loader.
*
* Also provides some useful methods that can be used by derived sync adapters.
*/
abstract class SyncAdapter(
context: Context
): AbstractThreadedSyncAdapter(
context,
true // isSyncable shouldn't be -1 because DAVx5 sets it to 0 or 1.
// However, if it is -1 by accident, set it to 1 to avoid endless sync loops.
) {
companion object {
fun priorityCollections(extras: Bundle): Set<Long> {
val ids = mutableSetOf<Long>()
extras.getString(SYNC_EXTRAS_PRIORITY_COLLECTIONS)?.let { rawIds ->
for (rawId in rawIds.split(','))
try {
ids += rawId.toLong()
} catch (e: NumberFormatException) {
Logger.log.log(Level.WARNING, "Couldn't parse SYNC_EXTRAS_PRIORITY_COLLECTIONS", e)
}
}
return ids
}
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface SyncAdapterEntryPoint {
fun appDatabase(): AppDatabase
}
private val syncAdapterEntryPoint = EntryPointAccessors.fromApplication(context, SyncAdapterEntryPoint::class.java)
internal val db = syncAdapterEntryPoint.appDatabase()
abstract fun sync(account: Account, extras: Bundle, authority: String, httpClient: Lazy<HttpClient>, provider: ContentProviderClient, syncResult: SyncResult)
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
Logger.log.log(Level.INFO, "$authority sync of $account has been initiated", extras.keySet().joinToString(", "))
// prevent multiple syncs of the same authority and account to run simultaneously
val currentSyncKey = Pair(authority, account)
if (ConcurrentUtils.runSingle(currentSyncKey) {
// required for ServiceLoader -> ical4j -> ical4android
Thread.currentThread().contextClassLoader = context.classLoader
val accountSettings by lazy { AccountSettings(context, account) }
val httpClient = lazy { HttpClient.Builder(context, accountSettings).build() }
try {
val runSync = /* always true in open-source edition */ true
if (runSync)
sync(account, extras, authority, httpClient, provider, syncResult)
} catch (e: InvalidAccountException) {
Logger.log.log(
Level.WARNING,
"Account was removed during synchronization",
e
)
} finally {
if (httpClient.isInitialized())
httpClient.value.close()
}
})
Logger.log.log(Level.INFO, "Sync for $currentSyncKey finished", syncResult)
else {
Logger.log.warning("There's already another running sync for $currentSyncKey, aborting")
}
}
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
Logger.log.log(Level.WARNING, "Security exception when opening content provider for $authority")
}
override fun onSyncCanceled() {
Logger.log.info("Sync thread cancelled! Interrupting sync")
super.onSyncCanceled()
}
override fun onSyncCanceled(thread: Thread) {
Logger.log.info("Sync thread ${thread.id} cancelled! Interrupting sync")
super.onSyncCanceled(thread)
}
protected fun checkSyncConditions(settings: AccountSettings): Boolean {
if (settings.getSyncWifiOnly()) {
// WiFi required
val connectivityManager = context.getSystemService<ConnectivityManager>()!!
// check for connected WiFi network
var wifiAvailable = false
connectivityManager.allNetworks.forEach { network ->
connectivityManager.getNetworkCapabilities(network)?.let { capabilities ->
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))
wifiAvailable = true
}
}
if (!wifiAvailable) {
Logger.log.info("Not on connected WiFi, stopping")
return false
}
// if execution reaches this point, we're on a connected WiFi
settings.getSyncWifiOnlySSIDs()?.let { onlySSIDs ->
// check required permissions and location status
if (!PermissionUtils.canAccessWifiSsid(context)) {
// not all permissions granted; show notification
val intent = Intent(context, WifiPermissionsActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.putExtra(WifiPermissionsActivity.EXTRA_ACCOUNT, settings.account)
PermissionUtils.notifyPermissions(context, intent)
Logger.log.warning("Can't access WiFi SSID, aborting sync")
return false
}
val wifi = context.getSystemService<WifiManager>()!!
val info = wifi.connectionInfo
if (info == null || !onlySSIDs.contains(info.ssid.trim('"'))) {
Logger.log.info("Connected to wrong WiFi network (${info.ssid}), aborting sync")
return false
} else
Logger.log.fine("Connected to WiFi network ${info.ssid}")
}
}
return true
}
}
}

View file

@ -0,0 +1,82 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.app.Service
import android.content.AbstractThreadedSyncAdapter
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.SyncResult
import android.os.Bundle
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.AccountSettings
import java.util.logging.Level
abstract class SyncAdapterService: Service() {
fun syncAdapter() = SyncAdapter(this)
override fun onBind(intent: Intent?) = syncAdapter().syncAdapterBinder!!
/**
* Entry point for the sync adapter framework.
*
* Handles incoming sync requests from the sync adapter framework.
*
* Although we do not use the sync adapter for syncing anymore, we keep this sole
* adapter to provide exported services, which allow android system components and calendar,
* contacts or task apps to sync via DAVx5.
*/
class SyncAdapter(
context: Context
): AbstractThreadedSyncAdapter(
context,
true // isSyncable shouldn't be -1 because DAVx5 sets it to 0 or 1.
// However, if it is -1 by accident, set it to 1 to avoid endless sync loops.
) {
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
// We seem to have to pass this old SyncFramework extra for an Android 7 workaround
val upload = extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD)
Logger.log.info("Sync request via sync adapter (upload=$upload)")
// Should we run the sync at all?
if (!SyncWorker.wifiConditionsMet(context, AccountSettings(context, account))) {
Logger.log.info("Sync conditions not met. Aborting sync adapter")
return
}
Logger.log.fine("Sync adapter now handing over to SyncWorker")
SyncWorker.enqueue(context, account, authority, upload = upload)
}
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
Logger.log.log(Level.WARNING, "Security exception for $account/$authority")
}
override fun onSyncCanceled() {
Logger.log.info("Ignoring sync adapter cancellation")
super.onSyncCanceled()
}
override fun onSyncCanceled(thread: Thread) {
Logger.log.info("Ignoring sync adapter cancellation")
super.onSyncCanceled(thread)
}
}
}
// exported sync adapter services; we need a separate class for each authority
class AddressBooksSyncAdapterService: SyncAdapterService()
class CalendarsSyncAdapterService: SyncAdapterService()
class ContactsSyncAdapterService: SyncAdapterService()
class JtxSyncAdapterService: SyncAdapterService()
class OpenTasksSyncAdapterService: SyncAdapterService()
class TasksOrgSyncAdapterService: SyncAdapterService()

View file

@ -11,7 +11,6 @@ import android.content.Context
import android.content.Intent
import android.content.SyncResult
import android.net.Uri
import android.os.Bundle
import android.os.RemoteException
import android.provider.CalendarContract
import android.provider.ContactsContract
@ -68,7 +67,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
val account: Account,
val accountSettings: AccountSettings,
val httpClient: HttpClient,
val extras: Bundle,
val extras: Array<String>,
val authority: String,
val syncResult: SyncResult,
val localCollection: CollectionType
@ -159,7 +158,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
Logger.log.info("Processing local deletes/updates")
val modificationsPresent = processLocallyDeleted() || uploadDirty()
if (extras.containsKey(SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC)) {
if (extras.contains(Syncer.SYNC_EXTRAS_FULL_RESYNC)) {
Logger.log.info("Forcing re-synchronization of all entries")
// forget sync state of collection (→ initial sync in case of SyncAlgorithm.COLLECTION_SYNC)
@ -256,7 +255,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
}, { e, local, remote ->
when (e) {
// sync was cancelled or account has been removed: re-throw to SyncAdapterService
// sync was cancelled or account has been removed: re-throw to SyncAdapterService (now BaseSyncer)
is InterruptedException,
is InterruptedIOException,
is InvalidAccountException ->
@ -466,8 +465,8 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
* [uploadDirty] were true), a sync is always required and this method
* should *not* be evaluated.
*
* Will return _true_ if [SyncAdapterService.SYNC_EXTRAS_RESYNC] and/or
* [SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC] is set in [extras].
* Will return _true_ if [Syncer.SYNC_EXTRAS_RESYNC] and/or
* [Syncer.SYNC_EXTRAS_FULL_RESYNC] is set in [extras].
*
* @param state remote sync state to compare local sync state with
*
@ -475,8 +474,8 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
* sync algorithm is required
*/
protected open fun syncRequired(state: SyncState?): Boolean {
if (extras.containsKey(SyncAdapterService.SYNC_EXTRAS_RESYNC) ||
extras.containsKey(SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC))
if (extras.contains(Syncer.SYNC_EXTRAS_RESYNC) ||
extras.contains(Syncer.SYNC_EXTRAS_FULL_RESYNC))
return true
val localState = localCollection.lastSyncState

View file

@ -1,63 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.ContentResolver
import android.content.Context
import android.provider.ContactsContract
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import at.bitfire.davdroid.resource.LocalAddressBook
enum class SyncStatus {
ACTIVE, PENDING, IDLE;
companion object {
/**
* Returns the sync status of a given account. Checks the account itself and possible
* sub-accounts (address book accounts).
*
* @param authorities sync authorities to check (usually taken from [syncAuthorities])
*
* @return sync status of the given account
*/
fun fromAccount(context: Context, authorities: Iterable<String>, account: Account): SyncStatus {
// check sync framework syncs are active or pending
if (authorities.any { ContentResolver.isSyncActive(account, it) })
return SyncStatus.ACTIVE
val addrBookAccounts = LocalAddressBook.findAll(context, null, account).map { it.account }
if (addrBookAccounts.any { ContentResolver.isSyncActive(it, ContactsContract.AUTHORITY) })
return SyncStatus.ACTIVE
if (authorities.any { ContentResolver.isSyncPending(account, it) } ||
addrBookAccounts.any { ContentResolver.isSyncPending(it, ContactsContract.AUTHORITY) })
return SyncStatus.PENDING
// Also check SyncWorkers
val workerNames = authorities.map { authority ->
SyncWorker.workerName(account, authority)
}
val workQuery = WorkQuery.Builder
.fromUniqueWorkNames(workerNames)
.addStates(listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED))
.build()
val workInfos = WorkManager.getInstance(context).getWorkInfos(workQuery).get()
when {
workInfos.any { workInfo ->
workInfo.state == WorkInfo.State.RUNNING
} -> return SyncStatus.ACTIVE
workInfos.any { workInfo ->
workInfo.state == WorkInfo.State.ENQUEUED
} -> return SyncStatus.PENDING
}
// None active or pending? Then we're idle ..
return SyncStatus.IDLE
}
}
}

View file

@ -88,11 +88,6 @@ object SyncUtils {
nm.notifyIfPossible(NotificationUtils.NOTIFY_TASKS_PROVIDER_TOO_OLD, notify.build())
}
fun removePeriodicSyncs(account: Account, authority: String) {
for (sync in ContentResolver.getPeriodicSyncs(account, authority))
ContentResolver.removePeriodicSync(sync.account, sync.authority, sync.extras)
}
/**
* Returns a list of all available sync authorities for main accounts (!= address book accounts):
*
@ -116,7 +111,6 @@ object SyncUtils {
return result
}
// task sync utils
@WorkerThread

View file

@ -8,30 +8,52 @@ import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.SyncResult
import android.os.Bundle
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.wifi.WifiManager
import android.provider.CalendarContract
import android.provider.ContactsContract
import androidx.annotation.IntDef
import androidx.concurrent.futures.CallbackToFutureAdapter
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import androidx.hilt.work.HiltWorker
import androidx.lifecycle.LiveData
import androidx.lifecycle.map
import androidx.work.*
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import androidx.work.WorkRequest
import androidx.work.Worker
import androidx.work.WorkerParameters
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.util.LiveDataUtils
import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible
import at.bitfire.davdroid.ui.account.WifiPermissionsActivity
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.util.closeCompat
import at.bitfire.ical4android.TaskProvider
import com.google.common.util.concurrent.ListenableFuture
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import java.util.concurrent.TimeUnit
import java.util.logging.Level
/**
* Handles sync requests
* Handles immediate sync requests, status queries and cancellation for one or multiple authorities
*/
@HiltWorker
class SyncWorker @AssistedInject constructor(
@ -41,104 +63,224 @@ class SyncWorker @AssistedInject constructor(
companion object {
const val ARG_ACCOUNT_NAME = "accountName"
const val ARG_ACCOUNT_TYPE = "accountType"
const val ARG_AUTHORITY = "authority"
// Worker input parameters
internal const val ARG_ACCOUNT_NAME = "accountName"
internal const val ARG_ACCOUNT_TYPE = "accountType"
internal const val ARG_AUTHORITY = "authority"
const val ARG_RESYNC = "resync"
private const val ARG_UPLOAD = "upload"
private const val ARG_RESYNC = "resync"
@IntDef(NO_RESYNC, RESYNC, FULL_RESYNC)
annotation class ArgResync
const val NO_RESYNC = 0
const val RESYNC = 1
const val FULL_RESYNC = 2
fun workerName(account: Account, authority: String): String {
return "explicit-sync $authority ${account.type}/${account.name}"
}
// This SyncWorker's tag
const val TAG_SYNC = "sync"
/**
* How often this work will be retried to run after soft (network) errors.
*
* Retry strategy is defined in work request ([enqueue]).
*/
internal const val MAX_RUN_ATTEMPTS = 5
/**
* Name of this worker.
* Used to distinguish between other work processes. There must only ever be one worker with the exact same name.
*/
fun workerName(account: Account, authority: String) =
"$TAG_SYNC $authority ${account.type}/${account.name}"
/**
* Requests immediate synchronization of an account with all applicable
* authorities (contacts, calendars, ).
*
* @param account account to sync
* @see enqueue
*/
fun requestSync(context: Context, account: Account, @ArgResync resync: Int = NO_RESYNC) {
fun enqueueAllAuthorities(
context: Context,
account: Account,
@ArgResync resync: Int = NO_RESYNC,
upload: Boolean = false
) {
for (authority in SyncUtils.syncAuthorities(context))
requestSync(context, account, authority, resync)
enqueue(context, account, authority, resync, upload)
}
/**
* Requests immediate synchronization of an account with a specific authority.
*
* @param account account to sync
* @param authority authority to sync (for instance: [CalendarContract.AUTHORITY]])
* @param resync whether to request re-synchronization
* @param account account to sync
* @param authority authority to sync (for instance: [CalendarContract.AUTHORITY])
* @param resync whether to request (full) re-synchronization or not
* @param upload see [ContentResolver.SYNC_EXTRAS_UPLOAD] used only for contacts sync
* and android 7 workaround
*/
fun requestSync(context: Context, account: Account, authority: String, @ArgResync resync: Int = NO_RESYNC) {
fun enqueue(
context: Context,
account: Account,
authority: String,
@ArgResync resync: Int = NO_RESYNC,
upload: Boolean = false
) {
// Worker arguments
val argumentsBuilder = Data.Builder()
.putString(ARG_AUTHORITY, authority)
.putString(ARG_ACCOUNT_NAME, account.name)
.putString(ARG_ACCOUNT_TYPE, account.type)
if (resync != 0)
if (resync != NO_RESYNC)
argumentsBuilder.putInt(ARG_RESYNC, resync)
argumentsBuilder.putBoolean(ARG_UPLOAD, upload)
// build work request
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED) // require a network connection
.build()
val workRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.addTag(TAG_SYNC)
.setInputData(argumentsBuilder.build())
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS
)
.setConstraints(constraints)
.build()
// enqueue and start syncing
Logger.log.log(Level.INFO, "Enqueueing unique worker: ${workerName(account, authority)}")
WorkManager.getInstance(context).enqueueUniqueWork(
workerName(account, authority),
ExistingWorkPolicy.KEEP, // if sync is already running, just continue
ExistingWorkPolicy.KEEP, // If sync is already running, just continue.
// Existing retried work will not be replaced (for instance when
// PeriodicSyncWorker enqueues another scheduled sync).
workRequest
)
}
fun stopSync(context: Context, account: Account, authority: String) {
WorkManager.getInstance(context).cancelUniqueWork(workerName(account, authority))
/**
* Stops running sync worker or removes pending sync from queue, for all authorities.
*/
fun cancelSync(context: Context, account: Account) {
for (authority in SyncUtils.syncAuthorities(context))
WorkManager.getInstance(context).cancelUniqueWork(workerName(account, authority))
}
/**
* Will tell whether a worker exists, which belongs to given account and authorities,
* and that is in the given worker state.
* Will tell whether >0 [SyncWorker] exists, belonging to given account and authorities,
* and which are/is in the given worker state.
*
* @param workState state of worker to match
* @param workStates list of states of workers to match
* @param account the account which the workers belong to
* @param authorities type of sync work
* @return boolean *true* if at least one worker with matching state was found; *false* otherwise
* @param authorities type of sync work, ie [CalendarContract.AUTHORITY]
* @return *true* if at least one worker with matching query was found; *false* otherwise
*/
fun existsForAccount(context: Context, workState: WorkInfo.State, account: Account, authorities: List<String>) =
LiveDataUtils.liveDataLogicOr(
authorities.map { authority -> isWorkerInState(context, workState, account, authority) }
)
fun isWorkerInState(context: Context, workState: WorkInfo.State, account: Account, authority: String) =
WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(workerName(account, authority)).map { workInfoList ->
workInfoList.any { workInfo -> workInfo.state == workState }
}
fun exists(
context: Context,
workStates: List<WorkInfo.State>,
account: Account? = null,
authorities: List<String>? = null
): LiveData<Boolean> {
val workQuery = WorkQuery.Builder
.fromTags(listOf(TAG_SYNC))
.addStates(workStates)
if (account != null && authorities != null)
workQuery.addUniqueWorkNames(
authorities.map { authority -> workerName(account, authority) }
)
return WorkManager.getInstance(context)
.getWorkInfosLiveData(workQuery.build()).map { workInfoList ->
workInfoList.isNotEmpty()
}
}
/**
* Finds out whether SyncWorkers with given statuses exist
* Checks whether user imposed sync conditions from settings are met:
* - Sync only on WiFi?
* - Sync only on specific WiFi (SSID)?
*
* @param statuses statuses to check
* @return whether SyncWorkers matching the statuses were found
* @param accountSettings Account settings of the account to check (and is to be synced)
* @return *true* if conditions are met; *false* if not
*/
fun existsWithStatuses(context: Context, statuses: List<WorkInfo.State>): LiveData<Boolean> {
val workQuery = WorkQuery.Builder
.fromStates(statuses)
.build()
return WorkManager.getInstance(context).getWorkInfosLiveData(workQuery).map {
it.isNotEmpty()
internal fun wifiConditionsMet(context: Context, accountSettings: AccountSettings): Boolean {
// May we sync without WiFi?
if (!accountSettings.getSyncWifiOnly())
return true // yes, continue
// WiFi required, is it available?
if (!wifiAvailable(context)) {
Logger.log.info("Not on connected WiFi, stopping")
return false
}
// If execution reaches this point, we're on a connected WiFi
// Check whether we are connected to the correct WiFi (in case SSID was provided)
return correctWifiSsid(context, accountSettings)
}
/**
* Checks whether we are connected to working WiFi
*/
internal fun wifiAvailable(context: Context): Boolean {
val connectivityManager = context.getSystemService<ConnectivityManager>()!!
connectivityManager.allNetworks.forEach { network ->
connectivityManager.getNetworkCapabilities(network)?.let { capabilities ->
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))
return true
}
}
return false
}
/**
* Checks whether we are connected to the correct wifi (SSID) defined by user in the
* account settings.
*
* Note: Should be connected to some wifi before calling.
*
* @param accountSettings Settings of account to check
* @return *true* if connected to the correct wifi OR no wifi names were specified in
* account settings; *false* otherwise
*/
internal fun correctWifiSsid(context: Context, accountSettings: AccountSettings): Boolean {
accountSettings.getSyncWifiOnlySSIDs()?.let { onlySSIDs ->
// check required permissions and location status
if (!PermissionUtils.canAccessWifiSsid(context)) {
// not all permissions granted; show notification
val intent = Intent(context, WifiPermissionsActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.putExtra(WifiPermissionsActivity.EXTRA_ACCOUNT, accountSettings.account)
PermissionUtils.notifyPermissions(context, intent)
Logger.log.warning("Can't access WiFi SSID, aborting sync")
return false
}
val wifi = context.getSystemService<WifiManager>()!!
val info = wifi.connectionInfo
if (info == null || !onlySSIDs.contains(info.ssid.trim('"'))) {
Logger.log.info("Connected to wrong WiFi network (${info.ssid}), aborting sync")
return false
}
Logger.log.fine("Connected to WiFi network ${info.ssid}")
}
return true
}
}
private val notificationManager = NotificationManagerCompat.from(applicationContext)
/** thread which runs the actual sync code (can be interrupted to stop synchronization) */
var syncThread: Thread? = null
override fun doWork(): Result {
// ensure we got the required arguments
val account = Account(
inputData.getString(ARG_ACCOUNT_NAME) ?: throw IllegalArgumentException("$ARG_ACCOUNT_NAME required"),
inputData.getString(ARG_ACCOUNT_TYPE) ?: throw IllegalArgumentException("$ARG_ACCOUNT_TYPE required")
@ -146,36 +288,34 @@ class SyncWorker @AssistedInject constructor(
val authority = inputData.getString(ARG_AUTHORITY) ?: throw IllegalArgumentException("$ARG_AUTHORITY required")
Logger.log.info("Running sync worker: account=$account, authority=$authority")
val syncAdapter = when (authority) {
// What are we going to sync? Select syncer based on authority
val syncer: Syncer = when (authority) {
applicationContext.getString(R.string.address_books_authority) ->
AddressBooksSyncAdapterService.AddressBooksSyncAdapter(applicationContext)
AddressBookSyncer(applicationContext)
CalendarContract.AUTHORITY ->
CalendarsSyncAdapterService.CalendarsSyncAdapter(applicationContext)
CalendarSyncer(applicationContext)
ContactsContract.AUTHORITY ->
ContactsSyncAdapterService.ContactsSyncAdapter(applicationContext)
ContactSyncer(applicationContext)
TaskProvider.ProviderName.JtxBoard.authority ->
JtxSyncAdapterService.JtxSyncAdapter(applicationContext)
JtxSyncer(applicationContext)
TaskProvider.ProviderName.OpenTasks.authority,
TaskProvider.ProviderName.TasksOrg.authority ->
TasksSyncAdapterService.TasksSyncAdapter(applicationContext)
TaskSyncer(applicationContext)
else ->
throw IllegalArgumentException("Invalid authority $authority")
}
// Pass flags to the sync adapter. Note that these may be sync framework flags, but they
// don't have anything to do with the sync framework anymore. They only exist because we
// still use the same sync code called from two locations (from WorkManager and from the
// sync framework).
val extras = Bundle()
extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) // manual sync
extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) // run immediately (don't queue)
// pass flags which are used by the sync code
when (extras.getInt(ARG_RESYNC)) {
FULL_RESYNC -> extras.putBoolean(SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC, true)
RESYNC -> extras.putBoolean(SyncAdapterService.SYNC_EXTRAS_RESYNC, true)
// pass possibly supplied flags to the selected syncer
val extras = mutableListOf<String>()
when (inputData.getInt(ARG_RESYNC, NO_RESYNC)) {
RESYNC -> extras.add(Syncer.SYNC_EXTRAS_RESYNC)
FULL_RESYNC -> extras.add(Syncer.SYNC_EXTRAS_FULL_RESYNC)
}
if (inputData.getBoolean(ARG_UPLOAD, false))
// Comes in through SyncAdapterService and is used only by ContactsSyncManager for an Android 7 workaround.
extras.add(ContentResolver.SYNC_EXTRAS_UPLOAD)
// acquire ContentProviderClient of authority to be synced
val provider: ContentProviderClient? =
try {
applicationContext.contentResolver.acquireContentProviderClient(authority)
@ -188,21 +328,58 @@ class SyncWorker @AssistedInject constructor(
return Result.failure()
}
// Start syncing. We still use the sync adapter framework's SyncResult to pass the sync results, but this
// is only for legacy reasons and can be replaced by an own result class in the future.
val result = SyncResult()
try {
syncThread = Thread.currentThread()
syncAdapter.onPerformSync(account, extras, authority, provider, result)
syncer.onPerformSync(account, extras.toTypedArray(), authority, provider, result)
} catch (e: SecurityException) {
syncAdapter.onSecurityException(account, extras, authority, result)
Logger.log.log(Level.WARNING, "Security exception when opening content provider for $authority")
} finally {
provider.closeCompat()
}
if (result.hasError())
return Result.failure(Data.Builder()
// Check for errors
if (result.hasError()) {
val syncResult = Data.Builder()
.putString("syncresult", result.toString())
.putString("syncResultStats", result.stats.toString())
.build())
.build()
// On soft errors the sync is retried a few times before considered failed
if (result.hasSoftError()) {
Logger.log.warning("Soft error while syncing: result=$result, stats=${result.stats}")
if (runAttemptCount < MAX_RUN_ATTEMPTS) {
Logger.log.warning("Retrying on soft error (attempt $runAttemptCount of $MAX_RUN_ATTEMPTS)")
return Result.retry()
}
Logger.log.warning("Max retries on soft errors reached ($runAttemptCount of $MAX_RUN_ATTEMPTS). Treating as failed")
notificationManager.notifyIfPossible(
NotificationUtils.NOTIFY_SYNC_ERROR,
NotificationUtils.newBuilder(applicationContext, NotificationUtils.CHANNEL_SYNC_IO_ERRORS)
.setSmallIcon(R.drawable.ic_sync_problem_notify)
.setContentTitle(account.name)
.setContentText(applicationContext.getString(R.string.sync_error_retry_limit_reached))
.setSubText(account.name)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_ERROR)
.build()
)
return Result.failure(syncResult)
}
// On a hard error - fail with an error message
// Note: SyncManager should have notified the user
if (result.hasHardError()) {
Logger.log.warning("Hard error while syncing: result=$result, stats=${result.stats}")
return Result.failure(syncResult)
}
}
return Result.success()
}

View file

@ -0,0 +1,96 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.AccountSettings
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import java.util.*
import java.util.logging.Level
/**
* Base class for sync code.
*
* Contains generic sync code, equal for all sync authorities, checks sync conditions and does
* validation.
*
* Also provides useful methods that can be used by derived syncers ie [CalendarSyncer], etc.
*/
abstract class Syncer(val context: Context) {
companion object {
/**
* Requests a re-synchronization of all entries. For instance, if this extra is
* set for a calendar sync, all remote events will be listed and checked for remote
* changes again.
*
* Useful if settings which modify the remote resource list (like the CalDAV setting
* "sync events n days in the past") have been changed.
*/
const val SYNC_EXTRAS_RESYNC = "resync"
/**
* Requests a full re-synchronization of all entries. For instance, if this extra is
* set for an address book sync, all contacts will be downloaded again and updated in the
* local storage.
*
* Useful if settings which modify parsing/local behavior have been changed.
*/
const val SYNC_EXTRAS_FULL_RESYNC = "full_resync"
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface SyncAdapterEntryPoint {
fun appDatabase(): AppDatabase
}
private val syncAdapterEntryPoint = EntryPointAccessors.fromApplication(context, SyncAdapterEntryPoint::class.java)
internal val db = syncAdapterEntryPoint.appDatabase()
abstract fun sync(account: Account, extras: Array<String>, authority: String, httpClient: Lazy<HttpClient>, provider: ContentProviderClient, syncResult: SyncResult)
fun onPerformSync(
account: Account,
extras: Array<String>,
authority: String,
provider: ContentProviderClient,
syncResult: SyncResult
) {
Logger.log.log(Level.INFO, "$authority sync of $account initiated", extras.joinToString(", "))
val accountSettings by lazy { AccountSettings(context, account) }
val httpClient = lazy { HttpClient.Builder(context, accountSettings).build() }
try {
val runSync = true /* ose */
if (runSync)
sync(account, extras, authority, httpClient, provider, syncResult)
} catch (e: InvalidAccountException) {
Logger.log.log(Level.WARNING, "Account was removed during synchronization", e)
} finally {
if (httpClient.isInitialized())
httpClient.value.close()
Logger.log.log(
Level.INFO,
"$authority sync of $account finished",
extras.joinToString(", "))
}
}
}

View file

@ -0,0 +1,108 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.os.Build
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.ical4android.AndroidTaskList
import at.bitfire.ical4android.TaskProvider
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.dmfs.tasks.contract.TaskContract
import java.util.logging.Level
/**
* Sync logic for tasks in CalDAV collections ({@code VTODO}).
*/
class TaskSyncer(context: Context): Syncer(context) {
override fun sync(
account: Account,
extras: Array<String>,
authority: String,
httpClient: Lazy<HttpClient>,
provider: ContentProviderClient,
syncResult: SyncResult
) {
try {
val providerName = TaskProvider.ProviderName.fromAuthority(authority)
val taskProvider = TaskProvider.fromProviderClient(context, providerName, provider)
// make sure account can be seen by task provider
if (Build.VERSION.SDK_INT >= 26) {
/* Warning: If setAccountVisibility is called, Android 12 broadcasts the
AccountManager.LOGIN_ACCOUNTS_CHANGED_ACTION Intent. This cancels running syncs
and starts them again! So make sure setAccountVisibility is only called when necessary. */
val am = AccountManager.get(context)
if (am.getAccountVisibility(account, providerName.packageName) != AccountManager.VISIBILITY_VISIBLE)
am.setAccountVisibility(account, providerName.packageName, AccountManager.VISIBILITY_VISIBLE)
}
val accountSettings = AccountSettings(context, account)
updateLocalTaskLists(taskProvider, account, accountSettings)
val taskLists = AndroidTaskList
.find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null)
for (taskList in taskLists) {
Logger.log.info("Synchronizing task list #${taskList.id} [${taskList.syncId}]")
TasksSyncManager(context, account, accountSettings, httpClient.value, extras, authority, syncResult, taskList).performSync()
}
} catch (e: TaskProvider.ProviderTooOldException) {
SyncUtils.notifyProviderTooOld(context, e)
syncResult.databaseError = true
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync task lists", e)
syncResult.databaseError = true
}
Logger.log.info("Task sync complete")
}
private fun updateLocalTaskLists(provider: TaskProvider, account: Account, settings: AccountSettings) {
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)
val remoteTaskLists = mutableMapOf<HttpUrl, Collection>()
if (service != null)
for (collection in db.collectionDao().getSyncTaskLists(service.id)) {
remoteTaskLists[collection.url] = collection
}
// delete/update local task lists
val updateColors = settings.getManageCalendarColors()
for (list in AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null))
list.syncId?.let {
val url = it.toHttpUrl()
val info = remoteTaskLists[url]
if (info == null) {
Logger.log.fine("Deleting obsolete local task list $url")
list.delete()
} else {
// remote CollectionInfo found for this local collection, update data
Logger.log.log(Level.FINE, "Updating local task list $url", info)
list.update(info, updateColors)
// we already have a local task list for this remote collection, don't take into consideration anymore
remoteTaskLists -= url
}
}
// create new local task lists
for ((_,info) in remoteTaskLists) {
Logger.log.log(Level.INFO, "Adding local task list", info)
LocalTaskList.create(account, provider, info)
}
}
}

View file

@ -1,7 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.syncadapter
class TasksOrgSyncAdapterService: TasksSyncAdapterService()

View file

@ -1,119 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.Context
import android.content.SyncResult
import android.os.Build
import android.os.Bundle
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.ical4android.AndroidTaskList
import at.bitfire.ical4android.TaskProvider
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.dmfs.tasks.contract.TaskContract
import java.util.logging.Level
/**
* Synchronization manager for CalDAV collections; handles tasks ({@code VTODO}).
*/
open class TasksSyncAdapterService: SyncAdapterService() {
override fun syncAdapter() = TasksSyncAdapter(this)
class TasksSyncAdapter(context: Context) : SyncAdapter(context) {
override fun sync(account: Account, extras: Bundle, authority: String, httpClient: Lazy<HttpClient>, provider: ContentProviderClient, syncResult: SyncResult) {
try {
val providerName = TaskProvider.ProviderName.fromAuthority(authority)
val taskProvider = TaskProvider.fromProviderClient(context, providerName, provider)
// make sure account can be seen by task provider
if (Build.VERSION.SDK_INT >= 26) {
/* Warning: If setAccountVisibility is called, Android 12 broadcasts the
AccountManager.LOGIN_ACCOUNTS_CHANGED_ACTION Intent. This cancels running syncs
and starts them again! So make sure setAccountVisibility is only called when necessary. */
val am = AccountManager.get(context)
if (am.getAccountVisibility(account, providerName.packageName) != AccountManager.VISIBILITY_VISIBLE)
am.setAccountVisibility(account, providerName.packageName, AccountManager.VISIBILITY_VISIBLE)
}
val accountSettings = AccountSettings(context, account)
/* don't run sync if
- sync conditions (e.g. "sync only in WiFi") are not met AND
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
*/
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
return
updateLocalTaskLists(taskProvider, account, accountSettings)
val priorityTaskLists = priorityCollections(extras)
val taskLists = AndroidTaskList
.find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null)
.sortedByDescending { priorityTaskLists.contains(it.id) }
for (taskList in taskLists) {
Logger.log.info("Synchronizing task list #${taskList.id} [${taskList.syncId}]")
TasksSyncManager(context, account, accountSettings, httpClient.value, extras, authority, syncResult, taskList).let {
it.performSync()
}
}
} catch (e: TaskProvider.ProviderTooOldException) {
SyncUtils.notifyProviderTooOld(context, e)
syncResult.databaseError = true
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync task lists", e)
syncResult.databaseError = true
}
Logger.log.info("Task sync complete")
}
private fun updateLocalTaskLists(provider: TaskProvider, account: Account, settings: AccountSettings) {
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)
val remoteTaskLists = mutableMapOf<HttpUrl, Collection>()
if (service != null)
for (collection in db.collectionDao().getSyncTaskLists(service.id)) {
remoteTaskLists[collection.url] = collection
}
// delete/update local task lists
val updateColors = settings.getManageCalendarColors()
for (list in AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null))
list.syncId?.let {
val url = it.toHttpUrl()
val info = remoteTaskLists[url]
if (info == null) {
Logger.log.fine("Deleting obsolete local task list $url")
list.delete()
} else {
// remote CollectionInfo found for this local collection, update data
Logger.log.log(Level.FINE, "Updating local task list $url", info)
list.update(info, updateColors)
// we already have a local task list for this remote collection, don't take into consideration anymore
remoteTaskLists -= url
}
}
// create new local task lists
for ((_,info) in remoteTaskLists) {
Logger.log.log(Level.INFO, "Adding local task list", info)
LocalTaskList.create(account, provider, info)
}
}
}
}

View file

@ -7,7 +7,6 @@ package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import at.bitfire.dav4jvm.DavCalendar
import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
@ -42,7 +41,7 @@ class TasksSyncManager(
account: Account,
accountSettings: AccountSettings,
httpClient: HttpClient,
extras: Bundle,
extras: Array<String>,
authority: String,
syncResult: SyncResult,
localCollection: LocalTaskList

View file

@ -11,14 +11,18 @@ import android.accounts.OnAccountsUpdateListener
import android.app.Activity
import android.app.Application
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.SyncStatusObserver
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.*
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.AnyThread
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
@ -31,11 +35,13 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.AccountListBinding
import at.bitfire.davdroid.databinding.AccountListItemBinding
import at.bitfire.davdroid.syncadapter.SyncStatus
import at.bitfire.davdroid.syncadapter.SyncUtils
import at.bitfire.davdroid.syncadapter.SyncUtils.syncAuthorities
import at.bitfire.davdroid.syncadapter.SyncWorker
import at.bitfire.davdroid.ui.account.AccountActivity
import com.google.android.material.snackbar.Snackbar
@ -53,7 +59,6 @@ class AccountListFragment: Fragment() {
private var syncStatusSnackbar: Snackbar? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
setHasOptionsMenu(true)
@ -215,7 +220,7 @@ class AccountListFragment: Fragment() {
class Model @Inject constructor(
application: Application,
private val warnings: AppWarningsManager
): AndroidViewModel(application), OnAccountsUpdateListener, SyncStatusObserver {
): AndroidViewModel(application), OnAccountsUpdateListener {
data class AccountInfo(
val account: Account,
@ -230,14 +235,11 @@ class AccountListFragment: Fragment() {
// Accounts
private val accountsUpdated = MutableLiveData<Boolean>()
private val syncFrameworkStatusChanged = MutableLiveData<Boolean>()
private val syncWorkersActive = SyncWorker.existsWithStatuses(
application.applicationContext, listOf(WorkInfo.State.RUNNING))
private val syncWorkersActive = SyncWorker.exists(application, listOf(WorkInfo.State.RUNNING))
val accounts = object : MediatorLiveData<List<AccountInfo>>() {
init {
addSource(accountsUpdated) { recalculate() }
addSource(syncFrameworkStatusChanged) { recalculate() }
addSource(syncWorkersActive) { recalculate() }
}
@ -253,7 +255,7 @@ class AccountListFragment: Fragment() {
val accountsWithInfo = sortedAccounts.map { account ->
AccountInfo(
account,
SyncStatus.fromAccount(context, syncAuthorities, account)
SyncStatus.fromAccount(context, account, syncAuthorities)
)
}
value = accountsWithInfo
@ -266,12 +268,6 @@ class AccountListFragment: Fragment() {
init {
// watch accounts
accountManager.addOnAccountsUpdatedListener(this, null, true)
// watch account status
ContentResolver.addStatusChangeListener(
ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE or ContentResolver.SYNC_OBSERVER_TYPE_PENDING,
this
)
}
@AnyThread
@ -279,11 +275,6 @@ class AccountListFragment: Fragment() {
accountsUpdated.postValue(true)
}
@AnyThread
override fun onStatusChanged(which: Int) {
syncFrameworkStatusChanged.postValue(true)
}
override fun onCleared() {
accountManager.removeOnAccountsUpdatedListener(this)
warnings.close()
@ -291,4 +282,45 @@ class AccountListFragment: Fragment() {
}
enum class SyncStatus {
ACTIVE, PENDING, IDLE;
companion object {
/**
* Returns the sync status of a given account. Checks the account itself and possible
* sub-accounts (address book accounts).
*
* @param account account to check
* @param authorities sync authorities to check (usually taken from [syncAuthorities])
*
* @return sync status of the given account
*/
fun fromAccount(context: Context, account: Account, authorities: List<String>): SyncStatus {
val workerNames = authorities.map { authority ->
SyncWorker.workerName(account, authority)
}
val workQuery = WorkQuery.Builder
.fromTags(listOf(SyncWorker.TAG_SYNC))
.addUniqueWorkNames(workerNames)
.addStates(listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED))
.build()
val workInfos = WorkManager.getInstance(context).getWorkInfos(workQuery).get()
return when {
workInfos.any { workInfo ->
workInfo.state == WorkInfo.State.RUNNING
} -> ACTIVE
workInfos.any {workInfo ->
workInfo.state == WorkInfo.State.ENQUEUED
} -> PENDING
else -> IDLE
}
}
}
}
}

View file

@ -11,6 +11,7 @@ import android.content.pm.ShortcutManager
import android.os.Build
import android.os.Bundle
import android.view.MenuItem
import android.widget.Toast
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.getSystemService
@ -109,9 +110,16 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
if (Build.VERSION.SDK_INT >= 25)
getSystemService<ShortcutManager>()?.reportShortcutUsed(UiUtils.SHORTCUT_SYNC_ALL)
// Check we are connected
if (!SyncWorker.wifiAvailable(applicationContext)) {
Toast.makeText(this, R.string.no_internet_connection, Toast.LENGTH_LONG).show()
return
}
// Enqueue sync worker for all accounts and authorities
val accounts = allAccounts()
for (account in accounts)
SyncWorker.requestSync(this, account)
SyncWorker.enqueueAllAuthorities(this, account)
}
}

View file

@ -54,7 +54,7 @@ class AppWarningsManager @Inject constructor(
init {
Logger.log.fine("Watching for warning conditions")
// Automatic sync
// Automatic Sync
syncStatusObserver = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this)
onStatusChanged(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS)

View file

@ -31,6 +31,8 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.WorkManager
import androidx.work.WorkQuery
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.BuildConfig
@ -43,13 +45,12 @@ import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker
import at.bitfire.davdroid.syncadapter.SyncWorker
import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.TaskProvider.ProviderName
import at.bitfire.ical4android.util.MiscUtils
import at.bitfire.ical4android.util.MiscUtils.ContentProviderClientHelper.closeCompat
import at.bitfire.ical4android.util.MiscUtils.UriHelper.asSyncAdapter as asCalendarSyncAdapter
import at.bitfire.vcard4android.Utils.asSyncAdapter as asContactsSyncAdapter
import at.techbee.jtx.JtxContract
import at.techbee.jtx.JtxContract.asSyncAdapter as asJtxSyncAdapter
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
@ -62,6 +63,7 @@ import org.apache.commons.io.FileUtils
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.text.WordUtils
import org.dmfs.tasks.contract.TaskContract
import java.io.*
import java.util.*
@ -69,6 +71,9 @@ import java.util.logging.Level
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import javax.inject.Inject
import at.bitfire.ical4android.util.MiscUtils.UriHelper.asSyncAdapter as asCalendarSyncAdapter
import at.bitfire.vcard4android.Utils.asSyncAdapter as asContactsSyncAdapter
import at.techbee.jtx.JtxContract.asSyncAdapter as asJtxSyncAdapter
@AndroidEntryPoint
class DebugInfoActivity : AppCompatActivity() {
@ -458,8 +463,8 @@ class DebugInfoActivity : AppCompatActivity() {
}
writer.append('\n')
writer.append("\nACCOUNTS\n\n")
// main accounts
writer.append("\nACCOUNTS\n\n")
val accountManager = AccountManager.get(context)
val mainAccounts = accountManager.getAccountsByType(context.getString(R.string.account_type))
val addressBookAccounts = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).toMutableList()
@ -553,6 +558,7 @@ class DebugInfoActivity : AppCompatActivity() {
private fun dumpMainAccount(account: Account, writer: Writer) {
writer.append(" - Account: ${account.name}\n")
writer.append(dumpAccount(account, AccountDumpInfo.mainAccount(context, account)))
writer.append(dumpSyncWorkersInfo(account))
try {
val accountSettings = AccountSettings(context, account)
writer.append(" WiFi only: ${accountSettings.getSyncWifiOnly()}")
@ -581,7 +587,7 @@ class DebugInfoActivity : AppCompatActivity() {
}
private fun dumpAccount(account: Account, infos: Iterable<AccountDumpInfo>): String {
val table = TextTable("Authority", "Syncable", "Auto-sync", "Interval", "Entries")
val table = TextTable("Authority", "getIsSyncable", "getSyncAutomatically", "PeriodicSyncWorker", "Interval", "Entries")
for (info in infos) {
var nrEntries = ""
var client: ContentProviderClient? = null
@ -599,19 +605,54 @@ class DebugInfoActivity : AppCompatActivity() {
} finally {
client?.closeCompat()
}
val accountSettings = AccountSettings(context, account)
table.addLine(
info.authority,
ContentResolver.getIsSyncable(account, info.authority),
ContentResolver.getSyncAutomatically(account, info.authority),
ContentResolver.getPeriodicSyncs(account, info.authority).firstOrNull()?.let { periodicSync ->
"${periodicSync.period / 60} min"
},
ContentResolver.getSyncAutomatically(account, info.authority), // content-triggered sync
PeriodicSyncWorker.isEnabled(context, account, info.authority), // should always be false for address book accounts
accountSettings.getSyncInterval(info.authority)?.let {"${it/60} min"},
nrEntries
)
}
return table.toString()
}
/**
* Gets sync workers info
* Note: WorkManager does not return worker names when queried, so we create them and ask
* whether they exist one by one
*/
private fun dumpSyncWorkersInfo(account: Account): String {
val table = TextTable("Tags", "Authority", "State", "Retries", "Generation", "ID")
listOf(
context.getString(R.string.address_books_authority),
CalendarContract.AUTHORITY,
TaskProvider.ProviderName.JtxBoard.authority,
TaskProvider.ProviderName.OpenTasks.authority,
TaskProvider.ProviderName.TasksOrg.authority
).forEach { authority ->
for (workerName in listOf(
SyncWorker.workerName(account, authority),
PeriodicSyncWorker.workerName(account, authority)
)) {
WorkManager.getInstance(context).getWorkInfos(
WorkQuery.Builder.fromUniqueWorkNames(listOf(workerName)).build()
).get().forEach { workInfo ->
table.addLine(
workInfo.tags.map { StringUtils.removeStartIgnoreCase(it, SyncWorker::class.java.getPackage()!!.name + ".") },
authority,
workInfo.state,
workInfo.runAttemptCount,
workInfo.generation,
workInfo.id
)
}
}
}
return table.toString()
}
}

View file

@ -21,15 +21,14 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentStatePagerAdapter
import androidx.lifecycle.*
import at.bitfire.davdroid.R
import at.bitfire.davdroid.syncadapter.SyncWorker
import at.bitfire.davdroid.databinding.ActivityAccountBinding
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.syncadapter.SyncWorker
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -88,8 +87,8 @@ class AccountActivity: AppCompatActivity() {
})
binding.sync.setOnClickListener {
SyncWorker.requestSync(this, model.account)
Snackbar.make(binding.viewPager, R.string.account_synchronizing_now, Snackbar.LENGTH_LONG).show()
// enqueue sync worker for all authorities of this account
SyncWorker.enqueueAllAuthorities(this, model.account)
}
}
@ -229,12 +228,12 @@ class AccountActivity: AppCompatActivity() {
override fun getItemPosition(obj: Any) = POSITION_NONE
override fun getPageTitle(position: Int): String =
when (position) {
idxCardDav -> activity.getString(R.string.account_carddav)
idxCalDav -> activity.getString(R.string.account_caldav)
idxWebcal -> activity.getString(R.string.account_webcal)
else -> throw IllegalArgumentException()
}
when (position) {
idxCardDav -> activity.getString(R.string.account_carddav)
idxCalDav -> activity.getString(R.string.account_caldav)
idxWebcal -> activity.getString(R.string.account_webcal)
else -> throw IllegalArgumentException()
}
}

View file

@ -10,7 +10,6 @@ import android.provider.CalendarContract
import android.provider.ContactsContract
import android.view.*
import android.widget.PopupMenu
import androidx.annotation.AnyThread
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
@ -28,18 +27,15 @@ import at.bitfire.davdroid.databinding.AccountCollectionsBinding
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.TaskUtils
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.syncadapter.SyncWorker
import at.bitfire.davdroid.ui.PermissionsActivity
import at.bitfire.davdroid.util.LiveDataUtils
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -271,7 +267,7 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList
@Assisted val accountModel: AccountActivity.Model,
@Assisted val serviceId: Long,
@Assisted val collectionType: String
): ViewModel(), SyncStatusObserver {
): ViewModel() {
@AssistedFactory
interface Factory {
@ -305,82 +301,21 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList
// observe RefreshCollectionsWorker status
val isRefreshing = RefreshCollectionsWorker.isWorkerInState(context, RefreshCollectionsWorker.workerName(serviceId), WorkInfo.State.RUNNING)
// observe whether sync framework is active
private var syncFrameworkStatusHandle: Any? = null
private val isSyncFrameworkActive = MutableLiveData<Boolean>()
private val isSyncFrameworkPending = MutableLiveData<Boolean>()
// observe SyncWorker state
private val authorities =
if (collectionType == Collection.TYPE_ADDRESSBOOK)
listOf(context.getString(R.string.address_books_authority), ContactsContract.AUTHORITY)
else
listOf(CalendarContract.AUTHORITY, taskProvider?.authority).filterNotNull()
private val isSyncWorkerRunning = SyncWorker.existsForAccount(context,
WorkInfo.State.RUNNING,
val isSyncActive = SyncWorker.exists(context,
listOf(WorkInfo.State.RUNNING),
accountModel.account,
authorities)
private val isSyncWorkerEnqueued = SyncWorker.existsForAccount(context,
WorkInfo.State.ENQUEUED,
val isSyncPending = SyncWorker.exists(context,
listOf(WorkInfo.State.ENQUEUED),
accountModel.account,
authorities)
// observe and combine states of sync framework and SyncWorker
val isSyncActive = LiveDataUtils.liveDataLogicOr(listOf(isSyncFrameworkActive, isSyncWorkerRunning))
val isSyncPending = LiveDataUtils.liveDataLogicOr(listOf(isSyncFrameworkPending, isSyncWorkerEnqueued))
init {
viewModelScope.launch(Dispatchers.Default) {
syncFrameworkStatusHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_PENDING +
ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE, this@Model)
checkSyncFrameworkStatus()
}
}
override fun onCleared() {
syncFrameworkStatusHandle?.let {
ContentResolver.removeStatusChangeListener(it)
}
}
@AnyThread
override fun onStatusChanged(which: Int) {
checkSyncFrameworkStatus()
}
@AnyThread
@Synchronized
private fun checkSyncFrameworkStatus() {
// SyncFramework only, isSyncFrameworkActive/Pending gets combined in logic OR with SyncWorker state
if (collectionType == Collection.TYPE_ADDRESSBOOK) {
// CardDAV tab
val mainAuthority = context.getString(R.string.address_books_authority)
val mainSyncActive = ContentResolver.isSyncActive(accountModel.account, mainAuthority)
val mainSyncPending = ContentResolver.isSyncPending(accountModel.account, mainAuthority)
val addrBookAccounts = LocalAddressBook.findAll(context, null, accountModel.account).map { it.account }
val syncActive = addrBookAccounts.any { ContentResolver.isSyncActive(it, ContactsContract.AUTHORITY) }
val syncPending = addrBookAccounts.any { ContentResolver.isSyncPending(it, ContactsContract.AUTHORITY) }
isSyncFrameworkActive.postValue(mainSyncActive || syncActive)
isSyncFrameworkPending.postValue(mainSyncPending || syncPending)
} else {
// CalDAV tab
val authorities = mutableListOf(CalendarContract.AUTHORITY)
taskProvider?.let {
authorities += it.authority
}
isSyncFrameworkActive.postValue(authorities.any {
ContentResolver.isSyncActive(accountModel.account, it)
})
isSyncFrameworkPending.postValue(authorities.any {
ContentResolver.isSyncPending(accountModel.account, it)
})
}
}
// actions
fun refresh() {

View file

@ -165,9 +165,9 @@ class RenameAccountFragment: DialogFragment() {
Logger.log.info("Updating account name references")
// cancel maybe running synchronization
ContentResolver.cancelSync(oldAccount, null)
SyncWorker.cancelSync(context, oldAccount)
for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)))
ContentResolver.cancelSync(addrBookAccount, null)
SyncWorker.cancelSync(context, addrBookAccount)
// update account name references in database
try {
@ -219,7 +219,7 @@ class RenameAccountFragment: DialogFragment() {
}
// synchronize again
SyncWorker.requestSync(context, newAccount)
SyncWorker.enqueueAllAuthorities(context, newAccount)
}
}

View file

@ -6,10 +6,8 @@ package at.bitfire.davdroid.ui.account
import android.accounts.Account
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.SyncStatusObserver
import android.os.Build
import android.os.Bundle
import android.provider.CalendarContract
@ -27,16 +25,16 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.preference.*
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.TaskUtils
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.syncadapter.SyncAdapterService
import at.bitfire.davdroid.syncadapter.Syncer
import at.bitfire.davdroid.syncadapter.SyncWorker
import at.bitfire.davdroid.ui.UiUtils
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.ical4android.TaskProvider
import at.bitfire.vcard4android.GroupMethod
import com.google.android.material.snackbar.Snackbar
@ -390,7 +388,7 @@ class SettingsActivity: AppCompatActivity() {
@ApplicationContext val context: Context,
val settings: SettingsManager,
@Assisted val account: Account
): ViewModel(), SyncStatusObserver, SettingsManager.OnChangeListener {
): ViewModel(), SettingsManager.OnChangeListener {
@AssistedFactory
interface Factory {
@ -399,8 +397,6 @@ class SettingsActivity: AppCompatActivity() {
private var accountSettings: AccountSettings? = null
private var statusChangeListener: Any? = null
// settings
val syncIntervalContacts = MutableLiveData<Long>()
val syncIntervalCalendars = MutableLiveData<Long>()
@ -433,26 +429,15 @@ class SettingsActivity: AppCompatActivity() {
accountSettings = AccountSettings(context, account)
settings.addOnChangeListener(this)
statusChangeListener = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this)
reload()
}
override fun onCleared() {
super.onCleared()
statusChangeListener?.let {
ContentResolver.removeStatusChangeListener(it)
statusChangeListener = null
}
settings.removeOnChangeListener(this)
}
override fun onStatusChanged(which: Int) {
Logger.log.info("Sync settings changed")
reload()
}
override fun onSettingsChanged() {
Logger.log.info("Settings changed")
reload()
@ -545,8 +530,8 @@ class SettingsActivity: AppCompatActivity() {
* Initiates calendar re-synchronization.
*
* @param fullResync whether sync shall download all events again
* (_true_: sets [SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC],
* _false_: sets [ContentResolver.SYNC_EXTRAS_MANUAL])
* (_true_: sets [Syncer.SYNC_EXTRAS_FULL_RESYNC],
* _false_: sets [Syncer.SYNC_EXTRAS_RESYNC])
* @param tasks whether tasks shall be synchronized, too (false: only events, true: events and tasks)
*/
private fun resyncCalendars(fullResync: Boolean, tasks: Boolean) {
@ -555,13 +540,17 @@ class SettingsActivity: AppCompatActivity() {
resync(TaskProvider.ProviderName.OpenTasks.authority, fullResync)
}
/**
* Initiates re-synchronization for given authority.
*
* @param authority authority to re-sync
* @param fullResync whether sync shall download all events again
* (_true_: sets [Syncer.SYNC_EXTRAS_FULL_RESYNC],
* _false_: sets [Syncer.SYNC_EXTRAS_RESYNC])
*/
private fun resync(authority: String, fullResync: Boolean) {
val resync =
if (fullResync)
SyncWorker.FULL_RESYNC
else
SyncWorker.RESYNC
SyncWorker.requestSync(context, account, authority, resync)
val resync = if (fullResync) SyncWorker.FULL_RESYNC else SyncWorker.RESYNC
SyncWorker.enqueue(context, account, authority, resync)
}
}

View file

@ -53,7 +53,7 @@ class AccountDetailsFragment : Fragment() {
@Inject lateinit var settings: SettingsManager
val loginModel by activityViewModels<LoginModel>()
val model by viewModels<AccountDetailsModel>()
val model by viewModels<Model>()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@ -140,7 +140,7 @@ class AccountDetailsFragment : Fragment() {
@HiltViewModel
class AccountDetailsModel @Inject constructor(
class Model @Inject constructor(
@ApplicationContext val context: Context,
val db: AppDatabase,
val settingsManager: SettingsManager
@ -155,6 +155,16 @@ class AccountDetailsFragment : Fragment() {
nameError.value = null
}
/**
* Creates a new main account with discovered services and enables periodic syncs with
* default sync interval times.
*
* @param name Name of the account
* @param credentials Server credentials
* @param config Discovered server capabilities for syncable authorities
* @param groupMethod Whether CardDAV contact groups are separate VCards or as contact categories
* @return *true* if account creation was succesful; *false* otherwise (for instance because an account with this name already exists)
*/
fun createAccount(name: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): LiveData<Boolean> {
val result = MutableLiveData<Boolean>()
viewModelScope.launch(Dispatchers.Default + NonCancellable) {
@ -175,6 +185,7 @@ class AccountDetailsFragment : Fragment() {
val accountSettings = AccountSettings(context, account)
val defaultSyncInterval = settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL)
// Configure CardDAV service
val addrBookAuthority = context.getString(R.string.address_books_authority)
if (config.cardDAV != null) {
// insert CardDAV service
@ -192,6 +203,7 @@ class AccountDetailsFragment : Fragment() {
} else
ContentResolver.setIsSyncable(account, addrBookAuthority, 0)
// Configure CalDAV service
if (config.calDAV != null) {
// insert CalDAV service
val id = insertService(name, Service.TYPE_CALDAV, config.calDAV)
@ -203,12 +215,15 @@ class AccountDetailsFragment : Fragment() {
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
accountSettings.setSyncInterval(CalendarContract.AUTHORITY, defaultSyncInterval)
// if task provider present, set task sync interval and enable sync
val taskProvider = TaskUtils.currentProvider(context)
if (taskProvider != null) {
ContentResolver.setIsSyncable(account, taskProvider.authority, 1)
accountSettings.setSyncInterval(taskProvider.authority, defaultSyncInterval)
// further changes will be handled by TasksWatcher on app start or when tasks app is (un)installed
}
Logger.log.info("Tasks provider ${taskProvider.authority} found. Tasks sync enabled.")
} else
Logger.log.info("No tasks provider found. Did not enable tasks sync.")
} else
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 0)

View file

@ -1,36 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.util
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
object LiveDataUtils {
/**
* Combines multiple [LiveData] inputs with logical OR to another [LiveData].
*
* It's value is *null* as soon as no input is added or as long as no input
* has emitted a value. As soon as at least one input has emitted a value,
* the value of the combined object becomes *true* or *false*.
*
* @param inputs inputs to be combined with logical OR
* @return [LiveData] that is *true* when at least one input becomes *true*; *false* otherwise
*/
fun liveDataLogicOr(inputs: Iterable<LiveData<Boolean>>) = object : MediatorLiveData<Boolean>() {
init {
inputs.forEach { liveData ->
addSource(liveData) {
recalculate()
}
}
}
fun recalculate() {
value = inputs.any { it.value == true }
}
}
}

View file

@ -7,7 +7,7 @@
<import type="android.view.View"/>
<variable
name="details"
type="at.bitfire.davdroid.ui.setup.AccountDetailsFragment.AccountDetailsModel"/>
type="at.bitfire.davdroid.ui.setup.AccountDetailsFragment.Model"/>
</data>
<LinearLayout

View file

@ -17,6 +17,7 @@
<string name="help">Help</string>
<string name="manage_accounts">Manage accounts</string>
<string name="share">Share</string>
<string name="no_internet_connection">No internet connection</string>
<string name="database_destructive_migration_title">Database corrupted</string>
<string name="database_destructive_migration_text">All accounts have been removed locally.</string>
@ -241,7 +242,6 @@
<string name="account_no_webcals">There are no calendar subscriptions (yet).</string>
<string name="account_swipe_down">Swipe down to refresh the list from the server.</string>
<string name="account_synchronize_now">Synchronize now</string>
<string name="account_synchronizing_now">Synchronizing now</string>
<string name="account_settings">Account settings</string>
<string name="account_rename">Rename account</string>
<string name="account_rename_new_name">Unsaved local data may be dismissed. Re-synchronization is required after renaming. New account name:</string>
@ -481,7 +481,7 @@
<string name="webdav_notification_upload">Uploading WebDAV file</string>
<string name="webdav_provider_root_title">WebDAV mount</string>
<!-- sync adapters -->
<!-- sync -->
<string name="sync_error_permissions">DAVx⁵ permissions</string>
<string name="sync_error_permissions_text">Additional permissions required</string>
<string name="sync_error_tasks_too_old">%s too old</string>
@ -491,6 +491,7 @@
<string name="sync_error_http_dav">HTTP server error %s</string>
<string name="sync_error_local_storage">Local storage error %s</string>
<string name="sync_error_retry">Retry</string>
<string name="sync_error_retry_limit_reached">Soft error (max retries reached)</string>
<string name="sync_error_view_item">View item</string>
<string name="sync_invalid_contact">Received invalid contact from server</string>
<string name="sync_invalid_event">Received invalid event from server</string>