0
0
mirror of https://github.com/ankidroid/Anki-Android.git synced 2024-09-20 03:52:15 +02:00

Scoped Storage: Handle storage being revoked

If a user uninstalls AnkiDroid, `preserveLegacyStorage` no longer
works

This means they have a deckPath of `/storage/emulated/0/AnkiDroid`
which can no longer be accessed.

Their data exists, but cannot be accessed unless:
* They restore from AnkiWeb
* They have a colpkg
* They manually copy the folder over
* They install a non-Google-Play copy

Options:
* Restore from AnkiWeb
* Restore folder access
   (https://github.com/ankidroid/Anki-Android/wiki/Full-Storage-Access)
* Restore backup (Hidden - Not Implemented)
* Get Help (Link to: Google Group)
* Delete collection and create a new one

Issue 5304
This commit is contained in:
David Allison 2023-03-04 03:05:25 +00:00 committed by Mike Hardy
parent 5c06c1806f
commit 136cfb3f8a
6 changed files with 161 additions and 32 deletions

View File

@ -22,6 +22,7 @@ import android.os.Environment
import android.text.format.Formatter
import androidx.annotation.CheckResult
import androidx.annotation.VisibleForTesting
import androidx.core.content.edit
import com.ichi2.anki.AnkiDroidFolder.DeleteOnUninstall
import com.ichi2.anki.exception.StorageAccessException
import com.ichi2.anki.preferences.Preferences
@ -552,6 +553,18 @@ open class CollectionHelper {
}
}
/**
* Resets the AnkiDroid directory to the [getDefaultAnkiDroidDirectory]
* Note: if [android.R.attr.preserveLegacyExternalStorage] is in use
* this will represent a change from `/AnkiDroid` to `/Android/data/...`
*/
fun resetAnkiDroidDirectory(context: Context) {
val preferences = AnkiDroidApp.getSharedPrefs(context)
val directory = getDefaultAnkiDroidDirectory(context)
Timber.d("resetting AnkiDroid directory to %s", directory)
preferences.edit { putString(PREF_COLLECTION_PATH, directory) }
}
/** Fetches additional collection data not required for
* application startup
*

View File

@ -466,22 +466,6 @@ open class DeckPicker :
startMigrateUserDataService()
return false
}
// If legacy storage is being used, ensure storage permission is obtained in order to access Legacy Directories
ScopedStorageService.Status.REQUIRES_PERMISSION -> {
// if a dialog was not shown, continue
val storagePermissionsResult = startupStoragePermissionManager.checkPermissions()
if (storagePermissionsResult.requiresPermissionDialog) {
Timber.i("postponing startup code - dialog shown")
startupStoragePermissionManager.displayStoragePermissionDialog()
return true
}
return false
}
ScopedStorageService.Status.PERMISSION_FAILED -> {
// TODO: Handle "user reinstalled & kept data" scenario.
// (Or edge case of permission removal)
return false
}
// App is already using Scoped Storage Directory for user data, no need to migrate & can proceed with startup
ScopedStorageService.Status.COMPLETED, ScopedStorageService.Status.NOT_NEEDED -> return false
}
@ -524,9 +508,13 @@ open class DeckPicker :
}
DIRECTORY_NOT_ACCESSIBLE -> {
Timber.i("AnkiDroid directory inaccessible")
val i = AdvancedSettingsFragment.getSubscreenIntent(this)
startActivityForResultWithoutAnimation(i, REQUEST_PATH_UPDATE)
showThemedToast(this, R.string.directory_inaccessible, false)
if (ScopedStorageService.collectionInaccessibleAfterUninstall(this)) {
showDatabaseErrorDialog(DatabaseErrorDialogType.DIALOG_STORAGE_UNAVAILABLE_AFTER_UNINSTALL)
} else {
val i = AdvancedSettingsFragment.getSubscreenIntent(this)
startActivityForResultWithoutAnimation(i, REQUEST_PATH_UPDATE)
showThemedToast(this, R.string.directory_inaccessible, false)
}
}
FUTURE_ANKIDROID_VERSION -> {
Timber.i("Displaying database versioning")
@ -1470,6 +1458,7 @@ open class DeckPicker :
}
fun exit() {
Timber.i("exit()")
CollectionHelper.instance.closeCollection(false, "DeckPicker:exit()")
finishWithoutAnimation()
}

View File

@ -18,10 +18,13 @@ package com.ichi2.anki.dialogs
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.net.Uri
import android.os.Bundle
import android.os.Message
import android.os.Parcelable
import android.view.KeyEvent
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.core.os.bundleOf
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.WhichButton
@ -35,9 +38,8 @@ import com.ichi2.async.Connection
import com.ichi2.compat.CompatHelper.Companion.getParcelableCompat
import com.ichi2.libanki.Consts
import com.ichi2.libanki.utils.TimeManager
import com.ichi2.utils.*
import com.ichi2.utils.UiUtil.makeBold
import com.ichi2.utils.contentNullable
import com.ichi2.utils.iconAttr
import kotlinx.parcelize.Parcelize
import net.ankiweb.rsdroid.BackendFactory
import timber.log.Timber
@ -340,6 +342,101 @@ class DatabaseErrorDialog : AsyncDialogFragment() {
}
}
}
DIALOG_STORAGE_UNAVAILABLE_AFTER_UNINSTALL -> {
val listItems = UninstallListItem.createList()
dialog.show {
contentNullable(message)
listItems(items = listItems.map { getString(it.stringRes) }, waitForPositiveButton = false) { _: MaterialDialog, index: Int, _: CharSequence ->
val listItem = listItems[index]
listItem.onClick(activity as DeckPicker)
if (listItem.dismissesDialog) {
this.dismiss()
}
}
noAutoDismiss()
cancelable(false)
}
}
}
}
/** List items for [DIALOG_STORAGE_UNAVAILABLE_AFTER_UNINSTALL] */
private enum class UninstallListItem(@StringRes val stringRes: Int, val dismissesDialog: Boolean, val onClick: (DeckPicker) -> Unit) {
RESTORE_FROM_ANKIWEB(
R.string.restore_data_from_ankiweb,
dismissesDialog = true,
{
this.displayResetToNewDirectoryDialog(it)
}
),
INSTALL_NON_PLAY_APP_RECOMMENDED(
R.string.install_non_play_store_ankidroid_recommended,
dismissesDialog = false,
{
val restoreUi = Uri.parse(it.getString(R.string.link_install_non_play_store_install))
it.openUrl(restoreUi)
}
),
INSTALL_NON_PLAY_APP_NORMAL(
R.string.install_non_play_store_ankidroid,
dismissesDialog = false,
{
val restoreUi = Uri.parse(it.getString(R.string.link_install_non_play_store_install))
it.openUrl(restoreUi)
}
),
RESTORE_FROM_BACKUP(
R.string.restore_data_from_backup,
dismissesDialog = true,
{
// TODO:
// it.showImportDialog() - colpkg only
// import to the default location
// AND on completion, reset the directory to here
// AND handle errors/partial restores
}
),
GET_HELP(
R.string.help_title_get_help,
dismissesDialog = false,
{
it.openUrl(Uri.parse(it.getString(R.string.link_forum)))
}
),
RECREATE_COLLECTION(
R.string.create_new_collection,
dismissesDialog = false,
{
this.displayResetToNewDirectoryDialog(it)
}
);
companion object {
/** A dialog which creates a new collection in an unsafe location */
fun displayResetToNewDirectoryDialog(context: DeckPicker) {
AlertDialog.Builder(context).show {
title(R.string.backup_new_collection)
iconAttr(R.attr.dialogErrorIcon)
message(R.string.new_unsafe_collection)
positiveButton(R.string.dialog_positive_create) {
Timber.w("Creating new collection")
val ch = CollectionHelper.instance
ch.closeCollection(false, "DatabaseErrorDialog: Before Create New Collection")
CollectionHelper.resetAnkiDroidDirectory(context)
context.exit()
}
negativeButton(R.string.dialog_cancel)
cancelable(false)
}
}
fun createList(): List<UninstallListItem> {
return if (isLoggedIn()) {
listOf(RESTORE_FROM_ANKIWEB, INSTALL_NON_PLAY_APP_NORMAL, RESTORE_FROM_BACKUP, GET_HELP, RECREATE_COLLECTION)
} else {
listOf(INSTALL_NON_PLAY_APP_RECOMMENDED, RESTORE_FROM_BACKUP, GET_HELP, RECREATE_COLLECTION)
}.filter { it != RESTORE_FROM_BACKUP } // filter non-implemented member
}
}
}
@ -386,6 +483,7 @@ class DatabaseErrorDialog : AsyncDialogFragment() {
databaseVersion
)
}
DIALOG_STORAGE_UNAVAILABLE_AFTER_UNINSTALL -> getString(R.string.directory_inaccessible_after_uninstall_summary, CollectionHelper.getCurrentAnkiDroidDirectory(requireContext()))
else -> requireArguments().getString("dialogMessage")
}
private val title: String
@ -402,6 +500,7 @@ class DatabaseErrorDialog : AsyncDialogFragment() {
INCOMPATIBLE_DB_VERSION -> resources.getString(R.string.incompatible_database_version_title)
DIALOG_DB_ERROR -> resources.getString(R.string.answering_error_title)
DIALOG_DISK_FULL -> resources.getString(R.string.storage_full_title)
DIALOG_STORAGE_UNAVAILABLE_AFTER_UNINSTALL -> resources.getString(R.string.directory_inaccessible_after_uninstall)
}
override val notificationMessage: String? get() = message
@ -435,7 +534,10 @@ class DatabaseErrorDialog : AsyncDialogFragment() {
INCOMPATIBLE_DB_VERSION,
/** If the disk space is full **/
DIALOG_DISK_FULL;
DIALOG_DISK_FULL,
/** If [android.R.attr.preserveLegacyExternalStorage] is no longer active */
DIALOG_STORAGE_UNAVAILABLE_AFTER_UNINSTALL;
}
companion object {

View File

@ -31,6 +31,7 @@ import com.ichi2.anki.servicelayer.ScopedStorageService.isLegacyStorage
import com.ichi2.anki.servicelayer.scopedstorage.MigrateEssentialFiles
import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData
import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.UserDataMigrationPreferences
import com.ichi2.anki.ui.windows.managespace.isInsideDirectoriesRemovedWithTheApp
import com.ichi2.utils.FileUtil.getParentsAndSelfRecursive
import com.ichi2.utils.FileUtil.isDescendantOf
import com.ichi2.utils.Permissions
@ -292,14 +293,6 @@ object ScopedStorageService {
return Status.NOT_NEEDED
}
if (!Permissions.hasStorageAccessPermission(context)) {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !Environment.isExternalStorageLegacy()) {
Status.PERMISSION_FAILED
} else {
Status.REQUIRES_PERMISSION
}
}
if (userMigrationIsInProgress(context)) {
return Status.IN_PROGRESS
}
@ -309,10 +302,31 @@ object ScopedStorageService {
enum class Status {
NEEDS_MIGRATION,
REQUIRES_PERMISSION,
PERMISSION_FAILED,
IN_PROGRESS,
COMPLETED,
NOT_NEEDED
}
/**
* @return whether the user's current collection is now inaccessible due to a 'reinstall'
* @see android.R.attr.preserveLegacyExternalStorage
* @see android.R.attr.requestLegacyExternalStorage
*/
fun collectionInaccessibleAfterUninstall(context: Context): Boolean {
// If we're < Q then `requestLegacyExternalStorage` was not introduced
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return false
}
if (Permissions.canManageExternalStorage(context)) {
return false
}
val collectionPath = File(CollectionHelper.getCollectionPath(context))
if (collectionPath.isInsideDirectoriesRemovedWithTheApp(context)) {
return false
}
return !Environment.isExternalStorageLegacy()
}
}

