diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt index 0c56675c7523..8157fb1e23cd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt @@ -18,13 +18,16 @@ package com.ichi2.anki.multiprofile import android.content.Context +import android.content.Intent import android.content.SharedPreferences import android.os.Build import android.webkit.CookieManager import android.webkit.WebView +import androidx.annotation.VisibleForTesting import androidx.core.content.ContextCompat import androidx.core.content.edit import com.ichi2.anki.CrashReportService +import com.ichi2.anki.IntentHandler import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.common.time.getTimestamp import org.acra.ACRA @@ -148,11 +151,16 @@ class ProfileManager private constructor( return newId } + /** + * Persists [newProfileId] as the active profile. + * + * @param newProfileId The [ProfileId] to activate on next launch. + */ + @VisibleForTesting + context(_: ProfileSwitchContext) fun switchActiveProfile(newProfileId: ProfileId) { Timber.i("Switching profile to ID: $newProfileId") - profileRegistry.setLastActiveProfileId(newProfileId) - triggerAppRestart() } private fun loadProfileData(profileId: ProfileId) { @@ -224,11 +232,6 @@ class ProfileManager private constructor( return ProfileRestrictedDirectory(directoryFile) } - private fun triggerAppRestart() { - Timber.w("Restarting app to apply profile switch") - // TODO: Implement process restart logic (e.g. ProcessPhoenix) - } - /** * Holds the meta-data for a profile. * Converted to JSON for storage to allow future extensibility (e.g. avatars, themes). @@ -330,6 +333,17 @@ class ProfileManager private constructor( fun contains(id: ProfileId): Boolean = globalPrefs.contains(id.value) } + /** + * A context representing that it is safe to switch profiles + * + * - Backups are not occurring + * - Sync is completed + * - Collection is not open + * + * @see ProfileSwitchGuard + */ + object ProfileSwitchContext + companion object { private const val MAX_ATTEMPTS = 10 const val PROFILE_REGISTRY_FILENAME = "profiles_prefs" diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchGuard.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchGuard.kt new file mode 100644 index 000000000000..85adb854621a --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchGuard.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.multiprofile + +import com.ichi2.anki.multiprofile.ProfileManager.ProfileSwitchContext + +/** + * Guards profile switching by running a set of safety checks + * before delegating to [ProfileManager]. + * + * @param profileManager The manager that persists the switch. + * @param checks Ordered list of safety checks to run + * before allowing the switch. + */ +class ProfileSwitchGuard( + private val profileManager: ProfileManager, + private val checks: List, +) { + sealed class Result { + data object Success : Result() + + /** + * Indicates the switch was blocked. + * @param reasons All currently active blocks that are NOT being ignored. + */ + data class Blocked( + val reasons: Set, + ) : Result() + } + + enum class BlockReason { + BACKUP_IN_PROGRESS, + MEDIA_SYNC_IN_PROGRESS, + COLLECTION_BUSY, + } + + fun interface SafetyCheck { + suspend fun verify(): BlockReason? + } + + /** + * Runs all checks. + * @param newProfileId The target profile. + * @param skipReasons A set of reasons the user has explicitly chosen to ignore. + */ + suspend operator fun invoke( + newProfileId: ProfileId, + skipReasons: Set = emptySet(), + ): Result { + val activeBlockedReasons = mutableSetOf() + + for (check in checks) { + val reason = check.verify() + if (reason != null && !skipReasons.contains(reason)) { + activeBlockedReasons.add(reason) + } + } + + return if (activeBlockedReasons.isEmpty()) { + with(ProfileSwitchContext) { profileManager.switchActiveProfile(newProfileId) } + Result.Success + } else { + Result.Blocked(activeBlockedReasons) + } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileSwitchGuardTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileSwitchGuardTest.kt new file mode 100644 index 000000000000..f3b6db219daf --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileSwitchGuardTest.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.multiprofile + +import com.ichi2.anki.multiprofile.ProfileManager.ProfileSwitchContext +import com.ichi2.anki.multiprofile.ProfileSwitchGuard.BlockReason +import com.ichi2.anki.multiprofile.ProfileSwitchGuard.Result +import com.ichi2.anki.multiprofile.ProfileSwitchGuard.SafetyCheck +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProfileSwitchGuardTest { + private val profileManager: ProfileManager = mockk(relaxed = true) + private val targetId = ProfileId("p_12345678") + + @Test + fun `invoke returns Success and switches when all checks pass`() = + runTest { + val check1 = + mockk { + coEvery { verify() } returns null + } + val check2 = + mockk { + coEvery { verify() } returns null + } + + val guard = ProfileSwitchGuard(profileManager, listOf(check1, check2)) + + val result = guard(targetId) + + assertEquals(Result.Success, result) + with(ProfileSwitchContext) { + verify(exactly = 1) { profileManager.switchActiveProfile(targetId) } + } + } + + @Test + fun `invoke returns Blocked and does NOT switch when a check fails`() = + runTest { + val check1 = + mockk { + coEvery { verify() } returns BlockReason.BACKUP_IN_PROGRESS + } + + val guard = ProfileSwitchGuard(profileManager, listOf(check1)) + + val result = guard(targetId) + + assertTrue(result is Result.Blocked) + assertEquals(setOf(BlockReason.BACKUP_IN_PROGRESS), (result as Result.Blocked).reasons) + + with(ProfileSwitchContext) { + verify(exactly = 0) { profileManager.switchActiveProfile(any()) } + } + } + + @Test + fun `invoke returns Success if the blocked reason is in skipReasons`() = + runTest { + val check1 = + mockk { + coEvery { verify() } returns BlockReason.MEDIA_SYNC_IN_PROGRESS + } + + val guard = ProfileSwitchGuard(profileManager, listOf(check1)) + + val result = guard(targetId, skipReasons = setOf(BlockReason.MEDIA_SYNC_IN_PROGRESS)) + + assertEquals(Result.Success, result) + with(ProfileSwitchContext) { + verify(exactly = 1) { profileManager.switchActiveProfile(targetId) } + } + } + + @Test + fun `invoke returns Blocked if multiple checks fail and only one is skipped`() = + runTest { + val syncCheck = + mockk { + coEvery { verify() } returns BlockReason.MEDIA_SYNC_IN_PROGRESS + } + val backupCheck = + mockk { + coEvery { verify() } returns BlockReason.BACKUP_IN_PROGRESS + } + + val guard = ProfileSwitchGuard(profileManager, listOf(syncCheck, backupCheck)) + + val result = guard(targetId, skipReasons = setOf(BlockReason.MEDIA_SYNC_IN_PROGRESS)) + + assertTrue(result is Result.Blocked) + val blockedReasons = (result as Result.Blocked).reasons + assertEquals(1, blockedReasons.size) + assertTrue(blockedReasons.contains(BlockReason.BACKUP_IN_PROGRESS)) + + with(ProfileSwitchContext) { + verify(exactly = 0) { profileManager.switchActiveProfile(any()) } + } + } + + @Test + fun `invoke collects all active blocked reasons if multiple fail`() = + runTest { + val check1 = mockk { coEvery { verify() } returns BlockReason.BACKUP_IN_PROGRESS } + val check2 = mockk { coEvery { verify() } returns BlockReason.COLLECTION_BUSY } + + val guard = ProfileSwitchGuard(profileManager, listOf(check1, check2)) + + val result = guard(targetId) + + assertTrue(result is Result.Blocked) + assertEquals( + setOf(BlockReason.BACKUP_IN_PROGRESS, BlockReason.COLLECTION_BUSY), + (result as Result.Blocked).reasons, + ) + } +}