0
0
mirror of https://github.com/ankidroid/Anki-Android.git synced 2024-09-19 19:42:17 +02:00

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
This commit is contained in:
David Allison 2021-11-26 21:10:39 +00:00 committed by Mike Hardy
parent 6f939212a9
commit f3d762287e
8 changed files with 262 additions and 0 deletions

View File

@ -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

View File

@ -0,0 +1,149 @@
/*
* Copyright (c) 2021 David Allison <davidallisongithub@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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<FixedTextView>(R.id.scoped_storage_content)
textView.text = ctx.getString(R.string.scoped_storage_terms_message, "??? minutes")
val userWillNotUninstall = view.findViewById<CheckBox>(R.id.scoped_storage_no_uninstall)
val noAnkiWeb = view.findViewById<CheckBox>(R.id.scoped_storage_no_ankiweb)
val ifAnkiWeb = view.findViewById<CheckBox>(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

View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2021 David Allison <davidallisongithub@gmail.com>
~
~ 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 <http://www.gnu.org/licenses/>.
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.ichi2.ui.FixedTextView
android:id="@+id/scoped_storage_content"
style="@style/custom_material_dialog_content"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="@string/scoped_storage_terms_message" />
<!-- 20dp margin on both: one of the two is removed -->
<CheckBox
android:id="@+id/scoped_storage_no_ankiweb"
style="@style/custom_material_dialog_content"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_marginTop="20dp"
android:text="@string/scoped_storage_term_1"
app:layout_constraintStart_toStartOf="@+id/scoped_storage_content"
app:layout_constraintTop_toBottomOf="@+id/scoped_storage_content" />
<CheckBox
android:id="@+id/scoped_storage_ankiweb"
style="@style/custom_material_dialog_content"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_marginTop="20dp"
android:text="@string/scoped_storage_term_1_ankiweb"
app:layout_constraintStart_toStartOf="@+id/scoped_storage_no_ankiweb"
app:layout_constraintTop_toBottomOf="@+id/scoped_storage_no_ankiweb" />
<CheckBox
android:id="@+id/scoped_storage_no_uninstall"
style="@style/custom_material_dialog_content"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:text="@string/scoped_storage_term_2"
app:layout_constraintStart_toStartOf="@+id/scoped_storage_ankiweb"
app:layout_constraintTop_toBottomOf="@+id/scoped_storage_ankiweb" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -408,4 +408,17 @@
<!-- Note Editor Toggle Sticky -->
<string name="note_editor_toggle_sticky">Make field %s sticky</string>
<!-- Scoped Storage Migration -->
<string name="scoped_storage_title">Storage migration</string>
<string name="scoped_storage_initial_message">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</string>
<string name="scoped_storage_terms_message">Migration will occur in the background. You will be unable to sync while migrating\n\nEstimated duration: %s</string>
<string name="scoped_storage_term_1">I have backed up my data</string>
<string name="scoped_storage_term_1_ankiweb">I have synced with AnkiWeb recently</string>
<string name="scoped_storage_term_2">I will not uninstall the app while data is being moved</string>
<string name="scoped_storage_select_all_terms">Please accept all items to continue</string>
<string name="scoped_storage_learn_more">Learn More</string>
<string name="scoped_storage_postpone">Postpone</string>
<string name="scoped_storage_migrate">Migrate</string>
</resources>

View File

@ -215,6 +215,7 @@
<string name="link_ankiweb_docs_cloze_deletion">https://docs.ankiweb.net/editing.html#cloze-deletion</string>
<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-array name="leech_action_values">
<item>0</item>

View File

@ -161,4 +161,9 @@
<item name="layout_constraintHeight_percent">0.125</item>
<item name="android:background">@drawable/ic_gesture_tap</item>
</style>
<style name="custom_material_dialog_content">
<!--<item name="android:textColor">?attr/md_content_color</item>-->
<item name="android:textSize">@dimen/md_content_textsize</item>
</style>
</resources>

View File

@ -0,0 +1 @@
See: https://developer.android.com/about/versions/11/privacy/storage

View File

@ -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