Skip to content

Commit 9e3dab7

Browse files
committed
feat: define trigger app restart guard
- unit test for profile guard Assisted-by: Gemini 3.1 Pro - Unit test: ProfileSwitchGuardTest
1 parent b9ab20d commit 9e3dab7

3 files changed

Lines changed: 240 additions & 7 deletions

File tree

AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@
1818
package com.ichi2.anki.multiprofile
1919

2020
import android.content.Context
21+
import android.content.Intent
2122
import android.content.SharedPreferences
2223
import android.os.Build
2324
import android.webkit.CookieManager
2425
import android.webkit.WebView
26+
import androidx.annotation.VisibleForTesting
2527
import androidx.core.content.ContextCompat
2628
import androidx.core.content.edit
2729
import com.ichi2.anki.CrashReportService
30+
import com.ichi2.anki.IntentHandler
2831
import com.ichi2.anki.common.time.TimeManager
2932
import com.ichi2.anki.common.time.getTimestamp
3033
import org.acra.ACRA
@@ -148,11 +151,16 @@ class ProfileManager private constructor(
148151
return newId
149152
}
150153

154+
/**
155+
* Persists [newProfileId] as the active profile.
156+
*
157+
* @param newProfileId The [ProfileId] to activate on next launch.
158+
*/
159+
@VisibleForTesting
160+
context(_: ProfileSwitchContext)
151161
fun switchActiveProfile(newProfileId: ProfileId) {
152162
Timber.i("Switching profile to ID: $newProfileId")
153-
154163
profileRegistry.setLastActiveProfileId(newProfileId)
155-
triggerAppRestart()
156164
}
157165