View File

@ -243,4 +243,14 @@
<string name="migration_successful_message">Migration successful</string>
<string name="migration_failed_message">Migration failed</string>
<string name="migration_transferred_size">Migrated %1$.0f MB of %2$.0f MB</string>
<string name="directory_inaccessible_after_uninstall" comment="Dialog title if AnkiDroid can't access the collection once the app is installed">Inaccessible collection</string>
<string name="directory_inaccessible_after_uninstall_summary" comment="the parameter is the path to the AnkiDroid folder. Typically /storage/emulated/0/AnkiDroid">We are unable to access your collection after AnkiDroid is uninstalled due to a change in Play Store Policy\n\nYour data is safe and can be restored. It is located at\n%s\n\nSelect an option below to restore:</string>
<string name="restore_data_from_ankiweb">Restore from AnkiWeb (recommended)</string>
<string name="install_non_play_store_ankidroid_recommended">Restore folder access (recommended)</string>
<string name="install_non_play_store_ankidroid">Restore folder access (advanced)</string>
<string name="restore_data_from_backup">Restore from .colpkg backup (advanced)</string>
<string name="create_new_collection">Create a new collection</string>
<string name="new_unsafe_collection">The new collection will be deleted from your phone if you uninstall AnkiDroid</string>
</resources>

View File

@ -163,6 +163,7 @@
<string name="link_distributions">https://apps.ankiweb.net/#download</string>
<string name="link_ankiweb_lost_email_instructions">https://github.com/ankidroid/Anki-Android/wiki/FAQ#forgotten-ankiweb-email-instructions</string>
<string name="link_scoped_storage_faq">https://github.com/ankidroid/Anki-Android/wiki/Storage-Migration-FAQ</string>
<string name="link_install_non_play_store_install">https://github.com/ankidroid/Anki-Android/wiki/Full-Storage-Access</string>
<string name="link_custom_sync_server_help_learn_more_en">https://docs.ankidroid.org/#_custom_sync_server</string>
<string-array name="leech_action_values">