From f3d762287ef0936825227414ef204f1e2aa3c876 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison-1@users.noreply.github.com> Date: Fri, 26 Nov 2021 21:10:39 +0000 Subject: [PATCH] feat(scoped-storage): Add consent dialog To release on the Play Store, we need to implement scoped storage Since the migration to scoped storage can take a while, and has a low, but nonzero chance at data corruption, we want to show a dialog to the user, ensuring that: * They understand that we need to do this * They understand the risks * They have backed up * They won't uninstall the app before/during the migration We also add a link to https://github.com/ankidroid/Anki-Android/wiki/Storage-Migration-FAQ to ensure that users are informed. Since we can set "preserveLegacyExternalStorage", we don't need the user to immediately migrate, but this stops working if the user uninstalls, and the directory becomes inaccessible Issue 5304 --- .../main/java/com/ichi2/anki/Preferences.java | 7 + .../dialogs/ScopedStorageMigrationDialog.kt | 149 ++++++++++++++++++ .../layout/scoped_storage_confirmation.xml | 64 ++++++++ AnkiDroid/src/main/res/values/02-strings.xml | 13 ++ AnkiDroid/src/main/res/values/constants.xml | 1 + AnkiDroid/src/main/res/values/styles.xml | 5 + docs/scoped_storage/README.md | 1 + docs/scoped_storage/consent.md | 22 +++ 8 files changed, 262 insertions(+) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ScopedStorageMigrationDialog.kt create mode 100644 AnkiDroid/src/main/res/layout/scoped_storage_confirmation.xml create mode 100644 docs/scoped_storage/README.md create mode 100644 docs/scoped_storage/consent.md diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Preferences.java b/AnkiDroid/src/main/java/com/ichi2/anki/Preferences.java index cc04b85220..739fbea99a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/Preferences.java +++ b/AnkiDroid/src/main/java/com/ichi2/anki/Preferences.java @@ -788,6 +788,13 @@ public class Preferences extends AnkiActivity { } } + /** Whether the user is logged on to AnkiWeb */ + public static boolean hasAnkiWebAccount(SharedPreferences preferences) { + String userName = preferences.getString("username", ""); + return !TextUtils.isEmpty(userName); + } + + /** * Temporary abstraction * Due to deprecation, we need to move from all Preference code in the Preference activity diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ScopedStorageMigrationDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ScopedStorageMigrationDialog.kt new file mode 100644 index 0000000000..d72a710d81 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ScopedStorageMigrationDialog.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2021 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.dialogs + +import android.app.Dialog +import android.content.Context +import android.net.Uri +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.view.View +import android.widget.CheckBox +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.util.DialogUtils +import com.ichi2.anki.* +import com.ichi2.anki.cardviewer.CardAppearance +import com.ichi2.ui.FixedTextView +import timber.log.Timber + +typealias OpenUri = (Uri) -> Unit + +/** + * Informs the user that a scoped storage migration will occur + * + * Explains risks of not migrating + * Explains time taken to migrate + * Obtains consent + * + * For info, see: docs\scoped_storage\README.md + * For design decisions, see: docs\scoped_storage\consent.md + */ +object ScopedStorageMigrationDialog { + @JvmStatic + fun showDialog(ctx: Context, openUri: OpenUri): Dialog { + return MaterialDialog.Builder(ctx) + .title(R.string.scoped_storage_title) + .content(R.string.scoped_storage_initial_message) + .positiveText(R.string.scoped_storage_migrate) + .onPositive { dialog, _ -> + run { + ScopedStorageMigrationConfirmationDialog.showDialog(ctx) + dialog.dismiss() + } + } + .neutralText(R.string.scoped_storage_learn_more) + .onNeutral { _, _ -> openMoreInfo(ctx, openUri) } + .negativeText(R.string.scoped_storage_postpone) + .onNegative { dialog, _ -> dialog.dismiss() } + .cancelable(false) + .autoDismiss(false) + .show() + } +} + +/** + * Obtains explicit consent that: + * + * * User will not uninstall the app + * * User has either + * * performed an AnkiWeb sync (if an account is found) + * * performed a regular backup + * + * Then performs a migration to scoped storage + */ +object ScopedStorageMigrationConfirmationDialog { + fun showDialog(ctx: Context): Dialog { + val li = LayoutInflater.from(ctx) + val view = li.inflate(R.layout.scoped_storage_confirmation, null) + + // scoped_storage_terms_message requires a format arg: estimated time taken + val textView = view.findViewById(R.id.scoped_storage_content) + textView.text = ctx.getString(R.string.scoped_storage_terms_message, "??? minutes") + + val userWillNotUninstall = view.findViewById(R.id.scoped_storage_no_uninstall) + val noAnkiWeb = view.findViewById(R.id.scoped_storage_no_ankiweb) + val ifAnkiWeb = view.findViewById(R.id.scoped_storage_ankiweb) + + // If the user has an AnkiWeb account, ask them to sync, otherwise ask them to backup + val hasAnkiWebAccount = Preferences.hasAnkiWebAccount(AnkiDroidApp.getSharedPrefs(ctx)) + + val backupMethodToDisable = if (hasAnkiWebAccount) noAnkiWeb else ifAnkiWeb + val backupMethodToUse = if (hasAnkiWebAccount) ifAnkiWeb else noAnkiWeb + + backupMethodToDisable.visibility = View.GONE + + // hack: should be performed in custom_material_dialog_content style + getContentColor(ctx)?.let { + textView.setTextColor(it) + userWillNotUninstall.setTextColor(it) + noAnkiWeb.setTextColor(it) + ifAnkiWeb.setTextColor(it) + } + + val checkboxesRequiredToContinue = listOf(userWillNotUninstall, backupMethodToUse) + + return MaterialDialog.Builder(ctx) + .title(R.string.scoped_storage_title) + .customView(view, true) + .positiveText(R.string.scoped_storage_migrate) + .onPositive { dialog, _ -> + if (checkboxesRequiredToContinue.all { x -> x.isChecked }) { + Timber.d("enable scoped storage migration") + dialog.dismiss() + } else { + UIUtils.showThemedToast(ctx, R.string.scoped_storage_select_all_terms, true) + } + } + .negativeText(R.string.scoped_storage_postpone) + .onNegative { dialog, _ -> dialog.dismiss() } + .cancelable(false) + .autoDismiss(false) + .show() + } + + private fun getContentColor(ctx: Context): Int? { + return try { + val isDarkTheme = CardAppearance.isInNightMode(AnkiDroidApp.getSharedPrefs(ctx)) + + val theme = if (isDarkTheme) com.afollestad.materialdialogs.R.style.MD_Dark else com.afollestad.materialdialogs.R.style.MD_Light + + val contextThemeWrapper = ContextThemeWrapper(ctx, theme) + val contentColorFallback = DialogUtils.resolveColor(contextThemeWrapper, android.R.attr.textColorSecondary) + DialogUtils.resolveColor(contextThemeWrapper, com.afollestad.materialdialogs.R.attr.md_content_color, contentColorFallback) + } catch (e: Exception) { + null + } + } +} + +/** Opens more info about scoped storage (AnkiDroid wiki) */ +private fun openMoreInfo(ctx: Context, openUri: (Uri) -> (Unit)) { + val faqUri = Uri.parse(ctx.getString(R.string.link_scoped_storage_faq)) + openUri(faqUri) +} + +fun openUrl(activity: AnkiActivity): OpenUri = activity::openUrl diff --git a/AnkiDroid/src/main/res/layout/scoped_storage_confirmation.xml b/AnkiDroid/src/main/res/layout/scoped_storage_confirmation.xml new file mode 100644 index 0000000000..3969d215fe --- /dev/null +++ b/AnkiDroid/src/main/res/layout/scoped_storage_confirmation.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AnkiDroid/src/main/res/values/02-strings.xml b/AnkiDroid/src/main/res/values/02-strings.xml index 4aed9d21e0..c7e13a6552 100644 --- a/AnkiDroid/src/main/res/values/02-strings.xml +++ b/AnkiDroid/src/main/res/values/02-strings.xml @@ -408,4 +408,17 @@ Make field %s sticky + + Storage migration + AnkiDroid must move its data to meet new Android privacy rules +\n\nIf you uninstall AnkiDroid before the migration, your data will be lost +\n\nPlease perform the migration as soon as possible + Migration will occur in the background. You will be unable to sync while migrating\n\nEstimated duration: %s + I have backed up my data + I have synced with AnkiWeb recently + I will not uninstall the app while data is being moved + Please accept all items to continue + Learn More + Postpone + Migrate diff --git a/AnkiDroid/src/main/res/values/constants.xml b/AnkiDroid/src/main/res/values/constants.xml index 2337fcdb6a..718cd62011 100644 --- a/AnkiDroid/src/main/res/values/constants.xml +++ b/AnkiDroid/src/main/res/values/constants.xml @@ -215,6 +215,7 @@ https://docs.ankiweb.net/editing.html#cloze-deletion https://apps.ankiweb.net/#download https://github.com/ankidroid/Anki-Android/wiki/FAQ#forgotten-ankiweb-email-instructions + https://github.com/ankidroid/Anki-Android/wiki/Storage-Migration-FAQ 0 diff --git a/AnkiDroid/src/main/res/values/styles.xml b/AnkiDroid/src/main/res/values/styles.xml index 5f8b84c5c1..1ff9fe6e0c 100644 --- a/AnkiDroid/src/main/res/values/styles.xml +++ b/AnkiDroid/src/main/res/values/styles.xml @@ -161,4 +161,9 @@ 0.125 @drawable/ic_gesture_tap + + diff --git a/docs/scoped_storage/README.md b/docs/scoped_storage/README.md new file mode 100644 index 0000000000..b48eae4e53 --- /dev/null +++ b/docs/scoped_storage/README.md @@ -0,0 +1 @@ +See: https://developer.android.com/about/versions/11/privacy/storage \ No newline at end of file diff --git a/docs/scoped_storage/consent.md b/docs/scoped_storage/consent.md new file mode 100644 index 0000000000..4efffcc1f0 --- /dev/null +++ b/docs/scoped_storage/consent.md @@ -0,0 +1,22 @@ +We should inform the users: + +* The driving force is privacy +* Uninstalling before migration will disable `preserveLegacyExternalStorage` and they will need to perform a manual migration + * Just tell them not to uninstall +* Syncing will temporarily be disabled +* Uninstalling during the process may cause data loss +* Uninstalling after the process will delete all data instead of just settings +* How long this will take + +We should obtain explicit consent: + +* Since we are moving files, this puts data itegrity at (low) risk +* To reduce issues from users uninstalling + +We may inform users: + +* Syncing and importing will be much faster +* The new location of storage +* USB access only + +We do this via a link to https://github.com/ankidroid/Anki-Android/wiki/Storage-Migration-FAQ \ No newline at end of file