Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2026 Ashish Yadav <mailtoashish693@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/

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<SafetyCheck>,
) {
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<BlockReason>,
) : Result()
}

enum class BlockReason {
Comment thread
david-allison marked this conversation as resolved.
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<BlockReason> = emptySet(),
): Result {
val activeBlockedReasons = mutableSetOf<BlockReason>()

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)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
* Copyright (c) 2026 Ashish Yadav <mailtoashish693@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/

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<SafetyCheck> {
coEvery { verify() } returns null
}
val check2 =
mockk<SafetyCheck> {
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<SafetyCheck> {
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<SafetyCheck> {
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<SafetyCheck> {
coEvery { verify() } returns BlockReason.MEDIA_SYNC_IN_PROGRESS
}
val backupCheck =
mockk<SafetyCheck> {
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<SafetyCheck> { coEvery { verify() } returns BlockReason.BACKUP_IN_PROGRESS }
val check2 = mockk<SafetyCheck> { 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,
)
}
}
Loading