158166
private fun loadProfileData(profileId: ProfileId) {
@@ -224,11 +232,6 @@ class ProfileManager private constructor(
224232
return ProfileRestrictedDirectory(directoryFile)
225233
}
226234

227-
private fun triggerAppRestart() {
228-
Timber.w("Restarting app to apply profile switch")
229-
// TODO: Implement process restart logic (e.g. ProcessPhoenix)
230-
}
231-
232235
/**
233236
* Holds the meta-data for a profile.
234237
* Converted to JSON for storage to allow future extensibility (e.g. avatars, themes).
@@ -330,6 +333,17 @@ class ProfileManager private constructor(
330333
fun contains(id: ProfileId): Boolean = globalPrefs.contains(id.value)
331334
}
332335

336+
/**
337+
* A context representing that it is safe to switch profiles
338+
*
339+
* - Backups are not occurring
340+
* - Sync is completed
341+
* - Collection is not open
342+
*
343+
* @see ProfileSwitchGuard
344+
*/
345+
object ProfileSwitchContext
346+
333347
companion object {
334348
private const val MAX_ATTEMPTS = 10
335349
const val PROFILE_REGISTRY_FILENAME = "profiles_prefs"
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright (c) 2026 Ashish Yadav <mailtoashish693@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11+
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12+
* details.
13+
*
14+
* You should have received a copy of the GNU General Public License along with
15+
* this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
package com.ichi2.anki.multiprofile
19+
20+
import com.ichi2.anki.multiprofile.ProfileManager.ProfileSwitchContext
21+
22+
/**
23+
* Guards profile switching by running a set of safety checks
24+
* before delegating to [ProfileManager].
25+
*
26+
* @param profileManager The manager that persists the switch.
27+
* @param checks Ordered list of safety checks to run
28+
* before allowing the switch.
29+
*/
30+
class ProfileSwitchGuard(
31+
private val profileManager: ProfileManager,
32+
private val checks: List<SafetyCheck>,
33+
) {
34+
sealed class Result {
35+
data object Success : Result()
36+
37+
/**
38+
* Indicates the switch was blocked.
39+
* @param reasons All currently active blocks that are NOT being ignored.
40+
*/
41+
data class Blocked(
42+
val reasons: Set<BlockReason>,
43+
) : Result()
44+
}
45+
46+
enum class BlockReason {
47+
BACKUP_IN_PROGRESS,
48+
MEDIA_SYNC_IN_PROGRESS,
49+
COLLECTION_BUSY,
50+
}
51+
52+
fun interface SafetyCheck {
53+
suspend fun verify(): BlockReason?
54+
}
55+
56+
/**
57+
* Runs all checks.
58+
* @param newProfileId The target profile.
59+
* @param skipReasons A set of reasons the user has explicitly chosen to ignore.
60+
*/
61+
suspend operator fun invoke(
62+
newProfileId: ProfileId,
63+
skipReasons: Set<BlockReason> = emptySet(),
64+
): Result {
65+
val activeBlockedReasons = mutableSetOf<BlockReason>()
66+
67+
for (check in checks) {
68+
val reason = check.verify()
69+
if (reason != null && !skipReasons.contains(reason)) {
70+
activeBlockedReasons.add(reason)
71+
}
72+
}
73+
74+
return if (activeBlockedReasons.isEmpty()) {
75+
with(ProfileSwitchContext) { profileManager.switchActiveProfile(newProfileId) }
76+
Result.Success
77+
} else {
78+
Result.Blocked(activeBlockedReasons)
79+
}
80+
}
81+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/*
2+
* Copyright (c) 2026 Ashish Yadav <mailtoashish693@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11+
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12+
* details.
13+
*
14+
* You should have received a copy of the GNU General Public License along with
15+
* this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
package com.ichi2.anki.multiprofile
19+
20+
import com.ichi2.anki.multiprofile.ProfileManager.ProfileSwitchContext
21+
import com.ichi2.anki.multiprofile.ProfileSwitchGuard.BlockReason
22+
import com.ichi2.anki.multiprofile.ProfileSwitchGuard.Result
23+
import com.ichi2.anki.multiprofile.ProfileSwitchGuard.SafetyCheck
24+
import io.mockk.coEvery
25+
import io.mockk.mockk
26+
import io.mockk.verify
27+
import kotlinx.coroutines.test.runTest
28+
import org.junit.Assert.assertEquals
29+
import org.junit.Assert.assertTrue
30+
import org.junit.Test
31+
32+
class ProfileSwitchGuardTest {
33+
private val profileManager: ProfileManager = mockk(relaxed = true)
34+
private val targetId = ProfileId("p_12345678")
35+
36+
@Test
37+
fun `invoke returns Success and switches when all checks pass`() =
38+
runTest {
39+
val check1 =
40+
mockk<SafetyCheck> {
41+
coEvery { verify() } returns null
42+
}
43+
val check2 =
44+
mockk<SafetyCheck> {
45+
coEvery { verify() } returns null
46+
}
47+
48+
val guard = ProfileSwitchGuard(profileManager, listOf(check1, check2))
49+
50+
val result = guard(targetId)
51+
52+
assertEquals(Result.Success, result)
53+
with(ProfileSwitchContext) {
54+
verify(exactly = 1) { profileManager.switchActiveProfile(targetId) }
55+
}
56+
}
57+
58+
@Test
59+
fun `invoke returns Blocked and does NOT switch when a check fails`() =
60+
runTest {
61+
val check1 =
62+
mockk<SafetyCheck> {
63+
coEvery { verify() } returns BlockReason.BACKUP_IN_PROGRESS
64+
}
65+
66+
val guard = ProfileSwitchGuard(profileManager, listOf(check1))
67+
68+
val result = guard(targetId)
69+
70+
assertTrue(result is Result.Blocked)
71+
assertEquals(setOf(BlockReason.BACKUP_IN_PROGRESS), (result as Result.Blocked).reasons)
72+
73+
with(ProfileSwitchContext) {
74+
verify(exactly = 0) { profileManager.switchActiveProfile(any()) }
75+
}
76+
}
77+
78+
@Test
79+
fun `invoke returns Success if the blocked reason is in skipReasons`() =
80+
runTest {
81+
val check1 =
82+
mockk<SafetyCheck> {
83+
coEvery { verify() } returns BlockReason.MEDIA_SYNC_IN_PROGRESS
84+
}
85+
86+
val guard = ProfileSwitchGuard(profileManager, listOf(check1))
87+
88+
val result = guard(targetId, skipReasons = setOf(BlockReason.MEDIA_SYNC_IN_PROGRESS))
89+
90+
assertEquals(Result.Success, result)
91+
with(ProfileSwitchContext) {
92+
verify(exactly = 1) { profileManager.switchActiveProfile(targetId) }
93+
}
94+
}
95+
96+
@Test
97+
fun `invoke returns Blocked if multiple checks fail and only one is skipped`() =
98+
runTest {
99+
val syncCheck =
100+
mockk<SafetyCheck> {
101+
coEvery { verify() } returns BlockReason.MEDIA_SYNC_IN_PROGRESS
102+
}
103+
val backupCheck =
104+
mockk<SafetyCheck> {
105+
coEvery { verify() } returns BlockReason.BACKUP_IN_PROGRESS
106+
}
107+
108+
val guard = ProfileSwitchGuard(profileManager, listOf(syncCheck, backupCheck))
109+
110+
val result = guard(targetId, skipReasons = setOf(BlockReason.MEDIA_SYNC_IN_PROGRESS))
111+
112+
assertTrue(result is Result.Blocked)
113+
val blockedReasons = (result as Result.Blocked).reasons
114+
assertEquals(1, blockedReasons.size)
115+
assertTrue(blockedReasons.contains(BlockReason.BACKUP_IN_PROGRESS))
116+
117+
with(ProfileSwitchContext) {
118+
verify(exactly = 0) { profileManager.switchActiveProfile(any()) }
119+
}
120+
}
121+
122+
@Test
123+
fun `invoke collects all active blocked reasons if multiple fail`() =
124+
runTest {
125+
val check1 = mockk<SafetyCheck> { coEvery { verify() } returns BlockReason.BACKUP_IN_PROGRESS }
126+
val check2 = mockk<SafetyCheck> { coEvery { verify() } returns BlockReason.COLLECTION_BUSY }
127+
128+
val guard = ProfileSwitchGuard(profileManager, listOf(check1, check2))
129+
130+
val result = guard(targetId)
131+
132+
assertTrue(result is Result.Blocked)
133+
assertEquals(
134+
setOf(BlockReason.BACKUP_IN_PROGRESS, BlockReason.COLLECTION_BUSY),
135+
(result as Result.Blocked).reasons,
136+
)
137+
}
138+
}

0 commit comments

Comments
 (0)