Skip to content

Commit 1ecf63d

Browse files
committed
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
1 parent de09411 commit 1ecf63d

8 files changed

Lines changed: 179 additions & 163 deletions

File tree

AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/CollectionPathMismatchRecoveryTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
1313
import androidx.test.espresso.matcher.ViewMatchers.withText
1414
import androidx.test.ext.junit.runners.AndroidJUnit4
1515
import com.ichi2.anki.CollectionHelper
16-
import com.ichi2.anki.CollectionHelper.getDefaultAnkiDroidDirectory
1716
import com.ichi2.anki.CollectionManager
1817
import com.ichi2.anki.DeckPicker
1918
import com.ichi2.anki.R
2019
import com.ichi2.anki.common.preferences.sharedPrefs
20+
import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory
2121
import com.ichi2.anki.tests.InstrumentedTest
2222
import com.ichi2.anki.testutil.disableIntroductionSlide
2323
import com.ichi2.anki.testutil.discardPreliminaryViews

AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/libanki/DBTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ import android.database.sqlite.SQLiteDatabase
1919
import android.database.sqlite.SQLiteDatabaseCorruptException
2020
import androidx.sqlite.db.SupportSQLiteDatabase
2121
import androidx.test.ext.junit.runners.AndroidJUnit4
22-
import com.ichi2.anki.CollectionHelper
2322
import com.ichi2.anki.libanki.DB
23+
import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory
2424
import com.ichi2.anki.tests.InstrumentedTest
2525
import com.ichi2.anki.testutil.GrantStoragePermission
2626
import net.ankiweb.rsdroid.database.AnkiSupportSQLiteDatabase
@@ -40,7 +40,7 @@ class DBTest : InstrumentedTest() {
4040
@Test
4141
@Throws(Exception::class)
4242
fun testDBCorruption() {
43-
val storagePath = CollectionHelper.getDefaultAnkiDroidDirectory(testContext)
43+
val storagePath = getDefaultAnkiDroidDirectory(testContext)
4444
val illFatedDBFile = File(storagePath, "illFatedDB.anki2")
4545

4646
// Make sure we have clean state to start with

AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import com.ichi2.anki.services.AlarmManagerService
5757
import com.ichi2.anki.services.NotificationService
5858
import com.ichi2.anki.settings.Prefs
5959
import com.ichi2.anki.settings.PrefsRepository
60+
import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory
6061
import com.ichi2.anki.ui.dialogs.ActivityAgnosticDialogs
6162
import com.ichi2.utils.ExceptionUtil
6263
import com.ichi2.utils.LanguageUtil
@@ -273,7 +274,7 @@ open class AnkiDroidApp :
273274
} catch (e: StorageAccessException) {
274275
Timber.e(e, "Could not initialize AnkiDroid directory")
275276
try {
276-
val defaultDir = CollectionHelper.getDefaultAnkiDroidDirectory(this)
277+
val defaultDir = getDefaultAnkiDroidDirectory(this)
277278
if (SdCard.isMounted && CollectionHelper.getCurrentAnkiDroidDirectory(this) == defaultDir) {
278279
// Don't send report if the user is using a custom directory as SD cards trip up here a lot
279280
sendExceptionReport(e, "AnkiDroidApp.onCreate")

AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt

Lines changed: 1 addition & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,7 @@ package com.ichi2.anki
55

66
import android.content.Context
77
import android.content.SharedPreferences
8-
import android.os.Environment
9-
import androidx.annotation.CheckResult
108
import androidx.annotation.VisibleForTesting
11-
import androidx.core.content.edit
129
import com.ichi2.anki.CollectionHelper.PREF_COLLECTION_PATH
1310
import com.ichi2.anki.CollectionHelper.getCurrentAnkiDroidDirectory
1411
import com.ichi2.anki.common.preferences.sharedPrefs
@@ -17,7 +14,7 @@ import com.ichi2.anki.exception.StorageAccessException
1714
import com.ichi2.anki.exception.SystemStorageException
1815
import com.ichi2.anki.libanki.Collection
1916
import com.ichi2.anki.libanki.CollectionFiles
20-
import com.ichi2.anki.storage.AnkiDroidFolder
17+
import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory
2118
import com.ichi2.anki.storage.StorageDecision
2219
import com.ichi2.preferences.getOrSetString
2320
import timber.log.Timber
@@ -96,137 +93,6 @@ object CollectionHelper {
9693
false
9794
}
9895

99-
/**
100-
* Get the absolute path to a directory that is suitable to be the default starting location
101-
* for the AnkiDroid directory.
102-
*
103-
* Currently, this is a directory named "AnkiDroid" at the top level of the non-app-specific external storage directory.
104-
*
105-
* When targeting API > 29, AnkiDroid will have to use Scoped Storage on any device of any API level.
106-
* Scoped Storage only allows access to App-Specific directories (without permissions).
107-
* Hence, AnkiDroid won't be able to access the directory used currently on all devices,
108-
* regardless of their API level, once AnkiDroid targets API > 29.
109-
* Instead, AnkiDroid will have to use an App-Specific directory to store the AnkiDroid directory.
110-
* This applies to the entire AnkiDroid userbase.
111-
*
112-
* Currently, if `TESTING_SCOPED_STORAGE` is set to `true`, AnkiDroid uses its External
113-
* App-Specific directory.
114-
*
115-
*
116-
* External App-Specific Storage is used since the only advantage Internal App-Specific Storage has over External
117-
* App-Specific storage is additional security, but AnkiDroid does not store sensitive data. Defaulting to
118-
* External Storage preserves the current behavior of the App
119-
* (AnkiDroid defaults to External before the Migration To Scoped Storage).
120-
*
121-
*
122-
* TODO: If External Storage isn't emulated, allow users to choose between External & Internal App-Specific Storage
123-
* instead of defaulting to External App-Specific Storage. This should be done since using either one may be more
124-
* useful for them. If External Storage is emulated, there is no use in providing the option since Internal
125-
* Storage can not provide more storage space than External Storage if External Storage is emulated.
126-
*
127-
* See the detailed explanation on storage locations & their classification below for more details.
128-
*
129-
* App-Specific storage refers to directories which are meant to store files that are meant to be used by a
130-
* particular app. Each app has its own Internal & External App-Specific directory. Under Scoped Storage,
131-
* an app can only access its own Internal & External App-Specific directory without needing permissions.
132-
*
133-
* Storage can be classified as Internal or External Storage.
134-
*
135-
* Internal Storage: This storage is characterized by the fact that it is always available since it always resides
136-
* on the device's own non-removable storage.
137-
*
138-
*
139-
* App-Specific Internal Storage can be accessed by ONLY the app which owns that directory (without any permissions).
140-
* It cannot be accessed by any other apps.
141-
* It cannot be accessed using the Files app on Android or by connecting a device to a pc via USB.
142-
*
143-
* External Storage:
144-
*
145-
* This storage is characterized only by the fact that it is not guaranteed to be available.
146-
*
147-
* It may be built-in, non-removable storage on the device which is being emulated to function like external storage.
148-
* In this case, it doesn't offer more space than Internal Storage.
149-
*
150-
* Or, it may be removable storage like an SD Card.
151-
*
152-
* App-Specific External Storage can be accessed by the app it is owned by without any permissions.
153-
* It can be accessed by any apps with the WRITE_EXTERNAL_STORAGE permission.
154-
* It can also be accessed via the Android Files app or by connecting the device to a PC via USB.
155-
*
156-
* Note: The Files app can be misleading. On Samsung devices, clicking on Internal Storage it actually shows the
157-
* emulated external storage (/storage/emulated/0/ in my case) - this is because from the point of view of the user,
158-
* emulated external storage is just more internal storage since it is built into the phone. This is why vendors
159-
* like Samsung may refer to external emulated storage as internal storage, even though for developers, they mean
160-
* very different things as explained above.
161-
*
162-
* @param directoryName The leaf folder name to use at the end of the returned path.
163-
* Defaults to `"AnkiDroid"` (the historical default-profile folder name).
164-
* Callers wanting a profile-specific layout can pass e.g. the profile id.
165-
* @return Absolute Path to the default location starting location for the AnkiDroid directory
166-
*
167-
* @throws SystemStorageException if `getExternalFilesDir` returns null
168-
*/
169-
// TODO Tracked in https://github.com/ankidroid/Anki-Android/issues/5304
170-
@CheckResult
171-
fun getDefaultAnkiDroidDirectory(
172-
context: Context,
173-
directoryName: String = "AnkiDroid",
174-
): File {
175-
val legacyStorage = selectAnkiDroidFolder(context) != AnkiDroidFolder.APP_PRIVATE
176-
return if (legacyStorage) {
177-
legacyAnkiDroidDirectory(directoryName)
178-
} else {
179-
File(getAppSpecificExternalAnkiDroidDirectory(context), directoryName)
180-
}
181-
}
182-
183-
/**
184-
* Returns the absolute path to the AnkiDroid directory under the primary/shared external storage directory.
185-
* This directory may be in emulated external storage, or can be an SD Card directory.
186-
*
187-
* @param directoryName The folder name to use at the end of the returned path. Defaults to
188-
* `"AnkiDroid"`. Non-default profiles can pass `ProfileId` here to get a
189-
* profile-specific layout.
190-
* @return Absolute path to the AnkiDroid directory in primary shared/external storage
191-
*/
192-
private fun legacyAnkiDroidDirectory(directoryName: String = "AnkiDroid"): File =
193-
File(Environment.getExternalStorageDirectory(), directoryName)
194-
195-
/**
196-
* Returns the absolute path to the AnkiDroid directory under the app-specific, primary/shared external storage
197-
* directory.
198-
*
199-
*
200-
* This directory may be in emulated external storage, or can be an SD Card directory.
201-
* If it is actually external storage, i.e., removable storage like an SD Card, instead of storage
202-
* built into the device itself, using this directory over internal storage can be beneficial since
203-
* it may be able to store more data.
204-
*
205-
*
206-
* AnkiDroid can access this directory without permissions, even under Scoped Storage
207-
* Other apps can access this directory if they have the WRITE_EXTERNAL_STORAGE permission
208-
*
209-
* @param context Used to get the External App-Specific directory for AnkiDroid
210-
* @return Returns the absolute path to the App-Specific External AnkiDroid directory
211-
*
212-
* @throws SystemStorageException if `getExternalFilesDir` returns null
213-
*/
214-
private fun getAppSpecificExternalAnkiDroidDirectory(context: Context): String? {
215-
val externalFilesDir = context.getExternalFilesDir(null)
216-
217-
// This value *may* be null but we strictly require it. This has caused NullPointerException
218-
// in previous releases as we dereference. We can't recover but for purposes of triage,
219-
// we will now check for null and if so try to log more information about why.
220-
if (externalFilesDir == null) {
221-
Timber.e("Attempting to determine collection path, but no valid external storage?")
222-
throw SystemStorageException.build(
223-
errorDetail = "getExternalFilesDir unexpectedly returned null",
224-
infoUri = "https://github.com/ankidroid/Anki-Android/issues/13207",
225-
)
226-
}
227-
return externalFilesDir.absolutePath
228-
}
229-
23096
/**
23197
* @return Returns an array of [File]s reflecting the directories that AnkiDroid can access without storage permissions
23298
* @see android.content.Context.getExternalFilesDirs
@@ -311,21 +177,6 @@ object CollectionHelper {
311177
)
312178
}
313179

314-
/**
315-
* Resets the AnkiDroid directory to [directory]
316-
* Note: if [android.R.attr.preserveLegacyExternalStorage] is in use
317-
* this will represent a change from `/AnkiDroid` to `/Android/data/...`
318-
*
319-
* @throws SystemStorageException if `getExternalFilesDir` returns null
320-
*/
321-
fun resetAnkiDroidDirectory(
322-
context: Context,
323-
directory: File = getDefaultAnkiDroidDirectory(context),
324-
) {
325-
Timber.d("resetting AnkiDroid directory to %s", directory)
326-
context.sharedPrefs().edit { putString(PREF_COLLECTION_PATH, directory.absolutePath) }
327-
}
328-
329180
/** Test-only override for [storageDecision]. @see ankiDroidDirectoryOverride */
330181
@VisibleForTesting
331182
var storageDecisionTestOverride: StorageDecision? = null

AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ import com.ichi2.anki.libanki.Consts
5353
import com.ichi2.anki.requireAnkiActivity
5454
import com.ichi2.anki.servicelayer.DebugInfoService
5555
import com.ichi2.anki.showImportDialog
56+
import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory
57+
import com.ichi2.anki.startup.resetAnkiDroidDirectory
5658
import com.ichi2.anki.ui.internationalization.sentenceCase
5759
import com.ichi2.anki.utils.ext.dismissAllDialogFragments
5860
import com.ichi2.utils.UiUtil.makeBold
@@ -166,7 +168,7 @@ class DatabaseErrorDialog : AsyncDialogFragment() {
166168
val shouldOfferResetToDefaultDirectory =
167169
try {
168170
val currentDir = CollectionHelper.getCurrentAnkiDroidDirectory(activity)
169-
val defaultDir = CollectionHelper.getDefaultAnkiDroidDirectory(activity)
171+
val defaultDir = getDefaultAnkiDroidDirectory(activity)
170172
currentDir.absolutePath != defaultDir.absolutePath
171173
} catch (e: Throwable) {
172174
Timber.w(e, "Failed to determine whether to offer reset-to-default directory option")
@@ -209,9 +211,9 @@ class DatabaseErrorDialog : AsyncDialogFragment() {
209211
}
210212
ErrorHandlingEntries.RESET_TO_DEFAULT_DIRECTORY -> {
211213
try {
212-
val defaultDir = CollectionHelper.getDefaultAnkiDroidDirectory(activity)
214+
val defaultDir = getDefaultAnkiDroidDirectory(activity)
213215
CollectionManager.closeCollectionBlocking()
214-
CollectionHelper.resetAnkiDroidDirectory(activity, defaultDir)
216+
resetAnkiDroidDirectory(activity, defaultDir)
215217
closeCollectionAndFinish()
216218
} catch (e: Throwable) {
217219
Timber.w(e, "Failed to reset AnkiDroid directory to default")
@@ -487,7 +489,7 @@ class DatabaseErrorDialog : AsyncDialogFragment() {
487489
dismissesDialog = false,
488490
{ activity ->
489491
Timber.i("Restoring from colpkg")
490-
val newAnkiDroidDirectory = CollectionHelper.getDefaultAnkiDroidDirectory(activity)
492+
val newAnkiDroidDirectory = getDefaultAnkiDroidDirectory(activity)
491493
activity.importColpkgListener = DatabaseRestorationListener(activity, newAnkiDroidDirectory)
492494

493495
activity.launchCatchingTask {
@@ -526,7 +528,7 @@ class DatabaseErrorDialog : AsyncDialogFragment() {
526528
fun displayCreateNewCollectionDialog(context: AnkiActivity) {
527529
val directory =
528530
try {
529-
CollectionHelper.getDefaultAnkiDroidDirectory(context)
531+
getDefaultAnkiDroidDirectory(context)
530532
} catch (e: SystemStorageException) {
531533
Timber.w(e, "failed to show 'Create new collection' dialog")
532534
FatalErrorDialog.build(context, InitializationError(StorageError(e))).show()
@@ -543,7 +545,7 @@ class DatabaseErrorDialog : AsyncDialogFragment() {
543545
"DatabaseErrorDialog: Before Create New Collection",
544546
)
545547
CollectionManager.closeCollectionBlocking()
546-
CollectionHelper.resetAnkiDroidDirectory(context, directory)
548+
resetAnkiDroidDirectory(context, directory)
547549
context.closeCollectionAndFinish()
548550
}
549551
negativeButton(R.string.dialog_cancel)

AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ import androidx.annotation.VisibleForTesting
1212
import androidx.core.content.ContextCompat
1313
import androidx.core.content.edit
1414
import com.ichi2.anki.CollectionHelper.PREF_COLLECTION_PATH
15-
import com.ichi2.anki.CollectionHelper.getDefaultAnkiDroidDirectory
1615
import com.ichi2.anki.common.crashreporting.CrashReportService
1716
import com.ichi2.anki.common.preferences.sharedPrefs
1817
import com.ichi2.anki.common.time.TimeManager
1918
import com.ichi2.anki.common.time.getTimestamp
19+
import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory
2020
import org.json.JSONObject
2121
import timber.log.Timber
2222
import java.io.File
@@ -390,7 +390,7 @@ class ProfileManager private constructor(
390390
* The default-location fallback used when the profile has never written `PREF_COLLECTION_PATH`.
391391
*
392392
* TODO: consolidate with the profile-creation path this should delegate to
393-
* `CollectionHelper.getDefaultAnkiDroidDirectory(profileContext, directoryName = ...)`
393+
* `getDefaultAnkiDroidDirectory(profileContext, directoryName = ...)`
394394
* that gives us legacy-storage handling and `SystemStorageException`-on-null for free, and keeps
395395
* the "where does a profile collection live" decision in a single place shared with
396396
* `ensureProfileCollectionPath`.

AnkiDroid/src/main/java/com/ichi2/anki/preferences/AdvancedSettingsFragment.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import com.ichi2.anki.launchCatchingTask
3333
import com.ichi2.anki.provider.CardContentProvider
3434
import com.ichi2.anki.settings.Prefs
3535
import com.ichi2.anki.snackbar.showSnackbar
36+
import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory
3637
import com.ichi2.anki.utils.openUrl
3738
import com.ichi2.utils.show
3839
import timber.log.Timber
@@ -67,7 +68,7 @@ class AdvancedSettingsFragment : SettingsFragment() {
6768
setTitle(R.string.dialog_collection_path_not_dir)
6869
setPositiveButton(R.string.dialog_ok) { _, _ -> }
6970
setNegativeButton(R.string.reset_custom_buttons) { _, _ ->
70-
text = CollectionHelper.getDefaultAnkiDroidDirectory(requireContext()).absolutePath
71+
text = getDefaultAnkiDroidDirectory(requireContext()).absolutePath
7172
}
7273
}
7374
false

0 commit comments

Comments
 (0)