Skip to content

Commit 6ae10e9

Browse files
committed
Add migration test; Update migration
1 parent b1fd967 commit 6ae10e9

File tree

2 files changed

+236
-0
lines changed

2 files changed

+236
-0
lines changed
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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+
}

app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration21.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import android.accounts.AccountManager
99
import android.content.ContentResolver
1010
import android.content.Context
1111
import android.os.Build
12+
import android.os.Bundle
1213
import at.bitfire.davdroid.R
1314
import at.bitfire.davdroid.sync.SyncDataType
1415
import dagger.Binds
@@ -46,6 +47,17 @@ class AccountSettingsMigration21 @Inject constructor(
4647
*/
4748
override fun migrate(account: Account) {
4849
if (Build.VERSION.SDK_INT >= 34) {
50+
// Request new dummy syncs (yes, seems like this is needed)
51+
val extras = Bundle().apply {
52+
putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
53+
putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true)
54+
}
55+
val possibleAuthorities = SyncDataType.EVENTS.possibleAuthorities() +
56+
SyncDataType.TASKS.possibleAuthorities() +
57+
SyncDataType.CONTACTS.possibleAuthorities()
58+
for (authority in possibleAuthorities)
59+
ContentResolver.requestSync(account, authority, extras)
60+
4961
// Cancel calendar account syncs
5062
cancelSyncs(calendarAccountType, SyncDataType.EVENTS.possibleAuthorities())
5163

@@ -54,6 +66,9 @@ class AccountSettingsMigration21 @Inject constructor(
5466

5567
// Cancel address book account syncs
5668
cancelSyncs(addressBookAccountType, SyncDataType.CONTACTS.possibleAuthorities())
69+
70+
// Now cancel syncs for all authorities again (yes, this seems to be needed additionally too)
71+
ContentResolver.cancelSync(account, null)
5772
}
5873
}
5974

0 commit comments

Comments
 (0)