mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2025-12-23 23:17:50 -05:00
Choose real or fake (for tests) SyncAdapter over DI (#1608)
* Choose real or fake (for tests) SyncAdapter over DI * Minor changes * Rename SyncAdapterServicesTest.kt to RealSyncAdapterTest.kt * Group sync adapter / sync framework classes into new package * Cache SyncAdapter in SyncAdapterServices * Add documentation to SyncAdapter interface and rename RealSyncAdapterTest to SyncAdapterImplTest
This commit is contained in:
@@ -10,7 +10,6 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.test.runner.AndroidJUnitRunner
|
||||
import at.bitfire.davdroid.di.TestCoroutineDispatchersModule
|
||||
import at.bitfire.davdroid.sync.SyncAdapterService
|
||||
import at.bitfire.davdroid.test.BuildConfig
|
||||
import at.bitfire.synctools.log.LogcatHandler
|
||||
import dagger.hilt.android.testing.HiltTestApplication
|
||||
@@ -36,9 +35,6 @@ class HiltTestRunner : AndroidJUnitRunner() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P)
|
||||
throw AssertionError("MockK requires Android P [https://mockk.io/ANDROID.html]")
|
||||
|
||||
// disable sync adapters
|
||||
SyncAdapterService.syncActive.set(false)
|
||||
|
||||
// set main dispatcher for tests (especially runTest)
|
||||
TestCoroutineDispatchersModule.initMainDispatcher()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.di
|
||||
|
||||
import at.bitfire.davdroid.sync.FakeSyncAdapter
|
||||
import at.bitfire.davdroid.sync.adapter.SyncAdapter
|
||||
import at.bitfire.davdroid.sync.adapter.SyncAdapterImpl
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.hilt.testing.TestInstallIn
|
||||
|
||||
@Module
|
||||
@TestInstallIn(components = [SingletonComponent::class], replaces = [SyncAdapterImpl.RealSyncAdapterModule::class])
|
||||
abstract class FakeSyncAdapterModule {
|
||||
@Binds
|
||||
abstract fun provide(impl: FakeSyncAdapter): SyncAdapter
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import android.content.Context
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.AbstractThreadedSyncAdapter
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import at.bitfire.davdroid.sync.adapter.SyncAdapter
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class FakeSyncAdapter @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
private val logger: Logger
|
||||
): AbstractThreadedSyncAdapter(context, true), SyncAdapter {
|
||||
|
||||
init {
|
||||
logger.info("FakeSyncAdapter created")
|
||||
}
|
||||
|
||||
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
logger.log(
|
||||
Level.INFO,
|
||||
"onPerformSync(account=$account, extras=$extras, authority=$authority, syncResult=$syncResult)",
|
||||
extras.keySet().map { key -> "extras[$key] = ${extras[key]}" }
|
||||
)
|
||||
|
||||
// fake 5 sec sync
|
||||
try {
|
||||
Thread.sleep(5000)
|
||||
} catch (_: InterruptedException) {
|
||||
logger.info("onPerformSync($account) cancelled")
|
||||
}
|
||||
|
||||
logger.info("onPerformSync($account) finished")
|
||||
}
|
||||
|
||||
|
||||
// SyncAdapter implementation and Hilt module
|
||||
|
||||
override fun getBinder(): IBinder = syncAdapterBinder
|
||||
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import at.bitfire.davdroid.sync.adapter.SyncAdapterImpl
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
@@ -44,7 +45,7 @@ import javax.inject.Provider
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@HiltAndroidTest
|
||||
class SyncAdapterServicesTest {
|
||||
class SyncAdapterImplTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
@@ -56,7 +57,7 @@ class SyncAdapterServicesTest {
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var syncAdapterProvider: Provider<SyncAdapterService.SyncAdapter>
|
||||
lateinit var syncAdapterImplProvider: Provider<SyncAdapterImpl>
|
||||
|
||||
@BindValue @MockK
|
||||
lateinit var syncWorkerManager: SyncWorkerManager
|
||||
@@ -90,7 +91,7 @@ class SyncAdapterServicesTest {
|
||||
@Test
|
||||
fun testSyncAdapter_onPerformSync_cancellation() = runTest {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val syncAdapter = syncAdapterProvider.get()
|
||||
val syncAdapter = syncAdapterImplProvider.get()
|
||||
|
||||
mockkObject(workManager) {
|
||||
// don't actually create a worker
|
||||
@@ -114,7 +115,7 @@ class SyncAdapterServicesTest {
|
||||
@Test
|
||||
fun testSyncAdapter_onPerformSync_returnsAfterTimeout() {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val syncAdapter = syncAdapterProvider.get()
|
||||
val syncAdapter = syncAdapterImplProvider.get()
|
||||
|
||||
mockkObject(workManager) {
|
||||
// don't actually create a worker
|
||||
@@ -135,7 +136,7 @@ class SyncAdapterServicesTest {
|
||||
@Test
|
||||
fun testSyncAdapter_onPerformSync_runsInTime() {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val syncAdapter = syncAdapterProvider.get()
|
||||
val syncAdapter = syncAdapterImplProvider.get()
|
||||
|
||||
mockkObject(workManager) {
|
||||
// don't actually create a worker
|
||||
@@ -154,4 +155,4 @@ class SyncAdapterServicesTest {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -186,7 +186,7 @@
|
||||
android:resource="@xml/account_authenticator"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".sync.CalendarsSyncAdapterService"
|
||||
android:name=".sync.adapter.CalendarsSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
@@ -197,7 +197,7 @@
|
||||
android:resource="@xml/sync_calendars"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".sync.JtxSyncAdapterService"
|
||||
android:name=".sync.adapter.JtxSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
@@ -208,7 +208,7 @@
|
||||
android:resource="@xml/sync_notes"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".sync.OpenTasksSyncAdapterService"
|
||||
android:name=".sync.adapter.OpenTasksSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
@@ -219,7 +219,7 @@
|
||||
android:resource="@xml/sync_opentasks"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".sync.TasksOrgSyncAdapterService"
|
||||
android:name=".sync.adapter.TasksOrgSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
@@ -254,7 +254,7 @@
|
||||
android:resource="@xml/account_authenticator_address_book"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".sync.ContactsSyncAdapterService"
|
||||
android:name=".sync.adapter.ContactsSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
|
||||
@@ -23,9 +23,9 @@ import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_READ_ON
|
||||
import at.bitfire.davdroid.resource.workaround.ContactDirtyVerifier
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.account.SystemAccountUtils
|
||||
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
|
||||
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
|
||||
import at.bitfire.synctools.storage.BatchOperation
|
||||
import at.bitfire.synctools.storage.ContactsBatchOperation
|
||||
import at.bitfire.vcard4android.AndroidAddressBook
|
||||
|
||||
@@ -11,6 +11,7 @@ import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
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.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_COLLECTION_ID
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.account.InvalidAccountException
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
abstract class SyncAdapterService: Service() {
|
||||
|
||||
/**
|
||||
* We don't use @AndroidEntryPoint / @Inject because it's unavoidable that instrumented tests sometimes accidentally / asynchronously
|
||||
* create a [SyncAdapterService] instance before Hilt is initialized during the tests.
|
||||
*/
|
||||
@dagger.hilt.EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface EntryPoint {
|
||||
fun syncAdapter(): SyncAdapter
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
if (BuildConfig.DEBUG && !syncActive.get()) {
|
||||
// only for debug builds/testing: syncActive flag
|
||||
val logger = Logger.getLogger(SyncAdapterService::class.java.name)
|
||||
logger.warning("SyncAdapterService.onBind() was called but syncActive = false. Returning fake sync adapter")
|
||||
|
||||
val fakeAdapter = object: AbstractThreadedSyncAdapter(this, true) {
|
||||
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
val message = StringBuilder()
|
||||
message.append("FakeSyncAdapter onPerformSync(account=$account, extras=$extras, authority=$authority, syncResult=$syncResult)")
|
||||
for (key in extras.keySet())
|
||||
message.append("\n\textras[$key] = ${extras[key]}")
|
||||
logger.warning(message.toString())
|
||||
|
||||
// fake 5 sec sync
|
||||
try {
|
||||
Thread.sleep(5000)
|
||||
} catch (_: InterruptedException) {
|
||||
logger.warning("FakeSyncAdapter onPerformSync($account) cancelled")
|
||||
}
|
||||
|
||||
logger.warning("FakeSyncAdapter onPerformSync($account) finished")
|
||||
}
|
||||
}
|
||||
return fakeAdapter.syncAdapterBinder
|
||||
}
|
||||
|
||||
// create sync adapter via Hilt
|
||||
val entryPoint = EntryPointAccessors.fromApplication<EntryPoint>(this)
|
||||
val syncAdapter = entryPoint.syncAdapter()
|
||||
return syncAdapter.syncAdapterBinder
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Flag to indicate whether the sync adapter should be active. When it is `false`, synchronization will not be run
|
||||
* (only intended for tests).
|
||||
*/
|
||||
val syncActive = AtomicBoolean(true)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* All Sync Adapter Framework related interaction should happen inside [SyncFrameworkIntegration].
|
||||
*/
|
||||
class SyncAdapter @Inject constructor(
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val serviceRepository: DavServiceRepository,
|
||||
@ApplicationContext context: Context,
|
||||
private val logger: Logger,
|
||||
private val syncConditionsFactory: SyncConditions.Factory,
|
||||
private val syncFrameworkIntegration: SyncFrameworkIntegration,
|
||||
private val syncWorkerManager: SyncWorkerManager
|
||||
): AbstractThreadedSyncAdapter(
|
||||
/* context = */ context,
|
||||
/* autoInitialize = */ true // Sets isSyncable=1 when isSyncable=-1 and SYNC_EXTRAS_INITIALIZE is set.
|
||||
// Doesn't matter for us because we have android:isAlwaysSyncable="true" for all sync adapters.
|
||||
) {
|
||||
|
||||
/**
|
||||
* Scope used to wait until the synchronization is finished. Will be cancelled when the sync framework
|
||||
* requests cancellation.
|
||||
*/
|
||||
private val waitScope = CoroutineScope(Dispatchers.Default)
|
||||
|
||||
override fun onPerformSync(accountOrAddressBookAccount: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
// We have to pass this old SyncFramework extra for an Android 7 workaround
|
||||
val upload = extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD)
|
||||
logger.info("Sync request via sync framework for $accountOrAddressBookAccount $authority (upload=$upload)")
|
||||
|
||||
// If we should sync an address book account - find the account storing the settings
|
||||
val account = if (accountOrAddressBookAccount.type == context.getString(R.string.account_type_address_book))
|
||||
AccountManager.get(context)
|
||||
.getUserData(accountOrAddressBookAccount, USER_DATA_COLLECTION_ID)
|
||||
?.toLongOrNull()
|
||||
?.let { collectionId ->
|
||||
collectionRepository.get(collectionId)?.let { collection ->
|
||||
serviceRepository.getBlocking(collection.serviceId)?.let { service ->
|
||||
Account(service.accountName, context.getString(R.string.account_type))
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
accountOrAddressBookAccount
|
||||
|
||||
if (account == null) {
|
||||
logger.warning("Address book account $accountOrAddressBookAccount doesn't have an associated collection")
|
||||
return
|
||||
}
|
||||
|
||||
// Check sync conditions
|
||||
val accountSettings = try {
|
||||
accountSettingsFactory.create(account)
|
||||
} catch (e: InvalidAccountException) {
|
||||
logger.log(Level.WARNING, "Account doesn't exist anymore", e)
|
||||
return
|
||||
}
|
||||
val syncConditions = syncConditionsFactory.create(accountSettings)
|
||||
// Should we run the sync at all?
|
||||
if (!syncConditions.wifiConditionsMet()) {
|
||||
logger.info("Sync conditions not met. Aborting sync framework initiated sync")
|
||||
return
|
||||
}
|
||||
|
||||
logger.fine("Starting OneTimeSyncWorker for $account $authority and waiting for it")
|
||||
val workerName = syncWorkerManager.enqueueOneTime(account, dataType = SyncDataType.fromAuthority(authority), fromUpload = upload)
|
||||
|
||||
// Android 14+ does not handle pending sync state correctly.
|
||||
// As a defensive workaround, we can cancel specifically this still pending sync only
|
||||
// See: https://github.com/bitfireAT/davx5-ose/issues/1458
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
logger.fine("Android 14+ bug: Canceling forever pending sync adapter framework sync request for " +
|
||||
"account=$account authority=$authority upload=$upload")
|
||||
syncFrameworkIntegration.cancelSync(account, authority, extras)
|
||||
}
|
||||
|
||||
/* Because we are not allowed to observe worker state on a background thread, we can not
|
||||
use it to block the sync adapter. Instead we use a Flow to get notified when the sync
|
||||
has finished. */
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
|
||||
try {
|
||||
val waitJob = waitScope.launch {
|
||||
// wait for finished worker state
|
||||
workManager.getWorkInfosForUniqueWorkFlow(workerName).collect { infoList ->
|
||||
for (info in infoList)
|
||||
if (info.state.isFinished) {
|
||||
if (info.state == WorkInfo.State.FAILED) {
|
||||
if (info.outputData.getBoolean(BaseSyncWorker.OUTPUT_TOO_MANY_RETRIES, false))
|
||||
syncResult.tooManyRetries = true
|
||||
else
|
||||
syncResult.databaseError = true
|
||||
}
|
||||
cancel("$workerName has finished")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
withTimeout(10 * 60 * 1000) { // block max. 10 minutes
|
||||
waitJob.join() // wait until worker has finished
|
||||
}
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
// waiting for work was cancelled, either by timeout or because the worker has finished
|
||||
logger.fine("Not waiting for OneTimeSyncWorker anymore.")
|
||||
}
|
||||
|
||||
logger.log(Level.INFO, "Returning to sync framework.", syncResult)
|
||||
}
|
||||
|
||||
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
|
||||
logger.log(Level.WARNING, "Security exception for $account/$authority")
|
||||
}
|
||||
|
||||
override fun onSyncCanceled() {
|
||||
logger.info("Sync adapter requested cancellation – won't cancel sync, but also won't block sync framework anymore")
|
||||
|
||||
// unblock sync framework
|
||||
waitScope.cancel()
|
||||
}
|
||||
|
||||
override fun onSyncCanceled(thread: Thread) = onSyncCanceled()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// exported sync adapter services; we need a separate class for each authority
|
||||
class CalendarsSyncAdapterService: SyncAdapterService()
|
||||
class ContactsSyncAdapterService: SyncAdapterService()
|
||||
class JtxSyncAdapterService: SyncAdapterService()
|
||||
class OpenTasksSyncAdapterService: SyncAdapterService()
|
||||
class TasksOrgSyncAdapterService: SyncAdapterService()
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync.adapter
|
||||
|
||||
import android.os.IBinder
|
||||
|
||||
/**
|
||||
* Interface for an Android sync adapter, as created by [SyncAdapterService].
|
||||
*
|
||||
* Sync adapters are bound services that communicate over IPC, so the only method is
|
||||
* [getBinder], which returns the sync adapter binder.
|
||||
*/
|
||||
interface SyncAdapter {
|
||||
|
||||
fun getBinder(): IBinder
|
||||
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync.adapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.AbstractThreadedSyncAdapter
|
||||
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 android.os.IBinder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.SyncConditions
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.account.InvalidAccountException
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* All Sync Adapter Framework related interaction should happen inside [SyncFrameworkIntegration].
|
||||
*/
|
||||
class SyncAdapterImpl @Inject constructor(
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val serviceRepository: DavServiceRepository,
|
||||
@ApplicationContext context: Context,
|
||||
private val logger: Logger,
|
||||
private val syncConditionsFactory: SyncConditions.Factory,
|
||||
private val syncFrameworkIntegration: SyncFrameworkIntegration,
|
||||
private val syncWorkerManager: SyncWorkerManager
|
||||
): AbstractThreadedSyncAdapter(
|
||||
/* context = */ context,
|
||||
/* autoInitialize = */ true // Sets isSyncable=1 when isSyncable=-1 and SYNC_EXTRAS_INITIALIZE is set.
|
||||
// Doesn't matter for us because we have android:isAlwaysSyncable="true" for all sync adapters.
|
||||
), SyncAdapter {
|
||||
|
||||
/**
|
||||
* Scope used to wait until the synchronization is finished. Will be cancelled when the sync framework
|
||||
* requests cancellation.
|
||||
*/
|
||||
private val waitScope = CoroutineScope(Dispatchers.Default)
|
||||
|
||||
override fun onPerformSync(accountOrAddressBookAccount: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
// We have to pass this old SyncFramework extra for an Android 7 workaround
|
||||
val upload = extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD)
|
||||
logger.info("Sync request via sync framework for $accountOrAddressBookAccount $authority (upload=$upload)")
|
||||
|
||||
// If we should sync an address book account - find the account storing the settings
|
||||
val account = if (accountOrAddressBookAccount.type == context.getString(R.string.account_type_address_book))
|
||||
AccountManager.get(context)
|
||||
.getUserData(accountOrAddressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID)
|
||||
?.toLongOrNull()
|
||||
?.let { collectionId ->
|
||||
collectionRepository.get(collectionId)?.let { collection ->
|
||||
serviceRepository.getBlocking(collection.serviceId)?.let { service ->
|
||||
Account(service.accountName, context.getString(R.string.account_type))
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
accountOrAddressBookAccount
|
||||
|
||||
if (account == null) {
|
||||
logger.warning("Address book account $accountOrAddressBookAccount doesn't have an associated collection")
|
||||
return
|
||||
}
|
||||
|
||||
// Check sync conditions
|
||||
val accountSettings = try {
|
||||
accountSettingsFactory.create(account)
|
||||
} catch (e: InvalidAccountException) {
|
||||
logger.log(Level.WARNING, "Account doesn't exist anymore", e)
|
||||
return
|
||||
}
|
||||
val syncConditions = syncConditionsFactory.create(accountSettings)
|
||||
// Should we run the sync at all?
|
||||
if (!syncConditions.wifiConditionsMet()) {
|
||||
logger.info("Sync conditions not met. Aborting sync framework initiated sync")
|
||||
return
|
||||
}
|
||||
|
||||
logger.fine("Starting OneTimeSyncWorker for $account $authority and waiting for it")
|
||||
val workerName = syncWorkerManager.enqueueOneTime(account, dataType = SyncDataType.Companion.fromAuthority(authority), fromUpload = upload)
|
||||
|
||||
// Android 14+ does not handle pending sync state correctly.
|
||||
// As a defensive workaround, we can cancel specifically this still pending sync only
|
||||
// See: https://github.com/bitfireAT/davx5-ose/issues/1458
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
logger.fine("Android 14+ bug: Canceling forever pending sync adapter framework sync request for " +
|
||||
"account=$account authority=$authority upload=$upload")
|
||||
syncFrameworkIntegration.cancelSync(account, authority, extras)
|
||||
}
|
||||
|
||||
/* Because we are not allowed to observe worker state on a background thread, we can not
|
||||
use it to block the sync adapter. Instead we use a Flow to get notified when the sync
|
||||
has finished. */
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
|
||||
try {
|
||||
val waitJob = waitScope.launch {
|
||||
// wait for finished worker state
|
||||
workManager.getWorkInfosForUniqueWorkFlow(workerName).collect { infoList ->
|
||||
for (info in infoList)
|
||||
if (info.state.isFinished) {
|
||||
if (info.state == WorkInfo.State.FAILED) {
|
||||
if (info.outputData.getBoolean(BaseSyncWorker.OUTPUT_TOO_MANY_RETRIES, false))
|
||||
syncResult.tooManyRetries = true
|
||||
else
|
||||
syncResult.databaseError = true
|
||||
}
|
||||
cancel("$workerName has finished")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
withTimeout(10 * 60 * 1000) { // block max. 10 minutes
|
||||
waitJob.join() // wait until worker has finished
|
||||
}
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
// waiting for work was cancelled, either by timeout or because the worker has finished
|
||||
logger.fine("Not waiting for OneTimeSyncWorker anymore.")
|
||||
}
|
||||
|
||||
logger.log(Level.INFO, "Returning to sync framework.", syncResult)
|
||||
}
|
||||
|
||||
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
|
||||
logger.log(Level.WARNING, "Security exception for $account/$authority")
|
||||
}
|
||||
|
||||
override fun onSyncCanceled() {
|
||||
logger.info("Sync adapter requested cancellation – won't cancel sync, but also won't block sync framework anymore")
|
||||
|
||||
// unblock sync framework
|
||||
waitScope.cancel()
|
||||
}
|
||||
|
||||
override fun onSyncCanceled(thread: Thread) = onSyncCanceled()
|
||||
|
||||
|
||||
// SyncAdapter implementation and Hilt module
|
||||
|
||||
override fun getBinder(): IBinder = syncAdapterBinder
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class RealSyncAdapterModule {
|
||||
@Binds
|
||||
abstract fun provide(impl: SyncAdapterImpl): SyncAdapter
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync.adapter
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
abstract class SyncAdapterService: Service() {
|
||||
|
||||
/**
|
||||
* We don't use @AndroidEntryPoint / @Inject because it's unavoidable that instrumented tests sometimes accidentally / asynchronously
|
||||
* create a [SyncAdapterService] instance before Hilt is initialized by the HiltTestRunner.
|
||||
*/
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface SyncAdapterServicesEntryPoint {
|
||||
fun syncAdapter(): SyncAdapter
|
||||
}
|
||||
|
||||
// create syncAdapter on demand and cache it
|
||||
val syncAdapter by lazy {
|
||||
val entryPoint = EntryPointAccessors.fromApplication<SyncAdapterServicesEntryPoint>(this)
|
||||
entryPoint.syncAdapter()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) = syncAdapter.getBinder()
|
||||
|
||||
}
|
||||
|
||||
// exported sync adapter services; we need a separate class for each authority
|
||||
class CalendarsSyncAdapterService: SyncAdapterService()
|
||||
class ContactsSyncAdapterService: SyncAdapterService()
|
||||
class JtxSyncAdapterService: SyncAdapterService()
|
||||
class OpenTasksSyncAdapterService: SyncAdapterService()
|
||||
class TasksOrgSyncAdapterService: SyncAdapterService()
|
||||
@@ -2,16 +2,16 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
package at.bitfire.davdroid.sync.adapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.SyncRequest
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import androidx.annotation.WorkerThread
|
||||
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import dagger.Lazy
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
@@ -132,7 +132,7 @@ class SyncFrameworkIntegration @Inject constructor(
|
||||
*
|
||||
* @param account account to enable/disable content change sync triggers for
|
||||
* @param enable *true* enables automatic sync; *false* disables it
|
||||
* @param authority sync authority (like [CalendarContract.AUTHORITY])
|
||||
* @param authority sync authority (like [android.provider.CalendarContract.AUTHORITY])
|
||||
* @return whether the content triggered sync was enabled successfully
|
||||
*/
|
||||
@WorkerThread
|
||||
@@ -163,7 +163,7 @@ class SyncFrameworkIntegration @Inject constructor(
|
||||
*
|
||||
* @param account account to enable/disable content change sync triggers for
|
||||
* @param enable *true* enables automatic sync; *false* disables it
|
||||
* @param authority sync authority (like [CalendarContract.AUTHORITY])
|
||||
* @param authority sync authority (like [android.provider.CalendarContract.AUTHORITY])
|
||||
* @return whether the content triggered sync was enabled successfully
|
||||
*/
|
||||
private fun setContentTrigger(account: Account, authority: String, enable: Boolean): Boolean =
|
||||
@@ -28,7 +28,7 @@ import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
|
||||
@@ -41,8 +41,8 @@ import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.account.InvalidAccountException
|
||||
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.techbee.jtx.JtxContract
|
||||
|
||||
@@ -10,7 +10,7 @@ import androidx.work.WorkInfo
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
|
||||
Reference in New Issue
Block a user