diff --git a/AnkiDroid/src/main/AndroidManifest.xml b/AnkiDroid/src/main/AndroidManifest.xml
index c02c7cfcedb1..6f7089417637 100644
--- a/AnkiDroid/src/main/AndroidManifest.xml
+++ b/AnkiDroid/src/main/AndroidManifest.xml
@@ -256,6 +256,10 @@
android:exported="false"
android:configChanges="keyboardHidden|orientation|screenSize"
/>
+
) {
@@ -2505,26 +2498,6 @@ open class DeckPicker :
private const val AUTOMATIC_SYNC_MINIMAL_INTERVAL_IN_MINUTES: Long = 10
private const val SWIPE_TO_SYNC_TRIGGER_DISTANCE = 400
- /**
- * Handles a [PermissionsRequestResults] for storage permissions to launch 'checkMedia'
- * Static to avoid a context leak
- */
- fun callbackHandlingStoragePermissionsCheckForCheckMedia(deckPickerRef: WeakReference): (permissionResultRaw: PermissionsRequestRawResults) -> Unit {
- fun handleStoragePermissionsCheckForCheckMedia(permissionResultRaw: PermissionsRequestRawResults) {
- val deckPicker = deckPickerRef.get() ?: return
- val permissionResult = PermissionsRequestResults.from(deckPicker, permissionResultRaw)
- if (permissionResult.allGranted) {
- deckPicker.launchCatchingTask {
- val mediaCheckResult = deckPicker.checkMedia() ?: return@launchCatchingTask
- deckPicker.showMediaCheckDialog(MediaCheckDialog.DIALOG_MEDIA_CHECK_RESULTS, mediaCheckResult)
- }
- } else {
- showThemedToast(deckPicker, R.string.check_media_failed, true)
- }
- }
- return ::handleStoragePermissionsCheckForCheckMedia
- }
-
// Animation utility methods used by renderPage() method
fun fadeIn(view: View?, duration: Int, translation: Float = 0f, startAction: Runnable? = Runnable { view!!.visibility = View.VISIBLE }): ViewPropertyAnimator {
view!!.alpha = 0f
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt
index e9327f7dc43e..fa0ffebe9fce 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt
@@ -21,17 +21,20 @@ import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import android.os.Environment
+import android.os.Parcelable
import androidx.annotation.CheckResult
+import androidx.annotation.RequiresApi
import androidx.core.content.edit
-import com.ichi2.anki.permissions.PermissionManager
-import com.ichi2.anki.permissions.PermissionsRequestResults
-import com.ichi2.anki.permissions.finishActivityAndShowAppPermissionManagementScreen
import com.ichi2.anki.servicelayer.PreferenceUpgradeService
import com.ichi2.anki.servicelayer.PreferenceUpgradeService.setPreferencesUpToDate
import com.ichi2.anki.servicelayer.ScopedStorageService.isLegacyStorage
-import com.ichi2.annotations.NeedsTest
+import com.ichi2.anki.ui.windows.permissions.Full30and31PermissionsFragment
+import com.ichi2.anki.ui.windows.permissions.PermissionsFragment
+import com.ichi2.anki.ui.windows.permissions.PermissionsUntil29Fragment
+import com.ichi2.anki.ui.windows.permissions.TiramisuPermissionsFragment
import com.ichi2.utils.Permissions
import com.ichi2.utils.VersionUtils.pkgVersionName
+import kotlinx.parcelize.Parcelize
import timber.log.Timber
/** Utilities for launching the first activity (currently the DeckPicker) */
@@ -125,19 +128,15 @@ object InitialActivity {
}
}
-/**
- * Whether we should try a startup with a permission dialog + folder which is safe from uninstalling
- * or go straight into AnkiDroid
- */
-sealed interface AnkiDroidFolder {
+sealed class AnkiDroidFolder(val permissionSet: PermissionSet) {
/**
* AnkiDroid will use the folder ~/AnkiDroid by default
- * To access it, we must first get [requiredPermissions].
+ * To access it, we must first get [permissionSet].permissions.
* This folder is not deleted when the user uninstalls the app, which reduces the risk of data loss,
* but increase the risk of space used on their storage when they don't want to.
* It can not be used on the play store starting with Sdk 30.
**/
- class PublicFolder(val requiredPermissions: Array) : AnkiDroidFolder
+ class PublicFolder(requiredPermissions: PermissionSet) : AnkiDroidFolder(requiredPermissions)
/**
* AnkiDroid will use the app-private folder: `~/Android/data/com.ichi2.anki[.A]/files/AnkiDroid`.
@@ -145,7 +144,29 @@ sealed interface AnkiDroidFolder {
* No permission dialog is required.
* Google will not allow [android.Manifest.permission.MANAGE_EXTERNAL_STORAGE], so this is default on the Play Store.
*/
- object AppPrivateFolder : AnkiDroidFolder
+ object AppPrivateFolder : AnkiDroidFolder(PermissionSet.APP_PRIVATE)
+
+ fun hasRequiredPermissions(context: Context): Boolean {
+ return Permissions.hasAllPermissions(context, permissionSet.permissions)
+ }
+}
+
+@Parcelize
+enum class PermissionSet(val permissions: List, val permissionsFragment: Class?) : Parcelable {
+ LEGACY_ACCESS(Permissions.legacyStorageAccessPermissions, PermissionsUntil29Fragment::class.java),
+
+ @RequiresApi(Build.VERSION_CODES.R)
+ EXTERNAL_MANAGER(listOf(Permissions.MANAGE_EXTERNAL_STORAGE), Full30and31PermissionsFragment::class.java),
+
+ /** [Permissions.legacyStorageAccessPermissions] are asked to get access to media,
+ * so the "check media" function can work, and media is synced */
+ @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+ TIRAMISU_EXTERNAL_MANAGER(
+ permissions = listOf(Permissions.MANAGE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE),
+ permissionsFragment = TiramisuPermissionsFragment::class.java
+ ),
+
+ APP_PRIVATE(emptyList(), null);
}
/**
@@ -163,135 +184,27 @@ internal fun selectAnkiDroidFolder(
// since it's fast & safe up to & including 'Q'
// If a user upgrades their OS from Android 10 to 11 then storage speed is severely reduced
// and a user should use one of the below options to provide faster speeds
- return AnkiDroidFolder.PublicFolder(
- arrayOf(
- Manifest.permission.READ_EXTERNAL_STORAGE,
- Manifest.permission.WRITE_EXTERNAL_STORAGE
- )
- )
+ return AnkiDroidFolder.PublicFolder(PermissionSet.LEGACY_ACCESS)
}
// If the user can manage external storage, we can access the safe folder & access is fast
return if (canManageExternalStorage) {
- AnkiDroidFolder.PublicFolder(arrayOf(Manifest.permission.MANAGE_EXTERNAL_STORAGE))
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ AnkiDroidFolder.PublicFolder(PermissionSet.TIRAMISU_EXTERNAL_MANAGER)
+ } else {
+ AnkiDroidFolder.PublicFolder(PermissionSet.EXTERNAL_MANAGER)
+ }
} else {
return AnkiDroidFolder.AppPrivateFolder
}
}
-/**
- * Logic related to [DeckPicker] startup - required permissions for storage
- * Handles: Accept, Deny + Permanent Deny of permissions
- *
- * Designed to allow expansion for more complex logic
- */
-@NeedsTest("New User: Accepts permission")
-@NeedsTest("New User: Denies permission then accepts")
-@NeedsTest("New User: Denies permission then denies permanently")
-@NeedsTest("New User: Denies permission permanently")
-@NeedsTest("Existing User: Permission Granted")
-@NeedsTest("Existing User: System removed permission")
-@NeedsTest("Existing User: Changes Deck")
-class StartupStoragePermissionManager private constructor(
- private val deckPicker: DeckPicker,
- ankidroidFolder: AnkiDroidFolder,
- useCallbackIfActivityRecreated: Boolean
-) {
- private var timesRequested: Int = 0
- private val requiredPermissions = when (ankidroidFolder) {
- is AnkiDroidFolder.AppPrivateFolder -> noPermissionDialogRequired
- is AnkiDroidFolder.PublicFolder -> ankidroidFolder.requiredPermissions
- }
-
- /**
- * Show "Please grant AnkiDroid the ‘Storage’ permission to continue" and open Android settings
- * for AnkiDroid's permissions
- */
- private fun onPermissionPermanentlyDenied() {
- // User denied access to file storage so show error toast and display "App Info"
- UIUtils.showThemedToast(deckPicker, R.string.startup_no_storage_permission, false)
- // note: this may not be defined on some Phones. In which case we still have a toast
- deckPicker.finishActivityAndShowAppPermissionManagementScreen()
- }
+fun selectAnkiDroidFolder(context: Context): AnkiDroidFolder {
+ val canAccessLegacyStorage = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || Environment.isExternalStorageLegacy()
+ val currentFolderIsAccessibleAndLegacy = canAccessLegacyStorage && isLegacyStorage(context, setCollectionPath = false) == true
- private fun onRegularStartup() {
- deckPicker.invalidateOptionsMenu()
- deckPicker.handleStartup()
- }
-
- private fun retryPermissionRequest(displayError: Boolean) {
- if (timesRequested < 3) {
- displayStoragePermissionDialog()
- } else {
- if (displayError) {
- Timber.w("doing nothing - app is probably broken")
- CrashReportService.sendExceptionReport("Multiple errors obtaining permissions", "InitialActivity::permissionManager")
- }
- onPermissionPermanentlyDenied()
- }
- }
-
- private val permissionManager = PermissionManager.register(
- activity = deckPicker,
- permissions = requiredPermissions,
- useCallbackIfActivityRecreated = useCallbackIfActivityRecreated,
- callback = { permissionDialogResultRaw ->
- val permissionDialogResult = PermissionsRequestResults.from(deckPicker, permissionDialogResultRaw)
- with(permissionDialogResult) {
- when {
- allGranted -> onRegularStartup()
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && hasPermanentlyDeniedPermissions -> onPermissionPermanentlyDenied()
- // try again (recurse), we need the permission
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && hasTemporarilyDeniedPermissions -> retryPermissionRequest(displayError = false)
- hasRejectedPermissions -> retryPermissionRequest(displayError = false)
- cancelled -> {
- if (timesRequested == 1) {
- UIUtils.showThemedToast(deckPicker, R.string.something_wrong, false)
- }
- retryPermissionRequest(displayError = true)
- }
- }
- }
- }
+ return selectAnkiDroidFolder(
+ canManageExternalStorage = Permissions.canManageExternalStorage(context),
+ currentFolderIsAccessibleAndLegacy = currentFolderIsAccessibleAndLegacy
)
-
- fun displayStoragePermissionDialog() {
- timesRequested++
- permissionManager.launchPermissionDialog()
- }
-
- fun checkPermissions() = permissionManager.checkPermissions()
-
- companion object {
- /** If no permissions are provided, no dialog is shown */
- private val noPermissionDialogRequired = emptyArray()
-
- /**
- * This **must** be called unconditionally, as part of initialization path inside after `super.onCreate`
- * */
- fun register(
- deckPicker: DeckPicker,
- useCallbackIfActivityRecreated: Boolean
- ): StartupStoragePermissionManager {
- // This must be called unconditionally due to the use of PermissionManager.register
- // This must be called after `onCreate` due to the use of deckPicker as a context
-
- val permissionRequest = selectAnkiDroidFolder(deckPicker)
- return StartupStoragePermissionManager(
- deckPicker,
- permissionRequest,
- useCallbackIfActivityRecreated = useCallbackIfActivityRecreated
- )
- }
-
- fun selectAnkiDroidFolder(context: Context): AnkiDroidFolder {
- val canAccessLegacyStorage = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || Environment.isExternalStorageLegacy()
- val currentFolderIsAccessibleAndLegacy = canAccessLegacyStorage && isLegacyStorage(context, setCollectionPath = false) == true
-
- return selectAnkiDroidFolder(
- canManageExternalStorage = Permissions.canManageExternalStorage(context),
- currentFolderIsAccessibleAndLegacy = currentFolderIsAccessibleAndLegacy
- )
- }
- }
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/permissions/PermissionManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/permissions/PermissionManager.kt
deleted file mode 100644
index 11dd369894ad..000000000000
--- a/AnkiDroid/src/main/java/com/ichi2/anki/permissions/PermissionManager.kt
+++ /dev/null
@@ -1,189 +0,0 @@
-/*
- * Copyright (c) 2023 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.permissions
-
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
-import android.os.Build
-import android.provider.Settings
-import androidx.activity.result.ActivityResultLauncher
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.annotation.CheckResult
-import androidx.annotation.RequiresApi
-import com.ichi2.anki.AnkiActivity
-import com.ichi2.anki.R
-import com.ichi2.anki.UIUtils
-import com.ichi2.utils.Permissions
-import timber.log.Timber
-import java.lang.ref.WeakReference
-
-/**
- * Handle permission requests via:
- * * [permissionDialogLauncher] returning whether it will execute the provided callback
- * * [PermissionsRequestResults] encapsulating temporary and permanent permission failure
- * @param permissions an array of permissions which PermissionManager will request (may be empty)
- * @param useCallbackIfActivityRecreated Whether [callback] should be executed if the activity was recreated.
- * Some logic may be re-executed on startup, and therefore the callback is unnecessary.
-*/
-class PermissionManager private constructor(
- activity: AnkiActivity,
- val permissions: Array,
- private val useCallbackIfActivityRecreated: Boolean,
- // callback must be supplied here to allow for recreation of the activity if destroyed
- private val callback: (permissionDialogResult: PermissionsRequestRawResults) -> Unit
-) {
-
- /**
- * Has a [ActivityResultLauncher.launch] method which accepts one or more permissions and displays
- * an Android permissions dialog. The callback is executed once the permissions dialog is closed
- * and focus returns to the app; except in one case, @see [useCallbackIfActivityRecreated]
- */
- private val permissionDialogLauncher: ActivityResultLauncher> =
- activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results ->
- if (!permissionsRequestedInCurrentInstance) {
- // This can occur if the activity that sent the request was deleted while requesting for permission.
- if (!useCallbackIfActivityRecreated) {
- // We may not want to execute the callback, because everything that it would do
- // is done during the creation of the new activity. So we can just do an early return
- return@registerForActivityResult
- }
- }
- callback.invoke(results)
- }
- private val activityRef = WeakReference(activity)
-
- // Whether permissions were requested in this instance
- private var permissionsRequestedInCurrentInstance: Boolean = false
-
- fun checkPermissions(): PermissionsCheckResult {
- val activity = activityRef.get() ?: throw Exception("activity disposed")
- val permissions = permissions.associateWith { Permissions.hasPermission(activity, it) }
- return PermissionsCheckResult(permissions)
- }
-
- @RequiresApi(Build.VERSION_CODES.R)
- private fun willRequestManageExternalStorage(context: Context): Boolean {
- val requiresManageExternalStoragePermission = permissions.contains(MANAGE_EXTERNAL_STORAGE)
- val isManageExternalStorageGranted = Permissions.hasPermission(context, MANAGE_EXTERNAL_STORAGE)
-
- return requiresManageExternalStoragePermission && !isManageExternalStorageGranted
- }
-
- /**
- * Launches a permission dialog. [callback] is executed after it is closed.
- * Should be called if [PermissionsCheckResult.requiresPermissionDialog] is true
- */
- @CheckResult
- fun launchPermissionDialog() {
- permissionsRequestedInCurrentInstance = true
- if (!permissions.any()) {
- throw IllegalStateException("permissions should be non-empty/requiresPermissionDialog was not called")
- }
-
- val activity = activityRef.get() ?: throw Exception("activity disposed")
-
- // 'Manage External Storage' needs special-casing as launchPermissionDialog can't request it
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && willRequestManageExternalStorage(activity)) {
- // Open an external screen and close the activity.
- // Accepting this permission closes the app
- UIUtils.showThemedToast(activity, R.string.startup_all_files_access_permission, false)
- activity.showManageAllFilesScreen()
- return
- }
-
- Timber.i("Showing dialog to request: '%s'", permissions.joinToString(", "))
- permissionDialogLauncher.launch(permissions)
- }
-
- fun launchDialogOrExecuteCallbackNow() {
- val permissions = checkPermissions()
- if (permissions.requiresPermissionDialog) {
- this.launchPermissionDialog()
- } else {
- callback.invoke(permissions.toPermissionsRequestRawResult()!!)
- }
- }
-
- companion object {
- @RequiresApi(Build.VERSION_CODES.R)
- private const val MANAGE_EXTERNAL_STORAGE = android.Manifest.permission.MANAGE_EXTERNAL_STORAGE
-
- /**
- * This ** must be called unconditionally, as part of AnkiDroid initialization path.
- * This is because we can't know whether we'll be receiving the result of the activity requesting the permissions.
- * We typically call it by assigning its result to a field during initialization of an activity.
- */
- fun register(
- activity: AnkiActivity,
- permissions: Array,
- useCallbackIfActivityRecreated: Boolean,
- callback: (permissionDialogResult: PermissionsRequestRawResults) -> Unit
- ): PermissionManager =
- PermissionManager(activity, permissions, useCallbackIfActivityRecreated, callback)
- }
-}
-
-/**
- * Closes the activity. Opens the Android settings for AnkiDroid if the phone provide this feature.
- * Lets a user grant any missing permissions which have been permanently denied
- * We finish the activity as setting permissions terminates the app
- */
-fun AnkiActivity.finishActivityAndShowAppPermissionManagementScreen() {
- this.finishWithoutAnimation()
- showAppPermissionManagementScreen()
-}
-
-private fun AnkiActivity.showAppPermissionManagementScreen() {
- startActivity(
- Intent(
- Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
- Uri.fromParts("package", this.packageName, null)
- )
- )
-}
-
-/**
- * Opens the Android 'MANAGE_ALL_FILES' page if the phone provides this feature.
- */
-@RequiresApi(Build.VERSION_CODES.R)
-fun AnkiActivity.showManageAllFilesScreen() {
- // This screen is simpler than the one from displayAppPermissionManagementScreen:
- // In 'AppPermissionManagement' a user has to go to permissions -> storage -> 'allow management of all files' -> dialog warning
- // In 'ManageAllFiles': a user selects the app which has permission
- val accessAllFilesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
- if (Permissions.isExternalStorageManager()) {
- recreate()
- } else {
- finish()
- }
- }
-
- val intent = Intent(
- Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
- Uri.fromParts("package", this.packageName, null)
- )
-
- // From the docs: [ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION]
- // In some cases, a matching Activity may not exist, so ensure you safeguard against this.
- if (intent.resolveActivity(packageManager) != null) {
- accessAllFilesLauncher.launch(intent)
- } else {
- // This also allows management of the all files permission (worse UI)
- finishActivityAndShowAppPermissionManagementScreen()
- }
-}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/permissions/PermissionsCheckResult.kt b/AnkiDroid/src/main/java/com/ichi2/anki/permissions/PermissionsCheckResult.kt
deleted file mode 100644
index fface6cfbf4e..000000000000
--- a/AnkiDroid/src/main/java/com/ichi2/anki/permissions/PermissionsCheckResult.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * Copyright (c) 2023 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.permissions
-
-/**
- * The result of checking the status of permissions
- * @param permissions A map, containing an entry for each required permission, associating to it whether it's already granted
- */
-open class PermissionsCheckResult(val permissions: Map) {
- val allGranted = permissions.all { it.value }
- val requiresPermissionDialog: Boolean = permissions.any { !it.value }
-
- /**
- * @return A [PermissionsRequestRawResults], or `null` if a permissions dialog is required.
- */
- fun toPermissionsRequestRawResult(): PermissionsRequestRawResults? {
- if (requiresPermissionDialog) {
- return null
- }
- return permissions
- }
-}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/permissions/PermissionsRequestResults.kt b/AnkiDroid/src/main/java/com/ichi2/anki/permissions/PermissionsRequestResults.kt
deleted file mode 100644
index 7e420b718419..000000000000
--- a/AnkiDroid/src/main/java/com/ichi2/anki/permissions/PermissionsRequestResults.kt
+++ /dev/null
@@ -1,99 +0,0 @@
-/*
- * Copyright (c) 2023 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.permissions
-
-import android.app.Activity
-import android.os.Build
-import androidx.annotation.RequiresApi
-import com.ichi2.anki.permissions.PermissionsRequestResults.PermissionRequestResult.*
-import com.ichi2.anki.permissions.PermissionsRequestResults.PermissionRequestResult.Companion.toPermissionRequestResult
-
-/**
- * Data to build a [PermissionsRequestResults], when there is no [Activity] to create it
- * Maps from a permission to whether it was granted
- */
-typealias PermissionsRequestRawResults = Map
-
-/**
- * The result of requesting permissions
- * After we perform a request, starting at API M, we can know if a permission is temporarily or permanently denied.
- */
-class PermissionsRequestResults(permissions: Map) {
- // If the permissions request interaction with the user is interrupted.
- // then we get an empty results array
- // https://developer.android.com/reference/androidx/core/app/ActivityCompat.OnRequestPermissionsResultCallback
- val cancelled = !permissions.any()
-
- val allGranted = !cancelled && permissions.all { it.value == GRANTED }
-
- val hasRejectedPermissions = permissions.any { it.value.isDenied() }
-
- @RequiresApi(Build.VERSION_CODES.M)
- val hasPermanentlyDeniedPermissions = permissions.any { it.value == PERMANENTLY_DENIED }
-
- @RequiresApi(Build.VERSION_CODES.M)
- val hasTemporarilyDeniedPermissions = permissions.any { it.value == TEMPORARILY_DENIED }
-
- companion object {
- fun allGranted(checkResult: PermissionsCheckResult): PermissionsRequestResults {
- if (!checkResult.allGranted) {
- throw IllegalStateException("allGranted called when permissions were not all granted")
- }
- return PermissionsRequestResults(checkResult.permissions.mapValues { GRANTED })
- }
-
- fun from(activity: Activity, rawResults: PermissionsRequestRawResults): PermissionsRequestResults {
- val permissions = rawResults.mapValues { toPermissionRequestResult(activity, it.key, it.value) }
- return PermissionsRequestResults(permissions)
- }
- }
-
- enum class PermissionRequestResult {
- GRANTED,
-
- // Pre 'M', we do not know if a permission is temporarily or permanently denied.
- DENIED,
-
- @RequiresApi(Build.VERSION_CODES.M)
- TEMPORARILY_DENIED,
-
- @RequiresApi(Build.VERSION_CODES.M)
- PERMANENTLY_DENIED;
-
- fun isDenied(): Boolean = this != GRANTED
-
- companion object {
- fun toPermissionRequestResult(activity: Activity, permission: String, granted: Boolean): PermissionRequestResult {
- if (granted) {
- return GRANTED
- }
-
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
- return DENIED
- }
-
- // Android doesn't let us easily determine if a permission was denied permanently or temporarily
- // Use shouldShowRequestPermissionRationale to handle this
-
- // Note: shouldShowRequestPermissionRationale will return FALSE if a permission dialog has not
- // been shown. This may not happen here as we call getPermissionResult after we have dialog results
- val isPermanentlyDenied = !activity.shouldShowRequestPermissionRationale(permission)
- return if (isPermanentlyDenied) PERMANENTLY_DENIED else TEMPORARILY_DENIED
- }
- }
- }
-}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/Full30and31PermissionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/Full30and31PermissionsFragment.kt
new file mode 100644
index 000000000000..164dd70fcf39
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/Full30and31PermissionsFragment.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2023 Brayan Oliveira
+ *
+ * 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.ui.windows.permissions
+
+import android.Manifest
+import android.os.Build
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.RequiresApi
+import com.ichi2.anki.R
+import com.ichi2.utils.Permissions
+import com.ichi2.utils.Permissions.canManageExternalStorage
+
+/**
+ * Permissions screen for requesting permissions in API 30 and 31,
+ * if the user [canManageExternalStorage], which isn't possible in the play store.
+ *
+ * Requested permissions:
+ * 1. All files access: [Permissions.MANAGE_EXTERNAL_STORAGE].
+ * Used for saving the collection in a public directory
+ * which isn't deleted when the app is uninstalled
+ */
+@RequiresApi(Build.VERSION_CODES.R)
+class Full30and31PermissionsFragment : PermissionsFragment() {
+ private val accessAllFilesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {}
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val view = inflater.inflate(R.layout.permissions_full_30_and_31, container, false)
+
+ val allFilesPermission = view.findViewById(R.id.all_files_permission)
+ allFilesPermission.permissions = listOf(Manifest.permission.MANAGE_EXTERNAL_STORAGE)
+ allFilesPermission.setButtonClickListener { _, permission ->
+ if (!permission.isGranted) {
+ accessAllFilesLauncher.showManageAllFilesScreen()
+ }
+ }
+
+ return view
+ }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionItem.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionItem.kt
new file mode 100644
index 000000000000..c3dcaebe3f73
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionItem.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2020 Hemanth Savarla
+ * Copyright (c) 2023 Brayan Oliveira
+ *
+ * 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.ui.windows.permissions
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.widget.FrameLayout
+import androidx.appcompat.widget.AppCompatButton
+import androidx.appcompat.widget.AppCompatImageView
+import androidx.core.content.withStyledAttributes
+import androidx.core.view.isVisible
+import com.ichi2.anki.R
+import com.ichi2.themes.Themes
+import com.ichi2.ui.FixedTextView
+import com.ichi2.utils.Permissions
+
+/**
+ * Layout object that can be used to get a permission from the user.
+ *
+ * XML attributes:
+ * * app:permissionTitle ([R.styleable.PermissionItem_permissionTitle]):
+ * Title of the permission
+ * * app:permissionSummary ([R.styleable.PermissionItem_permissionSummary]):
+ * Brief description of the permission. It can be used to explain to the user
+ * why the permission should be granted
+ * * app:permissionButtonText ([R.styleable.PermissionItem_permissionButtonText]):
+ * Text inside the permission button. Normally it should be something like `Grant access`
+ * * app:permissionNumber ([R.styleable.PermissionItem_permissionNumber]):
+ * Number to be displayed at the layout's side. Normally, it should be the
+ * PermissionItem position among others in a screen
+ * * app:permissionIcon ([R.styleable.PermissionItem_permissionIcon]):
+ * Icon to be shown at the side of the button
+ */
+class PermissionItem(context: Context, attrs: AttributeSet) : FrameLayout(context, attrs) {
+
+ private lateinit var button: AppCompatButton
+ private lateinit var checkMark: AppCompatImageView
+ private lateinit var number: FixedTextView
+ lateinit var permissions: List
+ val isGranted
+ get() = Permissions.hasAllPermissions(context, permissions)
+
+ init {
+ LayoutInflater.from(context).inflate(R.layout.permission_item, this, true)
+
+ context.withStyledAttributes(attrs, R.styleable.PermissionItem) {
+ number = findViewById(R.id.number).apply {
+ text = getText(R.styleable.PermissionItem_permissionNumber)
+ }
+ findViewById(R.id.title).text = getText(R.styleable.PermissionItem_permissionTitle)
+ findViewById(R.id.summary).text = getText(R.styleable.PermissionItem_permissionSummary)
+ button = findViewById(R.id.button).apply {
+ text = getText(R.styleable.PermissionItem_permissionButtonText)
+ val icon = getDrawable(R.styleable.PermissionItem_permissionIcon)
+ icon?.setTint(Themes.getColorFromAttr(context, android.R.attr.textColor))
+ setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null)
+ }
+ checkMark = findViewById(R.id.checkImage)
+ }
+ }
+
+ fun setButtonClickListener(listener: (AppCompatButton, PermissionItem) -> Unit) {
+ button.setOnClickListener { button ->
+ listener.invoke(button as AppCompatButton, this)
+ }
+ }
+
+ /**
+ * Sets the visibility of a checkmark icon at the side of the permission button.
+ * Useful for showing that the permission has been granted
+ * */
+ fun setCheckMarkVisibility(isVisible: Boolean) {
+ checkMark.isVisible = isVisible
+ }
+}
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
new file mode 100644
index 000000000000..c438387e9e61
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright (c) 2023 Brayan Oliveira
+ *
+ * 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.ui.windows.permissions
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.os.Parcelable
+import androidx.appcompat.widget.AppCompatButton
+import androidx.fragment.app.commit
+import com.ichi2.anki.AnkiActivity
+import com.ichi2.anki.PermissionSet
+import com.ichi2.anki.R
+import com.ichi2.compat.CompatHelper.Companion.getParcelableExtraCompat
+
+/**
+ * Screen responsible for getting permissions from the user.
+ *
+ * Prefer using [PermissionsActivity.getIntent] to get an intent to this activity.
+ *
+ * Advantages:
+ * * Explains why each permission should be granted
+ * * Easily reusable
+ * * Doesn't need to block any UI elements or background routines that depends on a permission.
+ * Nor needs to add callbacks after the permissions are granted
+ * * TODO Show which permissions are mandatory and which are optional
+ */
+class PermissionsActivity : AnkiActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ if (showedActivityFailedScreen(savedInstanceState)) {
+ return
+ }
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.permissions_activity)
+
+ findViewById(R.id.continue_button).setOnClickListener {
+ finish()
+ }
+
+ val permissionSet = intent.getParcelableExtraCompat(PERMISSIONS_SET_EXTRA) ?: return
+ val permissionsFragment = permissionSet.permissionsFragment?.newInstance() ?: return
+ supportFragmentManager.commit {
+ replace(R.id.fragment_container, permissionsFragment)
+ }
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun onBackPressed() {
+ // only close the activity by tapping the continue button
+ }
+
+ fun setContinueButtonEnabled(isEnabled: Boolean) {
+ findViewById(R.id.continue_button).isEnabled = isEnabled
+ }
+
+ companion object {
+ const val PERMISSIONS_SET_EXTRA = "permissionsSet"
+
+ fun getIntent(context: Context, permissionsSet: PermissionSet): Intent {
+ return Intent(context, PermissionsActivity::class.java).apply {
+ putExtra(PERMISSIONS_SET_EXTRA, permissionsSet as Parcelable)
+ }
+ }
+ }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsFragment.kt
new file mode 100644
index 000000000000..c83e41b81887
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsFragment.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2023 Brayan Oliveira
+ *
+ * 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.ui.windows.permissions
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.provider.Settings
+import androidx.activity.result.ActivityResultLauncher
+import androidx.annotation.RequiresApi
+import androidx.annotation.StringRes
+import androidx.core.view.allViews
+import androidx.fragment.app.Fragment
+import com.ichi2.anki.UIUtils
+
+/**
+ * Base class for constructing a permissions screen
+ */
+abstract class PermissionsFragment : Fragment() {
+ /**
+ * All the [PermissionItem]s in the fragment.
+ * Must be called ONLY AFTER [onCreateView]
+ */
+ val permissionItems: List
+ by lazy { view?.allViews?.filterIsInstance()?.toList() ?: emptyList() }
+
+ override fun onResume() {
+ super.onResume()
+ permissionItems.forEach { it.setCheckMarkVisibility(it.isGranted) }
+ (activity as? PermissionsActivity)?.setContinueButtonEnabled(
+ permissionItems.all { it.isGranted }
+ )
+ }
+
+ /**
+ * Opens the Android settings for AnkiDroid if the device provide this feature.
+ * Lets a user grant any missing permissions which have been permanently denied
+ */
+ private fun openAppSettingsScreen() {
+ startActivity(
+ Intent(
+ Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ Uri.fromParts("package", requireActivity().packageName, null)
+ )
+ )
+ }
+
+ protected fun showToastAndOpenAppSettingsScreen(@StringRes message: Int) {
+ UIUtils.showThemedToast(requireContext(), message, false)
+ openAppSettingsScreen()
+ }
+
+ /** Opens the Android 'MANAGE_ALL_FILES' page if the device provides this feature */
+ @RequiresApi(Build.VERSION_CODES.R)
+ protected fun ActivityResultLauncher.showManageAllFilesScreen() {
+ val intent = Intent(
+ Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
+ Uri.fromParts("package", requireActivity().packageName, null)
+ )
+
+ // From the docs: [ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION]
+ // In some cases, a matching Activity may not exist, so ensure you safeguard against this.
+ if (intent.resolveActivity(requireActivity().packageManager) != null) {
+ launch(intent)
+ } else {
+ openAppSettingsScreen()
+ }
+ }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsUntil29Fragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsUntil29Fragment.kt
new file mode 100644
index 000000000000..add3f3e3109c
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsUntil29Fragment.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2023 Brayan Oliveira
+ *
+ * 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.ui.windows.permissions
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.result.contract.ActivityResultContracts
+import com.ichi2.anki.R
+import com.ichi2.utils.Permissions
+import com.ichi2.utils.hasAnyOfPermissionsBeenDenied
+
+/**
+ * Permissions screen for requesting permissions until API 29.
+ *
+ * Requested permissions:
+ * 1. Storage access: [Permissions.legacyStorageAccessPermissions].
+ * Used for saving the collection in a public directory
+ * which isn't deleted when the app is uninstalled
+ */
+class PermissionsUntil29Fragment : PermissionsFragment() {
+ private val storageLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {}
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val view = inflater.inflate(R.layout.permissions_until_29, container, false)
+
+ val storagePermission = view.findViewById(R.id.storage_permission)
+ storagePermission.permissions = Permissions.legacyStorageAccessPermissions
+ storagePermission.setButtonClickListener { _, permission ->
+ if (permission.isGranted) return@setButtonClickListener
+
+ if (!hasAnyOfPermissionsBeenDenied(Permissions.legacyStorageAccessPermissions)) {
+ storageLauncher.launch(Permissions.legacyStorageAccessPermissions.toTypedArray())
+ } else {
+ showToastAndOpenAppSettingsScreen(R.string.startup_no_storage_permission)
+ }
+ }
+
+ return view
+ }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/TiramisuPermissionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/TiramisuPermissionsFragment.kt
new file mode 100644
index 000000000000..c53d800f0e87
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/TiramisuPermissionsFragment.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) 2023 Brayan Oliveira
+ *
+ * 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.ui.windows.permissions
+
+import android.Manifest
+import android.os.Build
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.RequiresApi
+import com.ichi2.anki.R
+import com.ichi2.utils.Permissions
+import com.ichi2.utils.Permissions.canManageExternalStorage
+import com.ichi2.utils.hasPermissionBeenDenied
+
+// TODO After #14129 (targetSdkVersion = 33) is done, separate 'Photos and videos' from
+// 'Music and audio' permissions
+/**
+ * Permissions screen for requesting permissions in API 33+,
+ * if the user [canManageExternalStorage], which isn't possible in the play store.
+ *
+ * Requested permissions:
+ * 1. All files access: [Permissions.MANAGE_EXTERNAL_STORAGE].
+ * Used for saving the collection in a public directory
+ * which isn't deleted when the app is uninstalled
+ * 2. Media access: [Permissions.legacyStorageAccessPermissions].
+ * Starting from API 33, there are new permissions for accessing media
+ * ([Permissions.tiramisuAudioPermission] and [Permissions.tiramisuPhotosAndVideosPermissions]),
+ * which are necessary to sync and check media in public directories.
+ * Since `targetSdkVersion` isn't >= 33 yet (#14129), the new permissions can't be used properly,
+ * and can be get by requesting [Manifest.permission.READ_EXTERNAL_STORAGE]
+ * (https://developer.android.com/about/versions/13/behavior-changes-13#granular-media-permissions).
+ */
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+class TiramisuPermissionsFragment : PermissionsFragment() {
+ private val accessAllFilesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {}
+ private val mediaAccessLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val view = inflater.inflate(R.layout.permissions_tiramisu, container, false)
+
+ val allFilesPermission = view.findViewById(R.id.all_files_permission)
+ allFilesPermission.permissions = listOf(Manifest.permission.MANAGE_EXTERNAL_STORAGE)
+ allFilesPermission.setButtonClickListener { _, permission ->
+ if (!permission.isGranted) {
+ accessAllFilesLauncher.showManageAllFilesScreen()
+ }
+ }
+
+ val mediaPermission = view.findViewById(R.id.media_permission)
+ // with targetSdkVersion < 33, the legacy storage access permissions work to get media access
+ mediaPermission.permissions = listOf(Manifest.permission.READ_EXTERNAL_STORAGE)
+ mediaPermission.setButtonClickListener { _, permission ->
+ if (permission.isGranted) return@setButtonClickListener
+
+ if (!hasPermissionBeenDenied(Manifest.permission.READ_EXTERNAL_STORAGE)) {
+ mediaAccessLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
+ } else {
+ showToastAndOpenAppSettingsScreen(R.string.startup_photos_and_videos_permission)
+ }
+ }
+
+ return view
+ }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/Permissions.kt b/AnkiDroid/src/main/java/com/ichi2/utils/Permissions.kt
index c2d93535d4dd..13917bf7a2d3 100644
--- a/AnkiDroid/src/main/java/com/ichi2/utils/Permissions.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/utils/Permissions.kt
@@ -17,7 +17,6 @@
package com.ichi2.utils
import android.Manifest
-import android.Manifest.permission.MANAGE_EXTERNAL_STORAGE
import android.content.Context
import android.content.pm.PackageManager
import android.content.pm.PackageManager.GET_PERMISSIONS
@@ -25,12 +24,29 @@ import android.os.Build
import android.os.Environment
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
import com.ichi2.compat.CompatHelper.Companion.getPackageInfoCompat
import com.ichi2.compat.PackageInfoFlagsCompat
import timber.log.Timber
import java.lang.Exception
object Permissions {
+ const val MANAGE_EXTERNAL_STORAGE = "android.permission.MANAGE_EXTERNAL_STORAGE"
+
+ @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+ val tiramisuPhotosAndVideosPermissions = listOf(
+ Manifest.permission.READ_MEDIA_IMAGES,
+ Manifest.permission.READ_MEDIA_VIDEO
+ )
+
+ @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+ val tiramisuAudioPermission = Manifest.permission.READ_MEDIA_AUDIO
+
+ val legacyStorageAccessPermissions = listOf(
+ Manifest.permission.READ_EXTERNAL_STORAGE,
+ Manifest.permission.WRITE_EXTERNAL_STORAGE
+ )
+
fun canUseCamera(context: Context): Boolean {
return hasPermission(context, Manifest.permission.CAMERA)
}
@@ -48,6 +64,10 @@ object Permissions {
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
}
+ fun hasAllPermissions(context: Context, permissions: Collection): Boolean {
+ return permissions.all { hasPermission(context, it) }
+ }
+
@RequiresApi(Build.VERSION_CODES.R)
fun isExternalStorageManager(): Boolean {
// BUG: Environment.isExternalStorageManager() crashes under robolectric
@@ -153,3 +173,11 @@ object Permissions {
context.arePermissionsDefinedInAnkiDroidManifest(MANAGE_EXTERNAL_STORAGE)
}
}
+
+fun Fragment.hasPermissionBeenDenied(permission: String): Boolean {
+ return shouldShowRequestPermissionRationale(permission)
+}
+
+fun Fragment.hasAnyOfPermissionsBeenDenied(permissions: Collection): Boolean {
+ return permissions.any { shouldShowRequestPermissionRationale(it) }
+}
diff --git a/AnkiDroid/src/main/res/color/text_color_dark.xml b/AnkiDroid/src/main/res/color/text_color_dark.xml
index 3a56508e7d23..50eb7c1a2c6e 100644
--- a/AnkiDroid/src/main/res/color/text_color_dark.xml
+++ b/AnkiDroid/src/main/res/color/text_color_dark.xml
@@ -1,5 +1,5 @@
-
+
\ No newline at end of file
diff --git a/AnkiDroid/src/main/res/drawable/color_circle.xml b/AnkiDroid/src/main/res/drawable/color_circle.xml
new file mode 100644
index 000000000000..7edb195dc4c5
--- /dev/null
+++ b/AnkiDroid/src/main/res/drawable/color_circle.xml
@@ -0,0 +1,5 @@
+
+
+
+
\ No newline at end of file
diff --git a/AnkiDroid/src/main/res/drawable/ic_check_circle.xml b/AnkiDroid/src/main/res/drawable/ic_check_circle.xml
new file mode 100644
index 000000000000..1ca2a95eac96
--- /dev/null
+++ b/AnkiDroid/src/main/res/drawable/ic_check_circle.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/AnkiDroid/src/main/res/layout/permission_item.xml b/AnkiDroid/src/main/res/layout/permission_item.xml
new file mode 100644
index 000000000000..a1c6bf724819
--- /dev/null
+++ b/AnkiDroid/src/main/res/layout/permission_item.xml
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/AnkiDroid/src/main/res/layout/permissions_activity.xml b/AnkiDroid/src/main/res/layout/permissions_activity.xml
new file mode 100644
index 000000000000..6de5469667c6
--- /dev/null
+++ b/AnkiDroid/src/main/res/layout/permissions_activity.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/AnkiDroid/src/main/res/layout/permissions_full_30_and_31.xml b/AnkiDroid/src/main/res/layout/permissions_full_30_and_31.xml
new file mode 100644
index 000000000000..163843b3c8b9
--- /dev/null
+++ b/AnkiDroid/src/main/res/layout/permissions_full_30_and_31.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/AnkiDroid/src/main/res/layout/permissions_tiramisu.xml b/AnkiDroid/src/main/res/layout/permissions_tiramisu.xml
new file mode 100644
index 000000000000..a9db6d08bc5d
--- /dev/null
+++ b/AnkiDroid/src/main/res/layout/permissions_tiramisu.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/AnkiDroid/src/main/res/layout/permissions_until_29.xml b/AnkiDroid/src/main/res/layout/permissions_until_29.xml
new file mode 100644
index 000000000000..15f3265815ef
--- /dev/null
+++ b/AnkiDroid/src/main/res/layout/permissions_until_29.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/AnkiDroid/src/main/res/values/01-core.xml b/AnkiDroid/src/main/res/values/01-core.xml
index 40db186a8d03..eacb80a9df94 100644
--- a/AnkiDroid/src/main/res/values/01-core.xml
+++ b/AnkiDroid/src/main/res/values/01-core.xml
@@ -117,7 +117,7 @@
grant AnkiDroid the 'Storage' permission. This needs to be a fairly generic message as implementations
of the permissions/app info screen will differ between devices. -->
Please grant AnkiDroid the ‘Storage’ permission to continue
- Please grant AnkiDroid the ‘All files access’ permission to continue
+ Please grant AnkiDroid the ‘Photos and videos’ permission to continue
+ AnkiDroid needs some permissions to work
+ Let\'s go!
+
+ Grant access
+
+ Storage access
+ Saves your collection in a safe place that will not be deleted if the app is uninstalled
+ All files access
+ Media access
+ Allows syncing and attaching media to your cards
diff --git a/AnkiDroid/src/main/res/values/03-dialogs.xml b/AnkiDroid/src/main/res/values/03-dialogs.xml
index 7ef578855bc8..ef3d9115c272 100644
--- a/AnkiDroid/src/main/res/values/03-dialogs.xml
+++ b/AnkiDroid/src/main/res/values/03-dialogs.xml
@@ -131,7 +131,6 @@
This may take a long time with large media collections
Checking media…
Media checked
- Media check failed
Files with invalid encoding: %d
Files in media folder but not used by any cards: %d
Files used on cards but not in media folder: %d
diff --git a/AnkiDroid/src/main/res/values/attrs.xml b/AnkiDroid/src/main/res/values/attrs.xml
index 9e2a12572c41..aae90ebaf455 100644
--- a/AnkiDroid/src/main/res/values/attrs.xml
+++ b/AnkiDroid/src/main/res/values/attrs.xml
@@ -56,6 +56,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/InitialActivityTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/InitialActivityTest.kt
index 4dee3567e462..0e6b0533ccc7 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/InitialActivityTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/InitialActivityTest.kt
@@ -38,6 +38,7 @@ import org.mockito.Mockito.mockStatic
import org.mockito.Mockito.times
import org.mockito.kotlin.never
import org.robolectric.annotation.Config
+import kotlin.test.assertTrue
@RunWith(AndroidJUnit4::class)
@Config(application = EmptyApplication::class) // no point in Application init if we don't use it
@@ -174,17 +175,12 @@ class InitialActivityTest : RobolectricTest() {
@Config(sdk = [R_OR_AFTER])
@Test
fun `Android 11 - After reinstall (with MANAGE_EXTERNAL_STORAGE)`() {
- val expectedPermissions = arrayOf(android.Manifest.permission.MANAGE_EXTERNAL_STORAGE)
-
- selectAnkiDroidFolder(
+ val ankiDroidFolder = selectAnkiDroidFolder(
canManageExternalStorage = true,
currentFolderIsAccessibleAndLegacy = false
- ).let {
- assertThat(
- (it as PublicFolder).requiredPermissions.asIterable(),
- contains(*expectedPermissions)
- )
- }
+ ) as PublicFolder
+
+ assertTrue(android.Manifest.permission.MANAGE_EXTERNAL_STORAGE in ankiDroidFolder.requiredPermissions)
}
@Config(sdk = [R_OR_AFTER])
@@ -196,6 +192,9 @@ class InitialActivityTest : RobolectricTest() {
)
}
+ private val AnkiDroidFolder.requiredPermissions
+ get() = permissionSet.permissions
+
/**
* Helper for [com.ichi2.anki.selectAnkiDroidFolder], making `currentFolderIsAccessibleAndLegacy` optional
*/
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
new file mode 100644
index 000000000000..69dd33d71767
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivityTest.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2023 Brayan Oliveira
+ *
+ * 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.ui.windows.permissions
+
+import androidx.fragment.app.commitNow
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.PermissionSet
+import com.ichi2.anki.R
+import com.ichi2.anki.RobolectricTest
+import com.ichi2.testutils.HamcrestUtils.containsInAnyOrder
+import org.hamcrest.MatcherAssert.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class PermissionsActivityTest : RobolectricTest() {
+
+ @Test
+ fun `Each screen starts normally and has the same permissions of a PermissionSet`() {
+ ActivityScenario.launch(PermissionsActivity::class.java).onActivity { activity ->
+ for (permissionSet in PermissionSet.values()) {
+ val fragment = permissionSet.permissionsFragment?.newInstance() ?: continue
+ activity.supportFragmentManager.commitNow {
+ replace(R.id.fragment_container, fragment)
+ }
+ val allPermissions = fragment.permissionItems.flatMap { it.permissions }
+
+ assertThat(permissionSet.permissions, containsInAnyOrder(allPermissions))
+ }
+ }
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt
index 14ae26eed838..6601b4d47142 100644
--- a/AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt
@@ -30,6 +30,7 @@ import com.ichi2.anki.pages.PagesActivity
import com.ichi2.anki.preferences.Preferences
import com.ichi2.anki.services.ReminderService.Companion.getReviewDeckIntent
import com.ichi2.anki.ui.windows.managespace.ManageSpaceActivity
+import com.ichi2.anki.ui.windows.permissions.PermissionsActivity
import com.ichi2.testutils.ActivityList.ActivityLaunchParam.Companion.get
import org.robolectric.Robolectric
import org.robolectric.android.controller.ActivityController
@@ -79,7 +80,8 @@ object ActivityList {
get(LoginActivity::class.java),
get(IntroductionActivity::class.java),
get(ManageNotetypes::class.java),
- get(ManageSpaceActivity::class.java)
+ get(ManageSpaceActivity::class.java),
+ get(PermissionsActivity::class.java)
)
}