Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Unverified Commit 5c7b792e authored by Sunik Kupfer's avatar Sunik Kupfer Committed by GitHub
Browse files

Support sync adapter pending sync indication on Android 14+ (#1676)

* Add AccountSettingsMigration21 to cancel pending address book syncs

* Add application context annotation

* Add log statement

* Increase account settings current version

* Add and update kdoc

* Call cancelSync via integration

* Optimize imports

* Update kdoc

* Updating log statement

* Also cancel calendar syncs

* Don't infer authority from account type

* Update kdoc

* Cancel only on Android 14+

* Cancel for all authorities and update kdoc

* Use cancelSync directly in migration

* Enable forever pending sync workaround by canceling sync adapter framework syncs on Android 14+

* Stop always returning false for pending sync state of sync adapter framework

* Cancel by request and empty bundle

* Cancel syncs for calendar, tasks, and contacts separately

* Minor edits to log statement and kdoc

* Add migration test; Update migration

* Log all extras instead of just upload flag

* Use lazy on syncFrameworkIntegration injection

* Multiple changes

- don't cancel address book accounts of all main accounts
- merge loops

* Add authority to log statement

* Replace complex state verification logic by status changed flow

* Cancel syncs account wide across all authorities

* Add some delay to allow dummy sync requests to be created

* Reduce wait until pending

* Drop Thread.sleep()

* Use a callback flow instead of mutable state flow

* Shorten first true filter

* Shorten remaining first true filter
parent c9da4961
Loading
Loading
Loading
Loading
+139 −0
Original line number Diff line number Diff line
/*
 * 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.content.ContentResolver
import android.content.Context
import android.content.SyncRequest
import android.os.Bundle
import android.provider.CalendarContract
import androidx.test.filters.SdkSuppress
import at.bitfire.davdroid.sync.account.TestAccount
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import java.util.logging.Logger
import javax.inject.Inject

@HiltAndroidTest
class AccountSettingsMigration21Test {

    @get:Rule
    val hiltRule = HiltAndroidRule(this)

    @Inject
    lateinit var migration: AccountSettingsMigration21

    @Inject
    @ApplicationContext
    lateinit var context: Context

    @Inject
    lateinit var logger: Logger

    lateinit var account: Account
    val authority = CalendarContract.AUTHORITY

    private val inPendingState = callbackFlow {
        val stateChangeListener = ContentResolver.addStatusChangeListener(
            ContentResolver.SYNC_OBSERVER_TYPE_PENDING or ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE
        ) {
            trySend(ContentResolver.isSyncPending(account, authority))
        }
        trySend(ContentResolver.isSyncPending(account, authority))
        awaitClose {
            ContentResolver.removeStatusChangeListener(stateChangeListener)
        }
    }

    @Before
    fun setUp() {
        hiltRule.inject()

        account = TestAccount.create()

        // Enable sync globally and for the test account
        ContentResolver.setIsSyncable(account, authority, 1)
    }

    @After
    fun tearDown() {
        TestAccount.remove(account)
    }


    @SdkSuppress(minSdkVersion = 34)
    @Test
    fun testCancelsSyncAndClearsPendingState() = runBlocking {
        // Move into forever pending state
        ContentResolver.requestSync(syncRequest())

        // Wait until we are in forever pending state (with timeout)
        withTimeout(10_000) {
            inPendingState.first { it }
        }

        // Assert again that we are now in the forever pending state
        assertTrue(ContentResolver.isSyncPending(account, authority))

        // Run the migration which should cancel the forever pending sync for all accounts
        migration.migrate(account)

        // Wait for the state to change (with timeout)
        withTimeout(10_000) {
            inPendingState.first { !it }
        }

        // Check the sync is now not pending anymore
        assertFalse(ContentResolver.isSyncPending(account, authority))
    }


    // helpers

    private fun syncRequest() = SyncRequest.Builder()
        .setSyncAdapter(account, authority)
        .syncOnce()
        .setExtras(Bundle())    // needed for Android 9
        .setExpedited(true)     // sync request will be scheduled at the front of the sync request queue
        .setManual(true)        // equivalent of setting both SYNC_EXTRAS_IGNORE_SETTINGS and SYNC_EXTRAS_IGNORE_BACKOFF
        .build()

    companion object {

        var globalAutoSyncBeforeTest = false

        @BeforeClass
        @JvmStatic
        fun before() {
            globalAutoSyncBeforeTest = ContentResolver.getMasterSyncAutomatically()

            // We'll request syncs explicitly and with SYNC_EXTRAS_IGNORE_SETTINGS
            ContentResolver.setMasterSyncAutomatically(false)
        }

        @AfterClass
        @JvmStatic
        fun after() {
            ContentResolver.setMasterSyncAutomatically(globalAutoSyncBeforeTest)
        }

    }

}
 No newline at end of file
+6 −1
Original line number Diff line number Diff line
@@ -355,7 +355,12 @@ class AccountSettings @AssistedInject constructor(

    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_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks"
+2 −1
Original line number Diff line number Diff line
@@ -10,7 +10,8 @@ import at.bitfire.davdroid.settings.AccountSettings
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].**
     *
+83 −0
Original line number Diff line number Diff line
/*
 * 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.content.ContentResolver
import android.os.Build
import android.os.Bundle
import android.provider.ContactsContract
import at.bitfire.davdroid.resource.LocalAddressBookStore
import at.bitfire.davdroid.sync.SyncDataType
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
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(
    private val localAddressBookStore: LocalAddressBookStore,
    private val logger: Logger
): AccountSettingsMigration {

    /**
     * Cancel any possibly forever pending account syncs of the different authorities
     */
    override fun migrate(account: Account) {
        if (Build.VERSION.SDK_INT >= 34) {
            // Request new dummy syncs (yes, seems like this is needed)
            val extras = Bundle().apply {
                putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
                putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true)
            }

            // Request calendar and tasks syncs and cancel all syncs account wide
            val possibleAuthorities = SyncDataType.EVENTS.possibleAuthorities() +
                    SyncDataType.TASKS.possibleAuthorities()
            for (authority in possibleAuthorities) {
                ContentResolver.requestSync(account, authority, extras)
                logger.info("Android 14+: Canceling all (possibly forever pending) sync adapter syncs for $authority and $account")
                // Ensure the sync framework processes the request right away
                ContentResolver.isSyncPending(account, authority)
                // Cancel the sync
                ContentResolver.cancelSync(account, null) // Ignores possibly set sync extras
            }

            // Request contacts sync (per address book account) and cancel all syncs address book account wide
            val addressBookAccounts = localAddressBookStore.getAddressBookAccounts(account) + account
            for (addressBookAccount in addressBookAccounts) {
                ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, extras)
                logger.info("Android 14+: Canceling all (possibly forever pending) sync adapter syncs for $addressBookAccount")
                // Ensure the sync framework processes the request right away
                ContentResolver.isSyncPending(account, ContactsContract.AUTHORITY)
                // Cancel the sync
                ContentResolver.cancelSync(addressBookAccount, null) // Ignores possibly set sync extras
            }
        }
    }


    @Module
    @InstallIn(SingletonComponent::class)
    abstract class AccountSettingsMigrationModule {
        @Binds @IntoMap
        @IntKey(21)
        abstract fun provide(impl: AccountSettingsMigration21): AccountSettingsMigration
    }

}
 No newline at end of file
+8 −5
Original line number Diff line number Diff line
@@ -11,6 +11,7 @@ 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
@@ -25,6 +26,7 @@ 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.Lazy
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@@ -59,6 +61,7 @@ class SyncAdapterImpl @Inject constructor(
    @ApplicationContext context: Context,
    private val logger: Logger,
    private val syncConditionsFactory: SyncConditions.Factory,
    private val syncFrameworkIntegration: Lazy<SyncFrameworkIntegration>,
    private val syncWorkerManager: SyncWorkerManager
): AbstractThreadedSyncAdapter(
    /* context = */ context,
@@ -117,11 +120,11 @@ class SyncAdapterImpl @Inject constructor(
        // 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=$accountOrAddressBookAccount authority=$authority upload=$upload")
//            syncFrameworkIntegration.cancelSync(accountOrAddressBookAccount, authority, extras)
//        }
        if (Build.VERSION.SDK_INT >= 34) {
            logger.fine("Android 14+ bug: Canceling forever pending sync adapter framework sync request for " +
                    "account=$accountOrAddressBookAccount authority=$authority extras=$extras")
            syncFrameworkIntegration.get().cancelSync(accountOrAddressBookAccount, 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
Loading