Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion AnkiDroid/src/main/res/layout/activity_permissions.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>

Expand Down
2 changes: 1 addition & 1 deletion AnkiDroid/src/main/res/values/01-core.xml
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@
<string name="new_unsafe_collection">The new collection will be deleted from your phone if you uninstall AnkiDroid</string>

<!-- Permissions screen -->
<string name="permissions_screen_headline">AnkiDroid needs some permissions to work</string>
<string name="permissions_screen_headline">AnkiDroid works better with these permissions</string>
<string name="permissions_screen_optional_headline" comment="Explains that there are optional permissions that can be granted to AnkiDroid.">AnkiDroid works best with these permissions</string>
<string name="storage_access_title">Storage access</string>
<string name="storage_access_summary">Saves your collection in a safe place that will not be deleted if the app is uninstalled</string>
Expand Down
4 changes: 4 additions & 0 deletions AnkiDroid/src/main/res/values/03-dialogs.xml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
<string name="dialog_remove">Remove</string>
<string name="dialog_exit" comment="Label for a button which will exit/finish the current screen">Exit</string>
<string name="dialog_continue">Continue</string>
<string name="dialog_skip">Skip</string>
<string name="dialog_processing">Processing…</string>
<string name="dialog_positive_create" comment="Create a new collection, probably erasing the previous one, may be necessary in case of error.">Create</string>
<string name="dialog_positive_delete" maxLength="28">Delete</string>
Expand Down Expand Up @@ -208,6 +209,9 @@
<string name="dismiss_backup_warning_new_user">Due to Android privacy changes, your data and automated backups will be deleted from your phone if the app is uninstalled</string>
<string name="dismiss_backup_warning_upgrade">Due to Android privacy changes, your data and automated backups will be inaccessible if the app is uninstalled</string>

<string name="private_storage_warning_title">Limited storage access</string>
<string name="private_storage_warning">Without this permission, your AnkiDroid collection will be stored in app private storage and will be permanently deleted if you uninstall AnkiDroid.</string>

<string name="create_deck_numeric_hint">If you have deck ordering issues (e.g. ‘10’ appears before ‘2’), replace ‘2’ with ‘02’</string>

<!-- Set Due Date -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand All @@ -47,22 +62,59 @@ class PermissionsActivityTest : RobolectricTest() {

@Test
fun testOnClickingContinueActivityFinishes() {
grantPermissions(*ARBITRARY_PERMISSION_SET.permissions.toTypedArray())
testActivity { activity ->
activity.setContinueButtonEnabled(true)
activity.findViewById<AppCompatButton>(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<AppCompatButton>(R.id.continue_button).performClick()
advanceRobolectricLooper()
val dialog = ShadowDialog.getLatestDialog()
assertThat("dialog is shown", dialog?.isShowing == true)
}
}

@Test
fun testCancelingDialogDoesNotFinish() {
testActivity { activity ->
activity.findViewById<AppCompatButton>(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(),
equalTo(getResourceString(R.string.something_wrong)),
)
}

@Test
fun testConfirmingDialogWritesCorrectPath() {
testActivity { activity ->
activity.findViewById<AppCompatButton>(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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -102,6 +103,30 @@ object CollectionHelper {
*/
fun getAppSpecificExternalDirectories(context: Context): List<File> = 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.
*
Expand Down
Loading