work manager manual sync (bitfireAT/davx5#109)

* rabase with dev branch

* added test for checking whether manual work-manager sync queues worker

* overwrite getForegroundInfo to show a "sync running" notification, to run expedited work on Android <12

* basic error state with sensible feedback from syncframework

* remove integer state flags and pass SyncResult as string

* Manual sync cancellation

* rabase with dev branch

* Minor changes

- add Jtx Board sync adapter to sync worker
- use new notification ID for sync worker

* status bar reflects sync of SyncWorker and sync framework correctly

* [WIP] custom hilt SyncComponent

* fix autoclose cast not available below api24

* SyncScope implementation using WeakReference

* Remove unnecessary logging call

* AddressBooksSyncAdapter.sync uses SyncWorker instead of ContentResolve to call requestSync

* add some code documentation

* move all utility objects into one package

* Also check SyncWorker state for accounts list sync status bar

* clean up imports

* Remove duplicate copyright notices

* Minor changes

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
This commit is contained in:
Sunik Kupfer 2022-12-13 19:57:34 +01:00 committed by Ricki Hirner
parent 262592a3d9
commit cfee0f3461
No known key found for this signature in database
GPG key ID: 79A019FCAAEDD3AA
49 changed files with 570 additions and 131 deletions

View file

@ -49,7 +49,7 @@ class SyncAdapterTest {
fun setUp() {
hiltRule.inject()
syncAdapter = TestSyncAdapter(context, db)
syncAdapter = TestSyncAdapter(targetContext)
}
@ -117,11 +117,11 @@ class SyncAdapterTest {
assertEquals(1, syncAdapter.syncCalled.get())
// check whether contextClassLoader is set
assertEquals(context.classLoader, Thread.currentThread().contextClassLoader)
assertEquals(targetContext.classLoader, Thread.currentThread().contextClassLoader)
}
class TestSyncAdapter(context: Context, db: AppDatabase): SyncAdapterService.SyncAdapter(context, db) {
class TestSyncAdapter(context: Context): SyncAdapterService.SyncAdapter(context) {
companion object {
/**
@ -148,4 +148,4 @@ class SyncAdapterTest {
}
}
}

View file

@ -0,0 +1,107 @@
/***************************************************************************************************
* 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.content.Context
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.impl.utils.SynchronousExecutor
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.NotificationUtils
import com.google.common.util.concurrent.ListenableFuture
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.TimeUnit
@HiltAndroidTest
class SyncWorkerTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
val context: 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")
@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)
// 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
fun removeAccount() {
accountManager.removeAccountExplicitly(account)
}
@Test
fun testRequestSync_enqueuesWorker() {
SyncWorker.requestSync(context, account, CalendarContract.AUTHORITY)
val workerName = SyncWorker.workerName(account, CalendarContract.AUTHORITY)
assertTrue(workScheduledOrRunning(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(workerName))
// here we could test whether stopping the work really interrupts the sync thread
}
private fun workScheduledOrRunning(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
}
for (workInfo in workInfoList) {
val state = workInfo.state
if (state == WorkInfo.State.RUNNING || state == WorkInfo.State.ENQUEUED)
return true
}
return false
}
}

View file

@ -12,7 +12,7 @@ import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.property.GetCTag
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.resource.LocalResource

View file

@ -59,6 +59,14 @@
<service android:name=".ForegroundService"/>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove">
</provider>
<service android:name=".ForegroundService"/>
<activity android:name=".ui.intro.IntroActivity" android:theme="@style/AppTheme.NoActionBar" />
<activity
android:name=".ui.AccountsActivity"

View file

@ -44,6 +44,7 @@ class App: Application(), Thread.UncaughtExceptionHandler, Configuration.Provide
@Inject lateinit var accountsUpdatedListener: AccountsUpdatedListener
@Inject lateinit var storageLowReceiver: StorageLowReceiver
@Inject lateinit var workerFactory: HiltWorkerFactory
override fun onCreate() {

View file

@ -8,7 +8,7 @@ import androidx.room.*
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.property.*
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.util.DavUtils
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.apache.commons.lang3.StringUtils

View file

@ -12,7 +12,7 @@ import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import at.bitfire.davdroid.DavUtils.MEDIA_TYPE_OCTET_STREAM
import at.bitfire.davdroid.util.DavUtils.MEDIA_TYPE_OCTET_STREAM
import okhttp3.HttpUrl
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull

View file

@ -0,0 +1,72 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.di
import at.bitfire.davdroid.log.Logger
import dagger.hilt.DefineComponent
import dagger.hilt.components.SingletonComponent
import java.lang.ref.WeakReference
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Scope
import javax.inject.Singleton
@Scope
@Retention(AnnotationRetention.BINARY)
annotation class SyncScoped
/**
* Custom Hilt component for running syncs, lifetime managed by [SyncComponentManager].
* Dependencies installed in this component and scoped with [SyncScoped] (like SyncValidators)
* will have a lifetime of all active syncs.
*/
@SyncScoped
@DefineComponent(parent = SingletonComponent::class)
interface SyncComponent
@DefineComponent.Builder
interface SyncComponentBuilder {
fun build(): SyncComponent
}
/**
* Manages the lifecycle of [SyncComponent] by using [WeakReference].
*
* @sample at.bitfire.davdroid.syncadapter.LicenseValidator
* @sample at.bitfire.davdroid.syncadapter.PaymentValidator
*/
@Singleton
class SyncComponentManager @Inject constructor(
val provider: Provider<SyncComponentBuilder>
) {
private var componentRef: WeakReference<SyncComponent>? = null
/**
* Returns a [SyncComponent]. When there is already a known [SyncComponent],
* it will be used. Otherwise, a new one will be created and returned.
*
* It is then stored using a [WeakReference] so as long as the component
* stays in memory, it will always be returned. When it's not used anymore
* by anyone, it can be removed by garbage collection. After this, it will be
* created again when [get] is called.
*
* @return singleton [SyncComponent] (will be garbage collected when not referenced anymore)
*/
@Synchronized
fun get(): SyncComponent {
val component = componentRef?.get()
// check for cached component
if (component != null)
return component
// cached component not available, build new one
val newComponent = provider.get().build()
componentRef = WeakReference(newComponent)
return newComponent
}
}

View file

@ -14,7 +14,7 @@ import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.Groups
import android.provider.ContactsContract.RawContacts
import android.util.Base64
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.SyncState

View file

@ -12,7 +12,7 @@ import android.net.Uri
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Events
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.log.Logger

View file

@ -7,7 +7,7 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentValues
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.SyncState
import at.bitfire.ical4android.JtxCollection

View file

@ -10,7 +10,7 @@ import android.content.ContentValues
import android.content.Context
import android.net.Uri
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.log.Logger

View file

@ -11,7 +11,7 @@ import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.exception.UnauthorizedException
import at.bitfire.dav4jvm.property.*
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.log.StringHandler

View file

@ -23,7 +23,7 @@ import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.closeCompat
import at.bitfire.davdroid.util.closeCompat
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Credentials

View file

@ -14,8 +14,7 @@ import android.os.Bundle
import android.provider.ContactsContract
import androidx.core.content.ContextCompat
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.closeCompat
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.util.closeCompat
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
@ -33,13 +32,9 @@ import java.util.logging.Level
class AddressBooksSyncAdapterService : SyncAdapterService() {
override fun syncAdapter() = AddressBooksSyncAdapter(this, appDatabase)
override fun syncAdapter() = AddressBooksSyncAdapter(this)
class AddressBooksSyncAdapter(
context: Context,
appDatabase: AppDatabase
) : SyncAdapter(context, appDatabase) {
class AddressBooksSyncAdapter(context: Context) : SyncAdapter(context) {
@EntryPoint
@InstallIn(SingletonComponent::class)
@ -64,10 +59,7 @@ class AddressBooksSyncAdapterService : SyncAdapterService() {
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)
val syncExtras = Bundle(extras)
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true)
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true)
ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, syncExtras)
SyncWorker.requestSync(context, addressBookAccount)
}
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync address books", e)

View file

@ -13,7 +13,7 @@ import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.*
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.SyncState

View file

@ -27,13 +27,9 @@ import kotlin.collections.set
class CalendarsSyncAdapterService: SyncAdapterService() {
override fun syncAdapter() = CalendarsSyncAdapter(this, appDatabase)
override fun syncAdapter() = CalendarsSyncAdapter(this)
class CalendarsSyncAdapter(
context: Context,
appDatabase: AppDatabase
) : SyncAdapter(context, appDatabase) {
class CalendarsSyncAdapter(context: Context) : SyncAdapter(context) {
override fun sync(account: Account, extras: Bundle, authority: String, httpClient: Lazy<HttpClient>, provider: ContentProviderClient, syncResult: SyncResult) {
try {

View file

@ -24,13 +24,9 @@ class ContactsSyncAdapterService: SyncAdapterService() {
const val PREVIOUS_GROUP_METHOD = "previous_group_method"
}
override fun syncAdapter() = ContactsSyncAdapter(this, appDatabase)
override fun syncAdapter() = ContactsSyncAdapter(this)
class ContactsSyncAdapter(
context: Context,
appDatabase: AppDatabase
) : SyncAdapter(context, appDatabase) {
class ContactsSyncAdapter(context: Context) : SyncAdapter(context) {
override fun sync(account: Account, extras: Bundle, authority: String, httpClient: Lazy<HttpClient>, provider: ContentProviderClient, syncResult: SyncResult) {
try {

View file

@ -16,8 +16,8 @@ import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.*
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.DavUtils.sameTypeAs
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.util.DavUtils.sameTypeAs
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.SyncState

View file

@ -30,13 +30,9 @@ import kotlin.collections.set
class JtxSyncAdapterService: SyncAdapterService() {
override fun syncAdapter() = JtxSyncAdapter(this, appDatabase)
override fun syncAdapter() = JtxSyncAdapter(this)
class JtxSyncAdapter(
context: Context,
appDatabase: AppDatabase
) : SyncAdapter(context, appDatabase) {
class JtxSyncAdapter(context: Context) : SyncAdapter(context) {
override fun sync(account: Account, extras: Bundle, authority: String, httpClient: Lazy<HttpClient>, provider: ContentProviderClient, syncResult: SyncResult) {
@ -113,4 +109,4 @@ class JtxSyncAdapterService: SyncAdapterService() {
}
}
}
}

View file

@ -13,7 +13,7 @@ import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.*
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.SyncState

View file

@ -12,19 +12,21 @@ import android.net.NetworkCapabilities
import android.net.wifi.WifiManager
import android.os.Bundle
import androidx.core.content.getSystemService
import at.bitfire.davdroid.ConcurrentUtils
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.PermissionUtils
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 dagger.hilt.android.AndroidEntryPoint
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
import javax.inject.Inject
@AndroidEntryPoint
abstract class SyncAdapterService: Service() {
companion object {
@ -61,9 +63,6 @@ abstract class SyncAdapterService: Service() {
const val SYNC_EXTRAS_FULL_RESYNC = "full_resync"
}
@Inject lateinit var appDatabase: AppDatabase
protected abstract fun syncAdapter(): AbstractThreadedSyncAdapter
override fun onBind(intent: Intent?) = syncAdapter().syncAdapterBinder!!
@ -78,8 +77,7 @@ abstract class SyncAdapterService: Service() {
* Also provides some useful methods that can be used by derived sync adapters.
*/
abstract class SyncAdapter(
context: Context,
val db: AppDatabase
context: Context
): AbstractThreadedSyncAdapter(
context,
true // isSyncable shouldn't be -1 because DAVx5 sets it to 0 or 1.
@ -104,6 +102,17 @@ abstract class SyncAdapterService: Service() {
}
@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) {
@ -136,8 +145,9 @@ abstract class SyncAdapterService: Service() {
}
})
Logger.log.log(Level.INFO, "Sync for $currentSyncKey finished", syncResult)
else
else {
Logger.log.warning("There's already another running sync for $currentSyncKey, aborting")
}
}
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {

View file

@ -18,7 +18,7 @@ import androidx.annotation.WorkerThread
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.PermissionUtils
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Service

View file

@ -0,0 +1,197 @@
/***************************************************************************************************
* 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 android.provider.ContactsContract
import androidx.concurrent.futures.CallbackToFutureAdapter
import androidx.core.app.NotificationCompat
import androidx.hilt.work.HiltWorker
import androidx.lifecycle.Transformations
import androidx.work.*
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.util.LiveDataUtils
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.logging.Level
/**
* Handles sync requests
*/
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters
) : Worker(appContext, workerParams) {
companion object {
const val ARG_ACCOUNT_NAME = "accountName"
const val ARG_ACCOUNT_TYPE = "accountType"
const val ARG_AUTHORITY = "authority"
fun workerName(account: Account, authority: String): String {
return "explicit-sync $authority ${account.type}/${account.name}"
}
/**
* Requests immediate synchronization of an account with all applicable
* authorities (contacts, calendars, ).
*
* @param account account to sync
*/
fun requestSync(context: Context, account: Account) {
for (authority in DavUtils.syncAuthorities(context))
requestSync(context, account, authority)
}
/**
* Requests immediate synchronization of an account with a specific authority.
*
* @param account account to sync
* @param authority authority to sync (for instance: [CalendarContract.AUTHORITY]])
*/
fun requestSync(context: Context, account: Account, authority: String) {
val arguments = Data.Builder()
.putString(ARG_AUTHORITY, authority)
.putString(ARG_ACCOUNT_NAME, account.name)
.putString(ARG_ACCOUNT_TYPE, account.type)
.build()
val workRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(arguments)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
workerName(account, authority),
ExistingWorkPolicy.KEEP, // if sync is already running, just continue
workRequest
)
}
fun stopSync(context: Context, account: Account, authority: String) {
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.
*
* @param workState state of worker 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
*/
fun isSomeWorkerInState(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) =
Transformations.map(WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(workerName(account, authority))) { workInfoList ->
workInfoList.any { workInfo -> workInfo.state == workState }
}
}
/** thread which runs the actual sync code (can be interrupted to stop synchronization) */
var syncThread: Thread? = null
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 sync worker: account=$account, authority=$authority")
val syncAdapter = when (authority) {
applicationContext.getString(R.string.address_books_authority) ->
AddressBooksSyncAdapterService.AddressBooksSyncAdapter(applicationContext)
CalendarContract.AUTHORITY ->
CalendarsSyncAdapterService.CalendarsSyncAdapter(applicationContext)
ContactsContract.AUTHORITY ->
ContactsSyncAdapterService.ContactsSyncAdapter(applicationContext)
TaskProvider.ProviderName.JtxBoard.authority ->
JtxSyncAdapterService.JtxSyncAdapter(applicationContext)
TaskProvider.ProviderName.OpenTasks.authority,
TaskProvider.ProviderName.TasksOrg.authority ->
TasksSyncAdapterService.TasksSyncAdapter(applicationContext)
else ->
throw IllegalArgumentException("Invalid authority $authority")
}
// Pass flags to the sync adapter. Note that these are 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 the WorkManager and from the sync framework).
val extras = Bundle(2)
extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) // manual sync
extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) // run immediately (don't queue)
val result = SyncResult()
val provider: ContentProviderClient? =
try {
applicationContext.contentResolver.acquireContentProviderClient(authority)
} catch (e: SecurityException) {
Logger.log.log(Level.WARNING, "Missing permissions to acquire ContentProviderClient for $authority", e)
null
}
if (provider == null) {
Logger.log.warning("Couldn't acquire ContentProviderClient for $authority")
return Result.failure()
}
try {
syncThread = Thread.currentThread()
syncAdapter.onPerformSync(account, extras, authority, provider, result)
} catch (e: SecurityException) {
syncAdapter.onSecurityException(account, extras, authority, result)
} finally {
provider.closeCompat()
}
if (result.hasError())
return Result.failure(Data.Builder()
.putString("syncresult", result.toString())
.putString("syncResultStats", result.stats.toString())
.build())
return Result.success()
}
override fun onStopped() {
Logger.log.info("Stopping sync thread")
syncThread?.interrupt()
}
override fun getForegroundInfoAsync(): ListenableFuture<ForegroundInfo> =
CallbackToFutureAdapter.getFuture { completer ->
val notification = NotificationUtils.newBuilder(applicationContext, NotificationUtils.CHANNEL_STATUS)
.setSmallIcon(R.drawable.ic_foreground_notify)
.setContentTitle(applicationContext.getString(R.string.foreground_service_notify_title))
.setContentText(applicationContext.getString(R.string.foreground_service_notify_text))
.setStyle(NotificationCompat.BigTextStyle())
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
completer.set(ForegroundInfo(NotificationUtils.NOTIFY_SYNC_EXPEDITED, notification))
}
}

View file

@ -30,13 +30,9 @@ import java.util.logging.Level
*/
open class TasksSyncAdapterService: SyncAdapterService() {
override fun syncAdapter() = TasksSyncAdapter(this, appDatabase)
override fun syncAdapter() = TasksSyncAdapter(this)
class TasksSyncAdapter(
context: Context,
appDatabase: AppDatabase,
) : SyncAdapter(context, appDatabase) {
class TasksSyncAdapter(context: Context) : SyncAdapter(context) {
override fun sync(account: Account, extras: Bundle, authority: String, httpClient: Lazy<HttpClient>, provider: ContentProviderClient, syncResult: SyncResult) {
try {
@ -121,4 +117,4 @@ open class TasksSyncAdapterService: SyncAdapterService() {
}
}
}

View file

@ -13,7 +13,7 @@ import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.*
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.SyncState

View file

@ -26,8 +26,8 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.DavUtils.SyncStatus
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.util.DavUtils.SyncStatus
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.AccountListBinding
import at.bitfire.davdroid.databinding.AccountListItemBinding

View file

@ -15,9 +15,9 @@ import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.getSystemService
import androidx.core.view.GravityCompat
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.ActivityAccountsBinding
import at.bitfire.davdroid.syncadapter.SyncWorker
import at.bitfire.davdroid.ui.intro.IntroActivity
import at.bitfire.davdroid.ui.setup.LoginActivity
import com.google.android.material.navigation.NavigationView
@ -111,7 +111,7 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
val accounts = allAccounts()
for (account in accounts)
DavUtils.requestSync(this, account)
SyncWorker.requestSync(this, account)
}
}

View file

@ -13,7 +13,7 @@ import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Filter
import android.widget.TextView
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.HomeSet

View file

@ -25,10 +25,10 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.PackageChangedReceiver
import at.bitfire.davdroid.PermissionUtils
import at.bitfire.davdroid.PermissionUtils.CALENDAR_PERMISSIONS
import at.bitfire.davdroid.PermissionUtils.CONTACT_PERMISSIONS
import at.bitfire.davdroid.PermissionUtils.havePermissions
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.util.PermissionUtils.CALENDAR_PERMISSIONS
import at.bitfire.davdroid.util.PermissionUtils.CONTACT_PERMISSIONS
import at.bitfire.davdroid.util.PermissionUtils.havePermissions
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.ActivityPermissionsBinding
import at.bitfire.ical4android.TaskProvider

View file

@ -20,8 +20,8 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentStatePagerAdapter
import androidx.lifecycle.*
import at.bitfire.davdroid.DavUtils
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
@ -88,7 +88,7 @@ class AccountActivity: AppCompatActivity() {
})
binding.sync.setOnClickListener {
DavUtils.requestSync(this, model.account)
SyncWorker.requestSync(this, model.account)
Snackbar.make(binding.viewPager, R.string.account_synchronizing_now, Snackbar.LENGTH_LONG).show()
}
}

View file

@ -7,7 +7,7 @@ package at.bitfire.davdroid.ui.account
import android.content.Intent
import android.view.*
import androidx.fragment.app.FragmentManager
import at.bitfire.davdroid.PermissionUtils
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.AccountCarddavItemBinding
import at.bitfire.davdroid.db.Collection

View file

@ -8,7 +8,7 @@ import android.content.Intent
import android.view.*
import androidx.fragment.app.FragmentManager
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.PermissionUtils
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.AccountCaldavItemBinding
import at.bitfire.davdroid.db.Collection

View file

@ -31,7 +31,9 @@ import at.bitfire.davdroid.db.Collection
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
@ -305,36 +307,55 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList
// observe RefreshCollectionsWorker status
val isRefreshing = RefreshCollectionsWorker.isWorkerInState(context, serviceId, WorkInfo.State.RUNNING)
// observe whether sync is active
private var syncStatusHandle: Any? = null
val isSyncActive = MutableLiveData<Boolean>()
val isSyncPending = MutableLiveData<Boolean>()
// 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.isSomeWorkerInState(context,
WorkInfo.State.RUNNING,
accountModel.account,
authorities)
private val isSyncWorkerEnqueued = SyncWorker.isSomeWorkerInState(context,
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) {
syncStatusHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_PENDING + ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE, this@Model)
checkSyncStatus()
syncFrameworkStatusHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_PENDING +
ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE, this@Model)
checkSyncFrameworkStatus()
}
}
override fun onCleared() {
syncStatusHandle?.let { ContentResolver.removeStatusChangeListener(it) }
}
fun refresh() {
RefreshCollectionsWorker.refreshCollections(context, serviceId)
syncFrameworkStatusHandle?.let {
ContentResolver.removeStatusChangeListener(it)
}
}
@AnyThread
override fun onStatusChanged(which: Int) {
checkSyncStatus()
checkSyncFrameworkStatus()
}
@AnyThread
@Synchronized
private fun checkSyncStatus() {
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)
@ -343,22 +364,31 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList
val syncActive = addrBookAccounts.any { ContentResolver.isSyncActive(it, ContactsContract.AUTHORITY) }
val syncPending = addrBookAccounts.any { ContentResolver.isSyncPending(it, ContactsContract.AUTHORITY) }
isSyncActive.postValue(mainSyncActive || syncActive)
isSyncPending.postValue(mainSyncPending || syncPending)
isSyncFrameworkActive.postValue(mainSyncActive || syncActive)
isSyncFrameworkPending.postValue(mainSyncPending || syncPending)
} else {
// CalDAV tab
val authorities = mutableListOf(CalendarContract.AUTHORITY)
taskProvider?.let {
authorities += it.authority
}
isSyncActive.postValue(authorities.any {
isSyncFrameworkActive.postValue(authorities.any {
ContentResolver.isSyncActive(accountModel.account, it)
})
isSyncPending.postValue(authorities.any {
isSyncFrameworkPending.postValue(authorities.any {
ContentResolver.isSyncPending(accountModel.account, it)
})
}
}
// actions
fun refresh() {
RefreshCollectionsWorker.refreshCollections(context, serviceId)
}
}
}

View file

@ -15,7 +15,7 @@ import androidx.fragment.app.viewModels
import androidx.lifecycle.*
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase

View file

@ -27,16 +27,15 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.closeCompat
import at.bitfire.davdroid.*
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.syncadapter.SyncWorker
import at.bitfire.davdroid.syncadapter.AccountsUpdatedListener
import at.bitfire.davdroid.util.closeCompat
import at.bitfire.ical4android.TaskProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
@ -220,7 +219,7 @@ class RenameAccountFragment: DialogFragment() {
}
// synchronize again
DavUtils.requestSync(context, newAccount)
SyncWorker.requestSync(context, newAccount)
}
}

View file

@ -27,7 +27,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.preference.*
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.PermissionUtils
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger

View file

@ -22,9 +22,9 @@ import androidx.lifecycle.*
import androidx.room.Transaction
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.PermissionUtils
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.closeCompat
import at.bitfire.davdroid.util.closeCompat
import at.bitfire.davdroid.databinding.AccountCaldavItemBinding
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection

View file

@ -26,7 +26,7 @@ import androidx.core.location.LocationManagerCompat
import androidx.core.text.HtmlCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import at.bitfire.davdroid.PermissionUtils
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.ActivityWifiPermissionsBinding
import at.bitfire.davdroid.log.Logger

View file

@ -10,9 +10,9 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import at.bitfire.davdroid.PermissionUtils
import at.bitfire.davdroid.PermissionUtils.CALENDAR_PERMISSIONS
import at.bitfire.davdroid.PermissionUtils.CONTACT_PERMISSIONS
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.util.PermissionUtils.CALENDAR_PERMISSIONS
import at.bitfire.davdroid.util.PermissionUtils.CONTACT_PERMISSIONS
import at.bitfire.davdroid.R
import at.bitfire.ical4android.TaskProvider
import javax.inject.Inject

View file

@ -2,7 +2,7 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid
package at.bitfire.davdroid.util
import android.content.ContentProviderClient
import android.os.Build

View file

@ -2,7 +2,7 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid
package at.bitfire.davdroid.util
import java.util.*

View file

@ -2,20 +2,23 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid
package at.bitfire.davdroid.util
import android.accounts.Account
import android.content.ContentResolver
import android.content.Context
import android.net.ConnectivityManager
import android.os.Build
import android.os.Bundle
import android.provider.CalendarContract
import android.provider.ContactsContract
import androidx.core.content.getSystemService
import androidx.work.WorkInfo
import at.bitfire.davdroid.Android10Resolver
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.TaskUtils
import at.bitfire.davdroid.syncadapter.SyncWorker
import okhttp3.HttpUrl
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
@ -178,27 +181,22 @@ object DavUtils {
if (addrBookAccounts.any { ContentResolver.isSyncActive(it, ContactsContract.AUTHORITY) })
return SyncStatus.ACTIVE
// check get pending syncs
// check pending syncs
if (authorities.any { ContentResolver.isSyncPending(account, it) } ||
addrBookAccounts.any { ContentResolver.isSyncPending(it, ContactsContract.AUTHORITY) })
return SyncStatus.PENDING
// Also check SyncWorkers
val pending = SyncWorker.isSomeWorkerInState(context, WorkInfo.State.ENQUEUED, account, authorities.toList()).value
if (pending != null && pending == true)
return SyncStatus.PENDING
val running = SyncWorker.isSomeWorkerInState(context, WorkInfo.State.RUNNING, account, authorities.toList()).value
if (running != null && running == true)
return SyncStatus.ACTIVE
return SyncStatus.IDLE
}
/**
* Requests an immediate, manual sync of all available authorities for the given account.
*
* @param account account to sync
*/
fun requestSync(context: Context, account: Account) {
for (authority in syncAuthorities(context)) {
val extras = Bundle(2)
extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) // manual sync
extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) // run immediately (don't queue)
ContentResolver.requestSync(account, authority, extras)
}
}
/**
* Returns a list of all available sync authorities for main accounts (!= address book accounts):

View file

@ -0,0 +1,36 @@
/***************************************************************************************************
* 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

@ -2,7 +2,7 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid
package at.bitfire.davdroid.util
import android.Manifest
import android.app.PendingIntent
@ -16,6 +16,8 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.location.LocationManagerCompat
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.PermissionsActivity

View file

@ -23,6 +23,7 @@ import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.*
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.webdav.cache.MemoryCache
import at.bitfire.davdroid.webdav.cache.SegmentedCache
import okhttp3.Headers

View file

@ -12,7 +12,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger

View file

@ -4,6 +4,7 @@
package at.bitfire.davdroid
import at.bitfire.davdroid.util.ConcurrentUtils
import org.junit.Assert.assertEquals
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger

View file

@ -4,6 +4,7 @@
package at.bitfire.davdroid
import at.bitfire.davdroid.util.DavUtils
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.*
import org.junit.Test