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) ) }