Compare commits

...

20 Commits

Author SHA1 Message Date
Sunik Kupfer
7f36e826d8 Cancel syncs for calendar, tasks, and contacts separately 2025-09-04 12:13:18 +02:00
Sunik Kupfer
3737d69397 Add FAB to cancel sync adapter syncs 2025-09-04 11:43:10 +02:00
Sunik Kupfer
2dbd5c02b6 Cancel by request and empty bundle 2025-09-04 11:09:23 +02:00
Sunik Kupfer
c12e9311f7 Stop always returning false for pending sync state of sync adapter framework 2025-09-04 10:58:40 +02:00
Sunik Kupfer
b663912feb Enable forever pending sync workaround by canceling sync adapter framework syncs on Android 14+ 2025-09-04 10:56:54 +02:00
Sunik Kupfer
3c484f253f Use cancelSync directly in migration 2025-09-04 10:53:50 +02:00
Sunik Kupfer
de7f8d2964 Cancel for all authorities and update kdoc 2025-09-04 10:53:50 +02:00
Sunik Kupfer
a79a39c25d Cancel only on Android 14+ 2025-09-04 10:53:50 +02:00
Sunik Kupfer
20675ed71b Update kdoc 2025-09-04 10:53:50 +02:00
Sunik Kupfer
881588f8e8 Don't infer authority from account type 2025-09-04 10:53:50 +02:00
Sunik Kupfer
0c31758880 Also cancel calendar syncs 2025-09-04 10:53:50 +02:00
Sunik Kupfer
9ffd59cd00 Updating log statement 2025-09-04 10:53:50 +02:00
Sunik Kupfer
c40b2b38bc Update kdoc 2025-09-04 10:53:50 +02:00
Sunik Kupfer
3025ea7491 Optimize imports 2025-09-04 10:53:50 +02:00
Sunik Kupfer
b84a812d7a Call cancelSync via integration 2025-09-04 10:53:50 +02:00
Sunik Kupfer
562afc5666 Add and update kdoc 2025-09-04 10:53:50 +02:00
Sunik Kupfer
8992859b63 Increase account settings current version 2025-09-04 10:53:50 +02:00
Sunik Kupfer
03013b5576 Add log statement 2025-09-04 10:53:50 +02:00
Sunik Kupfer
0028fc8722 Add application context annotation 2025-09-04 10:53:50 +02:00
Sunik Kupfer
1b4ebde896 Add AccountSettingsMigration21 to cancel pending address book syncs 2025-09-04 10:53:50 +02:00
7 changed files with 144 additions and 18 deletions

View File

