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”