Skip to content
Open
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
3 changes: 2 additions & 1 deletion AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ open class AnkiDroidApp :
}
}

private var profileManager: ProfileManager? = null
var profileManager: ProfileManager? = null
private set

private val profileContext: Context?
get() = profileManager?.activeProfileContext
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ichi2.anki.AnkiDroidApp
import com.ichi2.anki.CommonString
import com.ichi2.anki.common.utils.android.showThemedToast
import com.ichi2.compose.theme.AnkiDroidTheme
import timber.log.Timber

/**
* Lets the user switch between profiles.
Expand All @@ -22,14 +26,20 @@ import com.ichi2.compose.theme.AnkiDroidTheme
* preference_headers.xml launches it by class name.
*/
class SwitchProfilesFragment : Fragment() {
private val viewModel: SwitchProfilesViewModel by viewModels()

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View =
ComposeView(requireContext()).apply {
): View {
val profileManager = AnkiDroidApp.instance.profileManager
if (profileManager == null) {
Timber.w("Profile environment unavailable, closing the switch profile screen")
showThemedToast(requireContext(), CommonString.something_wrong, false)
parentFragmentManager.popBackStack()
return View(requireContext())
}
val viewModel: SwitchProfilesViewModel by viewModels { SwitchProfilesViewModel.factory(profileManager) }
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
AnkiDroidTheme {
Expand All @@ -48,4 +58,5 @@ class SwitchProfilesFragment : Fragment() {
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
package com.ichi2.anki.preferences.profiles

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.ichi2.anki.multiprofile.ProfileManager
import com.ichi2.anki.multiprofile.ProfileName
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
Expand All @@ -13,11 +17,12 @@ import timber.log.Timber
* State holder for [SwitchProfilesFragment]. Keeps the dialog visibility out
* of the view layer so it survives configuration changes.
*/
class SwitchProfilesViewModel : ViewModel() {
class SwitchProfilesViewModel(
private val profileManager: ProfileManager,
) : ViewModel() {
/** Profiles shown in the list. */
// TODO: load from ProfileManager.getAllProfiles once ProfileManager is wired into the app
val profiles: StateFlow<List<ProfileItem>>
field = MutableStateFlow<List<ProfileItem>>(emptyList())
field = MutableStateFlow(profileManager.profileItems())

val isAddProfileDialogVisible: StateFlow<Boolean>
field = MutableStateFlow(false)
Expand All @@ -34,8 +39,7 @@ class SwitchProfilesViewModel : ViewModel() {
fun addProfile(name: ProfileName) {
isAddProfileDialogVisible.value = false
Timber.i("Add profile confirmed (%d chars)", name.value.length)
// TODO: handle profile creation via ProfileManager.createNewProfile once
// ProfileManager is wired into the app
// TODO: handle profile creation via ProfileManager.createNewProfile
}

fun editProfile(profile: ProfileItem) {
Expand All @@ -47,4 +51,27 @@ class SwitchProfilesViewModel : ViewModel() {
Timber.i("Delete profile requested: %s", profile.id)
// TODO: implement profile deletion via ProfileManager
}

/** Reloads the list from the registry. */
fun refresh() {
profiles.value = profileManager.profileItems()
}

companion object {
fun factory(profileManager: ProfileManager): ViewModelProvider.Factory =
viewModelFactory {
initializer {
SwitchProfilesViewModel(profileManager)
}
}

/**
* The registry is backed by SharedPreferences, which has no defined order,
* so sort by name to keep the list stable across launches.
*/
private fun ProfileManager.profileItems(): List<ProfileItem> =
getAllProfiles()
.map { (id, metadata) -> ProfileItem(id = id, name = metadata.displayName.value) }
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
}
}
8 changes: 8 additions & 0 deletions AnkiDroid/src/test/java/com/ichi2/anki/AnkiDroidAppTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import org.junit.jupiter.api.assertDoesNotThrow
import org.junit.runner.RunWith
import java.io.File
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull

@RunWith(AndroidJUnit4::class)
Expand Down Expand Up @@ -84,6 +85,13 @@ class AnkiDroidAppTest {
)
}

@Test
fun `the application retains the profile manager`() {
val app = ApplicationProvider.getApplicationContext<AnkiDroidApp>()

assertNotNull(app.profileManager, "callers need the manager to reach the active profile")
}

@Test
fun `storage follows a non-default profile`() {
profileRegistry.edit(commit = true) { putString(KEY_LAST_ACTIVE_PROFILE_ID, "p_routed") }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2026 Ashish Yadav <mailtoashish693@gmail.com>

package com.ichi2.anki.preferences.profiles

import androidx.preference.Preference
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ichi2.anki.AnkiDroidApp
import com.ichi2.anki.CommonString
import com.ichi2.anki.R
import com.ichi2.anki.RobolectricTest
import com.ichi2.anki.preferences.HeaderFragment
import com.ichi2.anki.preferences.PreferencesActivity
import com.ichi2.anki.preferences.PreferencesFragment
import com.ichi2.anki.preferences.requirePreference
import io.mockk.every
import io.mockk.mockkObject
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.shadows.ShadowToast
import kotlin.test.assertEquals
import kotlin.test.assertIs

@RunWith(AndroidJUnit4::class)
class SwitchProfilesFragmentTest : RobolectricTest() {
@Test
fun `screen goes back with a message when profiles failed to load`() {
val app = AnkiDroidApp.instance
mockkObject(app) {
every { app.profileManager } returns null
val activity = startRegularActivity<PreferencesActivity>(PreferencesActivity.getIntent(targetContext))
val settings = (activity.fragment as PreferencesFragment).childFragmentManager

(settings.findFragmentById(R.id.settings_container) as HeaderFragment)
.requirePreference<Preference>(R.string.pref_switch_profile_screen_key)
.performClick()
advanceRobolectricLooper()

assertIs<HeaderFragment>(settings.findFragmentById(R.id.settings_container))
assertEquals(targetContext.getString(CommonString.something_wrong), ShadowToast.getTextOfLatestToast())
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,122 @@

package com.ichi2.anki.preferences.profiles

import android.content.Context
import androidx.core.content.edit
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ichi2.anki.multiprofile.ProfileId
import com.ichi2.anki.multiprofile.ProfileManager
import com.ichi2.anki.multiprofile.ProfileManager.Companion.PROFILE_REGISTRY_FILENAME
import com.ichi2.anki.multiprofile.ProfileName
import com.ichi2.anki.multiprofile.ProfileName.ValidationResult
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.test.assertEquals

@RunWith(AndroidJUnit4::class)
class SwitchProfilesViewModelTest {
private val viewModel = SwitchProfilesViewModel()
private lateinit var context: Context
private lateinit var profileManager: ProfileManager

private val prefs
get() = context.getSharedPreferences(PROFILE_REGISTRY_FILENAME, Context.MODE_PRIVATE)

@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
prefs.edit(commit = true) { clear() }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should do this in After as well

profileManager = ProfileManager.create(context)
}

@After
fun tearDown() {
prefs.edit(commit = true) { clear() }
}

private fun viewModel() = SwitchProfilesViewModel(profileManager)

private val validName: ProfileName
get() = (ProfileName.validate("Work") as ValidationResult.Valid).name

private fun createProfile(name: String) = profileManager.createNewProfile(nameOf(name))

private fun nameOf(raw: String) = (ProfileName.validate(raw) as ValidationResult.Valid).name

@Test
fun `add-profile dialog is hidden initially`() {
assertFalse(viewModel.isAddProfileDialogVisible.value)
assertFalse(viewModel().isAddProfileDialogVisible.value)
}

@Test
fun `showAddProfileDialog makes the dialog visible`() {
val viewModel = viewModel()
viewModel.showAddProfileDialog()
assertTrue(viewModel.isAddProfileDialogVisible.value)
}

@Test
fun `dismissAddProfileDialog hides the dialog`() {
val viewModel = viewModel()
viewModel.showAddProfileDialog()
viewModel.dismissAddProfileDialog()
assertFalse(viewModel.isAddProfileDialogVisible.value)
}

@Test
fun `addProfile hides the dialog`() {
val viewModel = viewModel()
viewModel.showAddProfileDialog()
viewModel.addProfile(validName)
assertFalse(viewModel.isAddProfileDialogVisible.value)
}

@Test
fun `the list starts with the Default profile`() {
assertEquals(listOf(ProfileId.DEFAULT), viewModel().profiles.value.map { it.id })
}

@Test
fun `every registered profile is listed`() {
val work = createProfile("Work")

val listed = viewModel().profiles.value.map { it.id }

assertEquals(setOf(ProfileId.DEFAULT, work), listed.toSet())
}

@Test
fun `profiles are listed by name so the order is stable`() {
createProfile("Zebra")
createProfile("Alpha")

val names = viewModel().profiles.value.map { it.name }

assertEquals(listOf("Alpha", "Default", "Zebra"), names)
}

@Test
fun `profiles are listed by name ignoring case`() {
createProfile("bravo")
createProfile("Alpha")

val names = viewModel().profiles.value.map { it.name }

assertEquals(listOf("Alpha", "bravo", "Default"), names)
}

@Test
fun `refresh picks up a profile added after the view model was created`() {
val viewModel = viewModel()
createProfile("Later")

assertEquals(1, viewModel.profiles.value.size, "not visible until refreshed")
viewModel.refresh()

assertEquals(listOf("Default", "Later"), viewModel.profiles.value.map { it.name })
}
}
Loading