Skip to content

Commit cde85c7

Browse files
david-allisonmikehardy
authored andcommitted
fix(database-error): handle 'new collection' under SystemStorageException
`getExternalFilesDir` is corrupt, and storage is inaccessible We can't create a new collection as we can't get the storage location so show a fatal error Fixes 19554
1 parent 15e1b05 commit cde85c7

File tree

8 files changed

+128
-31
lines changed

8 files changed

+128
-31
lines changed

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,11 @@ open class AnkiDroidApp :
461461
return Intent(Intent.ACTION_VIEW, parsed)
462462
} // TODO actually this can be done by translating "link_help" string for each language when the App is
463463

464+
@VisibleForTesting
465+
fun clearFatalError() {
466+
this.instance.fatalInitializationError = null
467+
}
468+
464469
/**
465470
* Get the url for the properly translated feedback page
466471
* @return

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import androidx.core.content.edit
2525
import com.ichi2.anki.AnkiDroidFolder.AppPrivateFolder
2626
import com.ichi2.anki.CollectionHelper.PREF_COLLECTION_PATH
2727
import com.ichi2.anki.CollectionHelper.getCurrentAnkiDroidDirectory
28-
import com.ichi2.anki.CollectionHelper.getDefaultAnkiDroidDirectory
2928
import com.ichi2.anki.backend.createDatabaseUsingAndroidFramework
3029
import com.ichi2.anki.exception.StorageAccessException
3130
import com.ichi2.anki.exception.SystemStorageException
@@ -313,15 +312,18 @@ object CollectionHelper {
313312
}
314313

315314
/**
316-
* Resets the AnkiDroid directory to the [getDefaultAnkiDroidDirectory]
315+
* Resets the AnkiDroid directory to [directory]
317316
* Note: if [android.R.attr.preserveLegacyExternalStorage] is in use
318317
* this will represent a change from `/AnkiDroid` to `/Android/data/...`
318+
*
319+
* @throws SystemStorageException if `getExternalFilesDir` returns null
319320
*/
320-
fun resetAnkiDroidDirectory(context: Context) {
321-
val preferences = context.sharedPrefs()
322-
val directory = getDefaultAnkiDroidDirectory(context)
321+
fun resetAnkiDroidDirectory(
322+
context: Context,
323+
directory: File = getDefaultAnkiDroidDirectory(context),
324+
) {
323325
Timber.d("resetting AnkiDroid directory to %s", directory)
324-
preferences.edit { putString(PREF_COLLECTION_PATH, directory.absolutePath) }
326+
context.sharedPrefs().edit { putString(PREF_COLLECTION_PATH, directory.absolutePath) }
325327
}
326328

327329
@Throws(UnknownDatabaseVersionException::class)

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

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ import com.ichi2.anki.dialogs.DeckPickerNoSpaceLeftDialog
129129
import com.ichi2.anki.dialogs.DialogHandlerMessage
130130
import com.ichi2.anki.dialogs.EditDeckDescriptionDialog
131131
import com.ichi2.anki.dialogs.EmptyCardsDialogFragment
132+
import com.ichi2.anki.dialogs.FatalErrorDialog
132133
import com.ichi2.anki.dialogs.ImportDialog.ImportDialogListener
133134
import com.ichi2.anki.dialogs.ImportFileSelectionFragment.ApkgImportResultLauncherProvider
134135
import com.ichi2.anki.dialogs.ImportFileSelectionFragment.CsvImportResultLauncherProvider
@@ -194,7 +195,6 @@ import com.ichi2.utils.customView
194195
import com.ichi2.utils.dp
195196
import com.ichi2.utils.message
196197
import com.ichi2.utils.negativeButton
197-
import com.ichi2.utils.neutralButton
198198
import com.ichi2.utils.positiveButton
199199
import com.ichi2.utils.show
200200
import com.ichi2.utils.title
@@ -1000,20 +1000,7 @@ open class DeckPicker :
10001000
Timber.i("Displaying database locked error")
10011001
showDatabaseErrorDialog(DatabaseErrorDialogType.DIALOG_DB_LOCKED)
10021002
}
1003-
is StartupFailure.InitializationError ->
1004-
AlertDialog.Builder(this).show {
1005-
title(R.string.ankidroid_init_failed_webview_title)
1006-
message(text = failure.toHumanReadableString(this@DeckPicker))
1007-
positiveButton(R.string.close) {
1008-
closeCollectionAndFinish()
1009-
}
1010-
failure.infoLink?.let { url ->
1011-
neutralButton(R.string.help) {
1012-
openUrl(url)
1013-
}
1014-
}
1015-
cancelable(false)
1016-
}
1003+
is StartupFailure.InitializationError -> FatalErrorDialog.build(this, failure).show()
10171004
is DiskFull -> displayNoStorageError()
10181005
is DBError -> displayDatabaseFailure(CustomExceptionData.fromException(failure.exception))
10191006
}

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ import com.ichi2.anki.CollectionManager
3636
import com.ichi2.anki.ConflictResolution
3737
import com.ichi2.anki.DatabaseRestorationListener
3838
import com.ichi2.anki.DeckPicker
39+
import com.ichi2.anki.FatalInitializationError.StorageError
40+
import com.ichi2.anki.InitialActivity.StartupFailure.InitializationError
3941
import com.ichi2.anki.LocalizedUnambiguousBackupTimeFormatter
4042
import com.ichi2.anki.R
4143
import com.ichi2.anki.ankiActivity
@@ -55,6 +57,7 @@ import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType.DIALOG
5557
import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType.INCOMPATIBLE_DB_VERSION
5658
import com.ichi2.anki.dialogs.DatabaseErrorDialog.UninstallListItem.Companion.createList
5759
import com.ichi2.anki.dialogs.ImportFileSelectionFragment.ImportOptions
60+
import com.ichi2.anki.exception.SystemStorageException
5861
import com.ichi2.anki.isLoggedIn
5962
import com.ichi2.anki.launchCatchingTask
6063
import com.ichi2.anki.libanki.Consts
@@ -443,7 +446,7 @@ class DatabaseErrorDialog : AsyncDialogFragment() {
443446
R.string.restore_data_from_ankiweb,
444447
dismissesDialog = true,
445448
{
446-
this.displayResetToNewDirectoryDialog(it)
449+
this.displayCreateNewCollectionDialog(it)
447450
},
448451
),
449452
INSTALL_NON_PLAY_APP_RECOMMENDED(
@@ -494,14 +497,22 @@ class DatabaseErrorDialog : AsyncDialogFragment() {
494497
R.string.create_new_collection,
495498
dismissesDialog = false,
496499
{
497-
this.displayResetToNewDirectoryDialog(it)
500+
this.displayCreateNewCollectionDialog(it)
498501
},
499502
),
500503
;
501504

502505
companion object {
503506
/** A dialog which creates a new collection in an unsafe location */
504-
fun displayResetToNewDirectoryDialog(context: AnkiActivity) {
507+
fun displayCreateNewCollectionDialog(context: AnkiActivity) {
508+
val directory =
509+
try {
510+
CollectionHelper.getDefaultAnkiDroidDirectory(context)
511+
} catch (e: SystemStorageException) {
512+
Timber.w(e, "failed to show 'Create new collection' dialog")
513+
FatalErrorDialog.build(context, InitializationError(StorageError(e))).show()
514+
return
515+
}
505516
AlertDialog.Builder(context).show {
506517
title(R.string.backup_new_collection)
507518
setIcon(R.drawable.ic_warning)
@@ -513,7 +524,7 @@ class DatabaseErrorDialog : AsyncDialogFragment() {
513524
"DatabaseErrorDialog: Before Create New Collection",
514525
)
515526
CollectionManager.closeCollectionBlocking()
516-
CollectionHelper.resetAnkiDroidDirectory(context)
527+
CollectionHelper.resetAnkiDroidDirectory(context, directory)
517528
context.closeCollectionAndFinish()
518529
}
519530
negativeButton(R.string.dialog_cancel)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright (c) 2025 David Allison <[email protected]>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
17+
package com.ichi2.anki.dialogs
18+
19+
import android.content.Context
20+
import androidx.appcompat.app.AlertDialog
21+
import com.ichi2.anki.AnkiActivity
22+
import com.ichi2.anki.InitialActivity.StartupFailure.InitializationError
23+
import com.ichi2.anki.R
24+
import com.ichi2.utils.cancelable
25+
import com.ichi2.utils.create
26+
import com.ichi2.utils.message
27+
import com.ichi2.utils.neutralButton
28+
import com.ichi2.utils.positiveButton
29+
import com.ichi2.utils.title
30+
import timber.log.Timber
31+
32+
object FatalErrorDialog {
33+
fun build(
34+
activity: AnkiActivity,
35+
failure: InitializationError,
36+
): AlertDialog {
37+
val context: Context = activity
38+
Timber.i("Displaying 'Fatal error'")
39+
return AlertDialog.Builder(context).create {
40+
title(R.string.ankidroid_init_failed_webview_title)
41+
message(text = failure.toHumanReadableString(context))
42+
positiveButton(R.string.close) {
43+
activity.closeCollectionAndFinish()
44+
}
45+
failure.infoLink?.let { url ->
46+
neutralButton(R.string.help) {
47+
activity.openUrl(url)
48+
}
49+
}
50+
cancelable(false)
51+
}
52+
}
53+
}

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

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,22 @@
1717
package com.ichi2.anki
1818

1919
import android.content.Context
20-
import android.content.Intent
2120
import androidx.appcompat.app.AlertDialog
22-
import androidx.core.content.edit
2321
import androidx.test.ext.junit.runners.AndroidJUnit4
22+
import com.ichi2.anki.dialogs.utils.ankiListView
2423
import com.ichi2.anki.dialogs.utils.message
24+
import com.ichi2.anki.dialogs.utils.title
25+
import com.ichi2.testutils.TestException
26+
import com.ichi2.testutils.withNoWritePermission
27+
import io.mockk.every
28+
import io.mockk.mockkObject
29+
import io.mockk.unmockkAll
2530
import org.hamcrest.CoreMatchers.containsString
31+
import org.hamcrest.CoreMatchers.equalTo
2632
import org.hamcrest.MatcherAssert.assertThat
2733
import org.junit.Test
2834
import org.junit.runner.RunWith
35+
import org.robolectric.Shadows.shadowOf
2936
import org.robolectric.annotation.Config
3037
import org.robolectric.annotation.Implementation
3138
import org.robolectric.annotation.Implements
@@ -43,13 +50,40 @@ class DeckPickerNoExternalFilesDirTest : RobolectricTest() {
4350

4451
// IntroductionActivity should be skipped by our code so we can show the error
4552
// without user interaction
46-
getPreferences().edit { putBoolean(IntroductionActivity.INTRODUCTION_SLIDES_SHOWN, false) }
47-
48-
startActivityNormallyOpenCollectionWithIntent(DeckPicker::class.java, Intent()).run {
53+
deckPicker(skipIntroduction = false) {
4954
val message = (ShadowDialog.getLatestDialog() as AlertDialog).message
5055
assertThat(message, containsString("getExternalFilesDir unexpectedly returned null"))
5156
}
5257
}
58+
59+
@Test
60+
fun `fatal error is shown after 'Create a new collection' and getExternalFilesDir is null`() =
61+
try {
62+
AnkiDroidApp.clearFatalError()
63+
withNoWritePermission {
64+
CollectionHelper.ankiDroidDirectoryOverride = tempFolder.newFolder()
65+
66+
mockkObject(CollectionHelper, CollectionManager, AnkiDroidApp)
67+
every { CollectionHelper.isCurrentAnkiDroidDirAccessible(any()) } returns false
68+
every { CollectionManager.getColUnsafe() } throws TestException("")
69+
every { AnkiDroidApp.isSdCardMounted } returns true
70+
71+
setIntroductionSlidesShown(true)
72+
73+
deckPicker {
74+
(ShadowDialog.getLatestDialog() as AlertDialog).also { dialog ->
75+
assertThat(dialog.title, equalTo("Inaccessible collection"))
76+
shadowOf(dialog.ankiListView).clickFirstItemContainingText("Create a new collection")
77+
}
78+
79+
val dialog = (ShadowDialog.getLatestDialog() as AlertDialog)
80+
assertThat(dialog.message, containsString("getExternalFilesDir unexpectedly returned null"))
81+
}
82+
}
83+
} finally {
84+
CollectionHelper.ankiDroidDirectoryOverride = null
85+
unmockkAll()
86+
}
5387
}
5488

5589
/**

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -706,7 +706,7 @@ class DeckPickerTest : RobolectricTest() {
706706
}
707707
}
708708

709-
private fun RobolectricTest.setIntroductionSlidesShown(shown: Boolean) {
709+
fun RobolectricTest.setIntroductionSlidesShown(shown: Boolean) {
710710
getPreferences().edit {
711711
putBoolean(IntroductionActivity.INTRODUCTION_SLIDES_SHOWN, shown)
712712
}

AnkiDroid/src/test/java/com/ichi2/anki/dialogs/utils/AlertDialogUtils.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import android.widget.TextView
2121
import androidx.appcompat.app.AlertDialog
2222
import androidx.core.view.isVisible
2323
import androidx.test.platform.app.InstrumentationRegistry
24+
import com.ichi2.anki.R
2425
import com.ichi2.utils.HandlerUtils.executeFunctionUsingHandler
2526
import com.ichi2.utils.getInputField
2627
import org.hamcrest.MatcherAssert.assertThat
@@ -44,6 +45,10 @@ val AlertDialog.message
4445
"android.R.id.message not found"
4546
}.text.toString()
4647

48+
val AlertDialog.ankiListView
49+
get() =
50+
requireNotNull(this.listView ?: findViewById(R.id.dialog_list_view)) { "ankiListView not found" }
51+
4752
fun AlertDialog.performPositiveClick() {
4853
// This exists as callOnClick did not call the listener
4954
val positiveButton = assertNotNull(getButton(DialogInterface.BUTTON_POSITIVE), message = "positive button")

0 commit comments

Comments
 (0)