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. *