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:
parent
5c06c1806f
commit
136cfb3f8a
@ -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
|
||||
*
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
Loading…
Reference in New Issue
Block a user