Skip to content

feat(profiles): switch to a profile from the list - #22872

Open
criticalAY wants to merge 7 commits into
ankidroid:mainfrom
criticalAY:profiles/switch-on-tap
Open

criticalAY wants to merge 7 commits into
ankidroid:mainfrom
criticalAY:profiles/switch-on-tap

Conversation

@criticalAY

Copy link
Copy Markdown
Contributor

Note

Assisted-by: Opus 5.5

Purpose / Description

Tapping a profile in the Switch profile list did nothing. Now it asks first, because the app restarts, then switches to that profile. If a sync or backup is running, it explains why it can't switch right now.

Fixes

Approach

check commit message

How Has This Been Tested?

02-confirm-light

Learning (optional, can help others)

The developer option that shows Switch profile is saved per profile like every other setting, so it has to be turned on again inside a new profile to switch back.

Checklist

Please, go through these checks before submitting the PR.

  • You have a descriptive commit message with a short title (first line, max 50 chars).
  • You have commented your code, particularly in hard-to-understand areas
  • You have performed a self-review of your own code
  • UI changes: include screenshots of all affected screens (in particular showing any new or changed strings)
  • UI Changes: You have tested your change using the Google Accessibility Scanner

@github-actions

Copy link
Copy Markdown
Contributor

Important

Maintainers: This PR contains Strings changes

  1. Sync Translations before merging this PR and wait for the action to complete
  2. Review and merge the auto-generated PR in order to sync all user-submitted translations
  3. Sync Translations again and merge the PR so the huge automated string changes caused by merging this PR are by themselves and easy to review

david-allison

This comment was marked as resolved.

@criticalAY criticalAY added the Multi Profile Anything that is related to AnkiDroid multiple profiles label Oct 30, 2026
@mikehardy

Copy link
Copy Markdown
Member

Assisted-by: grok-4.7
strings_pass@9fa2c22e93f63e85d3218125b54802771c858662

Thank you — confirm-before-restart with the existing guard checks matches what we want for profile switching. I rebased locally onto current main without pushing; lint (lintAll, ktlintCheck, lint-rules:test), unit (jacocoUnitTestReport), and emulator (TEST_RELEASE_BUILD=true, emulator-5556 / API35_GAPI) passed. GitHub CI on this tip is green.

SwitchProfilesFragment wires SwitchProfilesViewModel with ProfileSwitchGuard(profileManager, listOf(syncCheck(), mediaSyncCheck(), backupCheck())). A row tap calls requestSwitch; the active profile is a no-op, otherwise a confirm dialog uses switch_profile_title / switch_profile_message. Confirm runs switchProfile, which clears the pending id and launches switchGuard on IO. Success writes PREF_LAST_ACTIVE_PROFILE_ID and calls safeRestartApp; a block sets isSwitchBlocked with switch_profile_blocked and does not restart. View-model tests cover the no-op, dismiss, success plus one restart, guard block, and blocked dismiss; screen tests cover row click, confirm, blocked copy, and rename.

  1. Sync Translations before merging this PR and wait for the action to complete
  2. Review and merge the auto-generated PR in order to sync all user-submitted translations
  3. Sync Translations again and merge the PR so the huge automated string changes caused by merging this PR are by themselves and easy to review

some acceptable findings, no action required, just leaving for posterity: every BlockReason shares the same blocked copy; the active profile row is still clickable but requestSwitch returns before a dialog.

@david-allison
david-allison self-requested a review November 2, 2026 11:42
david-allison

This comment was marked as resolved.

@david-allison david-allison added the Needs Author Reply Waiting for a reply from the original author label Nov 2, 2026
@criticalAY criticalAY removed the Needs Author Reply Waiting for a reply from the original author label Nov 2, 2026

@david-allison david-allison left a comment

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.

GPT 6.1

diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/preferences/profiles/SwitchProfilesShortcutsReviewTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/preferences/profiles/SwitchProfilesShortcutsReviewTest.kt
new file mode 100644
index 0000000..40e1d71
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/preferences/profiles/SwitchProfilesShortcutsReviewTest.kt
@@ -0,0 +1,129 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.preferences.profiles
+
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.Intent
+import android.content.pm.ShortcutInfo
+import android.content.pm.ShortcutManager
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.AnkiDroidApp
+import com.ichi2.anki.CollectionManager
+import com.ichi2.anki.IntentHandler
+import com.ichi2.anki.Reviewer
+import com.ichi2.anki.RobolectricTest
+import com.ichi2.anki.backupLock
+import com.ichi2.anki.common.destinations.ReviewDeckDestination
+import com.ichi2.anki.multiprofile.ProfileId
+import com.ichi2.anki.multiprofile.ProfileManager
+import com.ichi2.anki.multiprofile.ProfileName
+import com.ichi2.anki.multiprofile.ProfileSwitchGuard
+import com.ichi2.anki.multiprofile.safeRestartApp
+import com.ichi2.anki.toIntent
+import com.ichi2.anki.worker.mediaSyncLock
+import com.ichi2.anki.worker.syncLock
+import kotlinx.coroutines.test.advanceUntilIdle
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.Shadows.shadowOf
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+@RunWith(AndroidJUnit4::class)
+class SwitchProfilesShortcutsReviewTest : RobolectricTest() {
+    override fun getCollectionStorageMode() = CollectionStorageMode.ON_DISK
+
+    @After
+    fun releaseRestartLocks() {
+        listOf(syncLock, mediaSyncLock, backupLock).filter { it.isLocked }.forEach { it.unlock() }
+    }
+
+    @Test
+    fun `a shortcut opens the originating deck before switching`() {
+        col.decks.rename(1L, "Private French")
+        addBasicNote("French question", "French answer")
+        val shortcut = pinShortcut()
+
+        assertTrue(launchPinnedShortcut(shortcut).any { it.component?.className == Reviewer::class.java.name })
+        assertEquals("Private French", col.decks.getLegacy(col.decks.selected())!!.name)
+    }
+
+    @Test
+    fun `a Default shortcut must not review Works deck with the same id`() =
+        runTest {
+            val app = AnkiDroidApp.instance
+            val originalManager = requireNotNull(app.profileManager)
+            col.decks.rename(1L, "Private French")
+            addBasicNote("French question", "French answer")
+            // This is the exact destination stored by DeckPicker.createIcon.
+            val shortcut = pinShortcut()
+            val target = originalManager.createNewProfile((ProfileName.validate("Work") as ProfileName.ValidationResult.Valid).name)
+            val model =
+                SwitchProfilesViewModel(originalManager, ProfileSwitchGuard(originalManager, emptyList())) {
+                    safeRestartApp(app)
+                    // Recreate the profile and backend as they would be after process restart.
+                    CollectionManager.discardBackend()
+                    releaseRestartLocks()
+                    app.profileManager =
+                        ProfileManager.create(
+                            object : ContextWrapper(app.baseContext) {
+                                override fun getApplicationContext(): Context? = null
+                            },
+                        )
+                }
+            try {
+                model.requestSwitch(model.profiles.value.single { it.id == target })
+                model.switchProfile()
+                advanceUntilIdle()
+                assertEquals(target, app.profileManager!!.activeProfileContext.profileId)
+                assertEquals(0, col.noteCount(), "Work must have its own collection")
+                col.decks.rename(1L, "Work Spanish")
+                addBasicNote("Spanish question", "Spanish answer")
+
+                val launched = launchPinnedShortcut(shortcut)
+                val wrongProfileReviewed =
+                    app.profileManager!!.activeProfileContext.profileId != ProfileId.DEFAULT &&
+                        launched.any { it.component?.className == Reviewer::class.java.name }
+                assertFalse(
+                    wrongProfileReviewed,
+                    "A Private French shortcut opened ${col.decks.getLegacy(col.decks.selected())!!.name} in Work",
+                )
+            } finally {
+                releaseRestartLocks()
+                CollectionManager.discardBackend()
+                app.profileManager = originalManager
+            }
+        }
+
+    private fun pinShortcut(): String {
+        val manager = targetContext.getSystemService(ShortcutManager::class.java)
+        shadowOf(manager).setIsRequestPinShortcutSupported(true)
+        val shortcut =
+            ShortcutInfo
+                .Builder(targetContext, "1")
+                .setShortLabel("Private French")
+                .setIntent(ReviewDeckDestination.ExternalLaunch(1L).toIntent(targetContext))
+                .build()
+        assertTrue(manager.requestPinShortcut(shortcut, null))
+        return shortcut.id
+    }
+
+    private fun launchPinnedShortcut(id: String): List<Intent> {
+        val manager = targetContext.getSystemService(ShortcutManager::class.java)
+        val shortcut = manager.pinnedShortcuts.single { it.id == id }
+        return if (shortcut.isEnabled) launchShortcut(requireNotNull(shortcut.intent)) else emptyList()
+    }
+
+    private fun launchShortcut(intent: Intent): List<Intent> =
+        Robolectric.buildActivity(IntentHandler::class.java, intent).use { controller ->
+            controller.create()
+            buildList {
+                val activity = shadowOf(controller.get())
+                while (true) add(activity.nextStartedActivity ?: break)
+            }
+        }
+}

@david-allison david-allison added the Needs Author Reply Waiting for a reply from the original author label Nov 2, 2026
criticalAY and others added 7 commits November 3, 2026 02:37
Tapping a profile now asks before switching, since the app restarts.
The switch goes through the safety checks, and if a sync or backup is
running it says so instead of restarting.

Assisted-by: Claude Opus 5.5
Leaving the screen right after confirming could cancel a switch that
was already saved, so the new profile only opened at the next start.
A confirmed switch now always reaches the restart, and other profiles
can't be picked until it does, so a second switch can't overwrite it.

Co-authored-by: David Allison <62114487+david-allison@users.noreply.github.com>
Assisted-by: Claude Opus 5.5
If saving the new profile failed, the app restarted anyway, and the
failed value stayed in memory where a later save could still store it.
It's now rolled back and the screen says the switch didn't work. If the
restart itself fails, the saved profile is switched back too.

Co-authored-by: David Allison <62114487+david-allison@users.noreply.github.com>
Assisted-by: Claude Opus 5.5
A profile whose folders couldn't be created only failed after the
restart, and AnkiDroid then fell back to Default. Its folders and
collection path are now created before the switch is saved, and the
switch is refused if that fails.

Co-authored-by: David Allison <62114487+david-allison@users.noreply.github.com>
Assisted-by: Claude Opus 5.5
The restart closed the collection and then handed over to ProcessPhoenix
as a separate step, so an operation queued in between could reopen it
just before the process was killed. Both now happen in one step on the
collection queue.

Assisted-by: Claude Opus 5.5
Reminder alarms and notifications only carry a reminder or deck ID, so
after a switch they fired into the new profile or kept showing the old
one's decks. The restart now unschedules the old profile's reminder
alarms and clears its notifications. The new profile schedules its own
reminders when it starts, as before.

Co-authored-by: David Allison <62114487+david-allison@users.noreply.github.com>
Assisted-by: Claude Opus 5.5
Deck shortcuts, widgets and reminder taps only store a deck ID, so after
a switch a Default shortcut reviewed whichever deck had that ID in the
new profile. Other profiles now tag them and their shortcut IDs, while
Default's stay as they were, and AnkiDroid only opens a deck in the
profile it came from. Otherwise it says which profile to switch to.

Co-authored-by: David Allison <62114487+david-allison@users.noreply.github.com>
Assisted-by: Claude Opus 5.5
@criticalAY
criticalAY force-pushed the profiles/switch-on-tap branch from 9fa2c22 to b297aa8 Compare November 3, 2026 04:59
@criticalAY criticalAY removed the Needs Author Reply Waiting for a reply from the original author label Nov 3, 2026
@mikehardy

Copy link
Copy Markdown
Member

Assisted-by: Grok 4.7
strings_pass@b297aa8253835a78bad911b6398628639129346b

Thank you — the follow-up commits keep profile switching safe through restart, deck shortcuts, and reminder handover. I rebased locally onto current main without pushing; lint (lintAll, ktlintCheck, lint-rules:test), unit (jacocoUnitTestReport), and emulator (TEST_RELEASE_BUILD=true, emulator-5556 / API35_GAPI) passed. GitHub CI on this tip is green.

SwitchProfilesFragment builds the view model with ProfileSwitchGuard(profileManager, listOf(syncCheck(), mediaSyncCheck(), backupCheck())) and safeRestartApp(applicationContext). switchProfile clears the dialog, sets isSwitching, and launches undispatched into withContext(NonCancellable). The guard’s switchActiveProfile calls prepareProfile and then commitLastActiveProfileId; a failed commit() rolls back and does not restart. safeRestartApp closes the collection on one queue task, unschedules reminder alarms, clears notifications, and triggers rebirth. IntentHandler refuses deck intents whose profile extra is not the running profile; DeckPicker scopes shortcut ids by profile. David’s review tests on this head passed in the unit lane.

  1. Sync Translations before merging this PR and wait for the action to complete
  2. Review and merge the auto-generated PR in order to sync all user-submitted translations
  3. Sync Translations again and merge the PR so the huge automated string changes caused by merging this PR are by themselves and easy to review

some acceptable findings, no action required, just leaving for posterity: the screenshot is the confirm dialog; switch_profile_blocked and switch_profile_failed have no screenshot, and the accessibility-scanner box is still unchecked; commitLastActiveProfileId rolls a failed commit() back with apply().

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Multi Profile Anything that is related to AnkiDroid multiple profiles Needs Review Strings

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants