From a6d0cebb0f047c7be328b349ef4936f98ab05775 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:55:38 +0530 Subject: [PATCH] fix(profiles): explain when a profile can't be opened If the selected profile failed to load at startup, AnkiDroid quietly opened Default instead. The Deck Picker now says so, and offers to use Default from the next start so a profile that keeps failing can't trap the user. Co-authored-by: David Allison <62114487+david-allison@users.noreply.github.com> Assisted-by: Claude Opus 5.5 --- .../main/java/com/ichi2/anki/DeckPicker.kt | 24 +- .../anki/dialogs/ProfileLoadFailureDialog.kt | 95 ++++++ .../ichi2/anki/multiprofile/ProfileManager.kt | 46 ++- .../anki/DeckPickerProfileLoadFailureTest.kt | 310 ++++++++++++++++++ .../anki/DeckPickerProfileRecoveryTest.kt | 193 +++++++++++ .../anki/ProfileLoadFailureScreenshotTest.kt | 36 ++ .../anki/multiprofile/ProfileManagerTest.kt | 100 ++++++ .../ichi2/testutils/DialogFragmentUtils.kt | 5 + .../src/main/res/values/02-strings.xml | 5 + 9 files changed, 812 insertions(+), 2 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ProfileLoadFailureDialog.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerProfileLoadFailureTest.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerProfileRecoveryTest.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/ProfileLoadFailureScreenshotTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index d52ef721781f..2187a3fbb629 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -155,6 +155,7 @@ import com.ichi2.anki.dialogs.FatalErrorDialog import com.ichi2.anki.dialogs.ImportFileSelectionFragment.ApkgImportResultLauncherProvider import com.ichi2.anki.dialogs.ImportFileSelectionFragment.CsvImportResultLauncherProvider import com.ichi2.anki.dialogs.ImportViewModel +import com.ichi2.anki.dialogs.ProfileLoadFailureDialog import com.ichi2.anki.dialogs.SchedulerUpgradeDialog import com.ichi2.anki.dialogs.SyncErrorDialog import com.ichi2.anki.dialogs.SyncErrorDialog.Companion.newInstance @@ -162,6 +163,7 @@ import com.ichi2.anki.dialogs.SyncErrorDialog.SyncErrorDialogListener import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.CustomStudyAction import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.CustomStudyAction.Companion.REQUEST_KEY +import com.ichi2.anki.dialogs.registerProfileLoadFailureClosedHandler import com.ichi2.anki.dialogs.setDeckPickerContextMenuResultListener import com.ichi2.anki.export.ExportDialogFragment import com.ichi2.anki.filtered.FilteredDeckOptionsFragment @@ -170,6 +172,7 @@ import com.ichi2.anki.introduction.hasCollectionStoragePermissions import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.libanki.sched.DeckNode import com.ichi2.anki.mediacheck.MediaCheckFragment +import com.ichi2.anki.multiprofile.ProfileManager import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.pages.AnkiPackageImporterFragment import com.ichi2.anki.progress.observeProgress @@ -671,6 +674,11 @@ open class DeckPicker : openScheduleReminders(did) } + // A fatal startup error waits until the profile explanation is closed, so it can't cover it + registerProfileLoadFailureClosedHandler { + (viewModel.flowOfStartupResponse.value as? StartupResponse.FatalError)?.let { handleStartupFailure(it.failure) } + } + pullToSyncWrapper.configureView( this, IMPORT_MIME_TYPES, @@ -961,6 +969,7 @@ open class DeckPicker : viewModel.flowOfStartupResponse.value = null invalidateOptionsMenu() showStartupScreensAndDialogs(sharedPrefs(), 0) + showProfileLoadFailureIfAny() if (tryShowStudyOptionsPanel()) { ResizablePaneManager( @@ -980,7 +989,11 @@ open class DeckPicker : // built by the time a failure arrives; rebuild it so onCreateOptionsMenu // sees the error and blanks it invalidateOptionsMenu() - handleStartupFailure(response.failure) + if (showProfileLoadFailureIfAny()) { + Timber.i("Showing the startup failure once the profile load failure is closed") + } else { + handleStartupFailure(response.failure) + } } } } @@ -1917,6 +1930,15 @@ open class DeckPicker : } } + private fun showProfileLoadFailureIfAny(): Boolean { + val failure = ProfileManager.consumeLoadFailure() + if (supportFragmentManager.findFragmentByTag(ProfileLoadFailureDialog.TAG) != null) return true + if (failure == null) return false + Timber.i("Showing the profile load failure") + ProfileLoadFailureDialog.newInstance(failure).show(supportFragmentManager, ProfileLoadFailureDialog.TAG) + return true + } + private fun showCollectionErrorDialog() { dialogHandler.sendMessage(CollectionLoadingErrorDialog().toMessage()) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ProfileLoadFailureDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ProfileLoadFailureDialog.kt new file mode 100644 index 000000000000..dbdc8599eb12 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ProfileLoadFailureDialog.kt @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki.dialogs + +import android.content.DialogInterface +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import com.ichi2.anki.CommonString +import com.ichi2.anki.analytics.AnalyticsDialogFragment +import com.ichi2.anki.common.utils.android.showThemedToast +import com.ichi2.anki.multiprofile.ProfileManager +import com.ichi2.anki.multiprofile.ProfileName +import com.ichi2.anki.utils.ext.requireString +import com.ichi2.anki.utils.ext.setFragmentResultListener +import com.ichi2.utils.create +import com.ichi2.utils.message +import com.ichi2.utils.neutralButton +import com.ichi2.utils.positiveButton +import com.ichi2.utils.title +import timber.log.Timber + +/** Tells the user their profile couldn't be opened, and offers to open Default from the next start. */ +class ProfileLoadFailureDialog : AnalyticsDialogFragment() { + private val profileName get() = ProfileName.fromTrustedSource(requireArguments().requireString(ARG_PROFILE_NAME)) + private val defaultProfileName get() = ProfileName.fromTrustedSource(requireArguments().requireString(ARG_DEFAULT_PROFILE_NAME)) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (ProfileManager.LoadFailure(profileName, defaultProfileName) != ProfileManager.loadFailure) { + Timber.w("Dismissing a restored profile load failure that no longer applies") + dismiss() + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog = + AlertDialog.Builder(requireContext()).create { + title(text = getString(CommonString.profile_load_failed_title, profileName.value)) + message(text = getString(CommonString.profile_load_failed_message, defaultProfileName.value, profileName.value)) + positiveButton(CommonString.dialog_ok) { + Timber.i("Profile load failure: OK pressed") + parentFragmentManager.setProfileLoadFailureClosedResult() + } + neutralButton(text = getString(CommonString.profile_load_failed_use_default, defaultProfileName.value)) + } + + override fun onStart() { + super.onStart() + (requireDialog() as AlertDialog).neutralButton?.setOnClickListener { useDefaultProfile() } + } + + override fun onCancel(dialog: DialogInterface) { + super.onCancel(dialog) + Timber.i("Profile load failure: cancelled") + parentFragmentManager.setProfileLoadFailureClosedResult() + } + + private fun useDefaultProfile() { + Timber.i("Profile load failure: Use Default pressed") + if (!ProfileManager.selectDefaultProfile(requireContext().applicationContext)) { + Timber.w("Could not save Default as the profile to open next") + showThemedToast(requireContext(), getString(CommonString.something_wrong), shortLength = true) + return + } + val manager = parentFragmentManager + dismiss() + manager.setProfileLoadFailureClosedResult() + } + + companion object { + const val TAG = "profileLoadFailure" + const val REQUEST_PROFILE_LOAD_FAILURE_CLOSED = "request_profile_load_failure_closed" + private const val ARG_PROFILE_NAME = "profileName" + private const val ARG_DEFAULT_PROFILE_NAME = "defaultProfileName" + + fun newInstance(failure: ProfileManager.LoadFailure) = + ProfileLoadFailureDialog().apply { + arguments = + Bundle().apply { + putString(ARG_PROFILE_NAME, failure.profileName.value) + putString(ARG_DEFAULT_PROFILE_NAME, failure.defaultProfileName.value) + } + } + } +} + +fun FragmentActivity.registerProfileLoadFailureClosedHandler(action: () -> Unit) { + setFragmentResultListener(ProfileLoadFailureDialog.REQUEST_PROFILE_LOAD_FAILURE_CLOSED) { _, _ -> action() } +} + +private fun FragmentManager.setProfileLoadFailureClosedResult() { + setFragmentResult(ProfileLoadFailureDialog.REQUEST_PROFILE_LOAD_FAILURE_CLOSED, Bundle()) +} 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 1366925f6236..23c872183118 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt @@ -3,11 +3,13 @@ package com.ichi2.anki.multiprofile +import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences import android.os.Build import android.webkit.CookieManager import android.webkit.WebView +import androidx.annotation.CheckResult import androidx.annotation.VisibleForTesting import androidx.core.content.ContextCompat import androidx.core.content.edit @@ -704,6 +706,11 @@ class ProfileManager private constructor( */ object ProfileSwitchContext + data class LoadFailure( + val profileName: ProfileName, + val defaultProfileName: ProfileName, + ) + companion object { private const val MAX_ATTEMPTS = 10 const val PROFILE_REGISTRY_FILENAME = "profiles_prefs" @@ -749,6 +756,22 @@ class ProfileManager private constructor( var attachError: Throwable? = null private set + var loadFailure: LoadFailure? = null + private set + + private var unreportedLoadFailure: LoadFailure? = null + + fun consumeLoadFailure(): LoadFailure? = unreportedLoadFailure.also { unreportedLoadFailure = null } + + @CheckResult + @SuppressLint("UseKtx") // edit {} drops the commit result, which tells the dialog whether it can close + fun selectDefaultProfile(context: Context): Boolean = + context + .getSharedPreferences(PROFILE_REGISTRY_FILENAME, Context.MODE_PRIVATE) + .edit() + .putString(PREF_LAST_ACTIVE_PROFILE_ID, ProfileId.DEFAULT.value) + .commit() + /** * Loads the active profile, or returns null when its environment cannot be * loaded. @@ -761,16 +784,37 @@ class ProfileManager private constructor( fun createOrNull(base: Context): ProfileManager? = try { attachError = null + loadFailure = null + unreportedLoadFailure = null create(base) } catch (e: Exception) { - // TODO: show this to the user instead of silently falling back to the Default profile attachError = e + loadFailure = readLoadFailure(base) + unreportedLoadFailure = loadFailure null } + private fun readLoadFailure(base: Context): LoadFailure? { + try { + val registryPrefs = (base.applicationContext ?: base).getSharedPreferences(PROFILE_REGISTRY_FILENAME, Context.MODE_PRIVATE) + val registry = ProfileRegistry(registryPrefs) + val failedId = registry.getLastActiveProfileId()?.takeUnless { it.isDefault() } ?: return null + return LoadFailure( + profileName = registry.getProfileMetadata(failedId)?.displayName ?: ProfileName.fromTrustedSource(failedId.value), + defaultProfileName = + registry.getProfileMetadata(ProfileId.DEFAULT)?.displayName + ?: ProfileName.fromTrustedSource(DEFAULT_PROFILE_DISPLAY_NAME), + ) + } catch (e: Exception) { + return null + } + } + @VisibleForTesting fun resetForTesting() { attachError = null + loadFailure = null + unreportedLoadFailure = null } /** diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerProfileLoadFailureTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerProfileLoadFailureTest.kt new file mode 100644 index 000000000000..9a0597eb1e07 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerProfileLoadFailureTest.kt @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki + +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.content.SharedPreferences +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.core.content.edit +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.InitialActivity.StartupFailure +import com.ichi2.anki.RobolectricTest.Companion.advanceRobolectricLooper +import com.ichi2.anki.dialogs.ProfileLoadFailureDialog +import com.ichi2.anki.dialogs.utils.message +import com.ichi2.anki.dialogs.utils.title +import com.ichi2.anki.multiprofile.ProfileId +import com.ichi2.anki.multiprofile.ProfileManager +import com.ichi2.anki.multiprofile.ProfileManager.Companion.PREF_LAST_ACTIVE_PROFILE_ID +import com.ichi2.anki.multiprofile.ProfileManager.Companion.PROFILE_REGISTRY_FILENAME +import com.ichi2.anki.multiprofile.ProfileName +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.android.controller.ActivityController +import org.robolectric.shadows.ShadowDialog +import org.robolectric.shadows.ShadowToast +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class DeckPickerProfileLoadFailureTest : RobolectricTest() { + private val registry + get() = targetContext.getSharedPreferences(PROFILE_REGISTRY_FILENAME, Context.MODE_PRIVATE) + + private val DeckPicker.profileFailureDialogs: List + get() = dialogsTitled(getString(CommonString.profile_load_failed_title, "Work")) + + private val DeckPicker.storageFullDialogs: List + get() = dialogsTitled(getString(CommonString.storage_full_title)) + + @After + fun resetProfileFailure() { + ProfileManager.resetForTesting() + } + + @Test + fun `startup explains that a profile could not be opened`() { + failToOpenProfile() + + deckPicker { + assertEquals(1, profileFailureDialogs.size) + } + } + + @Test + fun `a normal start shows no explanation`() { + deckPicker { + assertEquals(0, profileFailureDialogs.size) + } + } + + @Test + fun `the explanation names the profile that opened instead`() { + failToOpenProfile(defaultProfileName = "Home") + + deckPicker { + val dialog = profileFailureDialogs.single() + assertEquals(getString(CommonString.profile_load_failed_message, "Home", "Work"), dialog.message) + assertEquals( + getString(CommonString.profile_load_failed_use_default, "Home"), + dialog.getButton(DialogInterface.BUTTON_NEUTRAL).text.toString(), + ) + } + } + + @Test + fun `the explanation is shown above the storage warning`() { + failToOpenProfile() + + deckPicker { + assertEquals(1, storageFullDialogs.size) + assertEquals(profileFailureDialogs.single(), ShadowDialog.getLatestDialog()) + } + } + + @Test + fun `a startup error waits until OK is pressed`() { + assertStartupErrorFollows { getButton(DialogInterface.BUTTON_POSITIVE).performClick() } + } + + @Test + fun `a startup error waits until the explanation is cancelled`() { + assertStartupErrorFollows { cancel() } + } + + @Test + fun `a startup error waits until Default is chosen`() { + assertStartupErrorFollows { getButton(DialogInterface.BUTTON_NEUTRAL).performClick() } + } + + @Test + fun `an unmounted SD card closes the Deck Picker after the explanation`() { + failToOpenProfile() + + withStartupFailure(StartupFailure.SDCardNotMounted) { + deckPicker { + assertFalse(isFinishing) + + clickProfileFailureButton(DialogInterface.BUTTON_POSITIVE) + + assertTrue(isFinishing) + } + } + } + + @Test + fun `Use Default opens the Default profile from the next start`() { + failToOpenProfile() + + deckPicker { + clickProfileFailureButton(DialogInterface.BUTTON_NEUTRAL) + + assertEquals(ProfileId.DEFAULT.value, registry.getString(PREF_LAST_ACTIVE_PROFILE_ID, null)) + } + } + + @Test + fun `Use Default can be tried again when the choice is not saved`() { + failToOpenProfile() + mockkObject(ProfileManager.Companion) + try { + every { ProfileManager.selectDefaultProfile(any()) } returnsMany listOf(false, true) + + deckPicker { + clickProfileFailureButton(DialogInterface.BUTTON_NEUTRAL) + assertEquals(1, profileFailureDialogs.size) + assertEquals(getString(CommonString.something_wrong), ShadowToast.getTextOfLatestToast()) + + clickProfileFailureButton(DialogInterface.BUTTON_NEUTRAL) + assertEquals(0, profileFailureDialogs.size) + } + } finally { + unmockkObject(ProfileManager.Companion) + } + } + + @Test + fun `OK tries the profile again on the next start`() { + val work = failToOpenProfile() + + deckPicker { + clickProfileFailureButton(DialogInterface.BUTTON_POSITIVE) + + assertEquals(work.value, registry.getString(PREF_LAST_ACTIVE_PROFILE_ID, null)) + } + } + + @Test + fun `the explanation survives a configuration change`() { + failToOpenProfile() + val controller = startDeckPicker() + + val recreated = controller.recreateDeckPicker() + + assertEquals(1, recreated.profileFailureDialogs.size) + } + + @Test + fun `a dismissed explanation does not come back`() { + failToOpenProfile() + val controller = startDeckPicker() + controller.get().clickProfileFailureButton(DialogInterface.BUTTON_POSITIVE) + + val recreated = controller.recreateDeckPicker() + + assertEquals(0, recreated.profileFailureDialogs.size) + } + + @Test + fun `a closed explanation stays closed when the app returns`() { + failToOpenProfile() + + withStartupFailure(StartupFailure.DiskFull) { + val controller = startDeckPicker() + controller.get().clickProfileFailureButton(DialogInterface.BUTTON_POSITIVE) + + controller.leaveAndReturn() + + assertEquals(0, controller.get().profileFailureDialogs.size) + assertEquals(1, controller.get().storageFullDialogs.size) + } + } + + @Test + fun `the explanation is shown once after the process is restored`() { + failToOpenProfile() + val savedState = Bundle() + startDeckPicker() + .pause() + .stop() + .saveInstanceState(savedState) + .destroy() + assertNull(ProfileManager.createOrNull(targetContext)) + + val restored = + Robolectric + .buildActivity(DeckPicker::class.java, Intent()) + .create(savedState) + .start() + .restoreInstanceState(savedState) + .postCreate(savedState) + .resume() + .visible() + saveControllerForCleanup(restored) + restored.get().viewModel.awaitStartup() + advanceRobolectricLooper() + + assertEquals(1, restored.get().profileFailureDialogs.size) + } + + private fun assertStartupErrorFollows(close: AlertDialog.() -> Unit) { + failToOpenProfile() + + withStartupFailure(StartupFailure.DiskFull) { + deckPicker { + assertEquals(0, storageFullDialogs.size) + + profileFailureDialogs.single().close() + advanceRobolectricLooper() + + assertEquals(1, storageFullDialogs.size) + } + } + } + + private fun startDeckPicker(): ActivityController { + setIntroductionSlidesShown(true) + return startActivityControllerNormallyOpenCollectionWithIntent(DeckPicker::class.java, Intent()).also { + advanceRobolectricLooper() + assertEquals(1, it.get().profileFailureDialogs.size) + } + } + + private fun DeckPicker.clickProfileFailureButton(which: Int) { + profileFailureDialogs.single().getButton(which).performClick() + advanceRobolectricLooper() + } + + private fun DeckPicker.dialogsTitled(title: String): List = + ShadowDialog.getShownDialogs().filterIsInstance().distinct().filter { + it.isShowing && it.title == title && it.context.findAnkiActivity() === this + } +} + +fun RobolectricTest.failToOpenProfile( + profileName: String = "Work", + defaultProfileName: String? = null, +): ProfileId { + val manager = ProfileManager.create(targetContext) + defaultProfileName?.let { manager.renameProfile(ProfileId.DEFAULT, ProfileName.fromTrustedSource(it)) } + val profileId = manager.createNewProfile(ProfileName.fromTrustedSource(profileName)) + File(targetContext.filesDir.parentFile, profileId.value).writeText("a file where the profile folder belongs") + targetContext.getSharedPreferences(PROFILE_REGISTRY_FILENAME, Context.MODE_PRIVATE).edit(commit = true) { + putString(PREF_LAST_ACTIVE_PROFILE_ID, profileId.value) + } + assertNull(ProfileManager.createOrNull(targetContext)) + return profileId +} + +fun withStartupFailure( + failure: StartupFailure, + block: () -> Unit, +) { + mockkObject(InitialActivity) + try { + every { InitialActivity.getStartupFailureType(any(), any<() -> Boolean>()) } returns failure + block() + } finally { + unmockkObject(InitialActivity) + } +} + +val DeckPicker.profileLoadFailureDialog: ProfileLoadFailureDialog? + get() = supportFragmentManager.findFragmentByTag(ProfileLoadFailureDialog.TAG) as? ProfileLoadFailureDialog + +fun ActivityController.leaveAndReturn() { + pause() + .stop() + .start() + .resume() + .visible() + advanceRobolectricLooper() +} + +fun ActivityController.recreateDeckPicker(): DeckPicker { + recreate() + get().viewModel.awaitStartup() + advanceRobolectricLooper() + return get() +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerProfileRecoveryTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerProfileRecoveryTest.kt new file mode 100644 index 000000000000..dc4dc79487e5 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerProfileRecoveryTest.kt @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import android.annotation.SuppressLint +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.SharedPreferences +import android.os.Bundle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.InitialActivity.StartupFailure +import com.ichi2.anki.multiprofile.ProfileId +import com.ichi2.anki.multiprofile.ProfileManager +import com.ichi2.anki.multiprofile.ProfileManager.Companion.PREF_LAST_ACTIVE_PROFILE_ID +import com.ichi2.anki.multiprofile.ProfileManager.Companion.PROFILE_REGISTRY_FILENAME +import com.ichi2.testutils.neutralButton +import io.mockk.verify +import org.junit.After +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.android.controller.ActivityController +import org.robolectric.shadows.ShadowDialog +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class DeckPickerProfileRecoveryTest : RobolectricTest() { + private val app: AnkiDroidApp + get() = targetContext as AnkiDroidApp + + private var originalProfileManager: ProfileManager? = null + + private val defaultCollectionError = StartupFailure.DBError(IllegalStateException("Default collection could not be opened")) + + @Before + fun rememberProfileManager() { + originalProfileManager = app.profileManager + } + + @After + fun resetProfileManager() { + app.profileManager = originalProfileManager + ProfileManager.resetForTesting() + } + + @Test + @SuppressLint("CheckResult") + fun `profile recovery is available when Default startup fails`() { + failToOpenWork() + // Exercise the fatal startup branch, rather than the backup-space warning + // which is only reached after the fallback collection opens successfully. + withStartupFailure(StartupFailure.DiskFull) { + deckPicker { + verify { InitialActivity.getStartupFailureType(any(), any<() -> Boolean>()) } + val recovery = assertNotNull(profileLoadFailureDialog, "Failed Default startup must still offer profile recovery") + recovery.neutralButton.performClick() + advanceRobolectricLooper() + + assertEquals( + ProfileId.DEFAULT.value, + app.baseContext + .getSharedPreferences(PROFILE_REGISTRY_FILENAME, Context.MODE_PRIVATE) + .getString(PREF_LAST_ACTIVE_PROFILE_ID, null), + ) + } + } + } + + @Test + fun `restored profile failure is discarded when the selected profile now opens`() { + val work = failToOpenWork() + val controller = startDeckPicker() + assertNotNull(controller.get().profileLoadFailureDialog) + val savedState = controller.saveAndDestroy() + + assertTrue(File(app.baseContext.filesDir.parentFile, work.value).delete()) + // At Application.attachBaseContext the application context is unavailable. + // Keep the new manager on the framework base, as in a real process restart. + val base = + object : ContextWrapper(app.baseContext) { + override fun getApplicationContext(): Context? = null + } + app.profileManager = assertNotNull(ProfileManager.createOrNull(base)) + assertEquals(work, app.profileManager?.activeProfileContext?.profileId) + assertNull(ProfileManager.attachError) + // Work has never opened before in this fixture, so skip its onboarding. + setIntroductionSlidesShown(true) + + val restored = restoreDeckPicker(savedState) + + assertNull( + restored.profileLoadFailureDialog, + "Work opened successfully; a restored warning must not claim Default is active or offer a stale recovery action", + ) + } + + @Test + fun `profile recovery remains above fatal startup error after background and foreground`() { + failToOpenWork() + withStartupFailure(defaultCollectionError) { + val controller = startDeckPicker() + val recovery = assertNotNull(controller.get().profileLoadFailureDialog) + assertSame(recovery.dialog, ShadowDialog.getLatestDialog(), "Recovery is initially on top") + + controller.leaveAndReturn() + + assertSame( + recovery.dialog, + ShadowDialog.getLatestDialog(), + "Returning to the app must keep profile recovery reachable above a noncancelable error", + ) + } + } + + @Test + fun `profile recovery remains above fatal startup error after recreation`() { + failToOpenWork() + withStartupFailure(defaultCollectionError) { + val controller = startDeckPicker() + val recovery = assertNotNull(controller.get().profileLoadFailureDialog) + assertSame(recovery.dialog, ShadowDialog.getLatestDialog(), "Recovery is initially on top") + + val restored = assertNotNull(controller.recreateDeckPicker().profileLoadFailureDialog) + + assertSame(restored.dialog, ShadowDialog.getLatestDialog(), "Restored recovery must stay above the noncancelable startup error") + } + } + + @Test + fun `recovery does not silently dismiss when its registry cannot be written`() { + val work = failToOpenWork() + val recovery = assertNotNull(startDeckPicker().get().profileLoadFailureDialog) + val prefsDirectory = File(targetContext.filesDir.parentFile, "shared_prefs") + val registryFile = File(prefsDirectory, "$PROFILE_REGISTRY_FILENAME.xml") + assertTrue(registryFile.isFile) + val diskBefore = registryFile.readText() + assertTrue(diskBefore.contains(work.value)) + assumeTrue("The filesystem must support making directories unwritable", prefsDirectory.setWritable(false, false)) + try { + assertFalse(prefsDirectory.canWrite(), "The registry directory must actually be unwritable") + val dialog = recovery.requireDialog() + recovery.neutralButton.performClick() + advanceRobolectricLooper() + + assertEquals(diskBefore, registryFile.readText(), "The failed commit leaves the old selection on disk") + assertTrue(dialog.isShowing, "A failed recovery write must leave the recovery action available") + } finally { + assertTrue(prefsDirectory.setWritable(true, true)) + } + } + + private fun failToOpenWork(): ProfileId = failToOpenProfile().also { app.profileManager = null } + + private fun startDeckPicker(): ActivityController { + setIntroductionSlidesShown(true) + return startActivityControllerNormallyOpenCollectionWithIntent(DeckPicker::class.java, Intent()).also { + advanceRobolectricLooper() + } + } + + private fun ActivityController.saveAndDestroy(): Bundle = + Bundle().also { savedState -> + pause() + .stop() + .saveInstanceState(savedState) + .destroy() + } + + private fun restoreDeckPicker(savedState: Bundle): DeckPicker { + val controller: ActivityController = + Robolectric + .buildActivity(DeckPicker::class.java, Intent()) + .create(savedState) + .start() + .restoreInstanceState(savedState) + .postCreate(savedState) + .resume() + .visible() + saveControllerForCleanup(controller) + controller.get().viewModel.awaitStartup() + advanceRobolectricLooper() + return controller.get() + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ProfileLoadFailureScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ProfileLoadFailureScreenshotTest.kt new file mode 100644 index 000000000000..2a1206a0ccec --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ProfileLoadFailureScreenshotTest.kt @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki + +import com.ichi2.anki.multiprofile.ProfileManager +import org.junit.After +import org.junit.Test + +class ProfileLoadFailureScreenshotTest : ScreenshotTest() { + @After + fun resetProfileFailure() { + ProfileManager.resetForTesting() + } + + @Test + fun `profile load failure dialog`() { + failToOpenProfile() + + withDeckPicker(deckCount = 0) { + captureScreen("profile_load_failure_dialog") + } + } + + @Test + fun `profile load failure dialog with long names`() { + failToOpenProfile( + profileName = "Japanese vocabulary for the JLPT N2 exam in 2027", + defaultProfileName = "Medical school flashcards, second year", + ) + + withDeckPicker(deckCount = 0) { + captureScreen("profile_load_failure_dialog_long_names") + } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt index 06145da63731..082f656b335a 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt @@ -22,6 +22,7 @@ import com.ichi2.anki.multiprofile.ProfileManager.Companion.PROFILE_REGISTRY_FIL import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.runs import io.mockk.unmockkAll @@ -39,6 +40,7 @@ import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowEnvironment import timber.log.Timber import java.io.File +import java.io.IOException import java.util.UUID import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -101,6 +103,104 @@ class ProfileManagerTest { assertNull(manager, "must not brick startup") assertNotNull(ProfileManager.attachError, "Failure must be recorded for later logging") + assertNull(ProfileManager.consumeLoadFailure(), "no profile name to report") + } + + @Test + fun `a profile that fails to load is reported by name once`() { + registerUnloadableProfile() + + assertNull(ProfileManager.createOrNull(context)) + + assertEquals(loadFailure("Work", "Home"), ProfileManager.consumeLoadFailure()) + assertNull(ProfileManager.consumeLoadFailure(), "reported once per start") + } + + @Test + fun `a reported failure stays known until the next start`() { + registerUnloadableProfile() + ProfileManager.createOrNull(context) + + ProfileManager.consumeLoadFailure() + + assertEquals(loadFailure("Work", "Home"), ProfileManager.loadFailure) + } + + @Test + fun `choosing Default after a failed load opens Default on the next start`() { + registerUnloadableProfile() + ProfileManager.createOrNull(context) + + assertTrue(ProfileManager.selectDefaultProfile(context)) + + assertEquals(ProfileId.DEFAULT, ProfileManager.createOrNull(context)?.activeProfileContext?.profileId) + } + + @Test + fun `choosing Default reports a registry that is not saved`() { + val editor = mockk() + every { editor.putString(any(), any()) } returns editor + every { editor.commit() } returns false + val unsaved = + object : ContextWrapper(context) { + override fun getSharedPreferences( + name: String, + mode: Int, + ): SharedPreferences = mockk { every { edit() } returns editor } + } + + assertFalse(ProfileManager.selectDefaultProfile(unsaved)) + } + + @Test + fun `a profile that opens again clears the earlier failure`() { + val work = registerUnloadableProfile() + ProfileManager.createOrNull(context) + + File(appDataRoot, work.value).delete() + + assertEquals(work, ProfileManager.createOrNull(context)?.activeProfileContext?.profileId) + assertNull(ProfileManager.loadFailure) + assertNull(ProfileManager.consumeLoadFailure()) + } + + @Test + fun `a Default profile that fails to load is not reported`() { + mockkObject(ProfileContextWrapper) + every { ProfileContextWrapper.create(any(), any(), any()) } throws IOException("storage unavailable") + + assertNull(ProfileManager.createOrNull(context)) + + assertNull(ProfileManager.consumeLoadFailure(), "Default is what AnkiDroid falls back to") + } + + @Test + fun `a profile missing from the registry is reported by its ID`() { + ProfileManager.create(context) + val missing = ProfileId("p_0000beef") + File(appDataRoot, missing.value).writeText("a file where the profile folder belongs") + prefs.edit(commit = true) { putString(PREF_LAST_ACTIVE_PROFILE_ID, missing.value) } + + assertNull(ProfileManager.createOrNull(context)) + + assertEquals( + loadFailure(missing.value, "Default"), + ProfileManager.consumeLoadFailure(), + ) + } + + private fun loadFailure( + profileName: String, + defaultProfileName: String, + ) = ProfileManager.LoadFailure(ProfileName.fromTrustedSource(profileName), ProfileName.fromTrustedSource(defaultProfileName)) + + private fun registerUnloadableProfile(): ProfileId { + val manager = ProfileManager.create(context) + manager.renameProfile(ProfileId.DEFAULT, ProfileName.fromTrustedSource("Home")) + val work = manager.createNewProfile(ProfileName.fromTrustedSource("Work")) + File(appDataRoot, work.value).writeText("a file where the profile folder belongs") + prefs.edit(commit = true) { putString(PREF_LAST_ACTIVE_PROFILE_ID, work.value) } + return work } @Test diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/DialogFragmentUtils.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/DialogFragmentUtils.kt index 90d0bada38fd..b13c2dc438b5 100644 --- a/AnkiDroid/src/test/java/com/ichi2/testutils/DialogFragmentUtils.kt +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/DialogFragmentUtils.kt @@ -5,8 +5,13 @@ package com.ichi2.testutils import android.widget.Button import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment +import com.ichi2.utils.neutralButton import com.ichi2.utils.positiveButton /** The positive button of the [AlertDialog] shown by a [DialogFragment] */ val DialogFragment.positiveButton: Button get() = (requireDialog() as AlertDialog).positiveButton + +/** The neutral button of the [AlertDialog] shown by a [DialogFragment] */ +val DialogFragment.neutralButton: Button + get() = requireNotNull((requireDialog() as AlertDialog).neutralButton) diff --git a/common/android/src/main/res/values/02-strings.xml b/common/android/src/main/res/values/02-strings.xml index 4f48f3351e34..8a462cb466b9 100644 --- a/common/android/src/main/res/values/02-strings.xml +++ b/common/android/src/main/res/values/02-strings.xml @@ -467,5 +467,10 @@ opening the system text to speech settings fails">Failed to open text to speech Switch profile Edit profile Delete profile + + + Couldn’t open profile “%1$s” + Opened the “%1$s” profile instead. “%2$s” will be tried again next time. + Use “%1$s”