diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt
index 0d6d7e0637d2..141fb718ea3c 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt
@@ -260,6 +260,10 @@ fun selectStoragePermissions(context: Context): PermissionSet {
val canAccessLegacyStorage = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || Environment.isExternalStorageLegacy()
val currentFolderIsAccessibleAndLegacy = canAccessLegacyStorage && isLegacyStorage(context, setCollectionPath = false) == true
+ if (CollectionHelper.isAppPrivateStorage(context) && !InitialActivity.wasFreshInstall(context.sharedPrefs())) {
+ return PermissionSet.APP_PRIVATE
+ }
+
return selectStoragePermissions(
canManageExternalStorage = Permissions.canManageExternalStorage(context),
currentFolderIsAccessibleAndLegacy = currentFolderIsAccessibleAndLegacy,
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt
index 8847bee09070..64286d4ce824 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt
@@ -22,14 +22,13 @@ import android.os.Parcelable
import androidx.activity.addCallback
import androidx.core.content.IntentCompat
import androidx.fragment.app.commit
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.ichi2.anki.AnkiActivity
import com.ichi2.anki.PermissionSet
import com.ichi2.anki.R
+import com.ichi2.anki.common.storage.CollectionHelper
import com.ichi2.anki.common.utils.android.showThemedToast
import com.ichi2.anki.databinding.ActivityPermissionsBinding
-import com.ichi2.anki.ui.windows.permissions.PermissionsFragment.Companion.HAS_ALL_PERMISSIONS_KEY
-import com.ichi2.anki.ui.windows.permissions.PermissionsFragment.Companion.PERMISSIONS_FRAGMENT_RESULT_KEY
-import com.ichi2.anki.utils.ext.setFragmentResultListener
import com.ichi2.themes.Themes
import com.ichi2.themes.setTransparentStatusBar
import dev.androidbroadcast.vbpd.viewBinding
@@ -60,8 +59,6 @@ class PermissionsActivity : AnkiActivity(R.layout.activity_permissions) {
Themes.setTheme(this)
setTransparentStatusBar()
- binding.continueButton.setOnClickListener { finish() }
-
// #20881: Activity should not be launchd without extras
val permissionSet = IntentCompat.getParcelableExtra(intent, EXTRA_PERMISSIONS_SET, PermissionSet::class.java)
if (permissionSet == null) {
@@ -75,20 +72,36 @@ class PermissionsActivity : AnkiActivity(R.layout.activity_permissions) {
requireNotNull(permissionSet.permissionsFragment?.getDeclaredConstructor()?.newInstance()) {
"invalid permissionsFragment"
}
- setFragmentResultListener(PERMISSIONS_FRAGMENT_RESULT_KEY) { _, bundle ->
- val hasAllPermissions = bundle.getBoolean(HAS_ALL_PERMISSIONS_KEY)
- setContinueButtonEnabled(hasAllPermissions)
- }
supportFragmentManager.commit {
replace(R.id.fragment_container, permissionsFragment)
}
+
+ binding.continueButton.setOnClickListener {
+ if (permissionSet.hasRequiredPermissions(this)) {
+ finish()
+ return@setOnClickListener
+ }
+
+ showPrivateStorageWarningDialog()
+ }
// only close the activity by tapping the continue button
onBackPressedDispatcher.addCallback {}
}
- fun setContinueButtonEnabled(isEnabled: Boolean) {
- binding.continueButton.isEnabled = isEnabled
+ // TODO Rethink the UI - selection UI is preferred over a warning dialog: #21049
+ private fun showPrivateStorageWarningDialog() {
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.private_storage_warning_title)
+ .setMessage(R.string.private_storage_warning)
+ .setPositiveButton(R.string.dialog_continue) { _, _ ->
+ if (!CollectionHelper.setPrivateStoragePath(this)) {
+ showThemedToast(this, R.string.something_wrong, false)
+ return@setPositiveButton
+ }
+ finish()
+ }.setNegativeButton(R.string.dialog_cancel, null)
+ .show()
}
companion object {
diff --git a/AnkiDroid/src/main/res/layout/activity_permissions.xml b/AnkiDroid/src/main/res/layout/activity_permissions.xml
index 06e3c91e766e..bb788029da89 100644
--- a/AnkiDroid/src/main/res/layout/activity_permissions.xml
+++ b/AnkiDroid/src/main/res/layout/activity_permissions.xml
@@ -45,7 +45,7 @@
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:paddingVertical="12dp"
- android:text="@string/dialog_continue"
+ android:text="@string/dialog_skip"
app:layout_constraintBottom_toBottomOf="parent"
/>
diff --git a/AnkiDroid/src/main/res/values/01-core.xml b/AnkiDroid/src/main/res/values/01-core.xml
index 3f21d20a5157..b0f97a61a130 100644
--- a/AnkiDroid/src/main/res/values/01-core.xml
+++ b/AnkiDroid/src/main/res/values/01-core.xml
@@ -157,7 +157,7 @@
The new collection will be deleted from your phone if you uninstall AnkiDroid
- AnkiDroid needs some permissions to work
+ AnkiDroid works better with these permissions
AnkiDroid works best with these permissions
Storage access
Saves your collection in a safe place that will not be deleted if the app is uninstalled
diff --git a/AnkiDroid/src/main/res/values/03-dialogs.xml b/AnkiDroid/src/main/res/values/03-dialogs.xml
index 0a705f4eda2b..14b86a4598f2 100644
--- a/AnkiDroid/src/main/res/values/03-dialogs.xml
+++ b/AnkiDroid/src/main/res/values/03-dialogs.xml
@@ -60,6 +60,7 @@
Remove
Exit
Continue
+ Skip
Processing…
Create
Delete
@@ -208,6 +209,9 @@
Due to Android privacy changes, your data and automated backups will be deleted from your phone if the app is uninstalled
Due to Android privacy changes, your data and automated backups will be inaccessible if the app is uninstalled
+ Limited storage access
+ Without this permission, your AnkiDroid collection will be stored in app private storage and will be permanently deleted if you uninstall AnkiDroid.
+
If you have deck ordering issues (e.g. ‘10’ appears before ‘2’), replace ‘2’ with ‘02’
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivityTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivityTest.kt
index b60eadb4d1b5..c52440c08abb 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivityTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivityTest.kt
@@ -16,27 +16,42 @@
package com.ichi2.anki.ui.windows.permissions
import android.content.Context
+import android.content.DialogInterface
import android.content.Intent
import androidx.appcompat.widget.AppCompatButton
+import androidx.core.content.edit
import androidx.fragment.app.commitNow
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ActivityScenario.ActivityAction
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.AnkiDroidApp
import com.ichi2.anki.PermissionSet
import com.ichi2.anki.R
import com.ichi2.anki.RobolectricTest
+import com.ichi2.anki.common.storage.CollectionHelper
import com.ichi2.testutils.HamcrestUtils.containsInAnyOrder
+import com.ichi2.testutils.grantPermissions
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
+import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
+import org.robolectric.shadows.ShadowDialog
import org.robolectric.shadows.ShadowToast
+import java.io.File
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
@RunWith(AndroidJUnit4::class)
class PermissionsActivityTest : RobolectricTest() {
+ @Before
+ fun clearStorage() {
+ AnkiDroidApp.sharedPrefs().edit {
+ remove(CollectionHelper.PREF_COLLECTION_PATH)
+ }
+ }
+
@Test
fun testActivityCantBeClosedByBackButton() {
testActivity { activity ->
@@ -47,15 +62,36 @@ class PermissionsActivityTest : RobolectricTest() {
@Test
fun testOnClickingContinueActivityFinishes() {
+ grantPermissions(*ARBITRARY_PERMISSION_SET.permissions.toTypedArray())
testActivity { activity ->
- activity.setContinueButtonEnabled(true)
activity.findViewById(R.id.continue_button).performClick()
assertThat("activity is finishing", activity.isFinishing)
}
}
@Test
- fun `error toast is shown if EXTRA_PERMISSIONS_SET is missing`() {
+ fun testOnClickingContinueShowsDialogWhenStorageNotGranted() {
+ testActivity { activity ->
+ activity.findViewById(R.id.continue_button).performClick()
+ advanceRobolectricLooper()
+ val dialog = ShadowDialog.getLatestDialog()
+ assertThat("dialog is shown", dialog?.isShowing == true)
+ }
+ }
+
+ @Test
+ fun testCancelingDialogDoesNotFinish() {
+ testActivity { activity ->
+ activity.findViewById(R.id.continue_button).performClick()
+ advanceRobolectricLooper()
+ clickAlertDialogButton(DialogInterface.BUTTON_NEGATIVE, false)
+ advanceRobolectricLooper()
+ assertThat("activity is not finishing", !activity.isFinishing)
+ }
+ }
+
+ @Test
+ fun `error toast is shown if PERMISSIONS_SET_EXTRA is missing`() {
testInvalidActivityFinishes()
assertThat(
ShadowToast.getTextOfLatestToast(),
@@ -63,6 +99,22 @@ class PermissionsActivityTest : RobolectricTest() {
)
}
+ @Test
+ fun testConfirmingDialogWritesCorrectPath() {
+ testActivity { activity ->
+ activity.findViewById(R.id.continue_button).performClick()
+ advanceRobolectricLooper()
+ clickAlertDialogButton(DialogInterface.BUTTON_POSITIVE, false)
+ advanceRobolectricLooper()
+ val expectedPath = File(CollectionHelper.getAppSpecificExternalDirectories(activity).first(), "AnkiDroid").absolutePath
+ val actualPath =
+ AnkiDroidApp
+ .sharedPrefs()
+ .getString(CollectionHelper.PREF_COLLECTION_PATH, null)
+ assertThat("collection path is set to private dir", actualPath, equalTo(expectedPath))
+ }
+ }
+
@Test
fun `Each screen starts normally and has the same permissions of a PermissionSet`() {
testActivity { activity ->
diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/common/storage/CollectionHelper.kt b/anki-common/src/main/kotlin/com/ichi2/anki/common/storage/CollectionHelper.kt
index 244774323dd8..158e26c5d3be 100644
--- a/anki-common/src/main/kotlin/com/ichi2/anki/common/storage/CollectionHelper.kt
+++ b/anki-common/src/main/kotlin/com/ichi2/anki/common/storage/CollectionHelper.kt
@@ -6,6 +6,7 @@ package com.ichi2.anki.common.storage
import android.content.Context
import android.content.SharedPreferences
import androidx.annotation.VisibleForTesting
+import androidx.core.content.edit
import com.ichi2.anki.common.preferences.sharedPrefs
import com.ichi2.anki.common.utils.android.isInstrumentationTest
import com.ichi2.anki.exception.StorageAccessException
@@ -102,6 +103,30 @@ object CollectionHelper {
*/
fun getAppSpecificExternalDirectories(context: Context): List = context.getExternalFilesDirs(null)?.filterNotNull() ?: listOf()
+ /**
+ * Checks if the currently configured collection path is an app-specific directory.
+ */
+ fun isAppPrivateStorage(context: Context): Boolean {
+ val currentPath = context.sharedPrefs().getString(PREF_COLLECTION_PATH, null) ?: return false
+ return getAppSpecificExternalDirectories(context).any { currentPath.startsWith(it.absolutePath) }
+ }
+
+ /**
+ * Sets the collection path to the app-specific private directory.
+ * @return true if successful, false otherwise.
+ */
+ fun setPrivateStoragePath(context: Context): Boolean {
+ val externalFilesDirs = getAppSpecificExternalDirectories(context)
+ if (externalFilesDirs.isEmpty()) {
+ return false
+ }
+ val privateDir = File(externalFilesDirs.first(), "AnkiDroid")
+ context.sharedPrefs().edit {
+ putString(PREF_COLLECTION_PATH, privateDir.absolutePath)
+ }
+ return true
+ }
+
/**
* Returns the absolute path to the private AnkiDroid directory under the app-specific, internal storage directory.
*