1+ /*
2+ * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
3+ */
4+
5+ package at.bitfire.davdroid.settings.migration
6+
7+ import android.accounts.Account
8+ import android.content.ContentResolver
9+ import android.content.Context
10+ import android.content.SyncRequest
11+ import android.os.Bundle
12+ import android.provider.CalendarContract
13+ import androidx.test.filters.SdkSuppress
14+ import at.bitfire.davdroid.sync.account.TestAccount
15+ import dagger.hilt.android.qualifiers.ApplicationContext
16+ import dagger.hilt.android.testing.HiltAndroidRule
17+ import dagger.hilt.android.testing.HiltAndroidTest
18+ import junit.framework.AssertionFailedError
19+ import kotlinx.coroutines.delay
20+ import kotlinx.coroutines.runBlocking
21+ import kotlinx.coroutines.withTimeout
22+ import org.junit.After
23+ import org.junit.AfterClass
24+ import org.junit.Assert.assertFalse
25+ import org.junit.Assert.assertTrue
26+ import org.junit.Before
27+ import org.junit.BeforeClass
28+ import org.junit.Rule
29+ import org.junit.Test
30+ import java.util.Collections
31+ import java.util.LinkedList
32+ import java.util.logging.Logger
33+ import javax.inject.Inject
34+ import kotlin.time.Duration.Companion.seconds
35+
36+ @HiltAndroidTest
37+ class AccountSettingsMigration21Test {
38+
39+ @get:Rule
40+ val hiltRule = HiltAndroidRule (this )
41+
42+ @Inject
43+ lateinit var migration: AccountSettingsMigration21
44+
45+ @Inject
46+ @ApplicationContext
47+ lateinit var context: Context
48+
49+ @Inject
50+ lateinit var logger: Logger
51+
52+ lateinit var account: Account
53+ val authority = CalendarContract .AUTHORITY
54+
55+ private lateinit var stateChangeListener: Any
56+ private val recordedStates = Collections .synchronizedList(LinkedList <State >())
57+
58+ @Before
59+ fun setUp () {
60+ hiltRule.inject()
61+
62+ account = TestAccount .create()
63+
64+ // Enable sync globally and for the test account
65+ ContentResolver .setIsSyncable(account, authority, 1 )
66+
67+ // Remember states the sync framework reports as pairs of (sync pending, sync active).
68+ recordedStates.clear()
69+ onStatusChanged(0 ) // record first entry (pending = false, active = false)
70+ stateChangeListener = ContentResolver .addStatusChangeListener(
71+ ContentResolver .SYNC_OBSERVER_TYPE_PENDING or ContentResolver .SYNC_OBSERVER_TYPE_ACTIVE ,
72+ ::onStatusChanged
73+ )
74+ }
75+
76+ @After
77+ fun tearDown () {
78+ ContentResolver .removeStatusChangeListener(stateChangeListener)
79+ TestAccount .remove(account)
80+ }
81+
82+
83+ @SdkSuppress(minSdkVersion = 34 )
84+ @Test
85+ fun testCancelsSyncAndClearsPendingState () = runBlocking {
86+ // Move into known forever pending state
87+ verifySyncStates(
88+ listOf (
89+ State (pending = false , active = false ), // no sync pending or active
90+ State (pending = true , active = false , optional = true ), // sync becomes pending
91+ State (pending = true , active = true ), // ... and pending and active at the same time
92+ State (pending = true , active = false ) // ... and finishes, but stays pending
93+ )
94+ )
95+
96+ // Assert we are in the forever pending state
97+ assertTrue(ContentResolver .isSyncPending(account, authority))
98+
99+ // Run the migration which should cancel the sync for all accounts
100+ migration.migrate(account)
101+
102+ Thread .sleep(2000 )
103+
104+ // Check the sync is now not pending anymore
105+ assertFalse(ContentResolver .isSyncPending(account, authority))
106+ }
107+
108+
109+ // helpers
110+
111+ private fun syncRequest () = SyncRequest .Builder ()
112+ .setSyncAdapter(account, authority)
113+ .syncOnce()
114+ .setExtras(Bundle ()) // needed for Android 9
115+ .setExpedited(true ) // sync request will be scheduled at the front of the sync request queue
116+ .setManual(true ) // equivalent of setting both SYNC_EXTRAS_IGNORE_SETTINGS and SYNC_EXTRAS_IGNORE_BACKOFF
117+ .build()
118+
119+ /* *
120+ * Verifies that the given expected states match the recorded states.
121+ */
122+ private suspend fun verifySyncStates (expectedStates : List <State >) {
123+ // We use runBlocking for these tests because it uses the default dispatcher
124+ // which does not auto-advance virtual time and we need real system time to
125+ // test the sync framework behavior.
126+
127+ ContentResolver .requestSync(syncRequest())
128+
129+ // Even though the always-pending-bug is present on Android 14+, the sync active
130+ // state behaves correctly, so we can record the state changes as pairs (pending,
131+ // active) and expect a certain sequence of state pairs to verify the presence or
132+ // absence of the bug on different Android versions.
133+ withTimeout(60 .seconds) { // Usually takes less than 30 seconds
134+ while (recordedStates.size < expectedStates.size) {
135+ // verify already known states
136+ if (recordedStates.isNotEmpty())
137+ assertStatesEqual(expectedStates.subList(0 , recordedStates.size), recordedStates)
138+
139+ delay(500 ) // avoid busy-waiting
140+ }
141+
142+ assertStatesEqual(expectedStates, recordedStates)
143+ }
144+ }
145+
146+ /* *
147+ * Asserts whether [actualStates] and [expectedStates] are the same, under the condition
148+ * that expected states with the [State.optional] flag can be skipped.
149+ */
150+ private fun assertStatesEqual (expectedStates : List <State >, actualStates : List <State >) {
151+ fun fail () {
152+ throw AssertionFailedError (" Expected states=$expectedStates , actual=$actualStates " )
153+ }
154+
155+ // iterate through entries
156+ val expectedIterator = expectedStates.iterator()
157+ for (actual in actualStates) {
158+ if (! expectedIterator.hasNext())
159+ fail()
160+ var expected = expectedIterator.next()
161+
162+ // skip optional expected entries if they don't match the actual entry
163+ while (! actual.stateEquals(expected) && expected.optional) {
164+ if (! expectedIterator.hasNext())
165+ fail()
166+ expected = expectedIterator.next()
167+ }
168+
169+ if (! actual.stateEquals(expected))
170+ fail()
171+ }
172+ }
173+
174+
175+ // SyncStatusObserver implementation and data class
176+
177+ fun onStatusChanged (which : Int ) {
178+ val state = State (
179+ pending = ContentResolver .isSyncPending(account, authority),
180+ active = ContentResolver .isSyncActive(account, authority)
181+ )
182+ synchronized(recordedStates) {
183+ if (recordedStates.lastOrNull() != state) {
184+ logger.info(" $account syncState = $state " )
185+ recordedStates + = state
186+ }
187+ }
188+ }
189+
190+ data class State (
191+ val pending : Boolean ,
192+ val active : Boolean ,
193+ val optional : Boolean = false
194+ ) {
195+ fun stateEquals (other : State ) =
196+ pending == other.pending && active == other.active
197+ }
198+
199+
200+ companion object {
201+
202+ var globalAutoSyncBeforeTest = false
203+
204+ @BeforeClass
205+ @JvmStatic
206+ fun before () {
207+ globalAutoSyncBeforeTest = ContentResolver .getMasterSyncAutomatically()
208+
209+ // We'll request syncs explicitly and with SYNC_EXTRAS_IGNORE_SETTINGS
210+ ContentResolver .setMasterSyncAutomatically(false )
211+ }
212+
213+ @AfterClass
214+ @JvmStatic
215+ fun after () {
216+ ContentResolver .setMasterSyncAutomatically(globalAutoSyncBeforeTest)
217+ }
218+
219+ }
220+
221+ }
0 commit comments