From 702ef0c1571b6f773e71fd216e08c24ff54e725b Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:22:05 +0100 Subject: [PATCH 1/3] fix: redirect activities to IntentHandler if storage is undecided IntentHandler will handle routing: initially to DeckPicker, and later to a screen where the storage decision can be made Assisted-by: Claude Fable 5 --- .../java/com/ichi2/anki/CardTemplateEditor.kt | 4 + .../main/java/com/ichi2/anki/IntentHandler.kt | 9 +++ .../com/ichi2/anki/NoteTypeFieldEditor.kt | 4 + .../com/ichi2/anki/StudyOptionsActivity.kt | 4 + .../anki/multimedia/MultimediaActivity.kt | 4 + .../ichi2/anki/notetype/ManageNotetypes.kt | 4 + .../com/ichi2/anki/startup/StartupGuards.kt | 11 ++- .../ActivityStartupUndecidedStorageTest.kt | 74 +++++++++++++++++++ 8 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupUndecidedStorageTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index edab5d8254a4..946d74391b2d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt @@ -91,6 +91,7 @@ import com.ichi2.anki.previewer.TemplatePreviewerFragment import com.ichi2.anki.previewer.TemplatePreviewerPage import com.ichi2.anki.settings.Prefs import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.anki.startup.ensureStorageIsReady import com.ichi2.anki.ui.ResizablePaneManager import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.anki.utils.ext.dismissAllDialogFragments @@ -184,6 +185,9 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi return } super.onCreate(savedInstanceState) + if (!ensureStorageIsReady()) { + return + } // Load the args either from the intent or savedInstanceState bundle if (savedInstanceState == null) { // get note type id diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt b/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt index 6e5a67be7c98..d8dce115c1db 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt @@ -28,6 +28,7 @@ import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.noteeditor.NoteEditorLauncher import com.ichi2.anki.servicelayer.ScopedStorageService import com.ichi2.anki.settings.Prefs +import com.ichi2.anki.storage.StorageDecision import com.ichi2.anki.ui.windows.reviewer.ReviewerFragment import com.ichi2.anki.utils.MimeTypeUtils import com.ichi2.anki.worker.SyncWorker @@ -124,6 +125,8 @@ class IntentHandler : AbstractIntentHandler() { * * AnkiDroid is using a legacy directory to store user data but has access to it since storage permission * has been granted (as long as AnkiDroid targeted API < 30, requested legacy storage, and has not been uninstalled since) * + * The user must also have [decided][CollectionHelper.storageDecision] where data is stored; + * otherwise they are routed to the [DeckPicker]. */ @NeedsTest("clicking a file in 'Files' to import") private fun performActionIfStorageAccessible( @@ -131,6 +134,12 @@ class IntentHandler : AbstractIntentHandler() { action: String?, block: () -> Unit, ) { + if (CollectionHelper.storageDecision() != StorageDecision.Decided) { + // checked before permissions: the permissions required depend on the chosen folder + Timber.i("Storage is not configured, cancelling intent '%s'", action) + launchDeckPickerIfNoOtherTasks(reloadIntent) + return + } if (grantedStoragePermissions(this, showToast = true)) { Timber.i("User has storage permissions. Running intent: %s", action) block() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt index 91feffdf8247..5d1466fffdd8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt @@ -48,6 +48,7 @@ import com.ichi2.anki.libanki.NotetypeJson import com.ichi2.anki.libanki.exception.ConfirmModSchemaException import com.ichi2.anki.servicelayer.LanguageHintService.setLanguageHintForField import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.anki.startup.ensureStorageIsReady import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.anki.utils.ext.dismissAllDialogFragments import com.ichi2.anki.utils.ext.setCompoundDrawablesRelativeWithIntrinsicBoundsKt @@ -97,6 +98,9 @@ class NoteTypeFieldEditor : AnkiActivity(R.layout.activity_note_type_field_edito return } super.onCreate(savedInstanceState) + if (!ensureStorageIsReady()) { + return + } setContentView(R.layout.activity_note_type_field_editor) enableToolbar() binding.notetypeName.text = intent.getStringExtra(EXTRA_NOTETYPE_NAME) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsActivity.kt index 46643239b9b3..f50b85aeb33d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsActivity.kt @@ -29,6 +29,7 @@ import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.CustomStudyAction.Co import com.ichi2.anki.libanki.undoAvailable import com.ichi2.anki.libanki.undoLabel import com.ichi2.anki.observability.ChangeManager +import com.ichi2.anki.startup.ensureStorageIsReady import com.ichi2.anki.utils.ext.setFragmentResultListener import com.ichi2.ui.RtlCompliantActionProvider import kotlinx.coroutines.launch @@ -47,6 +48,9 @@ class StudyOptionsActivity : return } super.onCreate(savedInstanceState) + if (!ensureStorageIsReady()) { + return + } enableToolbar().apply { title = "" } if (savedInstanceState == null) { loadStudyOptionsFragment() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActivity.kt index 9d2998a344d7..29f3b311ffc1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActivity.kt @@ -32,6 +32,7 @@ import com.ichi2.anki.multimediacard.IMultimediaEditableNote import com.ichi2.anki.multimediacard.fields.IField import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider import com.ichi2.anki.snackbar.SnackbarBuilder +import com.ichi2.anki.startup.ensureStorageIsReady import com.ichi2.themes.setTransparentStatusBar import com.ichi2.utils.FragmentFactoryUtils import dev.androidbroadcast.vbpd.viewBinding @@ -75,6 +76,9 @@ class MultimediaActivity : return } super.onCreate(savedInstanceState) + if (!ensureStorageIsReady()) { + return + } setTransparentStatusBar() setSupportActionBar(binding.toolbar) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNotetypes.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNotetypes.kt index a451c34c1ae5..5b096d510088 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNotetypes.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNotetypes.kt @@ -47,6 +47,7 @@ import com.ichi2.anki.dialogs.showLoadingDialog import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.notetype.ManageNoteTypesState.UserMessage import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.anki.startup.ensureStorageIsReady import com.ichi2.anki.sync.userAcceptsSchemaChange import com.ichi2.anki.utils.Destination import com.ichi2.themes.setTransparentStatusBar @@ -97,6 +98,9 @@ class ManageNotetypes : AnkiActivity(R.layout.activity_manage_note_types) { return } super.onCreate(savedInstanceState) + if (!ensureStorageIsReady()) { + return + } setTransparentStatusBar() enableToolbar() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/startup/StartupGuards.kt b/AnkiDroid/src/main/java/com/ichi2/anki/startup/StartupGuards.kt index 1eeb1dde2353..dbb6cc46713a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/startup/StartupGuards.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/startup/StartupGuards.kt @@ -11,17 +11,20 @@ import com.ichi2.anki.storage.StorageDecision import timber.log.Timber /** - * Ensures storage is ready for use, finishes the activity is otherwise. + * Ensures storage is ready for use, finishes the activity and calls [redirectToMainEntryPoint] + * otherwise. * - * - the user has decided where the collection is stored - * - the app has permission to access + * - the user has decided where the collection is stored. ([ensureStorageIsConfigured]) + * - the app has permission to access the collection. ([ensureStoragePermissions]) * * This should be called AFTER a call to `super.`[onCreate][Activity.onCreate] * * @return `true`: activity may continue to start, `false`: [onCreate][Activity.onCreate] * should stop executing * - * @throws SystemStorageException if `getExternalFilesDir` returns null + * @throws SystemStorageException if `getExternalFilesDir` returns `null` + * + * @see redirectToMainEntryPoint */ fun Activity.ensureStorageIsReady(): Boolean { // The storage decision is checked first diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupUndecidedStorageTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupUndecidedStorageTest.kt new file mode 100644 index 000000000000..4e2a5f01cad3 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupUndecidedStorageTest.kt @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import com.ichi2.anki.storage.StorageDecision +import com.ichi2.testutils.ActivityList +import com.ichi2.testutils.ActivityList.ActivityLaunchParam +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner + +/** + * All activities start crash-free when [CollectionHelper.storageDecision] returns + * [StorageDecision.Undecided]. + * + * Unlike [ExternalEntryPointsUndecidedStorageTest], this covers in-app navigation and pinned + * shortcuts. + * + * Collection-requiring activities are expected to finish via [ensureStorageIsReady][com.ichi2.anki.startup.ensureStorageIsReady]. + * + * If this fails for a new activity, add the following after `super.onCreate`: + * ``` + * if (!ensureStorageIsReady()) { + * return + * } + * ``` + */ +@RunWith(ParameterizedRobolectricTestRunner::class) +class ActivityStartupUndecidedStorageTest : RobolectricTest() { + @ParameterizedRobolectricTestRunner.Parameter + @JvmField // required for Parameter + var launcher: ActivityLaunchParam? = null + + // Only used for display, but needs to be defined + @ParameterizedRobolectricTestRunner.Parameter(1) + @JvmField // required for Parameter + @Suppress("unused") + var activityName: String? = null + + @Before + fun setStorageUndecided() { + CollectionHelper.storageDecisionTestOverride = StorageDecision.Undecided + } + + @After + fun resetStorageDecision() { + CollectionHelper.storageDecisionTestOverride = null + } + + @Test + fun `startup is crash-free when storage is undecided`() { + val controller = launcher!!.build(targetContext) + // mirrors Android: an activity which finishes during onCreate (e.g. redirectToMainEntryPoint) + // does not receive the remaining lifecycle callbacks; controller.setup() would force them + controller.create() + if (!controller.get().isFinishing) { + controller + .start() + .postCreate(null) + .resume() + .visible() + } + advanceRobolectricLooper() + } + + companion object { + @Suppress("unused") + @ParameterizedRobolectricTestRunner.Parameters(name = "{1}") + @JvmStatic // required for initParameters + fun initParameters(): Collection> = ActivityList.allActivitiesAndIntents().map { arrayOf(it, it.simpleName) } + } +} From 281729f30b21c21173256a8201ae1e71cf6a3973 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:29:30 +0100 Subject: [PATCH 2/3] feat: launchCatching - handle StorageNotConfiguredException Assisted-by: Claude Fable 5 --- .../java/com/ichi2/anki/CoroutineHelpers.kt | 8 +++++ .../anki/CoroutineHelpersRobolectricTest.kt | 35 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt index 2bd30084515b..c5cf2280fae9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt @@ -34,10 +34,12 @@ import com.ichi2.anki.common.destinations.DeckOptionsDestination import com.ichi2.anki.dialogs.DatabaseErrorDialog import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType import com.ichi2.anki.exception.StorageAccessException +import com.ichi2.anki.exception.StorageNotConfiguredException import com.ichi2.anki.libanki.exception.InvalidSearchException import com.ichi2.anki.pages.fromCurrentDeck import com.ichi2.anki.pages.toIntent import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.anki.startup.redirectToMainEntryPoint import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.anki.utils.openUrl import com.ichi2.utils.create @@ -190,6 +192,12 @@ suspend fun FragmentActivity.runCatching( Timber.w(exc, errorMessage) exc.localizedMessage?.let { showSnackbar(it) } } + is StorageNotConfiguredException -> { + // expected before first-run setup completes: no crash report + // edge case when 'ensureStorageIsReady' was insufficient + Timber.w(exc, errorMessage) + if (!isFinishing) redirectToMainEntryPoint() + } is BackendNetworkException, is BackendSyncException, is StorageAccessException, is BackendCardTypeException -> { // these exceptions do not generate worthwhile crash reports Timber.i("Showing error dialog but not sending a crash report.") diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt new file mode 100644 index 000000000000..5dc27f0f7126 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import androidx.fragment.app.FragmentActivity +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.exception.StorageNotConfiguredException +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.assertNotNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class CoroutineHelpersRobolectricTest : RobolectricTest() { + /** + * A [StorageNotConfiguredException] escaping a coroutine means an activity raced or + * outlived its [ensureStorageIsReady][com.ichi2.anki.startup.ensureStorageIsReady] check: + * the user should be sent to the main entry point, which handles storage setup. + */ + @Test + fun `launchCatchingTask redirects to main entry point when storage is not configured`() { + val activity = Robolectric.buildActivity(FragmentActivity::class.java).create().get() + + activity.launchCatchingTask { throw StorageNotConfiguredException() } + advanceRobolectricLooper() + + assertTrue(activity.isFinishing, "activity should finish") + val redirect = shadowOf(activity).nextStartedActivity + assertNotNull(redirect, "the main entry point should be opened") + assertEquals(IntentHandler::class.qualifiedName, redirect.component?.className) + } +} From 8b93e25eeaaa19e26f24740216df1876c2c2feef Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:36:25 +0100 Subject: [PATCH 3/3] feat: classify undecided storage as a startup failure Assisted-by: Claude Fable 5 Co-authored-by: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> --- .../src/main/java/com/ichi2/anki/DeckPicker.kt | 6 ++++++ .../main/java/com/ichi2/anki/InitialActivity.kt | 15 +++++++++++++++ .../com/ichi2/anki/StorageDecisionGateTest.kt | 13 +++++++++++++ 3 files changed, 34 insertions(+) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index 2107066ef605..bcc71c87eeba 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -82,6 +82,7 @@ import com.ichi2.anki.InitialActivity.StartupFailure.DirectoryNotAccessible import com.ichi2.anki.InitialActivity.StartupFailure.DiskFull import com.ichi2.anki.InitialActivity.StartupFailure.FutureAnkidroidVersion import com.ichi2.anki.InitialActivity.StartupFailure.SDCardNotMounted +import com.ichi2.anki.InitialActivity.StartupFailure.StorageUndecided import com.ichi2.anki.IntentHandler.Companion.intentToReviewDeckFromShortcuts import com.ichi2.anki.account.AccountActivity import com.ichi2.anki.analytics.UsageAnalytics @@ -1025,6 +1026,11 @@ open class DeckPicker : is StartupFailure.InitializationError -> FatalErrorDialog.build(this, failure).show() is DiskFull -> displayNoStorageError() is DBError -> displayDatabaseFailure(CustomExceptionData.fromException(failure.exception)) + is StorageUndecided -> { + Timber.i("Displaying storage setup required") + // unreachable: storageDecision() cannot yet return Undecided outside tests + TODO("#19552 - replace with the storage setup flow.") + } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt index e97aacb35673..ba7fa3d3a4aa 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt @@ -37,6 +37,7 @@ import com.ichi2.anki.servicelayer.PreferenceUpgradeService import com.ichi2.anki.servicelayer.PreferenceUpgradeService.setPreferencesUpToDate import com.ichi2.anki.servicelayer.ScopedStorageService.isLegacyStorage import com.ichi2.anki.storage.AnkiDroidFolder +import com.ichi2.anki.storage.StorageDecision import com.ichi2.anki.ui.windows.permissions.InternetPermissionFragment import com.ichi2.anki.ui.windows.permissions.NotificationsPermissionFragment import com.ichi2.anki.ui.windows.permissions.PermissionsFragment @@ -61,6 +62,13 @@ object InitialActivity { return StartupFailure.InitializationError(it) } + // Opening the collection would throw a StorageNotConfiguredException, which is not worth + // a crash report + if (CollectionHelper.storageDecision() != StorageDecision.Decided) { + Timber.i("storage location is undecided") + return StartupFailure.StorageUndecided + } + val failure = try { CollectionManager.getColUnsafe() @@ -187,6 +195,13 @@ object InitialActivity { } data object DiskFull : StartupFailure() + + /** + * The user has not yet decided where the collection is stored. + * + * @see CollectionHelper.storageDecision + */ + data object StorageUndecided : StartupFailure() } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/StorageDecisionGateTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/StorageDecisionGateTest.kt index fb868c89aab4..a9958086648c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/StorageDecisionGateTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/StorageDecisionGateTest.kt @@ -7,6 +7,7 @@ import com.ichi2.anki.exception.StorageNotConfiguredException import com.ichi2.anki.storage.StorageDecision import org.junit.Test import org.junit.runner.RunWith +import kotlin.test.assertEquals import kotlin.test.assertFailsWith /** @@ -25,4 +26,16 @@ class StorageDecisionGateTest : RobolectricTest() { CollectionHelper.storageDecisionTestOverride = null } } + + /** No collection access should be attempted: a crash report would otherwise be generated */ + @Test + fun `startup failure is StorageUndecided when storage is undecided`() { + CollectionHelper.storageDecisionTestOverride = StorageDecision.Undecided + try { + val failure = InitialActivity.getStartupFailureType { true } + assertEquals(InitialActivity.StartupFailure.StorageUndecided, failure) + } finally { + CollectionHelper.storageDecisionTestOverride = null + } + } }