From 8938e9ad3ae6d6cdf23b3d4bdf57a02a53e0c35f Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:01:46 +0100 Subject: [PATCH 1/2] refactor: extract storage checks to :anki-common *`isLegacyStorage` * toast-free `grantedStoragePermissions` To allow WidgetPermissionReceiver to move to `:widgets` Part of 20737 Assisted-by: Claude Fable 5  Conflicts:  AnkiDroid/src/main/java/com/ichi2/widget/AnalyticsWidgetProvider.kt --- .../java/com/ichi2/anki/InitialActivity.kt | 2 +- .../main/java/com/ichi2/anki/IntentHandler.kt | 9 +- .../anki/servicelayer/ScopedStorageService.kt | 82 -------------- .../anki/ui/windows/managespace/FileUtils.kt | 2 +- .../ichi2/widget/AnalyticsWidgetProvider.kt | 4 +- .../ichi2/widget/WidgetPermissionReceiver.kt | 4 +- .../anki/common/storage/LegacyStorage.kt | 102 ++++++++++++++++++ 7 files changed, 110 insertions(+), 95 deletions(-) create mode 100644 anki-common/src/main/kotlin/com/ichi2/anki/common/storage/LegacyStorage.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt index 0d6d7e0637d2..a60353801669 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt @@ -34,12 +34,12 @@ import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.common.storage.AnkiDroidFolder import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.common.storage.StorageDecision +import com.ichi2.anki.common.storage.isLegacyStorage import com.ichi2.anki.common.utils.android.SdCard import com.ichi2.anki.compat.CompatHelper.Companion.sdkVersion import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.servicelayer.PreferenceUpgradeService import com.ichi2.anki.servicelayer.PreferenceUpgradeService.setPreferencesUpToDate -import com.ichi2.anki.servicelayer.ScopedStorageService.isLegacyStorage import com.ichi2.anki.ui.windows.permissions.InternetPermissionFragment import com.ichi2.anki.ui.windows.permissions.NotificationsPermissionFragment import com.ichi2.anki.ui.windows.permissions.PermissionsFragment diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt b/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt index 998d9fe6881e..615ed0a6d7f6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt @@ -16,11 +16,10 @@ import androidx.core.content.IntentCompat import androidx.work.WorkManager import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.common.coroutines.applicationScope -import com.ichi2.anki.common.permissions.hasLegacyStorageAccessPermission -import com.ichi2.anki.common.permissions.isExternalStorageManagerCompat import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.common.storage.StorageDecision +import com.ichi2.anki.common.storage.grantedStoragePermissions import com.ichi2.anki.common.utils.android.showThemedToast import com.ichi2.anki.common.utils.trimToLength import com.ichi2.anki.dialogs.DialogHandler.Companion.storeMessage @@ -29,7 +28,6 @@ import com.ichi2.anki.dialogs.requireDeckPickerOrShowError import com.ichi2.anki.exception.SystemStorageException import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.noteeditor.NoteEditorLauncher -import com.ichi2.anki.servicelayer.ScopedStorageService import com.ichi2.anki.settings.Prefs import com.ichi2.anki.ui.windows.reviewer.ReviewerFragment import com.ichi2.anki.utils.MimeTypeUtils @@ -371,10 +369,7 @@ class IntentHandler : AbstractIntentHandler() { context: Context, showToast: Boolean, ): Boolean { - val granted = - !ScopedStorageService.isLegacyStorage(context) || - hasLegacyStorageAccessPermission(context) || - isExternalStorageManagerCompat() + val granted = grantedStoragePermissions(context) if (!granted && showToast) { showThemedToast(context, context.getString(R.string.intent_handler_failed_no_storage_permission), false) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ScopedStorageService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ScopedStorageService.kt index d3b58384e8d8..e3ce091a1053 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ScopedStorageService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ScopedStorageService.kt @@ -20,93 +20,11 @@ package com.ichi2.anki.servicelayer import android.content.Context import android.os.Build import android.os.Environment -import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.ui.windows.managespace.isInsideDirectoriesRemovedWithTheApp import com.ichi2.utils.Permissions -import timber.log.Timber -import java.io.File object ScopedStorageService { - /** - * Checks if current directory being used by AnkiDroid to store user data is a Legacy Storage Directory. - * This directory is stored under [CollectionHelper.PREF_COLLECTION_PATH] in SharedPreferences - * - * DEPRECATED. Use either [com.ichi2.anki.services.getMediaMigrationState], or - * [com.ichi2.anki.ui.windows.managespace.isInsideDirectoriesRemovedWithTheApp]. - * - * @return `true` if AnkiDroid is storing user data in a Legacy Storage Directory. - * - * @throws com.ichi2.anki.exception.SystemStorageException if `getExternalFilesDir` returns null - */ - fun isLegacyStorage(context: Context): Boolean = isLegacyStorage(CollectionHelper.getCurrentAnkiDroidDirectory(context), context) - - /** - * Checks if current directory being used by AnkiDroid to store user data is a Legacy Storage Directory. - * This directory is stored under [CollectionHelper.PREF_COLLECTION_PATH] in SharedPreferences - * @return `true` if AnkiDroid is storing user data in a Legacy Storage Directory. - * - * @param setCollectionPath if `false`, null is returned. This stops an infinite loop - * if `isLegacyStorage` is called when obtaining the collection path - */ - fun isLegacyStorage( - context: Context, - setCollectionPath: Boolean, - ): Boolean? { - if (!setCollectionPath && - !context - .sharedPrefs() - .contains(CollectionHelper.PREF_COLLECTION_PATH) - ) { - return null - } - return isLegacyStorage(CollectionHelper.getCurrentAnkiDroidDirectory(context), context) - } - - /** - * @return `true` if [currentDirPath] is a Legacy Storage Directory. - * - * DEPRECATED. Use [com.ichi2.anki.ui.windows.managespace.isInsideDirectoriesRemovedWithTheApp]. - * - */ - fun isLegacyStorage( - currentDirPath: File, - context: Context, - ): Boolean { - val internalScopedDirPath = - CollectionHelper.getAppSpecificInternalAnkiDroidDirectory(context) - val currentDir = currentDirPath.canonicalFile - val externalScopedDirs = - CollectionHelper.getAppSpecificExternalDirectories(context).map { it.canonicalFile } - val internalScopedDir = File(internalScopedDirPath).canonicalFile - Timber.i( - "isLegacyStorage(): current dir: %s\nscoped external dirs: %s\nscoped internal dir: %s", - currentDirPath, - externalScopedDirs.joinToString(", "), - internalScopedDirPath, - ) - - // Loop to check if the current AnkiDroid directory or any of its parents are the same as the root directories - // for app-private external or internal storage - the only directories which will be accessible without - // permissions under scoped storage - val scopedDirectories = externalScopedDirs + internalScopedDir - var currentDirParent: File? = currentDir - while (currentDirParent != null) { - for (scopedDir in scopedDirectories) { - if (currentDirParent.compareTo(scopedDir) == 0) { - Timber.i("isLegacyStorage(): false") - return false - } - } - currentDirParent = currentDirParent.parentFile?.canonicalFile - } - - // If the current AnkiDroid directory isn't a sub directory of the app-private external or internal storage - // directories, then it must be in a legacy storage directory - Timber.i("isLegacyStorage(): true") - return true - } - /** * Whether the user's current collection is now inaccessible due to a 'reinstall' * diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/managespace/FileUtils.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/managespace/FileUtils.kt index 33db43801b9b..6443515afd1d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/managespace/FileUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/managespace/FileUtils.kt @@ -153,7 +153,7 @@ private suspend fun Context.getUserDataAndCacheSizeUsingGetPackageSizeInfo(): Lo * * externalDirs: /storage/emulated/0/Android/data/com.ichi2.anki * - * There is a similar method [com.ichi2.anki.servicelayer.ScopedStorageService.isLegacyStorage], + * There is a similar method [com.ichi2.anki.common.storage.isLegacyStorage], * but it has a different purpose and behavior, and I am not sure if I understand it well. */ @Suppress("DEPRECATION") // context.externalMediaDirs: see the doc for the method diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/AnalyticsWidgetProvider.kt b/AnkiDroid/src/main/java/com/ichi2/widget/AnalyticsWidgetProvider.kt index 6a2e4d25364e..2fbaad9f6b3c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/AnalyticsWidgetProvider.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/AnalyticsWidgetProvider.kt @@ -21,9 +21,9 @@ import android.appwidget.AppWidgetProvider import android.content.Context import android.content.Intent import androidx.annotation.CallSuper -import com.ichi2.anki.IntentHandler.Companion.grantedStoragePermissions import com.ichi2.anki.common.analytics.Analytics import com.ichi2.anki.common.analytics.UsageAnalytics +import com.ichi2.anki.common.storage.grantedStoragePermissions import timber.log.Timber /** @@ -87,7 +87,7 @@ abstract class AnalyticsWidgetProvider : AppWidgetProvider() { appWidgetIds: IntArray, ) { super.onUpdate(context, appWidgetManager, appWidgetIds) - if (runCatching { grantedStoragePermissions(context, showToast = false) }.getOrNull() != true) { + if (runCatching { grantedStoragePermissions(context) }.getOrNull() != true) { Timber.w("Opening widget ${this.javaClass.name} without storage access") return } diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetPermissionReceiver.kt b/AnkiDroid/src/main/java/com/ichi2/widget/WidgetPermissionReceiver.kt index 8b30448dd10b..657a209452b1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetPermissionReceiver.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/WidgetPermissionReceiver.kt @@ -20,8 +20,8 @@ package com.ichi2.widget import android.content.ComponentName import android.content.Context import android.content.Intent -import com.ichi2.anki.IntentHandler import com.ichi2.anki.common.android.AnkiBroadcastReceiver +import com.ichi2.anki.common.storage.grantedStoragePermissions /** * BroadcastReceiver to handle the scenario where storage permissions are granted, @@ -32,7 +32,7 @@ class WidgetPermissionReceiver : AnkiBroadcastReceiver() { context: Context, intent: Intent, ) { - if (IntentHandler.grantedStoragePermissions(context, showToast = false)) { + if (grantedStoragePermissions(context)) { val appWidgetManager = getAppWidgetManager(context) ?: return val widgetIds = appWidgetManager.getAppWidgetIdsEx(ComponentName(context, AddNoteWidget::class.java)) AddNoteWidget.updateWidgets(context, appWidgetManager, widgetIds) diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/common/storage/LegacyStorage.kt b/anki-common/src/main/kotlin/com/ichi2/anki/common/storage/LegacyStorage.kt new file mode 100644 index 000000000000..6b123d7af8ca --- /dev/null +++ b/anki-common/src/main/kotlin/com/ichi2/anki/common/storage/LegacyStorage.kt @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright (c) 2022 David Allison +// SPDX-FileCopyrightText: Copyright (c) 2022 Arthur Milchior + +package com.ichi2.anki.common.storage + +import android.content.Context +import com.ichi2.anki.common.permissions.hasLegacyStorageAccessPermission +import com.ichi2.anki.common.permissions.isExternalStorageManagerCompat +import com.ichi2.anki.common.preferences.sharedPrefs +import timber.log.Timber +import java.io.File + +/** + * Checks if current directory being used by AnkiDroid to store user data is a Legacy Storage Directory. + * This directory is stored under [CollectionHelper.PREF_COLLECTION_PATH] in SharedPreferences + * + * DEPRECATED. Use either [com.ichi2.anki.services.getMediaMigrationState], or + * [com.ichi2.anki.ui.windows.managespace.isInsideDirectoriesRemovedWithTheApp]. + * + * @return `true` if AnkiDroid is storing user data in a Legacy Storage Directory. + * + * @throws com.ichi2.anki.exception.SystemStorageException if `getExternalFilesDir` returns null + */ +fun isLegacyStorage(context: Context): Boolean = isLegacyStorage(CollectionHelper.getCurrentAnkiDroidDirectory(context), context) + +/** + * Checks if current directory being used by AnkiDroid to store user data is a Legacy Storage Directory. + * This directory is stored under [CollectionHelper.PREF_COLLECTION_PATH] in SharedPreferences + * @return `true` if AnkiDroid is storing user data in a Legacy Storage Directory. + * + * @param setCollectionPath if `false`, null is returned. This stops an infinite loop + * if `isLegacyStorage` is called when obtaining the collection path + */ +fun isLegacyStorage( + context: Context, + setCollectionPath: Boolean, +): Boolean? { + if (!setCollectionPath && + !context + .sharedPrefs() + .contains(CollectionHelper.PREF_COLLECTION_PATH) + ) { + return null + } + return isLegacyStorage(CollectionHelper.getCurrentAnkiDroidDirectory(context), context) +} + +/** + * @return `true` if [currentDirPath] is a Legacy Storage Directory. + * + * DEPRECATED. Use [com.ichi2.anki.ui.windows.managespace.isInsideDirectoriesRemovedWithTheApp]. + * + */ +fun isLegacyStorage( + currentDirPath: File, + context: Context, +): Boolean { + val internalScopedDirPath = + CollectionHelper.getAppSpecificInternalAnkiDroidDirectory(context) + val currentDir = currentDirPath.canonicalFile + val externalScopedDirs = + CollectionHelper.getAppSpecificExternalDirectories(context).map { it.canonicalFile } + val internalScopedDir = File(internalScopedDirPath).canonicalFile + Timber.i( + "isLegacyStorage(): current dir: %s\nscoped external dirs: %s\nscoped internal dir: %s", + currentDirPath, + externalScopedDirs.joinToString(", "), + internalScopedDirPath, + ) + + // Loop to check if the current AnkiDroid directory or any of its parents are the same as the root directories + // for app-private external or internal storage - the only directories which will be accessible without + // permissions under scoped storage + val scopedDirectories = externalScopedDirs + internalScopedDir + var currentDirParent: File? = currentDir + while (currentDirParent != null) { + for (scopedDir in scopedDirectories) { + if (currentDirParent.compareTo(scopedDir) == 0) { + Timber.i("isLegacyStorage(): false") + return false + } + } + currentDirParent = currentDirParent.parentFile?.canonicalFile + } + + // If the current AnkiDroid directory isn't a sub directory of the app-private external or internal storage + // directories, then it must be in a legacy storage directory + Timber.i("isLegacyStorage(): true") + return true +} + +/** Checks whether storage permissions are granted on the device. If the device is not using legacy storage, + * it verifies if the app has been granted the necessary storage access permission. + * @return `true`: if granted, otherwise `false` + * + * @throws com.ichi2.anki.exception.SystemStorageException if `getExternalFilesDir` returns null + */ +fun grantedStoragePermissions(context: Context): Boolean = + !isLegacyStorage(context) || + hasLegacyStorageAccessPermission(context) || + isExternalStorageManagerCompat() From 660ddc93886f15b5dff752ca9f303b002c8dc877 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:24:08 +0100 Subject: [PATCH 2/2] lint: remove unresolved symbols These links don't work now we're in `:anki-common` --- .../kotlin/com/ichi2/anki/common/storage/LegacyStorage.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/common/storage/LegacyStorage.kt b/anki-common/src/main/kotlin/com/ichi2/anki/common/storage/LegacyStorage.kt index 6b123d7af8ca..29aca500997f 100644 --- a/anki-common/src/main/kotlin/com/ichi2/anki/common/storage/LegacyStorage.kt +++ b/anki-common/src/main/kotlin/com/ichi2/anki/common/storage/LegacyStorage.kt @@ -15,8 +15,7 @@ import java.io.File * Checks if current directory being used by AnkiDroid to store user data is a Legacy Storage Directory. * This directory is stored under [CollectionHelper.PREF_COLLECTION_PATH] in SharedPreferences * - * DEPRECATED. Use either [com.ichi2.anki.services.getMediaMigrationState], or - * [com.ichi2.anki.ui.windows.managespace.isInsideDirectoriesRemovedWithTheApp]. + * DEPRECATED. Use either `getMediaMigrationState`, or `isInsideDirectoriesRemovedWithTheApp`. * * @return `true` if AnkiDroid is storing user data in a Legacy Storage Directory. * @@ -49,7 +48,7 @@ fun isLegacyStorage( /** * @return `true` if [currentDirPath] is a Legacy Storage Directory. * - * DEPRECATED. Use [com.ichi2.anki.ui.windows.managespace.isInsideDirectoriesRemovedWithTheApp]. + * DEPRECATED. Use `isInsideDirectoriesRemovedWithTheApp`. * */ fun isLegacyStorage(