Skip to content

Commit ffb8d50

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 8d2e785 commit ffb8d50

File tree

4 files changed

+258
-7
lines changed

4 files changed

+258
-7
lines changed

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
@@ -139,11 +142,19 @@ class ProfileManager private constructor(
139142
return newId
140143
}
141144

145+
/**
146+
* Persists [newProfileId] as the active profile.
147+
*
148+
* **Do not call directly.** Use [ProfileSwitchGuard] to ensure
149+
* backup/sync safety checks are run before switching.
150+
* Direct calls bypass all safety guards and may corrupt data.
151+
*
152+
* @param newProfileId The [ProfileId] to activate on next launch.
153+
*/
154+
@VisibleForTesting
142155
fun switchActiveProfile(newProfileId: ProfileId) {
143156
Timber.i("Switching profile to ID: $newProfileId")
144-
145157
profileRegistry.setLastActiveProfileId(newProfileId)
146-
triggerAppRestart()
147158
}
148159

149160
private fun loadProfileData(profileId: ProfileId) {
@@ -215,11 +226,6 @@ class ProfileManager private constructor(
215226
return ProfileRestrictedDirectory(directoryFile)
216227
}
217228

218-
private fun triggerAppRestart() {
219-
Timber.w("Restarting app to apply profile switch")
220-
// TODO: Implement process restart logic (e.g. ProcessPhoenix)
221-
}
222-
223229
/**
224230
* Holds the meta-data for a profile.
225231
* 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+
}

AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,4 +172,30 @@ class ProfileManagerTest {
172172

173173
assertEquals("Serialization round-trip failed!", original, reconstructed)
174174
}
175+
176+
@Test
177+
fun `switchActiveProfile persists new profile ID before restart`() {
178+
// Prevent Runtime.exit(0) from killing the test runner
179+
val mockRuntime = mockk<Runtime>(relaxed = true)
180+
mockkStatic(Runtime::class)
181+
every { Runtime.getRuntime() } returns mockRuntime
182+
183+
val manager = ProfileManager.create(context)
184+
val newProfileId = manager.createNewProfile("Work")
185+
186+
manager.switchActiveProfile(newProfileId)
187+
188+
verify(exactly = 1) { mockRuntime.exit(0) }
189+
190+
assertEquals(
191+
newProfileId.value,
192+
prefs.getString(KEY_LAST_ACTIVE_PROFILE_ID, null),
193+
)
194+
195+
val restarted = ProfileManager.create(context)
196+
assertTrue(
197+
restarted.activeProfileContext.filesDir.absolutePath
198+
.contains(newProfileId.value),
199+
)
200+
}
175201
}
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)