Skip to content

Commit b03b268

Browse files
criticalAYdavid-allison
authored andcommitted
refactor: extract generic permission helpers to :common:android
Move the Android-only permission utilities (hasPermission, hasAllPermissions, isExternalStorageManager(Compat), hasLegacyStorageAccessPermission, MANAGE_EXTERNAL_STORAGE) out of :AnkiDroid's Permissions object into :common:android as top-level functions. The remaining Permissions object in :AnkiDroid keeps Anki-specific functionality (startup permission sets, dialog interactions, settings). - adds androidx.core.ktx as a :common:android dependency, needed for ContextCompat.checkSelfPermission.
1 parent 119de88 commit b03b268

10 files changed

Lines changed: 99 additions & 97 deletions

File tree

AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import com.ichi2.anki.common.annotations.LegacyNotifications
4040
import com.ichi2.anki.common.annotations.NeedsTest
4141
import com.ichi2.anki.common.coroutines.applicationScope
4242
import com.ichi2.anki.common.crashreporting.CrashReportService.sendExceptionReport
43+
import com.ichi2.anki.common.permissions.hasLegacyStorageAccessPermission
4344
import com.ichi2.anki.common.utils.android.SdCard
4445
import com.ichi2.anki.common.utils.android.showThemedToast
4546
import com.ichi2.anki.common.utils.annotation.KotlinCleanup
@@ -66,7 +67,6 @@ import com.ichi2.utils.AdaptionUtil
6667
import com.ichi2.utils.ExceptionUtil
6768
import com.ichi2.utils.LanguageUtil
6869
import com.ichi2.utils.LanguageUtil.withAppLocale
69-
import com.ichi2.utils.Permissions
7070
import com.ichi2.utils.measureTime
7171
import com.ichi2.utils.setWebContentsDebuggingEnabled
7272
import com.ichi2.widget.DayRolloverAlarm
@@ -311,7 +311,7 @@ open class AnkiDroidApp :
311311

312312
/**
313313
* Manually initializes the collection directory and `.nomedia` if
314-
* [Permissions.hasLegacyStorageAccessPermission] is set
314+
* [hasLegacyStorageAccessPermission] is set
315315
*
316316
* On failure, sets [fatalInitializationError] to [storageError][FatalInitializationError.StorageError]
317317
*
@@ -332,7 +332,7 @@ open class AnkiDroidApp :
332332

333333
// TODO: This line is questionable, as it doesn't work on most post-scoped-storage
334334
// builds/Android versions, but we call initializeAnkiDroidDirectory later on startup
335-
if (!Permissions.hasLegacyStorageAccessPermission(this)) return
335+
if (!hasLegacyStorageAccessPermission(this)) return
336336

337337
try {
338338
CollectionHelper.initializeAnkiDroidDirectory(ankiDroidDir)

AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import androidx.annotation.CheckResult
2828
import androidx.annotation.RequiresApi
2929
import androidx.core.content.edit
3030
import com.ichi2.anki.common.crashreporting.CrashReportService
31+
import com.ichi2.anki.common.permissions.hasAllPermissions
3132
import com.ichi2.anki.common.utils.android.SdCard
3233
import com.ichi2.anki.compat.CompatHelper.Companion.sdkVersion
3334
import com.ichi2.anki.dialogs.DatabaseErrorDialog
@@ -210,7 +211,7 @@ sealed class AnkiDroidFolder(
210211
*/
211212
data object AppPrivateFolder : AnkiDroidFolder(PermissionSet.APP_PRIVATE)
212213

213-
fun hasRequiredPermissions(context: Context): Boolean = Permissions.hasAllPermissions(context, permissionSet.permissions)
214+
fun hasRequiredPermissions(context: Context): Boolean = hasAllPermissions(context, permissionSet.permissions)
214215
}
215216

216217
@Parcelize

AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import androidx.core.content.IntentCompat
2929
import androidx.work.WorkManager
3030
import com.ichi2.anki.common.annotations.NeedsTest
3131
import com.ichi2.anki.common.coroutines.applicationScope
32+
import com.ichi2.anki.common.permissions.hasLegacyStorageAccessPermission
33+
import com.ichi2.anki.common.permissions.isExternalStorageManagerCompat
3234
import com.ichi2.anki.common.utils.android.showThemedToast
3335
import com.ichi2.anki.common.utils.trimToLength
3436
import com.ichi2.anki.dialogs.DialogHandler.Companion.storeMessage
@@ -49,8 +51,6 @@ import com.ichi2.utils.ImportUtils.isInvalidViewIntent
4951
import com.ichi2.utils.ImportUtils.showImportUnsuccessfulDialog
5052
import com.ichi2.utils.IntentUtil.resolveMimeType
5153
import com.ichi2.utils.NetworkUtils
52-
import com.ichi2.utils.Permissions
53-
import com.ichi2.utils.Permissions.hasLegacyStorageAccessPermission
5454
import com.ichi2.utils.copyToClipboard
5555
import kotlinx.coroutines.Dispatchers
5656
import kotlinx.coroutines.launch
@@ -357,7 +357,7 @@ class IntentHandler : AbstractIntentHandler() {
357357
val granted =
358358
!ScopedStorageService.isLegacyStorage(context) ||
359359
hasLegacyStorageAccessPermission(context) ||
360-
Permissions.isExternalStorageManagerCompat()
360+
isExternalStorageManagerCompat()
361361

362362
if (!granted && showToast) {
363363
showThemedToast(context, context.getString(R.string.intent_handler_failed_no_storage_permission), false)

AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsFragment.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ import androidx.fragment.app.Fragment
3131
import androidx.fragment.app.setFragmentResult
3232
import com.ichi2.anki.R
3333
import com.ichi2.anki.common.annotations.NeedsTest
34+
import com.ichi2.anki.common.permissions.hasPermission
3435
import com.ichi2.anki.settings.Prefs
35-
import com.ichi2.utils.Permissions
3636
import com.ichi2.utils.Permissions.openAppSettingsScreen
3737
import com.ichi2.utils.Permissions.requestPermissionThroughDialogOrSettings
3838
import com.ichi2.utils.Permissions.showToastAndOpenAppSettingsScreen
@@ -108,7 +108,7 @@ abstract class PermissionsFragment(
108108
@NeedsTest("Shows the permission item when INTERNET permission is denied")
109109
@NeedsTest("Hides the permission item when INTERNET permission is already granted")
110110
protected fun PermissionsItem.initializeInternetPermissionItem() {
111-
if (Permissions.hasPermission(requireContext(), Manifest.permission.INTERNET)) {
111+
if (hasPermission(requireContext(), Manifest.permission.INTERNET)) {
112112
// If internet permission is already granted (which is the case for most of devices), hide the permission item.
113113
this.isVisible = false
114114
return

AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsItem.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ import android.widget.FrameLayout
2323
import androidx.core.content.withStyledAttributes
2424
import com.google.android.material.color.MaterialColors
2525
import com.ichi2.anki.R
26+
import com.ichi2.anki.common.permissions.hasAllPermissions
2627
import com.ichi2.anki.databinding.ViewPermissionsItemBinding
2728
import com.ichi2.anki.utils.ext.usingStyledAttributes
28-
import com.ichi2.utils.Permissions
2929
import timber.log.Timber
3030

3131
/**
@@ -60,7 +60,7 @@ class PermissionsItem(
6060
* The value of either app:permissions or app:permission.
6161
*/
6262
val permissions: List<String>
63-
val areGranted get() = Permissions.hasAllPermissions(context, permissions)
63+
val areGranted get() = hasAllPermissions(context, permissions)
6464

6565
init {
6666
binding.switchWidget.apply {

AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsStartingAt30Fragment.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,16 @@ import android.view.ViewGroup
2222
import androidx.activity.result.contract.ActivityResultContracts
2323
import androidx.annotation.RequiresApi
2424
import com.ichi2.anki.R
25+
import com.ichi2.anki.common.permissions.MANAGE_EXTERNAL_STORAGE
2526
import com.ichi2.anki.databinding.FragmentPermissionsStartingAt30Binding
26-
import com.ichi2.utils.Permissions
2727
import com.ichi2.utils.Permissions.canManageExternalStorage
2828

2929
/**
3030
* Permissions screen for requesting permissions at and above API 30,
3131
* if the user [canManageExternalStorage], which isn't possible in the play store.
3232
*
3333
* Requested permissions:
34-
* 1. All files access: [Permissions.MANAGE_EXTERNAL_STORAGE].
34+
* 1. All files access: [MANAGE_EXTERNAL_STORAGE].
3535
* Used for saving the collection in a public directory
3636
* which isn't deleted when the app is uninstalled
3737
*/

AnkiDroid/src/main/java/com/ichi2/utils/Permissions.kt

Lines changed: 2 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import android.content.Intent
2323
import android.content.pm.PackageManager
2424
import android.net.Uri
2525
import android.os.Build
26-
import android.os.Environment
2726
import android.provider.Settings
2827
import androidx.activity.result.ActivityResultLauncher
2928
import androidx.annotation.RequiresApi
@@ -35,22 +34,20 @@ import androidx.fragment.app.FragmentActivity
3534
import androidx.fragment.app.FragmentManager
3635
import com.ichi2.anki.PermissionSet
3736
import com.ichi2.anki.R
37+
import com.ichi2.anki.common.permissions.MANAGE_EXTERNAL_STORAGE
38+
import com.ichi2.anki.common.permissions.hasPermission
3839
import com.ichi2.anki.common.utils.android.isRobolectric
3940
import com.ichi2.anki.common.utils.android.showThemedToast
4041
import com.ichi2.anki.compat.CompatHelper.Companion.getPackageInfoCompat
4142
import com.ichi2.anki.compat.GET_PERMISSIONS_L
4243
import com.ichi2.anki.compat.PackageInfoFlagsCompat
4344
import com.ichi2.anki.settings.Prefs
4445
import com.ichi2.anki.ui.windows.permissions.PermissionsBottomSheet
45-
import com.ichi2.utils.Permissions.MANAGE_EXTERNAL_STORAGE
4646
import com.ichi2.utils.Permissions.arePermissionsDefinedInManifest
47-
import com.ichi2.utils.Permissions.isExternalStorageManager
4847
import timber.log.Timber
4948
import kotlin.reflect.KMutableProperty
5049

5150
object Permissions {
52-
const val MANAGE_EXTERNAL_STORAGE = "android.permission.MANAGE_EXTERNAL_STORAGE"
53-
5451
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
5552
val tiramisuPhotosAndVideosPermissions =
5653
listOf(
@@ -192,85 +189,6 @@ object Permissions {
192189

193190
fun canRecordAudio(context: Context): Boolean = hasPermission(context, RECORD_AUDIO_PERMISSION)
194191

195-
/**
196-
* Whether the app is granted [permission]
197-
*
198-
* Same as [androidx.core.content.ContextCompat.checkSelfPermission] except it corrects a bug related to [MANAGE_EXTERNAL_STORAGE].
199-
*/
200-
fun hasPermission(
201-
context: Context,
202-
permission: String,
203-
): Boolean {
204-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && permission == MANAGE_EXTERNAL_STORAGE) {
205-
// checkSelfPermission doesn't return PERMISSION_GRANTED, even if it's granted.
206-
return isExternalStorageManager()
207-
}
208-
209-
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
210-
}
211-
212-
/**
213-
* Whether the app is granted all permission of [permissions]
214-
*/
215-
fun hasAllPermissions(
216-
context: Context,
217-
permissions: Collection<String>,
218-
): Boolean = permissions.all { hasPermission(context, it) }
219-
220-
@RequiresApi(Build.VERSION_CODES.R)
221-
fun isExternalStorageManager(): Boolean {
222-
// BUG: Environment.isExternalStorageManager() crashes under robolectric
223-
// https://github.com/robolectric/robolectric/issues/7300
224-
if (isRobolectric) {
225-
return false // TODO: handle tests with both 'true' and 'false'
226-
}
227-
return Environment.isExternalStorageManager()
228-
}
229-
230-
/**
231-
* On < Android 11, returns false.
232-
* On >= Android 11, returns [isExternalStorageManager]
233-
*/
234-
fun isExternalStorageManagerCompat(): Boolean {
235-
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
236-
return false
237-
} else {
238-
isExternalStorageManager()
239-
}
240-
}
241-
242-
/**
243-
* Check if we have write access permission to the external storage
244-
* @param context
245-
* @return
246-
*/
247-
@JvmStatic // unit tests were flaky - maybe remove later
248-
private fun hasStorageWriteAccessPermission(
249-
context: Context,
250-
): Boolean = hasPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)
251-
252-
/**
253-
* Check if we have read access permission to the external storage
254-
* @param context
255-
* @return
256-
*/
257-
@JvmStatic // unit tests were flaky - maybe remove later
258-
private fun hasStorageReadAccessPermission(
259-
context: Context,
260-
): Boolean = hasPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE)
261-
262-
/**
263-
* Check if we have read and write access permission to the external storage
264-
* Note: This can return true >= R on a debug build or if storage is preserved
265-
*
266-
* @see IntentHandler.grantedStoragePermissions
267-
*
268-
* @param context
269-
*/
270-
@JvmStatic // unit tests were flaky - maybe remove later
271-
fun hasLegacyStorageAccessPermission(context: Context): Boolean =
272-
hasStorageReadAccessPermission(context) && hasStorageWriteAccessPermission(context)
273-
274192
/**
275193
* Detects if permissions are defined via <uses-permission> in the Manifest.
276194
* This does **not** mean the permission has been granted.

common/android/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ dependencies {
2828
implementation(project(":common"))
2929

3030
implementation(libs.androidx.annotation)
31+
implementation(libs.androidx.core.ktx)
3132
implementation(libs.jakewharton.timber)
3233
implementation(libs.kotlinx.coroutines.core)
3334

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// SPDX-FileCopyrightText: 2025 David Allison <davidallisongithub@gmail.com>
3+
// SPDX-FileCopyrightText: 2026 Ashish Yadav <mailtoashish693@gmail.com>
4+
5+
package com.ichi2.anki.common.permissions
6+
7+
import android.content.Context
8+
import android.content.pm.PackageManager
9+
import android.os.Build
10+
import androidx.core.content.ContextCompat
11+
12+
/**
13+
* Whether the app is granted [permission].
14+
*
15+
* Same as [ContextCompat.checkSelfPermission] except it corrects a bug
16+
* related to [MANAGE_EXTERNAL_STORAGE].
17+
*/
18+
fun hasPermission(
19+
context: Context,
20+
permission: String,
21+
): Boolean {
22+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && permission == MANAGE_EXTERNAL_STORAGE) {
23+
// checkSelfPermission doesn't return PERMISSION_GRANTED, even if it's granted.
24+
return isExternalStorageManager()
25+
}
26+
27+
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
28+
}
29+
30+
/**
31+
* Whether the app is granted all permissions in [permissions].
32+
*/
33+
fun hasAllPermissions(
34+
context: Context,
35+
permissions: Collection<String>,
36+
): Boolean = permissions.all { hasPermission(context, it) }
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// SPDX-FileCopyrightText: 2025 David Allison <davidallisongithub@gmail.com>
3+
// SPDX-FileCopyrightText: 2026 Ashish Yadav <mailtoashish693@gmail.com>
4+
5+
package com.ichi2.anki.common.permissions
6+
7+
import android.Manifest
8+
import android.content.Context
9+
import android.os.Build
10+
import android.os.Environment
11+
import androidx.annotation.RequiresApi
12+
import com.ichi2.anki.common.utils.android.isRobolectric
13+
14+
const val MANAGE_EXTERNAL_STORAGE = "android.permission.MANAGE_EXTERNAL_STORAGE"
15+
16+
@RequiresApi(Build.VERSION_CODES.R)
17+
fun isExternalStorageManager(): Boolean {
18+
// BUG: Environment.isExternalStorageManager() crashes under robolectric
19+
// https://github.com/robolectric/robolectric/issues/7300
20+
if (isRobolectric) {
21+
return false // TODO: handle tests with both 'true' and 'false'
22+
}
23+
return Environment.isExternalStorageManager()
24+
}
25+
26+
/**
27+
* On < Android 11, returns false.
28+
* On >= Android 11, returns [isExternalStorageManager].
29+
*/
30+
fun isExternalStorageManagerCompat(): Boolean =
31+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
32+
false
33+
} else {
34+
isExternalStorageManager()
35+
}
36+
37+
/**
38+
* Check if we have read and write access permission to the external storage.
39+
* Note: This can return true >= R on a debug build or if storage is preserved.
40+
*/
41+
fun hasLegacyStorageAccessPermission(context: Context): Boolean =
42+
hasStorageReadAccessPermission(context) && hasStorageWriteAccessPermission(context)
43+
44+
private fun hasStorageReadAccessPermission(context: Context): Boolean = hasPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE)
45+
46+
private fun hasStorageWriteAccessPermission(context: Context): Boolean = hasPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)

0 commit comments

Comments
 (0)