From 1ecf63d47f846d80f6d0fbc665f41afcd1f97704 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:10:02 +0100 Subject: [PATCH 1/6] refactor: move helpers to SetupStorage.kt Prep for moving CollectionHelper to :anki-common. Collection creation will become a startup concern, but collection access will move to :anki-common. * Part of 19552 * Part of 13574 * Part of 20737 Assisted-by: Claude Fable 5 --- .../CollectionPathMismatchRecoveryTest.kt | 2 +- .../com/ichi2/anki/tests/libanki/DBTest.kt | 4 +- .../main/java/com/ichi2/anki/AnkiDroidApp.kt | 3 +- .../java/com/ichi2/anki/CollectionHelper.kt | 151 +--------------- .../ichi2/anki/dialogs/DatabaseErrorDialog.kt | 14 +- .../ichi2/anki/multiprofile/ProfileManager.kt | 4 +- .../preferences/AdvancedSettingsFragment.kt | 3 +- .../com/ichi2/anki/startup/SetupStorage.kt | 161 ++++++++++++++++++ 8 files changed, 179 insertions(+), 163 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/startup/SetupStorage.kt diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/CollectionPathMismatchRecoveryTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/CollectionPathMismatchRecoveryTest.kt index b0a52139c1d0..f968bc8a9ed6 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/CollectionPathMismatchRecoveryTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/CollectionPathMismatchRecoveryTest.kt @@ -13,11 +13,11 @@ import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.CollectionHelper -import com.ichi2.anki.CollectionHelper.getDefaultAnkiDroidDirectory import com.ichi2.anki.CollectionManager import com.ichi2.anki.DeckPicker import com.ichi2.anki.R import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory import com.ichi2.anki.tests.InstrumentedTest import com.ichi2.anki.testutil.disableIntroductionSlide import com.ichi2.anki.testutil.discardPreliminaryViews diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/libanki/DBTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/libanki/DBTest.kt index 7a92583f42ce..aba63e693c0c 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/libanki/DBTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/libanki/DBTest.kt @@ -19,8 +19,8 @@ import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabaseCorruptException import androidx.sqlite.db.SupportSQLiteDatabase import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.ichi2.anki.CollectionHelper import com.ichi2.anki.libanki.DB +import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory import com.ichi2.anki.tests.InstrumentedTest import com.ichi2.anki.testutil.GrantStoragePermission import net.ankiweb.rsdroid.database.AnkiSupportSQLiteDatabase @@ -40,7 +40,7 @@ class DBTest : InstrumentedTest() { @Test @Throws(Exception::class) fun testDBCorruption() { - val storagePath = CollectionHelper.getDefaultAnkiDroidDirectory(testContext) + val storagePath = getDefaultAnkiDroidDirectory(testContext) val illFatedDBFile = File(storagePath, "illFatedDB.anki2") // Make sure we have clean state to start with diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index 008514cf9c2d..a704a74ed8ad 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -57,6 +57,7 @@ import com.ichi2.anki.services.AlarmManagerService import com.ichi2.anki.services.NotificationService import com.ichi2.anki.settings.Prefs import com.ichi2.anki.settings.PrefsRepository +import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory import com.ichi2.anki.ui.dialogs.ActivityAgnosticDialogs import com.ichi2.utils.ExceptionUtil import com.ichi2.utils.LanguageUtil @@ -273,7 +274,7 @@ open class AnkiDroidApp : } catch (e: StorageAccessException) { Timber.e(e, "Could not initialize AnkiDroid directory") try { - val defaultDir = CollectionHelper.getDefaultAnkiDroidDirectory(this) + val defaultDir = getDefaultAnkiDroidDirectory(this) if (SdCard.isMounted && CollectionHelper.getCurrentAnkiDroidDirectory(this) == defaultDir) { // Don't send report if the user is using a custom directory as SD cards trip up here a lot sendExceptionReport(e, "AnkiDroidApp.onCreate") diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt index ee3d86816a0a..e8874a76b836 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt @@ -5,10 +5,7 @@ package com.ichi2.anki import android.content.Context import android.content.SharedPreferences -import android.os.Environment -import androidx.annotation.CheckResult import androidx.annotation.VisibleForTesting -import androidx.core.content.edit import com.ichi2.anki.CollectionHelper.PREF_COLLECTION_PATH import com.ichi2.anki.CollectionHelper.getCurrentAnkiDroidDirectory import com.ichi2.anki.common.preferences.sharedPrefs @@ -17,7 +14,7 @@ import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.exception.SystemStorageException import com.ichi2.anki.libanki.Collection import com.ichi2.anki.libanki.CollectionFiles -import com.ichi2.anki.storage.AnkiDroidFolder +import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory import com.ichi2.anki.storage.StorageDecision import com.ichi2.preferences.getOrSetString import timber.log.Timber @@ -96,137 +93,6 @@ object CollectionHelper { false } - /** - * Get the absolute path to a directory that is suitable to be the default starting location - * for the AnkiDroid directory. - * - * Currently, this is a directory named "AnkiDroid" at the top level of the non-app-specific external storage directory. - * - * When targeting API > 29, AnkiDroid will have to use Scoped Storage on any device of any API level. - * Scoped Storage only allows access to App-Specific directories (without permissions). - * Hence, AnkiDroid won't be able to access the directory used currently on all devices, - * regardless of their API level, once AnkiDroid targets API > 29. - * Instead, AnkiDroid will have to use an App-Specific directory to store the AnkiDroid directory. - * This applies to the entire AnkiDroid userbase. - * - * Currently, if `TESTING_SCOPED_STORAGE` is set to `true`, AnkiDroid uses its External - * App-Specific directory. - * - * - * External App-Specific Storage is used since the only advantage Internal App-Specific Storage has over External - * App-Specific storage is additional security, but AnkiDroid does not store sensitive data. Defaulting to - * External Storage preserves the current behavior of the App - * (AnkiDroid defaults to External before the Migration To Scoped Storage). - * - * - * TODO: If External Storage isn't emulated, allow users to choose between External & Internal App-Specific Storage - * instead of defaulting to External App-Specific Storage. This should be done since using either one may be more - * useful for them. If External Storage is emulated, there is no use in providing the option since Internal - * Storage can not provide more storage space than External Storage if External Storage is emulated. - * - * See the detailed explanation on storage locations & their classification below for more details. - * - * App-Specific storage refers to directories which are meant to store files that are meant to be used by a - * particular app. Each app has its own Internal & External App-Specific directory. Under Scoped Storage, - * an app can only access its own Internal & External App-Specific directory without needing permissions. - * - * Storage can be classified as Internal or External Storage. - * - * Internal Storage: This storage is characterized by the fact that it is always available since it always resides - * on the device's own non-removable storage. - * - * - * App-Specific Internal Storage can be accessed by ONLY the app which owns that directory (without any permissions). - * It cannot be accessed by any other apps. - * It cannot be accessed using the Files app on Android or by connecting a device to a pc via USB. - * - * External Storage: - * - * This storage is characterized only by the fact that it is not guaranteed to be available. - * - * It may be built-in, non-removable storage on the device which is being emulated to function like external storage. - * In this case, it doesn't offer more space than Internal Storage. - * - * Or, it may be removable storage like an SD Card. - * - * App-Specific External Storage can be accessed by the app it is owned by without any permissions. - * It can be accessed by any apps with the WRITE_EXTERNAL_STORAGE permission. - * It can also be accessed via the Android Files app or by connecting the device to a PC via USB. - * - * Note: The Files app can be misleading. On Samsung devices, clicking on Internal Storage it actually shows the - * emulated external storage (/storage/emulated/0/ in my case) - this is because from the point of view of the user, - * emulated external storage is just more internal storage since it is built into the phone. This is why vendors - * like Samsung may refer to external emulated storage as internal storage, even though for developers, they mean - * very different things as explained above. - * - * @param directoryName The leaf folder name to use at the end of the returned path. - * Defaults to `"AnkiDroid"` (the historical default-profile folder name). - * Callers wanting a profile-specific layout can pass e.g. the profile id. - * @return Absolute Path to the default location starting location for the AnkiDroid directory - * - * @throws SystemStorageException if `getExternalFilesDir` returns null - */ - // TODO Tracked in https://github.com/ankidroid/Anki-Android/issues/5304 - @CheckResult - fun getDefaultAnkiDroidDirectory( - context: Context, - directoryName: String = "AnkiDroid", - ): File { - val legacyStorage = selectAnkiDroidFolder(context) != AnkiDroidFolder.APP_PRIVATE - return if (legacyStorage) { - legacyAnkiDroidDirectory(directoryName) - } else { - File(getAppSpecificExternalAnkiDroidDirectory(context), directoryName) - } - } - - /** - * Returns the absolute path to the AnkiDroid directory under the primary/shared external storage directory. - * This directory may be in emulated external storage, or can be an SD Card directory. - * - * @param directoryName The folder name to use at the end of the returned path. Defaults to - * `"AnkiDroid"`. Non-default profiles can pass `ProfileId` here to get a - * profile-specific layout. - * @return Absolute path to the AnkiDroid directory in primary shared/external storage - */ - private fun legacyAnkiDroidDirectory(directoryName: String = "AnkiDroid"): File = - File(Environment.getExternalStorageDirectory(), directoryName) - - /** - * Returns the absolute path to the AnkiDroid directory under the app-specific, primary/shared external storage - * directory. - * - * - * This directory may be in emulated external storage, or can be an SD Card directory. - * If it is actually external storage, i.e., removable storage like an SD Card, instead of storage - * built into the device itself, using this directory over internal storage can be beneficial since - * it may be able to store more data. - * - * - * AnkiDroid can access this directory without permissions, even under Scoped Storage - * Other apps can access this directory if they have the WRITE_EXTERNAL_STORAGE permission - * - * @param context Used to get the External App-Specific directory for AnkiDroid - * @return Returns the absolute path to the App-Specific External AnkiDroid directory - * - * @throws SystemStorageException if `getExternalFilesDir` returns null - */ - private fun getAppSpecificExternalAnkiDroidDirectory(context: Context): String? { - val externalFilesDir = context.getExternalFilesDir(null) - - // This value *may* be null but we strictly require it. This has caused NullPointerException - // in previous releases as we dereference. We can't recover but for purposes of triage, - // we will now check for null and if so try to log more information about why. - if (externalFilesDir == null) { - Timber.e("Attempting to determine collection path, but no valid external storage?") - throw SystemStorageException.build( - errorDetail = "getExternalFilesDir unexpectedly returned null", - infoUri = "https://github.com/ankidroid/Anki-Android/issues/13207", - ) - } - return externalFilesDir.absolutePath - } - /** * @return Returns an array of [File]s reflecting the directories that AnkiDroid can access without storage permissions * @see android.content.Context.getExternalFilesDirs @@ -311,21 +177,6 @@ object CollectionHelper { ) } - /** - * Resets the AnkiDroid directory to [directory] - * Note: if [android.R.attr.preserveLegacyExternalStorage] is in use - * this will represent a change from `/AnkiDroid` to `/Android/data/...` - * - * @throws SystemStorageException if `getExternalFilesDir` returns null - */ - fun resetAnkiDroidDirectory( - context: Context, - directory: File = getDefaultAnkiDroidDirectory(context), - ) { - Timber.d("resetting AnkiDroid directory to %s", directory) - context.sharedPrefs().edit { putString(PREF_COLLECTION_PATH, directory.absolutePath) } - } - /** Test-only override for [storageDecision]. @see ankiDroidDirectoryOverride */ @VisibleForTesting var storageDecisionTestOverride: StorageDecision? = null diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt index d25c91c6211c..52a4966790d2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt @@ -53,6 +53,8 @@ import com.ichi2.anki.libanki.Consts import com.ichi2.anki.requireAnkiActivity import com.ichi2.anki.servicelayer.DebugInfoService import com.ichi2.anki.showImportDialog +import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory +import com.ichi2.anki.startup.resetAnkiDroidDirectory import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.anki.utils.ext.dismissAllDialogFragments import com.ichi2.utils.UiUtil.makeBold @@ -166,7 +168,7 @@ class DatabaseErrorDialog : AsyncDialogFragment() { val shouldOfferResetToDefaultDirectory = try { val currentDir = CollectionHelper.getCurrentAnkiDroidDirectory(activity) - val defaultDir = CollectionHelper.getDefaultAnkiDroidDirectory(activity) + val defaultDir = getDefaultAnkiDroidDirectory(activity) currentDir.absolutePath != defaultDir.absolutePath } catch (e: Throwable) { Timber.w(e, "Failed to determine whether to offer reset-to-default directory option") @@ -209,9 +211,9 @@ class DatabaseErrorDialog : AsyncDialogFragment() { } ErrorHandlingEntries.RESET_TO_DEFAULT_DIRECTORY -> { try { - val defaultDir = CollectionHelper.getDefaultAnkiDroidDirectory(activity) + val defaultDir = getDefaultAnkiDroidDirectory(activity) CollectionManager.closeCollectionBlocking() - CollectionHelper.resetAnkiDroidDirectory(activity, defaultDir) + resetAnkiDroidDirectory(activity, defaultDir) closeCollectionAndFinish() } catch (e: Throwable) { Timber.w(e, "Failed to reset AnkiDroid directory to default") @@ -487,7 +489,7 @@ class DatabaseErrorDialog : AsyncDialogFragment() { dismissesDialog = false, { activity -> Timber.i("Restoring from colpkg") - val newAnkiDroidDirectory = CollectionHelper.getDefaultAnkiDroidDirectory(activity) + val newAnkiDroidDirectory = getDefaultAnkiDroidDirectory(activity) activity.importColpkgListener = DatabaseRestorationListener(activity, newAnkiDroidDirectory) activity.launchCatchingTask { @@ -526,7 +528,7 @@ class DatabaseErrorDialog : AsyncDialogFragment() { fun displayCreateNewCollectionDialog(context: AnkiActivity) { val directory = try { - CollectionHelper.getDefaultAnkiDroidDirectory(context) + getDefaultAnkiDroidDirectory(context) } catch (e: SystemStorageException) { Timber.w(e, "failed to show 'Create new collection' dialog") FatalErrorDialog.build(context, InitializationError(StorageError(e))).show() @@ -543,7 +545,7 @@ class DatabaseErrorDialog : AsyncDialogFragment() { "DatabaseErrorDialog: Before Create New Collection", ) CollectionManager.closeCollectionBlocking() - CollectionHelper.resetAnkiDroidDirectory(context, directory) + resetAnkiDroidDirectory(context, directory) context.closeCollectionAndFinish() } negativeButton(R.string.dialog_cancel) 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 826b33566b74..742e51484b55 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt @@ -12,11 +12,11 @@ import androidx.annotation.VisibleForTesting import androidx.core.content.ContextCompat import androidx.core.content.edit import com.ichi2.anki.CollectionHelper.PREF_COLLECTION_PATH -import com.ichi2.anki.CollectionHelper.getDefaultAnkiDroidDirectory import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.common.time.getTimestamp +import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory import org.json.JSONObject import timber.log.Timber import java.io.File @@ -390,7 +390,7 @@ class ProfileManager private constructor( * The default-location fallback used when the profile has never written `PREF_COLLECTION_PATH`. * * TODO: consolidate with the profile-creation path this should delegate to - * `CollectionHelper.getDefaultAnkiDroidDirectory(profileContext, directoryName = ...)` + * `getDefaultAnkiDroidDirectory(profileContext, directoryName = ...)` * that gives us legacy-storage handling and `SystemStorageException`-on-null for free, and keeps * the "where does a profile collection live" decision in a single place shared with * `ensureProfileCollectionPath`. diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AdvancedSettingsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AdvancedSettingsFragment.kt index a8db4355a3d4..db8caa4b3be6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AdvancedSettingsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AdvancedSettingsFragment.kt @@ -33,6 +33,7 @@ import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.provider.CardContentProvider import com.ichi2.anki.settings.Prefs import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory import com.ichi2.anki.utils.openUrl import com.ichi2.utils.show import timber.log.Timber @@ -67,7 +68,7 @@ class AdvancedSettingsFragment : SettingsFragment() { setTitle(R.string.dialog_collection_path_not_dir) setPositiveButton(R.string.dialog_ok) { _, _ -> } setNegativeButton(R.string.reset_custom_buttons) { _, _ -> - text = CollectionHelper.getDefaultAnkiDroidDirectory(requireContext()).absolutePath + text = getDefaultAnkiDroidDirectory(requireContext()).absolutePath } } false diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/startup/SetupStorage.kt b/AnkiDroid/src/main/java/com/ichi2/anki/startup/SetupStorage.kt new file mode 100644 index 000000000000..297fec9e4937 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/startup/SetupStorage.kt @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.startup + +import android.content.Context +import android.os.Environment +import androidx.annotation.CheckResult +import androidx.core.content.edit +import com.ichi2.anki.CollectionHelper +import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.exception.SystemStorageException +import com.ichi2.anki.selectAnkiDroidFolder +import com.ichi2.anki.storage.AnkiDroidFolder +import timber.log.Timber +import java.io.File + +/** + * Get the absolute path to a directory that is suitable to be the default starting location + * for the AnkiDroid directory. + * + * Currently, this is a directory named "AnkiDroid" at the top level of the non-app-specific external storage directory. + * + * When targeting API > 29, AnkiDroid will have to use Scoped Storage on any device of any API level. + * Scoped Storage only allows access to App-Specific directories (without permissions). + * Hence, AnkiDroid won't be able to access the directory used currently on all devices, + * regardless of their API level, once AnkiDroid targets API > 29. + * Instead, AnkiDroid will have to use an App-Specific directory to store the AnkiDroid directory. + * This applies to the entire AnkiDroid userbase. + * + * Currently, if `TESTING_SCOPED_STORAGE` is set to `true`, AnkiDroid uses its External + * App-Specific directory. + * + * + * External App-Specific Storage is used since the only advantage Internal App-Specific Storage has over External + * App-Specific storage is additional security, but AnkiDroid does not store sensitive data. Defaulting to + * External Storage preserves the current behavior of the App + * (AnkiDroid defaults to External before the Migration To Scoped Storage). + * + * + * TODO: If External Storage isn't emulated, allow users to choose between External & Internal App-Specific Storage + * instead of defaulting to External App-Specific Storage. This should be done since using either one may be more + * useful for them. If External Storage is emulated, there is no use in providing the option since Internal + * Storage can not provide more storage space than External Storage if External Storage is emulated. + * + * See the detailed explanation on storage locations & their classification below for more details. + * + * App-Specific storage refers to directories which are meant to store files that are meant to be used by a + * particular app. Each app has its own Internal & External App-Specific directory. Under Scoped Storage, + * an app can only access its own Internal & External App-Specific directory without needing permissions. + * + * Storage can be classified as Internal or External Storage. + * + * Internal Storage: This storage is characterized by the fact that it is always available since it always resides + * on the device's own non-removable storage. + * + * + * App-Specific Internal Storage can be accessed by ONLY the app which owns that directory (without any permissions). + * It cannot be accessed by any other apps. + * It cannot be accessed using the Files app on Android or by connecting a device to a pc via USB. + * + * External Storage: + * + * This storage is characterized only by the fact that it is not guaranteed to be available. + * + * It may be built-in, non-removable storage on the device which is being emulated to function like external storage. + * In this case, it doesn't offer more space than Internal Storage. + * + * Or, it may be removable storage like an SD Card. + * + * App-Specific External Storage can be accessed by the app it is owned by without any permissions. + * It can be accessed by any apps with the WRITE_EXTERNAL_STORAGE permission. + * It can also be accessed via the Android Files app or by connecting the device to a PC via USB. + * + * Note: The Files app can be misleading. On Samsung devices, clicking on Internal Storage it actually shows the + * emulated external storage (/storage/emulated/0/ in my case) - this is because from the point of view of the user, + * emulated external storage is just more internal storage since it is built into the phone. This is why vendors + * like Samsung may refer to external emulated storage as internal storage, even though for developers, they mean + * very different things as explained above. + * + * @param directoryName The leaf folder name to use at the end of the returned path. + * Defaults to `"AnkiDroid"` (the historical default-profile folder name). + * Callers wanting a profile-specific layout can pass e.g. the profile id. + * @return Absolute Path to the default location starting location for the AnkiDroid directory + * + * @throws SystemStorageException if `getExternalFilesDir` returns null + */ +// TODO Tracked in https://github.com/ankidroid/Anki-Android/issues/5304 +@CheckResult +fun getDefaultAnkiDroidDirectory( + context: Context, + directoryName: String = "AnkiDroid", +): File { + val legacyStorage = selectAnkiDroidFolder(context) != AnkiDroidFolder.APP_PRIVATE + return if (legacyStorage) { + legacyAnkiDroidDirectory(directoryName) + } else { + File(getAppSpecificExternalAnkiDroidDirectory(context), directoryName) + } +} + +/** + * Returns the absolute path to the AnkiDroid directory under the primary/shared external storage directory. + * This directory may be in emulated external storage, or can be an SD Card directory. + * + * @param directoryName The folder name to use at the end of the returned path. Defaults to + * `"AnkiDroid"`. Non-default profiles can pass `ProfileId` here to get a + * profile-specific layout. + * @return Absolute path to the AnkiDroid directory in primary shared/external storage + */ +private fun legacyAnkiDroidDirectory(directoryName: String = "AnkiDroid"): File = + File(Environment.getExternalStorageDirectory(), directoryName) + +/** + * Returns the absolute path to the AnkiDroid directory under the app-specific, primary/shared external storage + * directory. + * + * + * This directory may be in emulated external storage, or can be an SD Card directory. + * If it is actually external storage, i.e., removable storage like an SD Card, instead of storage + * built into the device itself, using this directory over internal storage can be beneficial since + * it may be able to store more data. + * + * + * AnkiDroid can access this directory without permissions, even under Scoped Storage + * Other apps can access this directory if they have the WRITE_EXTERNAL_STORAGE permission + * + * @param context Used to get the External App-Specific directory for AnkiDroid + * @return Returns the absolute path to the App-Specific External AnkiDroid directory + * + * @throws SystemStorageException if `getExternalFilesDir` returns null + */ +private fun getAppSpecificExternalAnkiDroidDirectory(context: Context): String? { + val externalFilesDir = context.getExternalFilesDir(null) + + // This value *may* be null but we strictly require it. This has caused NullPointerException + // in previous releases as we dereference. We can't recover but for purposes of triage, + // we will now check for null and if so try to log more information about why. + if (externalFilesDir == null) { + Timber.e("Attempting to determine collection path, but no valid external storage?") + throw SystemStorageException.build( + errorDetail = "getExternalFilesDir unexpectedly returned null", + infoUri = "https://github.com/ankidroid/Anki-Android/issues/13207", + ) + } + return externalFilesDir.absolutePath +} + +/** + * Resets the AnkiDroid directory to [directory] + * Note: if [android.R.attr.preserveLegacyExternalStorage] is in use + * this will represent a change from `/AnkiDroid` to `/Android/data/...` + * + * @throws SystemStorageException if `getExternalFilesDir` returns null + */ +fun resetAnkiDroidDirectory( + context: Context, + directory: File = getDefaultAnkiDroidDirectory(context), +) { + Timber.d("resetting AnkiDroid directory to %s", directory) + context.sharedPrefs().edit { putString(CollectionHelper.PREF_COLLECTION_PATH, directory.absolutePath) } +} From 080589ef26c119a6096f869f094634d28ae37e12 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:10:27 +0100 Subject: [PATCH 2/6] feat: choose the default collection path at startup Breaking change, this was previously done on first collection access. Now this is set in AnkiDroidApp, and reading throws StorageNotConfiguredException beforehand. This contains a workaround for the OS-level bug: `SystemStorageException` if the exception is caught, it is explicitly rethrown. This is preparation for a full 'storage decision' screen during onboarding * Part of 19552 * Part of 13574 * Part of 20737 Assisted-by: Claude Fable 5 --- .../main/java/com/ichi2/anki/AnkiDroidApp.kt | 2 + .../java/com/ichi2/anki/CollectionHelper.kt | 76 +++++++++------ .../java/com/ichi2/anki/CollectionManager.kt | 20 +++- .../com/ichi2/anki/startup/SetupStorage.kt | 27 ++++++ .../com/ichi2/anki/StorageDecisionGateTest.kt | 20 ++++ .../ichi2/anki/startup/SetupStorageTest.kt | 97 +++++++++++++++++++ .../testutils/TestApplicationSingletons.kt | 4 +- 7 files changed, 210 insertions(+), 36 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/startup/SetupStorageTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index a704a74ed8ad..1820e9c641a1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -57,6 +57,7 @@ import com.ichi2.anki.services.AlarmManagerService import com.ichi2.anki.services.NotificationService import com.ichi2.anki.settings.Prefs import com.ichi2.anki.settings.PrefsRepository +import com.ichi2.anki.startup.ensureCollectionPathSet import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory import com.ichi2.anki.ui.dialogs.ActivityAgnosticDialogs import com.ichi2.utils.ExceptionUtil @@ -258,6 +259,7 @@ open class AnkiDroidApp : // is not considered to be a fatal error, unless the directory itself is not writable. val ankiDroidDir = try { + ensureCollectionPathSet(this) CollectionHelper.getCurrentAnkiDroidDirectory(this) } catch (e: SystemStorageException) { fatalInitializationError = FatalInitializationError.StorageError(e) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt index e8874a76b836..374e0641e8f8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt @@ -11,12 +11,11 @@ import com.ichi2.anki.CollectionHelper.getCurrentAnkiDroidDirectory import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.common.utils.android.isInstrumentationTest import com.ichi2.anki.exception.StorageAccessException +import com.ichi2.anki.exception.StorageNotConfiguredException import com.ichi2.anki.exception.SystemStorageException import com.ichi2.anki.libanki.Collection import com.ichi2.anki.libanki.CollectionFiles -import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory import com.ichi2.anki.storage.StorageDecision -import com.ichi2.preferences.getOrSetString import timber.log.Timber import java.io.File import java.io.IOException @@ -81,7 +80,8 @@ object CollectionHelper { /** * Try to access the current AnkiDroid directory - * @return whether or not dir is accessible + * @return whether or not dir is accessible: `false` if inaccessible, not yet configured, + * or the system could not provide a storage location * @param context to get directory with */ fun isCurrentAnkiDroidDirAccessible(context: Context): Boolean = @@ -91,6 +91,12 @@ object CollectionHelper { } catch (e: StorageAccessException) { Timber.w(e) false + } catch (e: StorageNotConfiguredException) { + Timber.w(e) + false + } catch (e: SystemStorageException) { + Timber.w(e) + false } /** @@ -121,6 +127,17 @@ object CollectionHelper { /** A temporary override for [getCurrentAnkiDroidDirectory] */ var ankiDroidDirectoryOverride: File? = null + /** + * Set when startup failed to choose a default collection path because Android could not + * provide a storage location ([SystemStorageException]: OS bug/SD card issue). + * + * Reads of an unset collection path rethrow this instead of [StorageNotConfiguredException], + * so the storage failure is not mistaken for the expected 'no collection path set' state. + */ + // TODO: #19552 - consolidate with AnkiDroidApp.fatalError and StorageDecision (a dedicated + // 'storage unavailable' state) once the storage setup flow exists + var systemStorageFailure: SystemStorageException? = null + /** * Whether the user has chosen where the collection is stored. * @@ -133,10 +150,13 @@ object CollectionHelper { /** * @return the absolute path to the AnkiDroid directory. * - * @throws SystemStorageException if `getExternalFilesDir` returns null + * @throws StorageNotConfiguredException if no collection path has been set + * ([PREF_COLLECTION_PATH] is unset): a default is chosen during startup by + * [ensureCollectionPathSet][com.ichi2.anki.startup.ensureCollectionPathSet] + * @throws SystemStorageException if startup failed to choose a default collection path + * ([systemStorageFailure]) */ - fun getCurrentAnkiDroidDirectory(context: Context): File = - getCurrentAnkiDroidDirectoryOptionalContext(context.sharedPrefs()) { context } + fun getCurrentAnkiDroidDirectory(context: Context): File = getCurrentAnkiDroidDirectory(context.sharedPrefs()) fun getCollectionPaths(context: Context): CollectionFiles = CollectionFiles.FolderBasedCollection(getCurrentAnkiDroidDirectory(context)) @@ -144,38 +164,32 @@ object CollectionHelper { fun getMediaDirectory(context: Context) = getCollectionPaths(context).requireMediaFolder() /** - * An accessor which makes [Context] optional in the case that [PREF_COLLECTION_PATH] is set - * * @return the absolute path to the AnkiDroid directory. + * + * @throws StorageNotConfiguredException if no collection path has been set + * ([PREF_COLLECTION_PATH] is unset): a default is chosen during startup by + * [ensureCollectionPathSet][com.ichi2.anki.startup.ensureCollectionPathSet] + * @throws SystemStorageException if startup failed to choose a default collection path + * ([systemStorageFailure]) */ - // This uses a lambda as we typically depends on the `lateinit` appContext - // If we remove all Android references, we get a significant unit test speedup - @VisibleForTesting(otherwise = VisibleForTesting.NONE) - internal fun getCurrentAnkiDroidDirectoryOptionalContext( - preferences: SharedPreferences, - context: () -> Context, - ): File = - if (isInstrumentationTest) { + fun getCurrentAnkiDroidDirectory(preferences: SharedPreferences): File { + val collectionDirectory = preferences.getString(PREF_COLLECTION_PATH, null) + return if (isInstrumentationTest) { // create an "androidTest" directory inside the current collection directory which contains the test data // "/AnkiDroid/androidTest" would be a new collection path - val currentCollectionDirectory = - preferences.getOrSetString(PREF_COLLECTION_PATH) { - getDefaultAnkiDroidDirectory(context()).absolutePath - } - File( - currentCollectionDirectory, - "androidTest", - ) + File(collectionDirectory ?: throw collectionPathUnset(), "androidTest") } else { ankiDroidDirectoryOverride - ?: File( - preferences.getOrSetString(PREF_COLLECTION_PATH) { - getDefaultAnkiDroidDirectory( - context(), - ).absolutePath - }, - ) + ?: File(collectionDirectory ?: throw collectionPathUnset()) } + } + + /** + * The collection path is unset: either the expected pre-setup state + * ([StorageNotConfiguredException]) or startup failed to choose a default and the recorded + * [SystemStorageException] is rethrown. + */ + private fun collectionPathUnset(): Exception = systemStorageFailure ?: StorageNotConfiguredException() /** Test-only override for [storageDecision]. @see ankiDroidDirectoryOverride */ @VisibleForTesting diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CollectionManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CollectionManager.kt index 6b2026f3e095..1f50b595c0c8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CollectionManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CollectionManager.kt @@ -18,10 +18,10 @@ import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.CollectionManager.withOpenColOrNull import com.ichi2.anki.CollectionManager.withQueue import com.ichi2.anki.backend.createDatabaseUsingRustBackend -import com.ichi2.anki.common.android.appContext import com.ichi2.anki.common.utils.android.Threads import com.ichi2.anki.common.utils.android.isRobolectric import com.ichi2.anki.exception.StorageNotConfiguredException +import com.ichi2.anki.exception.SystemStorageException import com.ichi2.anki.libanki.Collection import com.ichi2.anki.libanki.CollectionFiles import com.ichi2.anki.libanki.LibAnki @@ -130,6 +130,8 @@ object CollectionManager { * * @throws StorageNotConfiguredException If [CollectionHelper.storageDecision] is undecided * (user has not selected a storage location). + * @throws SystemStorageException if startup failed to choose a default collection path + * ([CollectionHelper.systemStorageFailure]) */ suspend fun withCol( @WorkerThread block: Collection.() -> T, @@ -254,6 +256,8 @@ object CollectionManager { * * @throws StorageNotConfiguredException If [CollectionHelper.storageDecision] is undecided * (user has not selected a storage location). + * @throws SystemStorageException if startup failed to choose a default collection path + * ([CollectionHelper.systemStorageFailure]) */ suspend fun ensureOpen() { withQueue { @@ -266,9 +270,15 @@ object CollectionManager { * * @throws StorageNotConfiguredException If [CollectionHelper.storageDecision] is not * [StorageDecision.Decided] (user has not selected a storage location). + * @throws SystemStorageException if startup failed to choose a default collection path + * ([CollectionHelper.systemStorageFailure]) */ private fun ensureOpenInner() { - if (CollectionHelper.storageDecision() != StorageDecision.Decided) throw StorageNotConfiguredException() + if (CollectionHelper.storageDecision() != StorageDecision.Decided) { + // rethrow a recorded startup storage failure: an OS bug/SD card issue must not be + // reported as the expected 'storage not configured' state + throw CollectionHelper.systemStorageFailure ?: StorageNotConfiguredException() + } ensureBackendInner() emulatedOpenFailure?.triggerFailure() if (collection == null || collection!!.dbClosed) { @@ -290,8 +300,8 @@ object CollectionManager { } fun getCollectionDirectory() = - // Allow execution if appContext is not initialized - CollectionHelper.getCurrentAnkiDroidDirectoryOptionalContext(AnkiDroidApp.sharedPrefs()) { appContext } + // does not require appContext to be initialized + CollectionHelper.getCurrentAnkiDroidDirectory(AnkiDroidApp.sharedPrefs()) /** Ensures the AnkiDroid directory is created, then returns the path to the * folder and the name of the collection file inside it. */ @@ -330,6 +340,8 @@ object CollectionManager { * * @throws StorageNotConfiguredException If [CollectionHelper.storageDecision] is undecided * (user has not selected a storage location). + * @throws SystemStorageException if startup failed to choose a default collection path + * ([CollectionHelper.systemStorageFailure]) */ fun getColUnsafe(): Collection = logUIHangs { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/startup/SetupStorage.kt b/AnkiDroid/src/main/java/com/ichi2/anki/startup/SetupStorage.kt index 297fec9e4937..0c47e7971d61 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/startup/SetupStorage.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/startup/SetupStorage.kt @@ -8,12 +8,39 @@ import androidx.annotation.CheckResult import androidx.core.content.edit import com.ichi2.anki.CollectionHelper import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.exception.StorageNotConfiguredException import com.ichi2.anki.exception.SystemStorageException import com.ichi2.anki.selectAnkiDroidFolder import com.ichi2.anki.storage.AnkiDroidFolder import timber.log.Timber import java.io.File +/** + * Ensures [CollectionHelper.PREF_COLLECTION_PATH] is set, choosing and persisting + * [getDefaultAnkiDroidDirectory] if it is unset. + * + * This decides the storage location on the user's behalf: until a storage setup flow exists + * (#19552), the user is not asked. + * + * @throws SystemStorageException if `getExternalFilesDir` returns null. The failure is recorded + * in [CollectionHelper.systemStorageFailure] so reads report it rather than + * [StorageNotConfiguredException] + */ +fun ensureCollectionPathSet(context: Context) { + val preferences = context.sharedPrefs() + if (preferences.contains(CollectionHelper.PREF_COLLECTION_PATH)) return + val defaultPath = + try { + getDefaultAnkiDroidDirectory(context).absolutePath + } catch (e: SystemStorageException) { + CollectionHelper.systemStorageFailure = e + throw e + } + Timber.i("collection path set to default") + Timber.d("default collection path: %s", defaultPath) + preferences.edit { putString(CollectionHelper.PREF_COLLECTION_PATH, defaultPath) } +} + /** * Get the absolute path to a directory that is suitable to be the default starting location * for the AnkiDroid directory. diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/StorageDecisionGateTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/StorageDecisionGateTest.kt index a9958086648c..d454dc91af26 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/StorageDecisionGateTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/StorageDecisionGateTest.kt @@ -4,11 +4,13 @@ package com.ichi2.anki import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.exception.StorageNotConfiguredException +import com.ichi2.anki.exception.SystemStorageException import com.ichi2.anki.storage.StorageDecision import org.junit.Test import org.junit.runner.RunWith import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertSame /** * Proves the storage-decision gate in [CollectionManager.ensureOpenInner] is wired up. In production @@ -38,4 +40,22 @@ class StorageDecisionGateTest : RobolectricTest() { CollectionHelper.storageDecisionTestOverride = null } } + + /** + * A storage failure at startup ([SystemStorageException]: OS bug/SD card issue) must not + * masquerade as the expected 'storage not configured' state when opening the collection. + */ + @Test + fun `opening the collection reports a recorded startup storage failure`() { + val failure = SystemStorageException.build("simulated getExternalFilesDir failure") + CollectionHelper.storageDecisionTestOverride = StorageDecision.Undecided + CollectionHelper.systemStorageFailure = failure + try { + val thrown = assertFailsWith { CollectionManager.getColUnsafe() } + assertSame(failure, thrown) + } finally { + CollectionHelper.storageDecisionTestOverride = null + CollectionHelper.systemStorageFailure = null + } + } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/startup/SetupStorageTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/startup/SetupStorageTest.kt new file mode 100644 index 000000000000..27951824128a --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/startup/SetupStorageTest.kt @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.startup + +import androidx.core.content.edit +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.CollectionHelper +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.exception.StorageNotConfiguredException +import com.ichi2.anki.exception.SystemStorageException +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class SetupStorageTest : RobolectricTest() { + private val prefs + get() = targetContext.sharedPrefs() + + private val collectionPath: String? + get() = prefs.getString(CollectionHelper.PREF_COLLECTION_PATH, null) + + @After + fun resetCollectionHelperState() { + CollectionHelper.ankiDroidDirectoryOverride = null + CollectionHelper.systemStorageFailure = null + } + + /** + * Reading the collection path must not silently choose and persist a default: + * that decision belongs to startup ([ensureCollectionPathSet]). + */ + @Test + fun `reading the collection path throws when unset`() { + prefs.edit { remove(CollectionHelper.PREF_COLLECTION_PATH) } + + assertFailsWith { + CollectionHelper.getCurrentAnkiDroidDirectory(targetContext) + } + } + + /** + * A storage failure at startup must not masquerade as the expected 'no collection path set' + * state: the [SystemStorageException] edge case (OS bug/SD card issue) is reported as itself. + */ + @Test + fun `reading the collection path reports a recorded startup storage failure`() { + prefs.edit { remove(CollectionHelper.PREF_COLLECTION_PATH) } + val failure = SystemStorageException.build("simulated getExternalFilesDir failure") + CollectionHelper.systemStorageFailure = failure + + val thrown = + assertFailsWith { + CollectionHelper.getCurrentAnkiDroidDirectory(targetContext) + } + assertSame(failure, thrown) + } + + @Test + fun `reading the collection path returns the stored value`() { + prefs.edit { putString(CollectionHelper.PREF_COLLECTION_PATH, "/a/collection/path") } + + assertEquals(File("/a/collection/path"), CollectionHelper.getCurrentAnkiDroidDirectory(targetContext)) + } + + @Test + fun `the directory override takes precedence over the stored value`() { + prefs.edit { putString(CollectionHelper.PREF_COLLECTION_PATH, "/a/collection/path") } + CollectionHelper.ankiDroidDirectoryOverride = File("/an/override") + + assertEquals(File("/an/override"), CollectionHelper.getCurrentAnkiDroidDirectory(targetContext)) + } + + @Test + fun `ensureCollectionPathSet persists a default when unset`() { + prefs.edit { remove(CollectionHelper.PREF_COLLECTION_PATH) } + + ensureCollectionPathSet(targetContext) + + assertTrue(!collectionPath.isNullOrEmpty(), "a default collection path should be set") + } + + @Test + fun `ensureCollectionPathSet does not overwrite an existing value`() { + prefs.edit { putString(CollectionHelper.PREF_COLLECTION_PATH, "/a/custom/path") } + + ensureCollectionPathSet(targetContext) + + assertEquals("/a/custom/path", collectionPath) + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/testutils/TestApplicationSingletons.kt b/AnkiDroid/src/test/java/com/ichi2/anki/testutils/TestApplicationSingletons.kt index 27ccd8b6cafd..df3a3d8d997e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/testutils/TestApplicationSingletons.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/testutils/TestApplicationSingletons.kt @@ -6,14 +6,16 @@ import android.app.Application import com.ichi2.anki.common.android.Animations import com.ichi2.anki.navigation.initializeNavigator import com.ichi2.anki.settings.PrefsRepository +import com.ichi2.anki.startup.ensureCollectionPathSet import com.ichi2.testutils.EmptyApplication /** * Registers the global singletons that [com.ichi2.anki.AnkiDroidApp.onCreate] wires up, for tests * which use [EmptyApplication] (and therefore skip `AnkiDroidApp.onCreate`). */ -context(_: Application) +context(application: Application) fun registerTestApplicationSingletons() { initializeNavigator() Animations.setPreferencesProvider { context -> PrefsRepository(context) } + ensureCollectionPathSet(application) } From 33b2d1ab98d2e10f6139f2da83894605832519b8 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 13 Jun 2026 10:12:32 +0100 Subject: [PATCH 3/6] feat: storageDecision() is Undecided until a collection path is set The decision now mirrors whether `getCurrentAnkiDroidDirectory` is usable or throws. The path is set during startup (`ensureCollectionPathSet`), so `Undecided` is only observable if startup storage initialization failed. This is now classified as an `InitializationError` and displayed in the app startup flow (with a TODO for 19552 to improve this). * Part of 19552 * Part of 13574 Assisted-by: Claude Fable 5 --- .../main/java/com/ichi2/anki/AnkiDroidApp.kt | 7 ++ .../java/com/ichi2/anki/CollectionHelper.kt | 25 ++++++-- .../java/com/ichi2/anki/CollectionManager.kt | 15 ++++- .../main/java/com/ichi2/anki/DeckPicker.kt | 16 ++++- .../java/com/ichi2/anki/InitialActivity.kt | 10 ++- .../main/java/com/ichi2/anki/IntentHandler.kt | 3 +- .../anki/deckpicker/DeckPickerViewModel.kt | 5 +- .../com/ichi2/anki/startup/StartupGuards.kt | 3 +- .../java/com/ichi2/anki/DeckPickerTest.kt | 12 ++++ .../com/ichi2/anki/StorageDecisionGateTest.kt | 64 +++++++++++++------ 10 files changed, 124 insertions(+), 36 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index 1820e9c641a1..420d11725b5f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -533,6 +533,13 @@ open class AnkiDroidApp : */ fun sharedPrefs() = sharedPreferencesTestingOverride ?: instance.sharedPrefs() + /** + * [sharedPrefs], or `null` if unavailable: [instance] is not initialized when running + * under a test-only [Application] (e.g. `EmptyApplication`) or in pure JVM tests + */ + fun sharedPrefsOrNull(): SharedPreferences? = + sharedPreferencesTestingOverride ?: if (isInitialized) instance.sharedPrefs() else null + /** HACK: Whether an exception report has been thrown - TODO: Rewrite an ACRA Listener to do this */ @VisibleForTesting var sentExceptionReportHack = false diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt index 374e0641e8f8..286fb786c245 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt @@ -139,13 +139,28 @@ object CollectionHelper { var systemStorageFailure: SystemStorageException? = null /** - * Whether the user has chosen where the collection is stored. + * Whether the location of the collection has been decided: [StorageDecision.Decided] once + * [PREF_COLLECTION_PATH] is set (or a [ankiDroidDirectoryOverride] is active), which mirrors + * when [getCurrentAnkiDroidDirectory] can return a directory rather than throwing. * - * TODO: real implementation based on whether [PREF_COLLECTION_PATH] is set. - * TODO: What is a user revokes full storage? - * This currently returns [StorageDecision.Decided], so callers that gate on it are no-ops. + * The user is not asked yet: until the dedicated setup flow exists (#19552), the 'decision' + * is made on their behalf during startup by + * [ensureCollectionPathSet][com.ichi2.anki.startup.ensureCollectionPathSet], so this is + * [StorageDecision.Decided] by the time the collection is opened. + * + * @param preferences the preferences the collection path will be read from: pass the same + * (profile) context's preferences as the [getCurrentAnkiDroidDirectory] call being gated + * + * TODO: What if a user revokes full storage? */ - fun storageDecision(): StorageDecision = storageDecisionTestOverride ?: StorageDecision.Decided + fun storageDecision(preferences: SharedPreferences): StorageDecision { + storageDecisionTestOverride?.let { return it } + if (ankiDroidDirectoryOverride != null) return StorageDecision.Decided + // a recorded systemStorageFailure also leaves the path unset: startup has already + // surfaced it via AnkiDroidApp.fatalError, and reads rethrow it rather than + // reporting 'not configured' + return if (preferences.contains(PREF_COLLECTION_PATH)) StorageDecision.Decided else StorageDecision.Undecided + } /** * @return the absolute path to the AnkiDroid directory. diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CollectionManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CollectionManager.kt index 1f50b595c0c8..6b7f8051f56f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CollectionManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CollectionManager.kt @@ -20,6 +20,7 @@ import com.ichi2.anki.CollectionManager.withQueue import com.ichi2.anki.backend.createDatabaseUsingRustBackend import com.ichi2.anki.common.utils.android.Threads import com.ichi2.anki.common.utils.android.isRobolectric +import com.ichi2.anki.common.utils.isRunningAsUnitTest import com.ichi2.anki.exception.StorageNotConfiguredException import com.ichi2.anki.exception.SystemStorageException import com.ichi2.anki.libanki.Collection @@ -272,9 +273,21 @@ object CollectionManager { * [StorageDecision.Decided] (user has not selected a storage location). * @throws SystemStorageException if startup failed to choose a default collection path * ([CollectionHelper.systemStorageFailure]) + * @throws IllegalStateException if executed before `AnkiDroidApp.onCreate` */ private fun ensureOpenInner() { - if (CollectionHelper.storageDecision() != StorageDecision.Decided) { + // the decision is gated on the same preferences getCollectionDirectory() reads from + val decision = + CollectionHelper.storageDecisionTestOverride + ?: AnkiDroidApp.sharedPrefsOrNull()?.let { CollectionHelper.storageDecision(it) } + // test-only: in-memory test collections have no preferences and read no path + ?: if (isRunningAsUnitTest) { + StorageDecision.Decided + } else { + // the app instance is unset: treat this as a bug. + throw IllegalStateException("Collection accessed before AnkiDroidApp was initialized") + } + if (decision != StorageDecision.Decided) { // rethrow a recorded startup storage failure: an OS bug/SD card issue must not be // reported as the expected 'storage not configured' state throw CollectionHelper.systemStorageFailure ?: StorageNotConfiguredException() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index 078b9082cf7c..95fd7a439cfb 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -1002,6 +1002,9 @@ open class DeckPicker : override val requiredPermissions: PermissionSet get() = permissions + override val preferences: SharedPreferences + get() = context.sharedPrefs() + override fun initializeAnkiDroidFolder(): Boolean = CollectionHelper.isCurrentAnkiDroidDirAccessible(context) } @@ -1035,9 +1038,16 @@ open class DeckPicker : 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.") + // unreachable: Undecided requires PREF_COLLECTION_PATH to be unset, which only + // happens if ensureCollectionPathSet failed at startup; getStartupFailureType + // then returns InitializationError (fatalError) before checking the decision + // TODO: #19552 - replace with the storage setup flow + Timber.w("storage setup flow (#19552) not implemented; showing load-failure options") + CrashReportService.sendExceptionReport( + IllegalStateException("StorageUndecided reached without a startup failure"), + "DeckPicker::handleStartupFailure", + ) + showDatabaseErrorDialog(DatabaseErrorDialogType.DIALOG_LOAD_FAILED) } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt index ba7fa3d3a4aa..a606927d40ef 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt @@ -30,6 +30,7 @@ import androidx.core.content.edit import com.ichi2.anki.backend.DatabaseCorruption import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.permissions.hasAllPermissions +import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.common.utils.android.SdCard import com.ichi2.anki.compat.CompatHelper.Companion.sdkVersion import com.ichi2.anki.exception.StorageAccessException @@ -53,18 +54,21 @@ import timber.log.Timber object InitialActivity { @CheckResult fun getStartupFailureType(context: Context): StartupFailure? = - getStartupFailureType { CollectionHelper.isCurrentAnkiDroidDirAccessible(context) } + getStartupFailureType(context.sharedPrefs()) { CollectionHelper.isCurrentAnkiDroidDirAccessible(context) } /** Returns null on success */ @CheckResult - fun getStartupFailureType(initializeAnkiDroidDirectory: () -> Boolean): StartupFailure? { + fun getStartupFailureType( + preferences: SharedPreferences, + initializeAnkiDroidDirectory: () -> Boolean, + ): StartupFailure? { AnkiDroidApp.fatalError?.let { return StartupFailure.InitializationError(it) } // Opening the collection would throw a StorageNotConfiguredException, which is not worth // a crash report - if (CollectionHelper.storageDecision() != StorageDecision.Decided) { + if (CollectionHelper.storageDecision(preferences) != StorageDecision.Decided) { Timber.i("storage location is undecided") return StartupFailure.StorageUndecided } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt b/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt index d8dce115c1db..5175bea82504 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt @@ -18,6 +18,7 @@ import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.common.coroutines.applicationScope import com.ichi2.anki.common.permissions.hasLegacyStorageAccessPermission import com.ichi2.anki.common.permissions.isExternalStorageManagerCompat +import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.common.utils.android.showThemedToast import com.ichi2.anki.common.utils.trimToLength import com.ichi2.anki.dialogs.DialogHandler.Companion.storeMessage @@ -134,7 +135,7 @@ class IntentHandler : AbstractIntentHandler() { action: String?, block: () -> Unit, ) { - if (CollectionHelper.storageDecision() != StorageDecision.Decided) { + if (CollectionHelper.storageDecision(sharedPrefs()) != 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) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt index 182c2ac6b519..ba929477e5f8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt @@ -522,7 +522,7 @@ class DeckPickerViewModel : } Timber.d("handleStartup: Continuing after permission granted") - val failure = InitialActivity.getStartupFailureType(environment::initializeAnkiDroidFolder) + val failure = InitialActivity.getStartupFailureType(environment.preferences, environment::initializeAnkiDroidFolder) if (failure != null) { flowOfStartupResponse.value = StartupResponse.FatalError(failure) return @@ -540,6 +540,9 @@ class DeckPickerViewModel : val requiredPermissions: PermissionSet + /** The preferences of the (profile) context the collection path is read from */ + val preferences: SharedPreferences + fun initializeAnkiDroidFolder(): Boolean } 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 dbb6cc46713a..678484134595 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/startup/StartupGuards.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/startup/StartupGuards.kt @@ -6,6 +6,7 @@ import android.app.Activity import android.content.Intent import com.ichi2.anki.CollectionHelper import com.ichi2.anki.IntentHandler +import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.exception.SystemStorageException import com.ichi2.anki.storage.StorageDecision import timber.log.Timber @@ -45,7 +46,7 @@ fun Activity.ensureStorageIsReady(): Boolean { * @see redirectToMainEntryPoint */ private fun Activity.ensureStorageIsConfigured(): Boolean { - if (CollectionHelper.storageDecision() == StorageDecision.Decided) { + if (CollectionHelper.storageDecision(sharedPrefs()) == StorageDecision.Decided) { return true } Timber.w("finishing activity. Storage is not configured") diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt index 0f53d0da9462..8754ffadc61c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt @@ -254,6 +254,18 @@ class DeckPickerTest : RobolectricTest() { ) } + /** Until the storage setup flow exists (#19552), the user gets recovery options, not a crash */ + @Test + fun `storage undecided shows load-failure options rather than crashing`() { + // don't call .onCreate + val deckPicker = Robolectric.buildActivity(DeckPickerEx::class.java, Intent()).get() + deckPicker.handleStartupFailure(InitialActivity.StartupFailure.StorageUndecided) + assertThat( + deckPicker.databaseErrorDialog, + equalTo(DatabaseErrorDialogType.DIALOG_LOAD_FAILED), + ) + } + @Test fun databaseLockedWithPermissionIntegrationTest() { AnkiDroidApp.sentExceptionReportHack = false diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/StorageDecisionGateTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/StorageDecisionGateTest.kt index d454dc91af26..96075b26e37c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/StorageDecisionGateTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/StorageDecisionGateTest.kt @@ -2,43 +2,69 @@ package com.ichi2.anki +import androidx.core.content.edit import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.exception.StorageNotConfiguredException import com.ichi2.anki.exception.SystemStorageException import com.ichi2.anki.storage.StorageDecision +import org.junit.After import org.junit.Test import org.junit.runner.RunWith +import java.io.File import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertSame /** - * Proves the storage-decision gate in [CollectionManager.ensureOpenInner] is wired up. In production - * [CollectionHelper.storageDecision] always returns [com.ichi2.anki.storage.StorageDecision.Decided], so this never fires; - * here we force [com.ichi2.anki.storage.StorageDecision.Undecided] via the test override. + * Proves the storage-decision gate in [CollectionManager.ensureOpenInner] is wired up. + * [CollectionHelper.storageDecision] is [StorageDecision.Decided] once a collection path has + * been set ([CollectionHelper.PREF_COLLECTION_PATH]); tests force + * [StorageDecision.Undecided] via the test override. */ @RunWith(AndroidJUnit4::class) class StorageDecisionGateTest : RobolectricTest() { + private val prefs + get() = targetContext.sharedPrefs() + + @After + fun resetOverrides() { + CollectionHelper.ankiDroidDirectoryOverride = null + CollectionHelper.storageDecisionTestOverride = null + CollectionHelper.systemStorageFailure = null + } + + @Test + fun `storage decision is decided when the collection path is set`() { + prefs.edit { putString(CollectionHelper.PREF_COLLECTION_PATH, "/a/collection/path") } + assertEquals(StorageDecision.Decided, CollectionHelper.storageDecision(prefs)) + } + + @Test + fun `storage decision is undecided when the collection path is unset`() { + prefs.edit { remove(CollectionHelper.PREF_COLLECTION_PATH) } + assertEquals(StorageDecision.Undecided, CollectionHelper.storageDecision(prefs)) + } + + @Test + fun `storage decision is decided when a directory override is active`() { + prefs.edit { remove(CollectionHelper.PREF_COLLECTION_PATH) } + CollectionHelper.ankiDroidDirectoryOverride = File("/an/override") + assertEquals(StorageDecision.Decided, CollectionHelper.storageDecision(prefs)) + } + @Test fun `opening the collection throws when storage is undecided`() { CollectionHelper.storageDecisionTestOverride = StorageDecision.Undecided - try { - assertFailsWith { CollectionManager.getColUnsafe() } - } finally { - CollectionHelper.storageDecisionTestOverride = null - } + assertFailsWith { CollectionManager.getColUnsafe() } } /** 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 - } + val failure = InitialActivity.getStartupFailureType(prefs) { true } + assertEquals(InitialActivity.StartupFailure.StorageUndecided, failure) } /** @@ -50,12 +76,8 @@ class StorageDecisionGateTest : RobolectricTest() { val failure = SystemStorageException.build("simulated getExternalFilesDir failure") CollectionHelper.storageDecisionTestOverride = StorageDecision.Undecided CollectionHelper.systemStorageFailure = failure - try { - val thrown = assertFailsWith { CollectionManager.getColUnsafe() } - assertSame(failure, thrown) - } finally { - CollectionHelper.storageDecisionTestOverride = null - CollectionHelper.systemStorageFailure = null - } + + val thrown = assertFailsWith { CollectionManager.getColUnsafe() } + assertSame(failure, thrown) } } From 87f15c279cfaac19b75332d63e7b2cee4febeb7d Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 13 Jun 2026 10:57:32 +0100 Subject: [PATCH 4/6] refactor: move CollectionHelper to :anki-common * Part of 20737 Assisted-by: Claude Opus 4.8 --- .../kotlin}/com/ichi2/anki/CollectionHelper.kt | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) rename {AnkiDroid/src/main/java => anki-common/src/main/kotlin}/com/ichi2/anki/CollectionHelper.kt (93%) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionHelper.kt similarity index 93% rename from AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt rename to anki-common/src/main/kotlin/com/ichi2/anki/CollectionHelper.kt index 286fb786c245..ad17175258c2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt +++ b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionHelper.kt @@ -6,8 +6,6 @@ package com.ichi2.anki import android.content.Context import android.content.SharedPreferences import androidx.annotation.VisibleForTesting -import com.ichi2.anki.CollectionHelper.PREF_COLLECTION_PATH -import com.ichi2.anki.CollectionHelper.getCurrentAnkiDroidDirectory import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.common.utils.android.isInstrumentationTest import com.ichi2.anki.exception.StorageAccessException @@ -25,7 +23,7 @@ object CollectionHelper { * The preference key for the path to the current AnkiDroid directory * * This directory contains all AnkiDroid data and media for a given collection - * Except the Android preferences, cached files and [MetaDB] + * Except the Android preferences, cached files and `MetaDB` * * This can be changed by the Preferences screen * to allow a user to access a second collection via the same AnkiDroid app instance. @@ -101,7 +99,7 @@ object CollectionHelper { /** * @return Returns an array of [File]s reflecting the directories that AnkiDroid can access without storage permissions - * @see android.content.Context.getExternalFilesDirs + * @see Context.getExternalFilesDirs */ fun getAppSpecificExternalDirectories(context: Context): List = context.getExternalFilesDirs(null)?.filterNotNull() ?: listOf() @@ -144,9 +142,8 @@ object CollectionHelper { * when [getCurrentAnkiDroidDirectory] can return a directory rather than throwing. * * The user is not asked yet: until the dedicated setup flow exists (#19552), the 'decision' - * is made on their behalf during startup by - * [ensureCollectionPathSet][com.ichi2.anki.startup.ensureCollectionPathSet], so this is - * [StorageDecision.Decided] by the time the collection is opened. + * is made on their behalf during startup by `ensureCollectionPathSet` (app module), so this + * is [StorageDecision.Decided] by the time the collection is opened. * * @param preferences the preferences the collection path will be read from: pass the same * (profile) context's preferences as the [getCurrentAnkiDroidDirectory] call being gated @@ -167,7 +164,7 @@ object CollectionHelper { * * @throws StorageNotConfiguredException if no collection path has been set * ([PREF_COLLECTION_PATH] is unset): a default is chosen during startup by - * [ensureCollectionPathSet][com.ichi2.anki.startup.ensureCollectionPathSet] + * `ensureCollectionPathSet` (app module) * @throws SystemStorageException if startup failed to choose a default collection path * ([systemStorageFailure]) */ @@ -183,7 +180,7 @@ object CollectionHelper { * * @throws StorageNotConfiguredException if no collection path has been set * ([PREF_COLLECTION_PATH] is unset): a default is chosen during startup by - * [ensureCollectionPathSet][com.ichi2.anki.startup.ensureCollectionPathSet] + * `ensureCollectionPathSet` (app module) * @throws SystemStorageException if startup failed to choose a default collection path * ([systemStorageFailure]) */ From dd0d3a788d417127ed0b8504ce12214c48f4aae3 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 13 Jun 2026 11:10:49 +0100 Subject: [PATCH 5/6] refactor: migrate to common.storage package Assisted-by: Claude (Opus 4.8 & Fable 5) --- .../anki/dialogs/CollectionPathMismatchRecoveryTest.kt | 2 +- .../com/ichi2/anki/dialogs/DatabaseCorruptDialogTest.kt | 2 +- .../java/com/ichi2/anki/tests/ContentProviderTest.kt | 4 ++-- AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt | 1 + .../java/com/ichi2/anki/CollectionIntegrityStorageCheck.kt | 1 + .../src/main/java/com/ichi2/anki/CollectionManager.kt | 3 ++- AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt | 1 + AnkiDroid/src/main/java/com/ichi2/anki/Import.kt | 1 + AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt | 7 ++++--- AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt | 3 ++- .../src/main/java/com/ichi2/anki/ViewerResourceHandler.kt | 1 + .../main/java/com/ichi2/anki/cardviewer/CardMediaPlayer.kt | 2 +- .../main/java/com/ichi2/anki/deckpicker/BackgroundImage.kt | 2 +- .../java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt | 2 +- .../anki/dialogs/DeckPickerBackupNoSpaceLeftDialog.kt | 2 +- .../java/com/ichi2/anki/multiprofile/ProfileManager.kt | 2 +- .../com/ichi2/anki/preferences/AdvancedSettingsFragment.kt | 2 +- .../com/ichi2/anki/preferences/DeveloperOptionsFragment.kt | 2 +- .../com/ichi2/anki/servicelayer/ScopedStorageService.kt | 2 +- .../src/main/java/com/ichi2/anki/startup/SetupStorage.kt | 4 ++-- .../src/main/java/com/ichi2/anki/startup/StartupGuards.kt | 4 ++-- .../AndroidPermanentlyRevokedPermissionsDialog.kt | 2 +- .../src/main/java/com/ichi2/anki/utils/ext/Context.kt | 2 +- .../com/ichi2/anki/ActivityStartupUndecidedStorageTest.kt | 3 ++- .../com/ichi2/anki/DeckPickerNoExternalFilesDirTest.kt | 1 + .../src/test/java/com/ichi2/anki/DeckPickerOnDiskTest.kt | 1 + .../ichi2/anki/ExternalEntryPointsUndecidedStorageTest.kt | 3 ++- .../test/java/com/ichi2/anki/StorageDecisionGateTest.kt | 3 ++- .../java/com/ichi2/anki/jsaddons/TgzPackageExtractTest.kt | 2 +- .../java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt | 2 +- .../ichi2/anki/preferences/PreferencesScreenshotTest.kt | 2 +- .../test/java/com/ichi2/anki/startup/SetupStorageTest.kt | 2 +- .../test/java/com/ichi2/anki/startup/StartupGuardsTest.kt | 4 ++-- .../java/com/ichi2/testutils/BackupManagerTestUtilities.kt | 2 +- AnkiDroid/src/test/java/com/ichi2/testutils/DbUtils.kt | 2 +- .../com/ichi2/anki/{ => common}/storage/AnkiDroidFolder.kt | 2 +- .../ichi2/anki/{ => common/storage}/CollectionHelper.kt | 3 +-- .../com/ichi2/anki/{ => common}/storage/StorageDecision.kt | 4 ++-- .../ichi2/anki/exception/StorageNotConfiguredException.kt | 4 +++- 39 files changed, 54 insertions(+), 40 deletions(-) rename anki-common/src/main/kotlin/com/ichi2/anki/{ => common}/storage/AnkiDroidFolder.kt (96%) rename anki-common/src/main/kotlin/com/ichi2/anki/{ => common/storage}/CollectionHelper.kt (99%) rename anki-common/src/main/kotlin/com/ichi2/anki/{ => common}/storage/StorageDecision.kt (87%) diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/CollectionPathMismatchRecoveryTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/CollectionPathMismatchRecoveryTest.kt index f968bc8a9ed6..9374bffe74f9 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/CollectionPathMismatchRecoveryTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/CollectionPathMismatchRecoveryTest.kt @@ -12,11 +12,11 @@ import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.ichi2.anki.CollectionHelper import com.ichi2.anki.CollectionManager import com.ichi2.anki.DeckPicker import com.ichi2.anki.R import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory import com.ichi2.anki.tests.InstrumentedTest import com.ichi2.anki.testutil.disableIntroductionSlide diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/DatabaseCorruptDialogTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/DatabaseCorruptDialogTest.kt index ab46f3b6e6b7..975c6a726e3b 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/DatabaseCorruptDialogTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/DatabaseCorruptDialogTest.kt @@ -24,11 +24,11 @@ import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.ichi2.anki.CollectionHelper.getCurrentAnkiDroidDirectory import com.ichi2.anki.CollectionManager import com.ichi2.anki.DeckPicker import com.ichi2.anki.R import com.ichi2.anki.backend.DatabaseCorruption +import com.ichi2.anki.common.storage.CollectionHelper.getCurrentAnkiDroidDirectory import com.ichi2.anki.tests.InstrumentedTest import com.ichi2.anki.tests.Shared import com.ichi2.anki.testutil.discardPreliminaryViews diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt index 28776f94cbda..867695f1ed04 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt @@ -26,9 +26,10 @@ import android.net.Uri import anki.cards.FsrsMemoryState import anki.collection.OpChanges import anki.notetypes.StockNotetype -import com.ichi2.anki.CollectionHelper import com.ichi2.anki.CollectionManager import com.ichi2.anki.FlashCardsContract +import com.ichi2.anki.common.storage.CollectionHelper +import com.ichi2.anki.common.storage.StorageDecision import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.common.utils.annotation.KotlinCleanup import com.ichi2.anki.common.utils.emptyStringArray @@ -49,7 +50,6 @@ import com.ichi2.anki.libanki.getStockNotetype import com.ichi2.anki.libanki.sched.Scheduler import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.provider.pureAnswer -import com.ichi2.anki.storage.StorageDecision import com.ichi2.anki.testutil.DatabaseUtils.cursorFillWindow import com.ichi2.anki.testutil.GrantStoragePermission.storagePermission import com.ichi2.anki.testutil.addNote diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index 420d11725b5f..24e7ba8a838c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -36,6 +36,7 @@ import com.ichi2.anki.common.coroutines.applicationScope import com.ichi2.anki.common.crashreporting.CrashReportService.sendExceptionReport import com.ichi2.anki.common.permissions.hasLegacyStorageAccessPermission import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.common.utils.android.SdCard import com.ichi2.anki.common.utils.android.showThemedToast import com.ichi2.anki.common.utils.annotation.KotlinCleanup diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CollectionIntegrityStorageCheck.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CollectionIntegrityStorageCheck.kt index b69348b0c972..bad7b0a8ff20 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CollectionIntegrityStorageCheck.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CollectionIntegrityStorageCheck.kt @@ -15,6 +15,7 @@ package com.ichi2.anki import android.content.Context import android.text.format.Formatter +import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.utils.FileUtil import timber.log.Timber diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CollectionManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CollectionManager.kt index 6b7f8051f56f..023ab8a38745 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CollectionManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CollectionManager.kt @@ -18,6 +18,8 @@ import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.CollectionManager.withOpenColOrNull import com.ichi2.anki.CollectionManager.withQueue import com.ichi2.anki.backend.createDatabaseUsingRustBackend +import com.ichi2.anki.common.storage.CollectionHelper +import com.ichi2.anki.common.storage.StorageDecision import com.ichi2.anki.common.utils.android.Threads import com.ichi2.anki.common.utils.android.isRobolectric import com.ichi2.anki.common.utils.isRunningAsUnitTest @@ -28,7 +30,6 @@ import com.ichi2.anki.libanki.CollectionFiles import com.ichi2.anki.libanki.LibAnki import com.ichi2.anki.libanki.Storage.collection import com.ichi2.anki.libanki.importCollectionPackage -import com.ichi2.anki.storage.StorageDecision import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index 95fd7a439cfb..850aa30f3305 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -99,6 +99,7 @@ import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.destinations.PreferencesDestination import com.ichi2.anki.common.destinations.navigate import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.common.utils.android.isRobolectric import com.ichi2.anki.common.utils.android.showThemedToast diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Import.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Import.kt index bbadb529802c..57db4db39996 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/Import.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/Import.kt @@ -11,6 +11,7 @@ import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.common.destinations.CsvImporterDestination import com.ichi2.anki.common.destinations.addNextIntent import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.dialogs.AsyncDialogFragment import com.ichi2.anki.dialogs.ImportDialog import com.ichi2.anki.dialogs.ImportFileSelectionFragment diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt index a606927d40ef..0d6d7e0637d2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt @@ -31,14 +31,15 @@ import com.ichi2.anki.backend.DatabaseCorruption import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.permissions.hasAllPermissions import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.storage.AnkiDroidFolder +import com.ichi2.anki.common.storage.CollectionHelper +import com.ichi2.anki.common.storage.StorageDecision import com.ichi2.anki.common.utils.android.SdCard import com.ichi2.anki.compat.CompatHelper.Companion.sdkVersion import com.ichi2.anki.exception.StorageAccessException 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 @@ -231,7 +232,7 @@ enum class PermissionSet( /** * Returns the [PermissionSet] required to access the folder where AnkiDroid data is saved. - * [com.ichi2.anki.storage.AnkiDroidFolder.PUBLIC] is preferred, as it reduces risk of data loss. + * [AnkiDroidFolder.PUBLIC] is preferred, as it reduces risk of data loss. * When impossible, we use the app-private directory. * See https://github.com/ankidroid/Anki-Android/issues/5304 for more context. */ diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt b/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt index 5175bea82504..877fb7c433f5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt @@ -19,6 +19,8 @@ import com.ichi2.anki.common.coroutines.applicationScope import com.ichi2.anki.common.permissions.hasLegacyStorageAccessPermission import com.ichi2.anki.common.permissions.isExternalStorageManagerCompat import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.storage.CollectionHelper +import com.ichi2.anki.common.storage.StorageDecision import com.ichi2.anki.common.utils.android.showThemedToast import com.ichi2.anki.common.utils.trimToLength import com.ichi2.anki.dialogs.DialogHandler.Companion.storeMessage @@ -29,7 +31,6 @@ 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 diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ViewerResourceHandler.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ViewerResourceHandler.kt index 0ab4c3cab032..79156f7535ab 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ViewerResourceHandler.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ViewerResourceHandler.kt @@ -20,6 +20,7 @@ import android.content.Context import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import com.ichi2.anki.common.annotations.NeedsTest +import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.utils.AssetHelper.guessMimeType import timber.log.Timber import java.io.ByteArrayInputStream diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardMediaPlayer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardMediaPlayer.kt index 438dfeecfc31..e67132ee7392 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardMediaPlayer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardMediaPlayer.kt @@ -23,13 +23,13 @@ import androidx.annotation.VisibleForTesting import com.ichi2.anki.AbstractFlashcardViewer.Companion.getMediaBaseUrl import com.ichi2.anki.AndroidTtsError import com.ichi2.anki.AndroidTtsPlayer -import com.ichi2.anki.CollectionHelper.getMediaDirectory import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.cardviewer.MediaErrorBehavior.CONTINUE_MEDIA import com.ichi2.anki.cardviewer.MediaErrorBehavior.RETRY_MEDIA import com.ichi2.anki.cardviewer.MediaErrorBehavior.STOP_MEDIA import com.ichi2.anki.common.android.appContext import com.ichi2.anki.common.annotations.NeedsTest +import com.ichi2.anki.common.storage.CollectionHelper.getMediaDirectory import com.ichi2.anki.libanki.AvTag import com.ichi2.anki.libanki.Card import com.ichi2.anki.libanki.SoundOrVideoTag diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/BackgroundImage.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/BackgroundImage.kt index 00d2d56fd57e..b41626f6d73f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/BackgroundImage.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/BackgroundImage.kt @@ -21,9 +21,9 @@ import android.graphics.drawable.Drawable import android.net.Uri import android.provider.MediaStore import androidx.annotation.CheckResult -import com.ichi2.anki.CollectionHelper import com.ichi2.anki.DeckPicker import com.ichi2.anki.R +import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.preferences.AppearanceSettingsFragment import com.ichi2.anki.settings.Prefs import com.ichi2.anki.snackbar.showSnackbar diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt index 52a4966790d2..c629c744ad3c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt @@ -17,7 +17,6 @@ import androidx.core.os.BundleCompat import androidx.lifecycle.lifecycleScope import com.ichi2.anki.AnkiActivity import com.ichi2.anki.BackupManager -import com.ichi2.anki.CollectionHelper import com.ichi2.anki.CollectionManager import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.ConflictResolution @@ -30,6 +29,7 @@ import com.ichi2.anki.R import com.ichi2.anki.ankiActivity import com.ichi2.anki.backend.DatabaseCorruption import com.ichi2.anki.backend.getDatabaseVersion +import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType.DIALOG_CONFIRM_DATABASE_CHECK import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType.DIALOG_CONFIRM_RESTORE_BACKUP diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerBackupNoSpaceLeftDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerBackupNoSpaceLeftDialog.kt index 504929156a3b..cc8072107234 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerBackupNoSpaceLeftDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerBackupNoSpaceLeftDialog.kt @@ -19,10 +19,10 @@ package com.ichi2.anki.dialogs import android.os.Bundle import androidx.appcompat.app.AlertDialog import com.ichi2.anki.BackupManager -import com.ichi2.anki.CollectionHelper import com.ichi2.anki.DeckPicker import com.ichi2.anki.R import com.ichi2.anki.analytics.AnalyticsDialogFragment +import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.utils.create import com.ichi2.utils.message import com.ichi2.utils.positiveButton 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 742e51484b55..fb24cdee7ddd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt @@ -11,9 +11,9 @@ import android.webkit.WebView import androidx.annotation.VisibleForTesting import androidx.core.content.ContextCompat import androidx.core.content.edit -import com.ichi2.anki.CollectionHelper.PREF_COLLECTION_PATH import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.storage.CollectionHelper.PREF_COLLECTION_PATH import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.common.time.getTimestamp import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AdvancedSettingsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AdvancedSettingsFragment.kt index db8caa4b3be6..144cffd5c5fc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AdvancedSettingsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AdvancedSettingsFragment.kt @@ -22,11 +22,11 @@ import androidx.appcompat.app.AlertDialog import androidx.preference.EditTextPreference import androidx.preference.Preference import androidx.preference.SwitchPreferenceCompat -import com.ichi2.anki.CollectionHelper import com.ichi2.anki.CollectionManager import com.ichi2.anki.DeckPicker import com.ichi2.anki.MetaDB import com.ichi2.anki.R +import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.compat.CompatHelper import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.launchCatchingTask diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/DeveloperOptionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/DeveloperOptionsFragment.kt index 656a5319b639..8024d3f55afa 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/DeveloperOptionsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/DeveloperOptionsFragment.kt @@ -10,12 +10,12 @@ import androidx.preference.Preference import androidx.preference.SwitchPreferenceCompat import com.ichi2.anki.AnkiDroidApp import com.ichi2.anki.BuildConfig -import com.ichi2.anki.CollectionHelper import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.R import com.ichi2.anki.analytics.UsageAnalytics import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.common.utils.android.showThemedToast import com.ichi2.anki.dialogs.TtsVoicesDialogFragment import com.ichi2.anki.launchCatchingTask diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ScopedStorageService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ScopedStorageService.kt index b94e1375e4ba..d3b58384e8d8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ScopedStorageService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ScopedStorageService.kt @@ -20,8 +20,8 @@ package com.ichi2.anki.servicelayer import android.content.Context import android.os.Build import android.os.Environment -import com.ichi2.anki.CollectionHelper import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.ui.windows.managespace.isInsideDirectoriesRemovedWithTheApp import com.ichi2.utils.Permissions import timber.log.Timber diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/startup/SetupStorage.kt b/AnkiDroid/src/main/java/com/ichi2/anki/startup/SetupStorage.kt index 0c47e7971d61..a3b7e0493516 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/startup/SetupStorage.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/startup/SetupStorage.kt @@ -6,12 +6,12 @@ import android.content.Context import android.os.Environment import androidx.annotation.CheckResult import androidx.core.content.edit -import com.ichi2.anki.CollectionHelper import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.storage.AnkiDroidFolder +import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.exception.StorageNotConfiguredException import com.ichi2.anki.exception.SystemStorageException import com.ichi2.anki.selectAnkiDroidFolder -import com.ichi2.anki.storage.AnkiDroidFolder import timber.log.Timber import java.io.File 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 678484134595..cf8b2c0f5b4c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/startup/StartupGuards.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/startup/StartupGuards.kt @@ -4,11 +4,11 @@ package com.ichi2.anki.startup import android.app.Activity import android.content.Intent -import com.ichi2.anki.CollectionHelper import com.ichi2.anki.IntentHandler import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.storage.CollectionHelper +import com.ichi2.anki.common.storage.StorageDecision import com.ichi2.anki.exception.SystemStorageException -import com.ichi2.anki.storage.StorageDecision import timber.log.Timber /** diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/AndroidPermanentlyRevokedPermissionsDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/AndroidPermanentlyRevokedPermissionsDialog.kt index c347748846e9..2b22e02c05de 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/AndroidPermanentlyRevokedPermissionsDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/AndroidPermanentlyRevokedPermissionsDialog.kt @@ -21,8 +21,8 @@ import android.content.Context import android.content.DialogInterface import androidx.appcompat.app.AlertDialog import com.ichi2.anki.AnkiActivity -import com.ichi2.anki.CollectionHelper import com.ichi2.anki.R +import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType import com.ichi2.anki.dialogs.DatabaseErrorDialog.UninstallListItem.Companion.createNoStorageList import com.ichi2.utils.cancelable diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Context.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Context.kt index 9d66528785cb..1e60ec181210 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Context.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Context.kt @@ -22,7 +22,7 @@ import android.graphics.BitmapFactory import android.util.AttributeSet import androidx.annotation.AttrRes import androidx.annotation.StyleRes -import com.ichi2.anki.CollectionHelper +import com.ichi2.anki.common.storage.CollectionHelper import java.io.File import kotlin.contracts.ExperimentalContracts import kotlin.contracts.InvocationKind diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupUndecidedStorageTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupUndecidedStorageTest.kt index 4e2a5f01cad3..aa7948126d30 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupUndecidedStorageTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupUndecidedStorageTest.kt @@ -2,7 +2,8 @@ package com.ichi2.anki -import com.ichi2.anki.storage.StorageDecision +import com.ichi2.anki.common.storage.CollectionHelper +import com.ichi2.anki.common.storage.StorageDecision import com.ichi2.testutils.ActivityList import com.ichi2.testutils.ActivityList.ActivityLaunchParam import org.junit.After diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerNoExternalFilesDirTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerNoExternalFilesDirTest.kt index f47b71ba7175..279705710a36 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerNoExternalFilesDirTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerNoExternalFilesDirTest.kt @@ -21,6 +21,7 @@ import androidx.appcompat.app.AlertDialog import androidx.core.content.edit import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.common.utils.android.SdCard import com.ichi2.anki.dialogs.utils.ankiListView import com.ichi2.anki.dialogs.utils.message diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerOnDiskTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerOnDiskTest.kt index e45c7ccf6a2b..5533895b7257 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerOnDiskTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerOnDiskTest.kt @@ -22,6 +22,7 @@ import com.ichi2.anki.DeckPickerTest.CollectionType import com.ichi2.anki.DeckPickerTest.DeckPickerEx import com.ichi2.anki.backend.getDatabaseVersion import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.common.utils.annotation.KotlinCleanup import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType import com.ichi2.anki.exception.UnknownDatabaseVersionException diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ExternalEntryPointsUndecidedStorageTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ExternalEntryPointsUndecidedStorageTest.kt index ddfe1cc48ea5..d54445a436e1 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ExternalEntryPointsUndecidedStorageTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ExternalEntryPointsUndecidedStorageTest.kt @@ -10,7 +10,8 @@ import android.content.ContentProvider import android.content.Intent import android.os.Build import androidx.core.net.toUri -import com.ichi2.anki.storage.StorageDecision +import com.ichi2.anki.common.storage.CollectionHelper +import com.ichi2.anki.common.storage.StorageDecision import com.ichi2.testutils.ExternalEntryPoints.EntryPoint import com.ichi2.testutils.grantPermissions import org.junit.After diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/StorageDecisionGateTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/StorageDecisionGateTest.kt index 96075b26e37c..de32d3654bb3 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/StorageDecisionGateTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/StorageDecisionGateTest.kt @@ -5,9 +5,10 @@ package com.ichi2.anki import androidx.core.content.edit import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.storage.CollectionHelper +import com.ichi2.anki.common.storage.StorageDecision import com.ichi2.anki.exception.StorageNotConfiguredException import com.ichi2.anki.exception.SystemStorageException -import com.ichi2.anki.storage.StorageDecision import org.junit.After import org.junit.Test import org.junit.runner.RunWith diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/jsaddons/TgzPackageExtractTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/jsaddons/TgzPackageExtractTest.kt index 78261e7d8d62..28766ca61790 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/jsaddons/TgzPackageExtractTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/jsaddons/TgzPackageExtractTest.kt @@ -17,8 +17,8 @@ package com.ichi2.anki.jsaddons import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.ichi2.anki.CollectionHelper import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.testutils.ShadowStatFs import com.ichi2.utils.FileOperation.Companion.getFileResource import junit.framework.TestCase.assertTrue 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 d2ce7fb7177b..82aa2d3b27ca 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt @@ -25,8 +25,8 @@ import android.webkit.WebView import androidx.core.content.edit import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.ichi2.anki.CollectionHelper.PREF_COLLECTION_PATH import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.storage.CollectionHelper.PREF_COLLECTION_PATH import com.ichi2.anki.multiprofile.ProfileManager.Companion.KEY_LAST_ACTIVE_PROFILE_ID import com.ichi2.anki.multiprofile.ProfileManager.Companion.PROFILE_REGISTRY_FILENAME import io.mockk.every diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/preferences/PreferencesScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/preferences/PreferencesScreenshotTest.kt index 99157a0de425..2c203b2193f6 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/preferences/PreferencesScreenshotTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/preferences/PreferencesScreenshotTest.kt @@ -4,9 +4,9 @@ package com.ichi2.anki.preferences import androidx.preference.Preference import androidx.test.core.app.ActivityScenario -import com.ichi2.anki.CollectionHelper import com.ichi2.anki.R import com.ichi2.anki.ScreenshotTest +import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.settings.Prefs import org.junit.Test diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/startup/SetupStorageTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/startup/SetupStorageTest.kt index 27951824128a..9603c7599ffd 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/startup/SetupStorageTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/startup/SetupStorageTest.kt @@ -4,9 +4,9 @@ package com.ichi2.anki.startup import androidx.core.content.edit import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.ichi2.anki.CollectionHelper import com.ichi2.anki.RobolectricTest import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.exception.StorageNotConfiguredException import com.ichi2.anki.exception.SystemStorageException import org.junit.After diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/startup/StartupGuardsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/startup/StartupGuardsTest.kt index 3142e106c265..5ce0694e3232 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/startup/StartupGuardsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/startup/StartupGuardsTest.kt @@ -6,11 +6,11 @@ import android.app.Activity import android.content.Intent import androidx.core.content.edit import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.ichi2.anki.CollectionHelper import com.ichi2.anki.IntentHandler import com.ichi2.anki.RobolectricTest import com.ichi2.anki.common.preferences.sharedPrefs -import com.ichi2.anki.storage.StorageDecision +import com.ichi2.anki.common.storage.CollectionHelper +import com.ichi2.anki.common.storage.StorageDecision import org.junit.After import org.junit.Test import org.junit.runner.RunWith diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/BackupManagerTestUtilities.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/BackupManagerTestUtilities.kt index 48cd7a1a6160..9c4c160fa6cf 100644 --- a/AnkiDroid/src/test/java/com/ichi2/testutils/BackupManagerTestUtilities.kt +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/BackupManagerTestUtilities.kt @@ -17,7 +17,7 @@ package com.ichi2.testutils import android.content.Context import com.ichi2.anki.BackupManager.Companion.enoughDiscSpace -import com.ichi2.anki.CollectionHelper +import com.ichi2.anki.common.storage.CollectionHelper import org.junit.Assert.assertTrue import java.lang.IllegalStateException diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/DbUtils.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/DbUtils.kt index ee911e36146b..eb10e96a0d0e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/testutils/DbUtils.kt +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/DbUtils.kt @@ -16,8 +16,8 @@ package com.ichi2.testutils import android.content.Context -import com.ichi2.anki.CollectionHelper import com.ichi2.anki.backend.createDatabaseUsingAndroidFramework +import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.libanki.DB object DbUtils { diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/storage/AnkiDroidFolder.kt b/anki-common/src/main/kotlin/com/ichi2/anki/common/storage/AnkiDroidFolder.kt similarity index 96% rename from anki-common/src/main/kotlin/com/ichi2/anki/storage/AnkiDroidFolder.kt rename to anki-common/src/main/kotlin/com/ichi2/anki/common/storage/AnkiDroidFolder.kt index abb209b44c16..a52681975c9f 100644 --- a/anki-common/src/main/kotlin/com/ichi2/anki/storage/AnkiDroidFolder.kt +++ b/anki-common/src/main/kotlin/com/ichi2/anki/common/storage/AnkiDroidFolder.kt @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later -package com.ichi2.anki.storage +package com.ichi2.anki.common.storage /** * Where AnkiDroid stores its collection. diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionHelper.kt b/anki-common/src/main/kotlin/com/ichi2/anki/common/storage/CollectionHelper.kt similarity index 99% rename from anki-common/src/main/kotlin/com/ichi2/anki/CollectionHelper.kt rename to anki-common/src/main/kotlin/com/ichi2/anki/common/storage/CollectionHelper.kt index ad17175258c2..244774323dd8 100644 --- a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionHelper.kt +++ b/anki-common/src/main/kotlin/com/ichi2/anki/common/storage/CollectionHelper.kt @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright (c) 2015 Timothy Rae -package com.ichi2.anki +package com.ichi2.anki.common.storage import android.content.Context import android.content.SharedPreferences @@ -13,7 +13,6 @@ import com.ichi2.anki.exception.StorageNotConfiguredException import com.ichi2.anki.exception.SystemStorageException import com.ichi2.anki.libanki.Collection import com.ichi2.anki.libanki.CollectionFiles -import com.ichi2.anki.storage.StorageDecision import timber.log.Timber import java.io.File import java.io.IOException diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/storage/StorageDecision.kt b/anki-common/src/main/kotlin/com/ichi2/anki/common/storage/StorageDecision.kt similarity index 87% rename from anki-common/src/main/kotlin/com/ichi2/anki/storage/StorageDecision.kt rename to anki-common/src/main/kotlin/com/ichi2/anki/common/storage/StorageDecision.kt index c6f13062b480..2c0e913bcf35 100644 --- a/anki-common/src/main/kotlin/com/ichi2/anki/storage/StorageDecision.kt +++ b/anki-common/src/main/kotlin/com/ichi2/anki/common/storage/StorageDecision.kt @@ -1,11 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later -package com.ichi2.anki.storage +package com.ichi2.anki.common.storage /** * Whether the user has chosen where AnkiDroid stores its collection. * - * On a fresh install the location must be chosen explicitly (see [com.ichi2.anki.AnkiDroidFolder]); there is no + * On a fresh install the location must be chosen explicitly (see [AnkiDroidFolder]); there is no * silent default, and the collection must not be opened until a choice has been made. */ sealed interface StorageDecision { diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/exception/StorageNotConfiguredException.kt b/anki-common/src/main/kotlin/com/ichi2/anki/exception/StorageNotConfiguredException.kt index 3ab64cc5a3bf..932a8e1e5d5a 100644 --- a/anki-common/src/main/kotlin/com/ichi2/anki/exception/StorageNotConfiguredException.kt +++ b/anki-common/src/main/kotlin/com/ichi2/anki/exception/StorageNotConfiguredException.kt @@ -2,9 +2,11 @@ package com.ichi2.anki.exception +import com.ichi2.anki.common.storage.StorageDecision + /** * Thrown when the collection is accessed before the user has chosen where it should be stored - * (the [storage decision][com.ichi2.anki.storage.StorageDecision] is `Undecided`). + * (the [storage decision][StorageDecision] is `Undecided`). * * Use `StorageAccessException` if a known path is unusable. */ From f86073c5730e457e471496fdd834dbcf0a2fd40e Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:02:19 +0100 Subject: [PATCH 6/6] refactor: getAppSpecificExternalAnkiDroidDirectory return value Assisted-by: Claude Fable 5 --- AnkiDroid/src/main/java/com/ichi2/anki/startup/SetupStorage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/startup/SetupStorage.kt b/AnkiDroid/src/main/java/com/ichi2/anki/startup/SetupStorage.kt index a3b7e0493516..cf875dfd0bf7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/startup/SetupStorage.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/startup/SetupStorage.kt @@ -156,7 +156,7 @@ private fun legacyAnkiDroidDirectory(directoryName: String = "AnkiDroid"): File * * @throws SystemStorageException if `getExternalFilesDir` returns null */ -private fun getAppSpecificExternalAnkiDroidDirectory(context: Context): String? { +private fun getAppSpecificExternalAnkiDroidDirectory(context: Context): String { val externalFilesDir = context.getExternalFilesDir(null) // This value *may* be null but we strictly require it. This has caused NullPointerException