From ac80f8aa294b23749547b2c591eeb600fdc6fee0 Mon Sep 17 00:00:00 2001 From: Brayan Oliveira <69634269+brayandso@users.noreply.github.com> Date: Thu, 13 Jul 2023 11:05:47 -0300 Subject: [PATCH 01/13] refactor: add some Permissions utils --- .../main/java/com/ichi2/utils/Permissions.kt | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) 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) } +} From ee5e3d6d13a6fde00c85f99c9177f86db053520f Mon Sep 17 00:00:00 2001 From: Brayan Oliveira <69634269+brayandso@users.noreply.github.com> Date: Thu, 13 Jul 2023 11:07:10 -0300 Subject: [PATCH 02/13] feat!: change text primary disabled color Use transparency to take account of the text background, so it isn't invisible in gray backgrounds --- AnkiDroid/src/main/res/color/text_color_dark.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From fbb15b93241ee9837c2ac929c44f5b35bde36abc Mon Sep 17 00:00:00 2001 From: Brayan Oliveira <69634269+brayandso@users.noreply.github.com> Date: Thu, 13 Jul 2023 11:22:39 -0300 Subject: [PATCH 03/13] feat: PermissionItem Layout object that can be used to display a permission and get it. Ideally, it would use material components to deliver a better appearance, but we don't have material themes yet --- .../ui/windows/permissions/PermissionItem.kt | 90 +++++++++++++++++++ .../src/main/res/drawable/color_circle.xml | 5 ++ .../src/main/res/drawable/ic_check_circle.xml | 10 +++ .../src/main/res/layout/permission_item.xml | 87 ++++++++++++++++++ AnkiDroid/src/main/res/values/attrs.xml | 16 ++++ 5 files changed, 208 insertions(+) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionItem.kt create mode 100644 AnkiDroid/src/main/res/drawable/color_circle.xml create mode 100644 AnkiDroid/src/main/res/drawable/ic_check_circle.xml create mode 100644 AnkiDroid/src/main/res/layout/permission_item.xml 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/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/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 @@ + + + + + + + + + + + + + From b4a0cbb06b91d1719665ca204da3db5aba8793ef Mon Sep 17 00:00:00 2001 From: Brayan Oliveira <69634269+brayandso@users.noreply.github.com> Date: Thu, 13 Jul 2023 11:24:05 -0300 Subject: [PATCH 04/13] feat: PermissionsFragment --- .../permissions/PermissionsFragment.kt | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsFragment.kt 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..7ee229585079 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsFragment.kt @@ -0,0 +1,79 @@ +/* + * 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) } + } + + /** + * 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() + } + } +} From 728553a56cf01aade7e5311a3e5ad74d9e279fd2 Mon Sep 17 00:00:00 2001 From: Brayan Oliveira <69634269+brayandso@users.noreply.github.com> Date: Thu, 13 Jul 2023 11:27:33 -0300 Subject: [PATCH 05/13] feat: PermissionsUntil29Fragment --- .../permissions/PermissionsUntil29Fragment.kt | 59 +++++++++++++++++++ .../main/res/layout/permissions_until_29.xml | 21 +++++++ AnkiDroid/src/main/res/values/01-core.xml | 7 +++ 3 files changed, 87 insertions(+) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsUntil29Fragment.kt create mode 100644 AnkiDroid/src/main/res/layout/permissions_until_29.xml 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/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..8e09a13c3de8 100644 --- a/AnkiDroid/src/main/res/values/01-core.xml +++ b/AnkiDroid/src/main/res/values/01-core.xml @@ -328,4 +328,11 @@ Create a new collection The new collection will be deleted from your phone if you uninstall AnkiDroid + + + Grant access + + Storage access + Saves your collection in a safe place that will not be deleted if the app is uninstalled From 93e888bbf639e09edaef6c07faeb93e2e2e11937 Mon Sep 17 00:00:00 2001 From: Brayan Oliveira <69634269+brayandso@users.noreply.github.com> Date: Thu, 13 Jul 2023 11:34:50 -0300 Subject: [PATCH 06/13] feat: Full30and31PermissionsFragment --- .../Full30and31PermissionsFragment.kt | 59 +++++++++++++++++++ .../res/layout/permissions_full_30_and_31.xml | 21 +++++++ AnkiDroid/src/main/res/values/01-core.xml | 2 + 3 files changed, 82 insertions(+) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/Full30and31PermissionsFragment.kt create mode 100644 AnkiDroid/src/main/res/layout/permissions_full_30_and_31.xml 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/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/values/01-core.xml b/AnkiDroid/src/main/res/values/01-core.xml index 8e09a13c3de8..730f40fff7d5 100644 --- a/AnkiDroid/src/main/res/values/01-core.xml +++ b/AnkiDroid/src/main/res/values/01-core.xml @@ -335,4 +335,6 @@ Storage access Saves your collection in a safe place that will not be deleted if the app is uninstalled + All files access From 8f9705bf3456cb5a1b6b4b316b4b4213f8be8830 Mon Sep 17 00:00:00 2001 From: Brayan Oliveira <69634269+brayandso@users.noreply.github.com> Date: Thu, 13 Jul 2023 11:39:47 -0300 Subject: [PATCH 07/13] feat: TiramisuPermissionsFragment --- .../TiramisuPermissionsFragment.kt | 84 +++++++++++++++++++ .../main/res/layout/permissions_tiramisu.xml | 32 +++++++ AnkiDroid/src/main/res/values/01-core.xml | 3 + 3 files changed, 119 insertions(+) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/TiramisuPermissionsFragment.kt create mode 100644 AnkiDroid/src/main/res/layout/permissions_tiramisu.xml 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/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/values/01-core.xml b/AnkiDroid/src/main/res/values/01-core.xml index 730f40fff7d5..2dab6f3c67b5 100644 --- a/AnkiDroid/src/main/res/values/01-core.xml +++ b/AnkiDroid/src/main/res/values/01-core.xml @@ -118,6 +118,7 @@ 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 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) ) } From d8bed64a82ab9f0f8e7f9827145c3ea0e98aa3bc Mon Sep 17 00:00:00 2001 From: Brayan Oliveira <69634269+brayandso@users.noreply.github.com> Date: Thu, 13 Jul 2023 12:55:51 -0300 Subject: [PATCH 10/13] feat: Enable/disable the permissions screen continue button if all permissions have been granted --- .../ichi2/anki/ui/windows/permissions/PermissionsFragment.kt | 3 +++ 1 file changed, 3 insertions(+) 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 index 7ee229585079..c83e41b81887 100644 --- 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 @@ -40,6 +40,9 @@ abstract class PermissionsFragment : Fragment() { override fun onResume() { super.onResume() permissionItems.forEach { it.setCheckMarkVisibility(it.isGranted) } + (activity as? PermissionsActivity)?.setContinueButtonEnabled( + permissionItems.all { it.isGranted } + ) } /** From c15505556b8f6678bb4cc1af632fafba89db7139 Mon Sep 17 00:00:00 2001 From: Brayan Oliveira <69634269+brayandso@users.noreply.github.com> Date: Thu, 13 Jul 2023 12:56:10 -0300 Subject: [PATCH 11/13] test: Each screen starts normally and has the same permissions of a PermissionSet --- .../permissions/PermissionsActivityTest.kt | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivityTest.kt 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)) + } + } + } +} From c379263d85778408167f05753b87c60d579c76d3 Mon Sep 17 00:00:00 2001 From: Brayan Oliveira <69634269+brayandso@users.noreply.github.com> Date: Thu, 13 Jul 2023 13:06:52 -0300 Subject: [PATCH 12/13] feat: Request permissions with a permission screen Simplifies things by not having to handle callbacks or blocking UI elements/background tasks while getting a permission. Also, it can be easily launched in other screens if necessary, which can avoid crashes like #13518 Additionally, adds a better UI/UX for the user --- .../java/com/ichi2/anki/CollectionHelper.kt | 2 +- .../main/java/com/ichi2/anki/DeckPicker.kt | 53 ++------ .../java/com/ichi2/anki/InitialActivity.kt | 123 +----------------- AnkiDroid/src/main/res/values/03-dialogs.xml | 1 - 4 files changed, 20 insertions(+), 159 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt index 928e555f9e7e..606455c712d0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt @@ -420,7 +420,7 @@ open class CollectionHelper { // TODO Tracked in https://github.com/ankidroid/Anki-Android/issues/5304 @CheckResult fun getDefaultAnkiDroidDirectory(context: Context): String { - val legacyStorage = StartupStoragePermissionManager.selectAnkiDroidFolder(context) != AppPrivateFolder + val legacyStorage = selectAnkiDroidFolder(context) != AppPrivateFolder return if (!legacyStorage) { File(getAppSpecificExternalAnkiDroidDirectory(context), "AnkiDroid").absolutePath } else { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index d1dead6cf9e1..205dff63f256 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -25,7 +25,6 @@ package com.ichi2.anki -import android.Manifest import android.app.Activity import android.content.* import android.database.SQLException @@ -37,6 +36,7 @@ import android.util.TypedValue import android.view.* import android.view.View.OnLongClickListener import android.widget.* +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog @@ -85,9 +85,6 @@ import com.ichi2.anki.exception.ImportExportException import com.ichi2.anki.export.ActivityExportingDelegate import com.ichi2.anki.export.ExportType import com.ichi2.anki.notetype.ManageNotetypes -import com.ichi2.anki.permissions.PermissionManager -import com.ichi2.anki.permissions.PermissionsRequestRawResults -import com.ichi2.anki.permissions.PermissionsRequestResults import com.ichi2.anki.preferences.AdvancedSettingsFragment import com.ichi2.anki.preferences.sharedPrefs import com.ichi2.anki.receiver.SdCardReceiver @@ -101,6 +98,7 @@ import com.ichi2.anki.services.getMediaMigrationState import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.stats.AnkiStatsTaskHandler import com.ichi2.anki.ui.dialogs.storageMigrationFailedDialogIsShownOrPending +import com.ichi2.anki.ui.windows.permissions.PermissionsActivity import com.ichi2.anki.web.HostNumFactory import com.ichi2.anki.widgets.DeckAdapter import com.ichi2.annotations.NeedsTest @@ -134,7 +132,6 @@ import org.json.JSONException import timber.log.Timber import java.io.File import java.lang.Runnable -import java.lang.ref.WeakReference import kotlin.math.roundToLong import kotlin.time.Duration.Companion.milliseconds import kotlin.time.ExperimentalTime @@ -248,15 +245,9 @@ open class DeckPicker : private lateinit var mCustomStudyDialogFactory: CustomStudyDialogFactory private lateinit var mContextMenuFactory: DeckPickerContextMenu.Factory - private lateinit var startupStoragePermissionManager: StartupStoragePermissionManager - - // used for check media - private val checkMediaStoragePermissionCheck = PermissionManager.register( - this, - arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE), - useCallbackIfActivityRecreated = true, - callback = callbackHandlingStoragePermissionsCheckForCheckMedia(WeakReference(this)) - ) + private val permissionScreenLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + recreate() + } private var migrateStorageAfterMediaSyncCompleted = false @@ -329,7 +320,6 @@ open class DeckPicker : // Then set theme and content view super.onCreate(savedInstanceState) - startupStoragePermissionManager = StartupStoragePermissionManager.register(this, useCallbackIfActivityRecreated = false) // handle the first load: display the app introduction if (!hasShownAppIntro()) { val appIntro = Intent(this, IntroductionActivity::class.java) @@ -495,11 +485,11 @@ open class DeckPicker : * This method triggers backups, sync, and may re-show dialogs * that may have been dismissed. Make this run only once? */ - fun handleStartup() { - val storagePermissionsResult = startupStoragePermissionManager.checkPermissions() - if (storagePermissionsResult.requiresPermissionDialog) { + private fun handleStartup() { + val ankiDroidFolder = selectAnkiDroidFolder(this) + if (!ankiDroidFolder.hasRequiredPermissions(this)) { Timber.i("postponing startup code - dialog shown") - startupStoragePermissionManager.displayStoragePermissionDialog() + permissionScreenLauncher.launch(PermissionsActivity.getIntent(this, ankiDroidFolder.permissionSet)) return } @@ -1536,7 +1526,10 @@ open class DeckPicker : * If has the storage permission, job is scheduled, otherwise storage permission is asked first. */ override fun mediaCheck() { - checkMediaStoragePermissionCheck.launchDialogOrExecuteCallbackNow() + launchCatchingTask { + val mediaCheckResult = checkMedia() ?: return@launchCatchingTask + showMediaCheckDialog(MediaCheckDialog.DIALOG_MEDIA_CHECK_RESULTS, mediaCheckResult) + } } override fun deleteUnused(unused: List) { @@ -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 ad4df4493af5..fa0ffebe9fce 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt @@ -25,9 +25,6 @@ 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 @@ -35,7 +32,6 @@ 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.annotations.NeedsTest import com.ichi2.utils.Permissions import com.ichi2.utils.VersionUtils.pkgVersionName import kotlinx.parcelize.Parcelize @@ -203,119 +199,12 @@ internal fun selectAnkiDroidFolder( } } -/** - * 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.permissionSet.permissions.toTypedArray() - } - - /** - * 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() - } - - 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() - } - } +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 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/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 From 5cb1ac5f8adf5847d70c6775ca32d517206ef23e Mon Sep 17 00:00:00 2001 From: Brayan Oliveira <69634269+brayandso@users.noreply.github.com> Date: Thu, 13 Jul 2023 13:07:55 -0300 Subject: [PATCH 13/13] refactor: remove unused Permissions files got unused after permissions started being handled by permission screens With the new permission screens, the "Please grant AnkiDroid the 'All files access' permission to continue" string isn't necessary anymore --- .../anki/permissions/PermissionManager.kt | 189 ------------------ .../permissions/PermissionsCheckResult.kt | 36 ---- .../permissions/PermissionsRequestResults.kt | 99 --------- AnkiDroid/src/main/res/values/01-core.xml | 1 - 4 files changed, 325 deletions(-) delete mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/permissions/PermissionManager.kt delete mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/permissions/PermissionsCheckResult.kt delete mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/permissions/PermissionsRequestResults.kt 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/res/values/01-core.xml b/AnkiDroid/src/main/res/values/01-core.xml index 2495c8391ed7..eacb80a9df94 100644 --- a/AnkiDroid/src/main/res/values/01-core.xml +++ b/AnkiDroid/src/main/res/values/01-core.xml @@ -117,7 +117,6 @@ 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