@@ -354,7 +354,12 @@ class AccountSettings @AssistedInject constructor(
companion object { companion object {
const val CURRENT_VERSION = 20 /**
* Current (usually the newest) account settings version. It's used to
* determine whether a migration ([AccountSettingsMigration])
* should be performed.
*/
const val CURRENT_VERSION = 21
const val KEY_SETTINGS_VERSION = "version" const val KEY_SETTINGS_VERSION = "version"
const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks" const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks"

View File

@@ -10,7 +10,8 @@ import at.bitfire.davdroid.settings.AccountSettings
interface AccountSettingsMigration { interface AccountSettingsMigration {
/** /**
* Migrate the account settings from the old version to the new version. * Migrate the account settings from the old version to the new version which
* is set in [AccountSettings.CURRENT_VERSION].
* *
* **The new (target) version number is registered in the Hilt module as [Int] key of the multi-binding of [AccountSettings].** * **The new (target) version number is registered in the Hilt module as [Int] key of the multi-binding of [AccountSettings].**
* *

View File

@@ -0,0 +1,76 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings.migration
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.provider.CalendarContract
import android.provider.ContactsContract
import at.bitfire.davdroid.R
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntKey
import dagger.multibindings.IntoMap
import java.util.logging.Logger
import javax.inject.Inject
/**
* On Android 14+ the pending sync state of the Sync Adapter Framework is not handled correctly.
* As a workaround we cancel incoming sync requests (clears pending flag) after enqueuing our own
* sync worker (work manager). With version 4.5.3 we started cancelling pending syncs for DAVx5
* accounts, but forgot to do that for address book accounts. With version 4.5.4 we also cancel
* those, but only when contact data of an address book has been edited.
*
* This migration cancels (once only) any possibly still wrongly pending address book and calendar
* (+tasks) account syncs.
*/
class AccountSettingsMigration21 @Inject constructor(
@ApplicationContext private val context: Context,
private val syncFrameworkIntegration: SyncFrameworkIntegration,
private val logger: Logger
): AccountSettingsMigration {
private val accountManager = AccountManager.get(context)
private val calendarAccountType = context.getString(R.string.account_type)
private val addressBookAccountType = context.getString(R.string.account_type_address_book)
override fun migrate(account: Account) {
if (Build.VERSION.SDK_INT >= 34) {
// Cancel any (after an update) possibly forever pending calendar (+tasks) account syncs
cancelSyncs(calendarAccountType, CalendarContract.AUTHORITY)
// Cancel any (after an update) possibly forever pending address book account syncs
cancelSyncs(addressBookAccountType, ContactsContract.AUTHORITY)
}
}
/**
* Cancels any (possibly forever pending) syncs for the accounts of given account type for all
* authorities.
*/
private fun cancelSyncs(accountType: String, authority: String) {
accountManager.getAccountsByType(accountType).forEach { account ->
logger.info("Android 14+: Canceling all (possibly forever pending) syncs for $account")
syncFrameworkIntegration.cancelSync(account, authority, Bundle())
}
}
@Module
@InstallIn(SingletonComponent::class)
abstract class AccountSettingsMigrationModule {
@Binds @IntoMap
@IntKey(21)
abstract fun provide(impl: AccountSettingsMigration21): AccountSettingsMigration
}
}

View File

@@ -11,6 +11,7 @@ import android.content.ContentProviderClient
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.SyncResult import android.content.SyncResult
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import androidx.work.WorkInfo import androidx.work.WorkInfo
@@ -59,6 +60,7 @@ class SyncAdapterImpl @Inject constructor(
@ApplicationContext context: Context, @ApplicationContext context: Context,
private val logger: Logger, private val logger: Logger,
private val syncConditionsFactory: SyncConditions.Factory, private val syncConditionsFactory: SyncConditions.Factory,
private val syncFrameworkIntegration: SyncFrameworkIntegration,
private val syncWorkerManager: SyncWorkerManager private val syncWorkerManager: SyncWorkerManager
): AbstractThreadedSyncAdapter( ): AbstractThreadedSyncAdapter(
/* context = */ context, /* context = */ context,
@@ -117,11 +119,11 @@ class SyncAdapterImpl @Inject constructor(
// Android 14+ does not handle pending sync state correctly. // Android 14+ does not handle pending sync state correctly.
// As a defensive workaround, we can cancel specifically this still pending sync only // As a defensive workaround, we can cancel specifically this still pending sync only
// See: https://github.com/bitfireAT/davx5-ose/issues/1458 // See: https://github.com/bitfireAT/davx5-ose/issues/1458
// if (Build.VERSION.SDK_INT >= 34) { if (Build.VERSION.SDK_INT >= 34) {
// logger.fine("Android 14+ bug: Canceling forever pending sync adapter framework sync request for " + logger.fine("Android 14+ bug: Canceling forever pending sync adapter framework sync request for " +
// "account=$accountOrAddressBookAccount authority=$authority upload=$upload") "account=$accountOrAddressBookAccount authority=$authority upload=$upload")
// syncFrameworkIntegration.cancelSync(accountOrAddressBookAccount, authority, extras) syncFrameworkIntegration.cancelSync(accountOrAddressBookAccount, authority, extras)
// } }
/* Because we are not allowed to observe worker state on a background thread, we can not /* 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 use it to block the sync adapter. Instead we use a Flow to get notified when the sync

View File

@@ -101,11 +101,9 @@ class SyncFrameworkIntegration @Inject constructor(
} }
/** /**
* Cancels the sync request in the Sync Framework for Android 14+. * Cancels the sync request in the Sync Adapter Framework by sync request. This
* This is a workaround for the bug that the sync framework does not handle pending syncs correctly * is the defensive approach canceling only one specific sync request with matching
* on Android 14+ (API level 34+). * sync extras.
*
* See: https://github.com/bitfireAT/davx5-ose/issues/1458
* *
* @param account The account for which the sync request should be canceled. * @param account The account for which the sync request should be canceled.
* @param authority The authority for which the sync request should be canceled. * @param authority The authority for which the sync request should be canceled.
@@ -193,12 +191,6 @@ class SyncFrameworkIntegration @Inject constructor(
*/ */
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
fun isSyncPending(account: Account, dataTypes: Iterable<SyncDataType>): Flow<Boolean> { fun isSyncPending(account: Account, dataTypes: Iterable<SyncDataType>): Flow<Boolean> {
// Android 14+ does not handle pending sync state correctly.
// For now we simply always return false
// See also sync cancellation in [SyncAdapterImpl.onPerformSync]
if (Build.VERSION.SDK_INT >= 34)
return flowOf(false)
// Determine the pending state for each data type of the account as separate flows // Determine the pending state for each data type of the account as separate flows
val pendingStateFlows: List<Flow<Boolean>> = dataTypes.mapNotNull { dataType -> val pendingStateFlows: List<Flow<Boolean>> = dataTypes.mapNotNull { dataType ->
// Map datatype to authority // Map datatype to authority

View File

@@ -5,6 +5,8 @@
package at.bitfire.davdroid.ui package at.bitfire.davdroid.ui
import android.accounts.Account import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
@@ -13,6 +15,7 @@ import android.net.ConnectivityManager
import android.net.Network import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import android.os.Build
import android.os.PowerManager import android.os.PowerManager
import android.provider.CalendarContract import android.provider.CalendarContract
import android.provider.ContactsContract import android.provider.ContactsContract
@@ -22,6 +25,7 @@ import androidx.lifecycle.ViewModel
import androidx.work.WorkInfo import androidx.work.WorkInfo
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkQuery import androidx.work.WorkQuery
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.repository.AccountRepository import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
@@ -296,4 +300,34 @@ class AccountsModel @AssistedInject constructor(
false false
} }
fun cancelSyncAdapterSyncs() {
if (Build.VERSION.SDK_INT >= 34) {
val calendarAccountType = context.getString(R.string.account_type)
val addressBookAccountType = context.getString(R.string.account_type_address_book)
// Cancel any (after an update) possibly forever pending calendar account syncs
cancelSyncs(calendarAccountType, SyncDataType.EVENTS.possibleAuthorities())
// Cancel any (after an update) possibly forever pending tasks account syncs
cancelSyncs(calendarAccountType, SyncDataType.TASKS.possibleAuthorities())
// Cancel any (after an update) possibly forever pending address book account syncs
cancelSyncs(addressBookAccountType, SyncDataType.CONTACTS.possibleAuthorities())
}
}
/**
* Cancels any (possibly forever pending) syncs for the accounts of given account type for all
* authorities.
*/
private fun cancelSyncs(accountType: String, authorities: List<String>) {
val accountManager = AccountManager.get(context)
accountManager.getAccountsByType(accountType).forEach { account ->
logger.info("Android 14+: Canceling all (possibly forever pending) syncs for $account")
for (authority in authorities)
ContentResolver.cancelSync(account, authority)
}
}
}
} }

View File

@@ -23,6 +23,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BatterySaver import androidx.compose.material.icons.filled.BatterySaver
import androidx.compose.material.icons.filled.CancelScheduleSend
import androidx.compose.material.icons.filled.DataSaverOn import androidx.compose.material.icons.filled.DataSaverOn
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material.icons.filled.NotificationsOff
@@ -111,6 +112,7 @@ fun AccountsScreen(
} }
AccountsScreen( AccountsScreen(
cancelSyncAdapterSyncs = { model.cancelSyncAdapterSyncs() },
accountsDrawerHandler = accountsDrawerHandler, accountsDrawerHandler = accountsDrawerHandler,
accounts = accounts, accounts = accounts,
showSyncAll = showSyncAll, showSyncAll = showSyncAll,
@@ -131,6 +133,7 @@ fun AccountsScreen(
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
@Composable @Composable
fun AccountsScreen( fun AccountsScreen(
cancelSyncAdapterSyncs: () -> Unit,
accountsDrawerHandler: AccountsDrawerHandler, accountsDrawerHandler: AccountsDrawerHandler,
accounts: List<AccountsModel.AccountInfo>, accounts: List<AccountsModel.AccountInfo>,
showSyncAll: Boolean = true, showSyncAll: Boolean = true,
@@ -228,6 +231,17 @@ fun AccountsScreen(
contentDescription = stringResource(R.string.accounts_sync_all) contentDescription = stringResource(R.string.accounts_sync_all)
) )
} }
FloatingActionButton(
onClick = cancelSyncAdapterSyncs,
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = MaterialTheme.colorScheme.onSecondary,
modifier = Modifier.padding(top = 24.dp)
) {
Icon(
Icons.Default.CancelScheduleSend,
contentDescription = stringResource(R.string.accounts_sync_all)
)
}
} }
}, },
snackbarHost = { SnackbarHost(snackbarHostState) } snackbarHost = { SnackbarHost(snackbarHostState) }
@@ -321,6 +335,7 @@ fun AccountsScreen(
@Preview @Preview
fun AccountsScreen_Preview_Empty() { fun AccountsScreen_Preview_Empty() {
AccountsScreen( AccountsScreen(
cancelSyncAdapterSyncs = {},
accountsDrawerHandler = object: AccountsDrawerHandler() { accountsDrawerHandler = object: AccountsDrawerHandler() {
@Composable @Composable
override fun MenuEntries(snackbarHostState: SnackbarHostState) { override fun MenuEntries(snackbarHostState: SnackbarHostState) {
@@ -337,6 +352,7 @@ fun AccountsScreen_Preview_Empty() {
@Preview @Preview
fun AccountsScreen_Preview_OneAccount() { fun AccountsScreen_Preview_OneAccount() {
AccountsScreen( AccountsScreen(
cancelSyncAdapterSyncs = {},
accountsDrawerHandler = object: AccountsDrawerHandler() { accountsDrawerHandler = object: AccountsDrawerHandler() {
@Composable @Composable
override fun MenuEntries(snackbarHostState: SnackbarHostState) { override fun MenuEntries(snackbarHostState: SnackbarHostState) {