Skip to content

Commit 080589e

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

7 files changed

Lines changed: 210 additions & 36 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
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.ensureCollectionPathSet
6061
import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory
6162
import com.ichi2.anki.ui.dialogs.ActivityAgnosticDialogs
6263
import com.ichi2.utils.ExceptionUtil
@@ -258,6 +259,7 @@ open class AnkiDroidApp :
258259
// is not considered to be a fatal error, unless the directory itself is not writable.
259260
val ankiDroidDir =
260261
try {
262+
ensureCollectionPathSet(this)
261263
CollectionHelper.getCurrentAnkiDroidDirectory(this)
262264
} catch (e: SystemStorageException) {
263265
fatalInitializationError = FatalInitializationError.StorageError(e)

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

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,11 @@ import com.ichi2.anki.CollectionHelper.getCurrentAnkiDroidDirectory
1111
import com.ichi2.anki.common.preferences.sharedPrefs
1212
import com.ichi2.anki.common.utils.android.isInstrumentationTest
1313
import com.ichi2.anki.exception.StorageAccessException
14+
import com.ichi2.anki.exception.StorageNotConfiguredException
1415
import com.ichi2.anki.exception.SystemStorageException
1516
import com.ichi2.anki.libanki.Collection
1617
import com.ichi2.anki.libanki.CollectionFiles
17-
import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory
1818
import com.ichi2.anki.storage.StorageDecision
19-
import com.ichi2.preferences.getOrSetString
2019
import timber.log.Timber
2120
import java.io.File
2221
import java.io.IOException
@@ -81,7 +80,8 @@ object CollectionHelper {
8180

8281
/**
8382
* Try to access the current AnkiDroid directory
84-
* @return whether or not dir is accessible
83+
* @return whether or not dir is accessible: `false` if inaccessible, not yet configured,
84+
* or the system could not provide a storage location
8585
* @param context to get directory with
8686
*/
8787
fun isCurrentAnkiDroidDirAccessible(context: Context): Boolean =
@@ -91,6 +91,12 @@ object CollectionHelper {
9191
} catch (e: StorageAccessException) {
9292
Timber.w(e)
9393
false
94+
} catch (e: StorageNotConfiguredException) {
95+
Timber.w(e)
96+
false
97+
} catch (e: SystemStorageException) {
98+
Timber.w(e)
99+
false
94100
}
95101

96102
/**
@@ -121,6 +127,17 @@ object CollectionHelper {
121127
/** A temporary override for [getCurrentAnkiDroidDirectory] */
122128
var ankiDroidDirectoryOverride: File? = null
123129

130+
/**
131+
* Set when startup failed to choose a default collection path because Android could not
132+
* provide a storage location ([SystemStorageException]: OS bug/SD card issue).
133+
*
134+
* Reads of an unset collection path rethrow this instead of [StorageNotConfiguredException],
135+
* so the storage failure is not mistaken for the expected 'no collection path set' state.
136+
*/
137+
// TODO: #19552 - consolidate with AnkiDroidApp.fatalError and StorageDecision (a dedicated
138+
// 'storage unavailable' state) once the storage setup flow exists
139+
var systemStorageFailure: SystemStorageException? = null
140+
124141
/**
125142
* Whether the user has chosen where the collection is stored.
126143
*
@@ -133,49 +150,46 @@ object CollectionHelper {
133150
/**
134151
* @return the absolute path to the AnkiDroid directory.
135152
*
136-
* @throws SystemStorageException if `getExternalFilesDir` returns null
153+
* @throws StorageNotConfiguredException if no collection path has been set
154+
* ([PREF_COLLECTION_PATH] is unset): a default is chosen during startup by
155+
* [ensureCollectionPathSet][com.ichi2.anki.startup.ensureCollectionPathSet]
156+
* @throws SystemStorageException if startup failed to choose a default collection path
157+
* ([systemStorageFailure])
137158
*/
138-
fun getCurrentAnkiDroidDirectory(context: Context): File =
139-
getCurrentAnkiDroidDirectoryOptionalContext(context.sharedPrefs()) { context }
159+
fun getCurrentAnkiDroidDirectory(context: Context): File = getCurrentAnkiDroidDirectory(context.sharedPrefs())
140160

141161
fun getCollectionPaths(context: Context): CollectionFiles = CollectionFiles.FolderBasedCollection(getCurrentAnkiDroidDirectory(context))
142162

143163
// TODO: Duplicates collection.mediaFolder
144164
fun getMediaDirectory(context: Context) = getCollectionPaths(context).requireMediaFolder()
145165

146166
/**
147-
* An accessor which makes [Context] optional in the case that [PREF_COLLECTION_PATH] is set
148-
*
149167
* @return the absolute path to the AnkiDroid directory.
168+
*
169+
* @throws StorageNotConfiguredException if no collection path has been set
170+
* ([PREF_COLLECTION_PATH] is unset): a default is chosen during startup by
171+
* [ensureCollectionPathSet][com.ichi2.anki.startup.ensureCollectionPathSet]
172+
* @throws SystemStorageException if startup failed to choose a default collection path
173+
* ([systemStorageFailure])
150174
*/
151-
// This uses a lambda as we typically depends on the `lateinit` appContext
152-
// If we remove all Android references, we get a significant unit test speedup
153-
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
154-
internal fun getCurrentAnkiDroidDirectoryOptionalContext(
155-
preferences: SharedPreferences,
156-
context: () -> Context,
157-
): File =
158-
if (isInstrumentationTest) {
175+
fun getCurrentAnkiDroidDirectory(preferences: SharedPreferences): File {
176+
val collectionDirectory = preferences.getString(PREF_COLLECTION_PATH, null)
177+
return if (isInstrumentationTest) {
159178
// create an "androidTest" directory inside the current collection directory which contains the test data
160179
// "/AnkiDroid/androidTest" would be a new collection path
161-
val currentCollectionDirectory =
162-
preferences.getOrSetString(PREF_COLLECTION_PATH) {
163-
getDefaultAnkiDroidDirectory(context()).absolutePath
164-
}
165-
File(
166-
currentCollectionDirectory,
167-
"androidTest",
168-
)
180+
File(collectionDirectory ?: throw collectionPathUnset(), "androidTest")
169181
} else {
170182
ankiDroidDirectoryOverride
171-
?: File(
172-
preferences.getOrSetString(PREF_COLLECTION_PATH) {
173-
getDefaultAnkiDroidDirectory(
174-
context(),
175-
).absolutePath
176-
},
177-
)
183+
?: File(collectionDirectory ?: throw collectionPathUnset())
178184
}
185+
}
186+
187+
/**
188+
* The collection path is unset: either the expected pre-setup state
189+
* ([StorageNotConfiguredException]) or startup failed to choose a default and the recorded
190+
* [SystemStorageException] is rethrown.
191+
*/
192+
private fun collectionPathUnset(): Exception = systemStorageFailure ?: StorageNotConfiguredException()
179193

180194
/** Test-only override for [storageDecision]. @see ankiDroidDirectoryOverride */
181195
@VisibleForTesting

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ import com.ichi2.anki.CollectionManager.withCol
1818
import com.ichi2.anki.CollectionManager.withOpenColOrNull
1919
import com.ichi2.anki.CollectionManager.withQueue
2020
import com.ichi2.anki.backend.createDatabaseUsingRustBackend
21-
import com.ichi2.anki.common.android.appContext
2221
import com.ichi2.anki.common.utils.android.Threads
2322
import com.ichi2.anki.common.utils.android.isRobolectric
2423
import com.ichi2.anki.exception.StorageNotConfiguredException
24+
import com.ichi2.anki.exception.SystemStorageException
2525
import com.ichi2.anki.libanki.Collection
2626
import com.ichi2.anki.libanki.CollectionFiles
2727
import com.ichi2.anki.libanki.LibAnki
@@ -130,6 +130,8 @@ object CollectionManager {
130130
*
131131
* @throws StorageNotConfiguredException If [CollectionHelper.storageDecision] is undecided
132132
* (user has not selected a storage location).
133+
* @throws SystemStorageException if startup failed to choose a default collection path
134+
* ([CollectionHelper.systemStorageFailure])
133135
*/
134136
suspend fun <T> withCol(
135137
@WorkerThread block: Collection.() -> T,
@@ -254,6 +256,8 @@ object CollectionManager {
254256
*
255257
* @throws StorageNotConfiguredException If [CollectionHelper.storageDecision] is undecided
256258
* (user has not selected a storage location).
259+
* @throws SystemStorageException if startup failed to choose a default collection path
260+
* ([CollectionHelper.systemStorageFailure])
257261
*/
258262
suspend fun ensureOpen() {
259263
withQueue {
@@ -266,9 +270,15 @@ object CollectionManager {
266270
*
267271
* @throws StorageNotConfiguredException If [CollectionHelper.storageDecision] is not
268272
* [StorageDecision.Decided] (user has not selected a storage location).
273+
* @throws SystemStorageException if startup failed to choose a default collection path
274+
* ([CollectionHelper.systemStorageFailure])
269275
*/
270276
private fun ensureOpenInner() {
271-
if (CollectionHelper.storageDecision() != StorageDecision.Decided) throw StorageNotConfiguredException()
277+
if (CollectionHelper.storageDecision() != StorageDecision.Decided) {
278+
// rethrow a recorded startup storage failure: an OS bug/SD card issue must not be
279+
// reported as the expected 'storage not configured' state
280+
throw CollectionHelper.systemStorageFailure ?: StorageNotConfiguredException()
281+
}
272282
ensureBackendInner()
273283
emulatedOpenFailure?.triggerFailure()
274284
if (collection == null || collection!!.dbClosed) {
@@ -290,8 +300,8 @@ object CollectionManager {
290300
}
291301

292302
fun getCollectionDirectory() =
293-
// Allow execution if appContext is not initialized
294-
CollectionHelper.getCurrentAnkiDroidDirectoryOptionalContext(AnkiDroidApp.sharedPrefs()) { appContext }
303+
// does not require appContext to be initialized
304+
CollectionHelper.getCurrentAnkiDroidDirectory(AnkiDroidApp.sharedPrefs())
295305

296306
/** Ensures the AnkiDroid directory is created, then returns the path to the
297307
* folder and the name of the collection file inside it. */
@@ -330,6 +340,8 @@ object CollectionManager {
330340
*
331341
* @throws StorageNotConfiguredException If [CollectionHelper.storageDecision] is undecided
332342
* (user has not selected a storage location).
343+
* @throws SystemStorageException if startup failed to choose a default collection path
344+
* ([CollectionHelper.systemStorageFailure])
333345
*/
334346
fun getColUnsafe(): Collection =
335347
logUIHangs {

AnkiDroid/src/main/java/com/ichi2/anki/startup/SetupStorage.kt

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,39 @@ import androidx.annotation.CheckResult
88
import androidx.core.content.edit
99
import com.ichi2.anki.CollectionHelper
1010
import com.ichi2.anki.common.preferences.sharedPrefs
11+
import com.ichi2.anki.exception.StorageNotConfiguredException
1112
import com.ichi2.anki.exception.SystemStorageException
1213
import com.ichi2.anki.selectAnkiDroidFolder
1314
import com.ichi2.anki.storage.AnkiDroidFolder
1415
import timber.log.Timber
1516
import java.io.File
1617

18+
/**
19+
* Ensures [CollectionHelper.PREF_COLLECTION_PATH] is set, choosing and persisting
20+
* [getDefaultAnkiDroidDirectory] if it is unset.
21+
*
22+
* This decides the storage location on the user's behalf: until a storage setup flow exists
23+
* (#19552), the user is not asked.
24+
*
25+
* @throws SystemStorageException if `getExternalFilesDir` returns null. The failure is recorded
26+
* in [CollectionHelper.systemStorageFailure] so reads report it rather than
27+
* [StorageNotConfiguredException]
28+
*/
29+
fun ensureCollectionPathSet(context: Context) {
30+
val preferences = context.sharedPrefs()
31+
if (preferences.contains(CollectionHelper.PREF_COLLECTION_PATH)) return
32+
val defaultPath =
33+
try {
34+
getDefaultAnkiDroidDirectory(context).absolutePath
35+
} catch (e: SystemStorageException) {
36+
CollectionHelper.systemStorageFailure = e
37+
throw e
38+
}
39+
Timber.i("collection path set to default")
40+
Timber.d("default collection path: %s", defaultPath)
41+
preferences.edit { putString(CollectionHelper.PREF_COLLECTION_PATH, defaultPath) }
42+
}
43+
1744
/**
1845
* Get the absolute path to a directory that is suitable to be the default starting location
1946
* for the AnkiDroid directory.

AnkiDroid/src/test/java/com/ichi2/anki/StorageDecisionGateTest.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ package com.ichi2.anki
44

55
import androidx.test.ext.junit.runners.AndroidJUnit4
66
import com.ichi2.anki.exception.StorageNotConfiguredException
7+
import com.ichi2.anki.exception.SystemStorageException
78
import com.ichi2.anki.storage.StorageDecision
89
import org.junit.Test
910
import org.junit.runner.RunWith
1011
import kotlin.test.assertEquals
1112
import kotlin.test.assertFailsWith
13+
import kotlin.test.assertSame
1214

1315
/**
1416
* Proves the storage-decision gate in [CollectionManager.ensureOpenInner] is wired up. In production
@@ -38,4 +40,22 @@ class StorageDecisionGateTest : RobolectricTest() {
3840
CollectionHelper.storageDecisionTestOverride = null
3941
}
4042
}
43+
44+
/**
45+
* A storage failure at startup ([SystemStorageException]: OS bug/SD card issue) must not
46+
* masquerade as the expected 'storage not configured' state when opening the collection.
47+
*/
48+
@Test
49+
fun `opening the collection reports a recorded startup storage failure`() {
50+
val failure = SystemStorageException.build("simulated getExternalFilesDir failure")
51+
CollectionHelper.storageDecisionTestOverride = StorageDecision.Undecided
52+
CollectionHelper.systemStorageFailure = failure
53+
try {
54+
val thrown = assertFailsWith<SystemStorageException> { CollectionManager.getColUnsafe() }
55+
assertSame(failure, thrown)
56+
} finally {
57+
CollectionHelper.storageDecisionTestOverride = null
58+
CollectionHelper.systemStorageFailure = null
59+
}
60+
}
4161
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
3+
package com.ichi2.anki.startup
4+
5+
import androidx.core.content.edit
6+
import androidx.test.ext.junit.runners.AndroidJUnit4
7+
import com.ichi2.anki.CollectionHelper
8+
import com.ichi2.anki.RobolectricTest
9+
import com.ichi2.anki.common.preferences.sharedPrefs
10+
import com.ichi2.anki.exception.StorageNotConfiguredException
11+
import com.ichi2.anki.exception.SystemStorageException
12+
import org.junit.After
13+
import org.junit.Test
14+
import org.junit.runner.RunWith
15+
import java.io.File
16+
import kotlin.test.assertEquals
17+
import kotlin.test.assertFailsWith
18+
import kotlin.test.assertSame
19+
import kotlin.test.assertTrue
20+
21+
@RunWith(AndroidJUnit4::class)
22+
class SetupStorageTest : RobolectricTest() {
23+
private val prefs
24+
get() = targetContext.sharedPrefs()
25+
26+
private val collectionPath: String?
27+
get() = prefs.getString(CollectionHelper.PREF_COLLECTION_PATH, null)
28+
29+
@After
30+
fun resetCollectionHelperState() {
31+
CollectionHelper.ankiDroidDirectoryOverride = null
32+
CollectionHelper.systemStorageFailure = null
33+
}
34+
35+
/**
36+
* Reading the collection path must not silently choose and persist a default:
37+
* that decision belongs to startup ([ensureCollectionPathSet]).
38+
*/
39+
@Test
40+
fun `reading the collection path throws when unset`() {
41+
prefs.edit { remove(CollectionHelper.PREF_COLLECTION_PATH) }
42+
43+
assertFailsWith<StorageNotConfiguredException> {
44+
CollectionHelper.getCurrentAnkiDroidDirectory(targetContext)
45+
}
46+
}
47+
48+
/**
49+
* A storage failure at startup must not masquerade as the expected 'no collection path set'
50+
* state: the [SystemStorageException] edge case (OS bug/SD card issue) is reported as itself.
51+
*/
52+
@Test
53+
fun `reading the collection path reports a recorded startup storage failure`() {
54+
prefs.edit { remove(CollectionHelper.PREF_COLLECTION_PATH) }
55+
val failure = SystemStorageException.build("simulated getExternalFilesDir failure")
56+
CollectionHelper.systemStorageFailure = failure
57+
58+
val thrown =
59+
assertFailsWith<SystemStorageException> {
60+
CollectionHelper.getCurrentAnkiDroidDirectory(targetContext)
61+
}
62+
assertSame(failure, thrown)
63+
}
64+
65+
@Test
66+
fun `reading the collection path returns the stored value`() {
67+
prefs.edit { putString(CollectionHelper.PREF_COLLECTION_PATH, "/a/collection/path") }
68+
69+
assertEquals(File("/a/collection/path"), CollectionHelper.getCurrentAnkiDroidDirectory(targetContext))
70+
}
71+
72+
@Test
73+
fun `the directory override takes precedence over the stored value`() {
74+
prefs.edit { putString(CollectionHelper.PREF_COLLECTION_PATH, "/a/collection/path") }
75+
CollectionHelper.ankiDroidDirectoryOverride = File("/an/override")
76+
77+
assertEquals(File("/an/override"), CollectionHelper.getCurrentAnkiDroidDirectory(targetContext))
78+
}
79+
80+
@Test
81+
fun `ensureCollectionPathSet persists a default when unset`() {
82+
prefs.edit { remove(CollectionHelper.PREF_COLLECTION_PATH) }
83+
84+
ensureCollectionPathSet(targetContext)
85+
86+
assertTrue(!collectionPath.isNullOrEmpty(), "a default collection path should be set")
87+
}
88+
89+
@Test
90+
fun `ensureCollectionPathSet does not overwrite an existing value`() {
91+
prefs.edit { putString(CollectionHelper.PREF_COLLECTION_PATH, "/a/custom/path") }
92+
93+
ensureCollectionPathSet(targetContext)
94+
95+
assertEquals("/a/custom/path", collectionPath)
96+
}
97+
}

0 commit comments

Comments
 (0)