Update sync progress bar when pending in SAF (#1445)

* Provide flow to check if sync of account and authority is pending in sync framework

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Show if sync is pending in sync framework

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Show if sync is pending in sync framework

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Fix kdoc

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Cancel any pending SAF syncs on sync request

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Cancel sync adapter sync only after having enqueued our worker sync

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Improve accuracy by also checking isSyncActive

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Remove log statements

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Only query pending state

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Cancel sync adapter sync only on android 14 and 15

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Cancel sync adapter sync with authority

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Cancel sync adapter sync by using sync request instead of canceling directly

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Include android 16

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Include all versions after Android 14

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add test which documents wrong pending sync check behaviour

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Revert "Add test which documents wrong pending sync check behaviour"

This reverts commit 8c538149ff2cb032d6355232c1736e103dcc9a18.

* Drop Android 14+ always pending sync work around

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Differentiate better between enqueued and pending syncs

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Refactor sync pending check to use flow for address book accounts

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Always return false for sync pending state check on Android 14+

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Refactor sync pending check to use flow for address book accounts

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add comments

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update comment

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Shorten variable name

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update comments and variable name

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Remvoe obsolete call and add argument names as comments

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Remove sync active check from listener

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
This commit is contained in:
Sunik Kupfer
2025-06-25 13:54:44 +02:00
committed by GitHub
parent a26847cf10
commit 42cd8d8631
5 changed files with 175 additions and 20 deletions

View File

@@ -6,6 +6,7 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.content.ContentProviderClient
import android.content.Context
import android.provider.ContactsContract
@@ -23,6 +24,9 @@ import at.bitfire.davdroid.sync.account.setAndVerifyUserData
import at.bitfire.davdroid.util.DavUtils.lastSegment
import com.google.common.base.CharMatcher
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
@@ -115,17 +119,10 @@ class LocalAddressBookStore @Inject constructor(
return addressBookAccount
}
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalAddressBook> {
val accountManager = AccountManager.get(context)
return accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
.filter { addressBookAccount ->
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME) == account.name &&
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE) == account.type
}
.map { addressBookAccount ->
localAddressBookFactory.create(account, addressBookAccount, provider)
}
}
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalAddressBook> =
getAddressBookAccounts(account).map { addressBookAccount ->
localAddressBookFactory.create(account, addressBookAccount, provider)
}
override fun update(provider: ContentProviderClient, localCollection: LocalAddressBook, fromCollection: Collection) {
var currentAccount = localCollection.addressBookAccount
@@ -197,6 +194,45 @@ class LocalAddressBookStore @Inject constructor(
accountManager.removeAccountExplicitly(addressBookAccount)
}
/**
* Returns all address book accounts that belong to the given account.
*
* @param account Account which has the address books.
* @return List of address book accounts.
*/
fun getAddressBookAccounts(account: Account): List<Account> =
AccountManager.get(context).let { accountManager ->
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
.filter { addressBookAccount ->
account.name == accountManager.getUserData(
addressBookAccount,
LocalAddressBook.USER_DATA_ACCOUNT_NAME
) && account.type == accountManager.getUserData(
addressBookAccount,
LocalAddressBook.USER_DATA_ACCOUNT_TYPE
)
}
}
/**
* Returns all address book accounts that belong to the given account in a flow.
*
* @param account Account which has the address books.
* @return List of address book accounts as flow.
*/
fun getAddressBookAccountsFlow(account: Account): Flow<List<Account>> = callbackFlow {
val accountManager = AccountManager.get(context)
val listener = OnAccountsUpdateListener { accounts ->
trySend(getAddressBookAccounts(account))
}
accountManager.addOnAccountsUpdatedListener(
/* listener = */ listener,
/* handler = */ null,
/* updateImmediately = */ true
)
awaitClose { accountManager.removeOnAccountsUpdatedListener(listener) }
}
companion object {

View File

@@ -143,13 +143,13 @@ abstract class SyncAdapterService: Service() {
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()) {
@@ -160,6 +160,7 @@ abstract class SyncAdapterService: Service() {
logger.fine("Starting OneTimeSyncWorker for $account $authority and waiting for it")
val workerName = syncWorkerManager.enqueueOneTime(account, dataType = SyncDataType.fromAuthority(authority), fromUpload = upload)
/* 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. */

View File

@@ -6,8 +6,18 @@ package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.ContentResolver
import android.os.Build
import android.provider.CalendarContract
import androidx.annotation.WorkerThread
import at.bitfire.davdroid.resource.LocalAddressBookStore
import dagger.Lazy
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import java.util.logging.Logger
import javax.inject.Inject
@@ -19,6 +29,7 @@ import javax.inject.Inject
* Sync requests from the Sync Adapter Framework are handled by [SyncAdapterService].
*/
class SyncFrameworkIntegration @Inject constructor(
private val localAddressBookStore: Lazy<LocalAddressBookStore>,
private val logger: Logger
) {
@@ -134,4 +145,59 @@ class SyncFrameworkIntegration @Inject constructor(
/* return */ !ContentResolver.getSyncAutomatically(account, authority)
}
/**
* Observe whether any of the given data types is currently pending for sync.
*
* @param account account to observe sync status for
* @param dataTypes data types to observe sync status for
* @return flow emitting true if any of the given data types has a sync pending, false otherwise
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun isSyncPending(account: Account, dataTypes: Iterable<SyncDataType>): Flow<Boolean> =
if (Build.VERSION.SDK_INT >= 34) {
// On Android 14+ pending sync checks always return true (bug), so we don't need to check.
// See: https://github.com/bitfireAT/davx5-ose/issues/1458
flowOf(false)
} else {
val authorities = dataTypes.flatMap { it.possibleAuthorities() }
// Use address book accounts if needed
val accountsFlow = if (dataTypes.contains(SyncDataType.CONTACTS))
localAddressBookStore.get().getAddressBookAccountsFlow(account)
else
flowOf(listOf(account))
// Observe sync pending state for the given accounts and authorities
accountsFlow.flatMapLatest { accounts ->
callbackFlow {
// Observe sync pending state
val listener = ContentResolver.addStatusChangeListener(
ContentResolver.SYNC_OBSERVER_TYPE_PENDING
) {
trySend(anyPendingSync(accounts, authorities))
}
// Emit initial value
trySend(anyPendingSync(accounts, authorities))
// Clean up listener on close
awaitClose { ContentResolver.removeStatusChangeListener(listener) }
}
}.distinctUntilChanged()
}
/**
* Check if any of the given accounts and authorities have a sync pending.
*
* @param accounts accounts to check sync status for
* @param authorities authorities to check sync status for
* @return true if any of the given accounts and authorities has a sync pending, false otherwise
*/
private fun anyPendingSync(accounts: List<Account>, authorities: List<String>): Boolean =
accounts.any { account ->
authorities.any { authority ->
ContentResolver.isSyncPending(account, authority)
}
}
}

View File

@@ -26,6 +26,7 @@ import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
@@ -40,11 +41,14 @@ import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import java.text.Collator
@@ -58,7 +62,8 @@ class AccountsModel @AssistedInject constructor(
private val db: AppDatabase,
introPageFactory: IntroPageFactory,
private val logger: Logger,
private val syncWorkerManager: SyncWorkerManager
private val syncWorkerManager: SyncWorkerManager,
private val syncFrameWork: SyncFrameworkIntegration
): ViewModel() {
@AssistedFactory
@@ -66,7 +71,6 @@ class AccountsModel @AssistedInject constructor(
fun create(syncAccountsOnInit: Boolean): AccountsModel
}
// Accounts UI state
enum class FABStyle {
@@ -92,7 +96,41 @@ class AccountsModel @AssistedInject constructor(
private val workManager = WorkManager.getInstance(context)
private val runningWorkers = workManager.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING))
val accountInfos: Flow<List<AccountInfo>> = combine(accounts, runningWorkers) { accounts, workInfos ->
@OptIn(ExperimentalCoroutinesApi::class)
private val accountsSyncPending: Flow<List<Account>> =
accounts.flatMapLatest { accounts ->
if (accounts.isEmpty())
flowOf(emptyList())
else {
// To create the Flow<List<Account?>> that emits the accounts with pending sync,
val pendingSyncAccountsFlows: List<Flow<Account?>> =
// for each existing account with unknown sync pending state ...
accounts.map { account ->
// ... create a Flow<Boolean> which emits the sync pending state
syncFrameWork.isSyncPending(account, SyncDataType.entries)
.map { hasPendingSync ->
// ... and map this boolean answer back to its Account if it is pending, or null if not.
if (hasPendingSync) account else null
}
}
// Combine all account flows Flow<Account?> in the list into a single flow, emitting a list of
// accounts with pending sync. The null values which we filter out are the non-pending accounts.
// Now, whenever any account's pending state changes, the combined flow emits the updated list.
combine(pendingSyncAccountsFlows) { combinedAccounts ->
// combinedAccounts is an Array<Account?> of the most recently emitted values of the
// pendingSyncCheckFlows, with one entry for every pendingSyncCheckFlow that is either
// the account name (sync pending) or null (no sync pending).
combinedAccounts.filterNotNull()
}
}
}
val accountInfos: Flow<List<AccountInfo>> = combine(
accounts,
runningWorkers,
accountsSyncPending
) { accounts, workInfos, accountsSyncPending ->
val collator = Collator.getInstance()
accounts
@@ -116,6 +154,9 @@ class AccountsModel @AssistedInject constructor(
}
} -> AccountProgress.Pending
account in accountsSyncPending
-> AccountProgress.Pending
else -> AccountProgress.Idle
}

View File

@@ -10,6 +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.worker.OneTimeSyncWorker
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -22,22 +23,32 @@ import javax.inject.Inject
class AccountProgressUseCase @Inject constructor(
@ApplicationContext val context: Context,
private val syncFramework: SyncFrameworkIntegration,
private val syncWorkerManager: SyncWorkerManager
) {
/**
* Returns the current sync state of the account.
*/
operator fun invoke(
account: Account,
serviceFlow: Flow<Service?>,
dataTypes: Iterable<SyncDataType>
): Flow<AccountProgress> {
val serviceRefreshing = isServiceRefreshing(serviceFlow)
val syncPending = isSyncPending(account, dataTypes)
val syncEnqueued = isSyncEnqueued(account, dataTypes)
val syncPending = syncFramework.isSyncPending(account, dataTypes)
val syncRunning = isSyncRunning(account, dataTypes)
return combine(serviceRefreshing, syncPending, syncRunning) { refreshing, pending, syncing ->
return combine(
serviceRefreshing,
syncEnqueued,
syncPending,
syncRunning
) { refreshing, enqueued, pending, syncing ->
when {
refreshing || syncing -> AccountProgress.Active
pending -> AccountProgress.Pending
enqueued || pending -> AccountProgress.Pending
else -> AccountProgress.Idle
}
}
@@ -53,13 +64,13 @@ class AccountProgressUseCase @Inject constructor(
}
@OptIn(ExperimentalCoroutinesApi::class)
fun isSyncPending(account: Account, dataTypes: Iterable<SyncDataType>): Flow<Boolean> =
fun isSyncEnqueued(account: Account, dataTypes: Iterable<SyncDataType>): Flow<Boolean> =
syncWorkerManager.hasAnyFlow(
workStates = listOf(WorkInfo.State.ENQUEUED),
account = account,
dataTypes = dataTypes,
whichTag = { _, authority ->
// we are only interested in pending OneTimeSyncWorkers because there's always a pending PeriodicSyncWorker
// we are only interested in enqueued OneTimeSyncWorkers because there's always an enqueued PeriodicSyncWorker
OneTimeSyncWorker.workerName(account, authority)
}
)