Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
151 changes: 1 addition & 150 deletions AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading