Skip to content

Commit b391840

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 b391840

3 files changed

Lines changed: 232 additions & 7 deletions

File tree

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

Lines changed: 13 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,19 @@ class ProfileManager private constructor(
148151
return newId
149152
}
150153

154+
/**
155+
* Persists [newProfileId] as the active profile.
156+
*
157+
* **Do not call directly.** Use [ProfileSwitchGuard] to ensure
158+
* backup/sync safety checks are run before switching.
159+
* Direct calls bypass all safety guards and may corrupt data.
160+
*
161+
* @param newProfileId The [ProfileId] to activate on next launch.
162+
*/
163+
@VisibleForTesting
151164
fun switchActiveProfile(newProfileId: ProfileId) {
152165
Timber.i("Switching profile to ID: $newProfileId")
153-
154166
profileRegistry.setLastActiveProfileId(newProfileId)
155-
triggerAppRestart()
156167
}
157168

158169
private fun loadProfileData(profileId: ProfileId) {
@@ -224,11 +235,6 @@ class ProfileManager private constructor(
224235
return ProfileRestrictedDirectory(directoryFile)
225236
}
226237

227-
private fun triggerAppRestart() {
228-
Timber.w("Restarting app to apply profile switch")
229-
// TODO: Implement process restart logic (e.g. ProcessPhoenix)
230-
}
231-
232238
/**
233239
* Holds the meta-data for a profile.
234240
* Converted to JSON for storage to allow future extensibility (e.g. avatars, themes).
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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+
/**
21+
* Guards profile switching by running a set of safety checks
22+
* before delegating to [ProfileManager].
23+
*
24+
* @param profileManager The manager that persists the switch.
25+
* @param checks Ordered list of safety checks to run
26+
* before allowing the switch.
27+
*/
28+
class ProfileSwitchGuard(
29+
private val profileManager: ProfileManager,
30+
private val checks: List<SafetyCheck>,
31+
) {
32+
sealed class Result {
33+
data object Success : Result()
34+
35+
/**
36+
* Indicates the switch was blocked.
37+
* @param reasons All currently active blocks that are NOT being ignored.
38+
*/
39+
data class Blocked(
40+
val reasons: Set<BlockReason>,
41+
) : Result()
42+
}
43+
44+
enum class BlockReason {
45+
BACKUP_IN_PROGRESS,
46+
MEDIA_SYNC_IN_PROGRESS,
47+
COLLECTION_BUSY,
48+
}
49+
50+
fun interface SafetyCheck {
51+
suspend fun verify(): BlockReason?
52+
}
53+
54+
/**
55+
* Runs all checks.
56+
* @param newProfileId The target profile.
57+
* @param skipReasons A set of reasons the user has explicitly chosen to ignore.
58+
*/
59+
suspend operator fun invoke(
60+
newProfileId: ProfileId,
61+
skipReasons: Set<BlockReason> = emptySet(),
62+
): Result {
63+
val activeBlockedReasons = mutableSetOf<BlockReason>()
64+
65+
for (check in checks) {
66+
val reason = check.verify()
67+
if (reason != null && !skipReasons.contains(reason)) {
68+
activeBlockedReasons.add(reason)
69+
}
70+
}
71+
72+
return if (activeBlockedReasons.isEmpty()) {
73+
profileManager.switchActiveProfile(newProfileId)
74+
Result.Success
75+
} else {
76+
Result.Blocked(activeBlockedReasons)
77+
}
78+
}
79+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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 android.content.Context
21+
import android.content.SharedPreferences
22+
import androidx.core.content.edit
23+
import androidx.test.core.app.ApplicationProvider
24+
import androidx.test.ext.junit.runners.AndroidJUnit4
25+
import com.ichi2.anki.multiprofile.ProfileSwitchGuard.BlockReason
26+
import com.ichi2.anki.multiprofile.ProfileSwitchGuard.Result
27+
import com.ichi2.anki.multiprofile.ProfileSwitchGuard.SafetyCheck
28+
import com.ichi2.testutils.assertFalse
29+
import io.mockk.coEvery
30+
import io.mockk.confirmVerified
31+
import io.mockk.mockk
32+
import io.mockk.verify
33+
import kotlinx.coroutines.runBlocking
34+
import kotlinx.coroutines.test.runTest
35+
import org.junit.Assert.assertEquals
36+
import org.junit.Assert.assertTrue
37+
import org.junit.Before
38+
import org.junit.Test
39+
import org.junit.runner.RunWith
40+
import kotlin.test.assertEquals
41+
42+
class ProfileSwitchGuardTest {
43+
private val profileManager: ProfileManager = mockk(relaxed = true)
44+
private val targetId = ProfileId("p_12345678")
45+
46+
@Test
47+
fun `invoke returns Success and switches when all checks pass`() =
48+
runTest {
49+
val check1 =
50+
mockk<SafetyCheck> {
51+
coEvery { verify() } returns null
52+
}
53+
val check2 =
54+
mockk<SafetyCheck> {
55+
coEvery { verify() } returns null
56+
}
57+
58+
val guard = ProfileSwitchGuard(profileManager, listOf(check1, check2))
59+
60+
val result = guard(targetId)
61+
62+
assertEquals(Result.Success, result)
63+
verify(exactly = 1) { profileManager.switchActiveProfile(targetId) }
64+
}
65+
66+
@Test
67+
fun `invoke returns Blocked and does NOT switch when a check fails`() =
68+
runTest {
69+
val check1 =
70+
mockk<SafetyCheck> {
71+
coEvery { verify() } returns BlockReason.BACKUP_IN_PROGRESS
72+
}
73+
74+
val guard = ProfileSwitchGuard(profileManager, listOf(check1))
75+
76+
val result = guard(targetId)
77+
78+
assertTrue(result is Result.Blocked)
79+
assertEquals(setOf(BlockReason.BACKUP_IN_PROGRESS), (result as Result.Blocked).reasons)
80+
81+
verify(exactly = 0) { profileManager.switchActiveProfile(any()) }
82+
}
83+
84+
@Test
85+
fun `invoke returns Success if the blocked reason is in skipReasons`() =
86+
runTest {
87+
val check1 =
88+
mockk<SafetyCheck> {
89+
coEvery { verify() } returns BlockReason.MEDIA_SYNC_IN_PROGRESS
90+
}
91+
92+
val guard = ProfileSwitchGuard(profileManager, listOf(check1))
93+
94+
val result = guard(targetId, skipReasons = setOf(BlockReason.MEDIA_SYNC_IN_PROGRESS))
95+
96+
assertEquals(Result.Success, result)
97+
verify(exactly = 1) { profileManager.switchActiveProfile(targetId) }
98+
}
99+
100+
@Test
101+
fun `invoke returns Blocked if multiple checks fail and only one is skipped`() =
102+
runTest {
103+
val syncCheck =
104+
mockk<SafetyCheck> {
105+
coEvery { verify() } returns BlockReason.MEDIA_SYNC_IN_PROGRESS
106+
}
107+
val backupCheck =
108+
mockk<SafetyCheck> {
109+
coEvery { verify() } returns BlockReason.BACKUP_IN_PROGRESS
110+
}
111+
112+
val guard = ProfileSwitchGuard(profileManager, listOf(syncCheck, backupCheck))
113+
114+
val result = guard(targetId, skipReasons = setOf(BlockReason.MEDIA_SYNC_IN_PROGRESS))
115+
116+
assertTrue(result is Result.Blocked)
117+
val blockedReasons = (result as Result.Blocked).reasons
118+
assertEquals(1, blockedReasons.size)
119+
assertTrue(blockedReasons.contains(BlockReason.BACKUP_IN_PROGRESS))
120+
121+
verify(exactly = 0) { profileManager.switchActiveProfile(any()) }
122+
}
123+
124+
@Test
125+
fun `invoke collects all active blocked reasons if multiple fail`() =
126+
runTest {
127+
val check1 = mockk<SafetyCheck> { coEvery { verify() } returns BlockReason.BACKUP_IN_PROGRESS }
128+
val check2 = mockk<SafetyCheck> { coEvery { verify() } returns BlockReason.COLLECTION_BUSY }
129+
130+
val guard = ProfileSwitchGuard(profileManager, listOf(check1, check2))
131+
132+
val result = guard(targetId)
133+
134+
assertTrue(result is Result.Blocked)
135+
assertEquals(
136+
setOf(BlockReason.BACKUP_IN_PROGRESS, BlockReason.COLLECTION_BUSY),
137+
(result as Result.Blocked).reasons,
138+
)
139+
}
140+
}

0 commit comments

Comments
 (0)