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

Unverified Commit 98c0b0c3 authored by Sunik Kupfer's avatar Sunik Kupfer Committed by GitHub
Browse files

[Sync framework] Fix sync always pending on Android 14+ (#1463)



* Android 14 and 15 workaround: tell the sync framework to cancel any pending syncs

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

* Add test which documents wrong pending sync check behaviour

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

* Exclude android 13 and below

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

* Cancel only own sync request

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

* Cancel only after enqueuing sync worker

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

* Move test to AndroidSyncFrameworkTest

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

* Reset master sync state

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

* Remove limited parallelism and increase test timeout

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

* Rename test method

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

* Add assert message

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

* Update comment

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

* Add sdk suppress annotation

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

* Use runBlocking to be able to catch the timeout exception

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

* Extract pending sync cancellation to method

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

* Enhance sync framework tests for Android versions 9 to 16, verifying correct and incorrect pending states

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

* Add tests for sync always pending behavior on Android 10 and 11

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

* Remove obsolete unmockkAll call

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

* Make tests a bit more reliable

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

* Extract cancelSync method call to utils to stub the call more easily

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

* Remove some unnecessary calls and update stub

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

* Update expected states lists

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

* Move cancelSyncInSyncFramework to SyncFrameworkIntegration

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

* Pass the whole sync extras bundle when cancelling sync

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

* [WIP] Initialize pending sync state reporting wrong behaviour

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

* Optimize SyncAdapterServicesTest

* Remove unused property

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

* Reset master sync state

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

* Revert "Reset master sync state"

This reverts commit 4bfe73a25af138f5ee4be5c8753d83b4f61268b3.

* Revert "Remove unused property"

This reverts commit 7c0fdbf392d3a7e9986700c5ee12bb1abe2417f0.

* Reapply "Reset master sync state"

This reverts commit 5f7f0f9bce9ae906c17bcaff4fdce3d1e8c72358.

* Reapply "Remove unused property"

This reverts commit f1d5009f8a2cfecc78d515f3e3f7e0532e5872df.

* Increase timeout to 2 min

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

* [WIP] Optimize tests

* Optimize sync framework tests

* SyncAdapterServices FakeSyncAdapter: support interrupting

---------

Signed-off-by: default avatarSunik Kupfer <kupfer@bitfire.at>
Co-authored-by: default avatarRicki Hirner <hirner@bitfire.at>
parent ed7a477d
Loading
Loading
Loading
Loading
+196 −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.sync

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 io.mockk.junit4.MockKRule
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import java.util.Collections
import java.util.LinkedList
import java.util.logging.Logger
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds

@HiltAndroidTest
class AndroidSyncFrameworkTest {

    @get:Rule
    val hiltRule = HiltAndroidRule(this)

    @get:Rule
    val mockkRule = MockKRule(this)

    @Inject
    @ApplicationContext
    lateinit var context: Context

    @Inject
    lateinit var logger: Logger

    lateinit var account: Account
    val authority = CalendarContract.AUTHORITY

    private lateinit var stateChangeListener: Any
    private val recordedStates = Collections.synchronizedList(LinkedList<State>())

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

        account = TestAccount.create()

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

        // Remember states the sync framework reports as pairs of (sync pending, sync active).
        recordedStates.clear()
        onStatusChanged(0)      // record first entry (pending = false, active = false)
        stateChangeListener = ContentResolver.addStatusChangeListener(
            ContentResolver.SYNC_OBSERVER_TYPE_PENDING or ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE,
            ::onStatusChanged
        )
    }

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


    /**
     * Correct behaviour of the sync framework on Android 13 and below.
     * Pending state is correctly reflected
     */
    @SdkSuppress(maxSdkVersion = 33)
    @Test
    fun testVerifySyncAlwaysPending_correctBehaviour_android13() {
        assertSyncStates(
            listOf(
                State(pending = false, active = false),     // no sync pending or active
                State(pending = true, active = false),      // sync becomes pending
                State(pending = true, active = true),       // ... and pending and active at the same time
                State(pending = false, active = true),      // ... and then only active
                State(pending = false, active = false)      // sync finished
            )
        )
    }

    /**
     * Wrong behaviour of the sync framework on Android 14+.
     * Pending state stays true forever (after initial run), active state behaves correctly
     */
    @SdkSuppress(minSdkVersion = 34 /*, maxSdkVersion = 36 */)
    @Test
    fun testVerifySyncAlwaysPending_wrongBehaviour_android14() {
        assertSyncStates(
            listOf(
                State(pending = false, active = false),     // no sync pending or active
                State(pending = true, active = false),      // sync becomes pending
                State(pending = true, active = true),       // ... and pending and active at the same time
                State(pending = true, active = false)       // ... and finishes, but stays pending
            )
        )
    }


    // 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()

    /**
     * Verifies that the given expected states match the recorded states.
     */
    private fun assertSyncStates(expectedStates: List<State>) = runBlocking {
        // We use runBlocking for these tests because it uses the default dispatcher
        // which does not auto-advance virtual time and we need real system time to
        // test the sync framework behavior.

        ContentResolver.requestSync(syncRequest())

        // Even though the always-pending-bug is present on Android 14+, the sync active
        // state behaves correctly, so we can record the state changes as pairs (pending,
        // active) and expect a certain sequence of state pairs to verify the presence or
        // absence of the bug on different Android versions.
        withTimeout(60.seconds) { // Usually takes less than 30 seconds
            while (recordedStates.size < expectedStates.size) {
                // verify already known states
                if (recordedStates.isNotEmpty())
                    assertEquals(expectedStates.subList(0, recordedStates.size), recordedStates)

                delay(500) // avoid busy-waiting
            }

            assertEquals(expectedStates, recordedStates)
        }
    }


    fun onStatusChanged(which: Int) {
        val state = State(
            pending = ContentResolver.isSyncPending(account, authority),
            active = ContentResolver.isSyncActive(account, authority)
        )
        synchronized(recordedStates) {
            if (recordedStates.lastOrNull() != state) {
                logger.info("$account syncState = $state")
                recordedStates += state
            }
        }
    }

    data class State(
        val pending: Boolean,
        val active: Boolean
    )


    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
+21 −46
Original line number Diff line number Diff line
@@ -5,6 +5,7 @@
package at.bitfire.davdroid.sync

import android.accounts.Account
import android.content.ContentResolver
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
@@ -13,17 +14,16 @@ import androidx.hilt.work.HiltWorkerFactory
import androidx.work.WorkInfo
import androidx.work.WorkManager
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.account.TestAccount
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.Awaits
import io.mockk.coEvery
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.junit4.MockKRule
import io.mockk.just
import io.mockk.mockk
@@ -39,47 +39,34 @@ import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.Timeout
import java.util.logging.Logger
import javax.inject.Inject
import javax.inject.Provider
import kotlin.coroutines.cancellation.CancellationException

@HiltAndroidTest
class SyncAdapterServicesTest {

    lateinit var account: Account

    @Inject
    lateinit var accountSettingsFactory: AccountSettings.Factory
    @get:Rule
    val hiltRule = HiltAndroidRule(this)

    @Inject
    lateinit var collectionRepository: DavCollectionRepository
    @get:Rule
    val mockkRule = MockKRule(this)

    @Inject @ApplicationContext
    lateinit var context: Context

    @Inject
    lateinit var logger: Logger
    lateinit var syncAdapterProvider: Provider<SyncAdapterService.SyncAdapter>

    @Inject
    lateinit var serviceRepository: DavServiceRepository

    @Inject
    lateinit var syncConditionsFactory: SyncConditions.Factory
    @BindValue @MockK
    lateinit var syncWorkerManager: SyncWorkerManager

    @Inject
    lateinit var workerFactory: HiltWorkerFactory

    @get:Rule
    val hiltRule = HiltAndroidRule(this)

    @get:Rule
    val mockkRule = MockKRule(this)

    // test methods should run quickly and not wait 60 seconds for a sync timeout or something like that
    @get:Rule
    val timeoutRule: Timeout = Timeout.seconds(5)
    lateinit var account: Account

    private var masterSyncStateBeforeTest = ContentResolver.getMasterSyncAutomatically()

    @Before
    fun setUp() {
@@ -87,33 +74,23 @@ class SyncAdapterServicesTest {
        TestUtils.setUpWorkManager(context, workerFactory)

        account = TestAccount.create()

        ContentResolver.setMasterSyncAutomatically(true)
        ContentResolver.setSyncAutomatically(account, CalendarContract.AUTHORITY, true)
        ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
    }

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


    private fun syncAdapter(
        syncWorkerManager: SyncWorkerManager
    ): SyncAdapterService.SyncAdapter =
        SyncAdapterService.SyncAdapter(
            accountSettingsFactory = accountSettingsFactory,
            collectionRepository = collectionRepository,
            serviceRepository = serviceRepository,
            context = context,
            logger = logger,
            syncConditionsFactory = syncConditionsFactory,
            syncWorkerManager = syncWorkerManager
        )


    @Test
    fun testSyncAdapter_onPerformSync_cancellation() = runTest {
        val syncWorkerManager = mockk<SyncWorkerManager>()
        val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
        val workManager = WorkManager.getInstance(context)
        val syncAdapter = syncAdapterProvider.get()

        mockkObject(workManager) {
            // don't actually create a worker
@@ -136,9 +113,8 @@ class SyncAdapterServicesTest {

    @Test
    fun testSyncAdapter_onPerformSync_returnsAfterTimeout() {
        val syncWorkerManager = mockk<SyncWorkerManager>()
        val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
        val workManager = WorkManager.getInstance(context)
        val syncAdapter = syncAdapterProvider.get()

        mockkObject(workManager) {
            // don't actually create a worker
@@ -158,9 +134,8 @@ class SyncAdapterServicesTest {

    @Test
    fun testSyncAdapter_onPerformSync_runsInTime() {
        val syncWorkerManager = mockk<SyncWorkerManager>()
        val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
        val workManager = WorkManager.getInstance(context)
        val syncAdapter = syncAdapterProvider.get()

        mockkObject(workManager) {
            // don't actually create a worker
+22 −3
Original line number Diff line number Diff line
@@ -13,6 +13,7 @@ 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
@@ -57,16 +58,25 @@ abstract class SyncAdapterService: Service() {
    override fun onBind(intent: Intent?): IBinder {
        if (BuildConfig.DEBUG && !syncActive.get()) {
            // only for debug builds/testing: syncActive flag
            val logger = Logger.getLogger(this@SyncAdapterService::class.java.name)
            logger.log(Level.WARNING, "SyncAdapterService.onBind() was called but syncActive = false. Ignoring")
            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, false) {
            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
@@ -105,6 +115,7 @@ abstract class SyncAdapterService: Service() {
        @ApplicationContext context: Context,
        private val logger: Logger,
        private val syncConditionsFactory: SyncConditions.Factory,
        private val syncFrameworkIntegration: SyncFrameworkIntegration,
        private val syncWorkerManager: SyncWorkerManager
    ): AbstractThreadedSyncAdapter(
        /* context = */ context,
@@ -160,6 +171,14 @@ 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)

            // 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
+25 −0
Original line number Diff line number Diff line
@@ -6,7 +6,9 @@ package at.bitfire.davdroid.sync

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
@@ -94,6 +96,29 @@ class SyncFrameworkIntegration @Inject constructor(
            setSyncOnContentChange(account, authority, false)
    }

    /**
     * Cancels the sync request in the Sync Framework for Android 14+.
     * This is a workaround for the bug that the sync framework does not handle pending syncs correctly
     * on Android 14+ (API level 34+).
     *
     * See: https://github.com/bitfireAT/davx5-ose/issues/1458
     *
     * @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 extras The original extras Bundle used to start the sync.
     */
    fun cancelSync(account: Account, authority: String, extras: Bundle) {
        // Recreate the sync request which was used to start this sync
        val syncRequest = SyncRequest.Builder()
            .setSyncAdapter(account, authority)
            .setExtras(extras)
            .syncOnce()
            .build()

        // Cancel it
        ContentResolver.cancelSync(syncRequest)
    }

    /**
     * Enables/disables sync adapter automatic sync (content triggered sync) for the given
     * account and authority. Does *not* call [ContentResolver.setIsSyncable].