Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,15 @@ internal fun selectStoragePermissions(
}

fun selectStoragePermissions(context: Context): PermissionSet {
// `false`: the collection is app-private, so it can be accessed without storage permissions
// `null`: no collection path is set
val currentFolderIsLegacy = isLegacyStorage(context, setCollectionPath = false)
if (currentFolderIsLegacy == false) {
return PermissionSet.APP_PRIVATE
}

val canAccessLegacyStorage = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || Environment.isExternalStorageLegacy()
val currentFolderIsAccessibleAndLegacy = canAccessLegacyStorage && isLegacyStorage(context, setCollectionPath = false) == true
val currentFolderIsAccessibleAndLegacy = canAccessLegacyStorage && currentFolderIsLegacy == true

return selectStoragePermissions(
canManageExternalStorage = Permissions.canManageExternalStorage(context),
Expand Down
14 changes: 7 additions & 7 deletions AnkiDroid/src/main/java/com/ichi2/anki/startup/SetupStorage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ fun ensureCollectionPathSet(context: Context) {
* @param directoryName The leaf folder name to use at the end of the returned path.
* Defaults to `"AnkiDroid"` (the historical default-profile folder name).
* Callers wanting a profile-specific layout can pass e.g. the profile id.
* @param folder The storage location to return the default directory for.
* Defaults to [selectAnkiDroidFolder].
* @return Absolute Path to the default location starting location for the AnkiDroid directory
*
* @throws SystemStorageException if `getExternalFilesDir` returns null
Expand All @@ -116,14 +118,12 @@ fun ensureCollectionPathSet(context: Context) {
fun getDefaultAnkiDroidDirectory(
context: Context,
directoryName: String = "AnkiDroid",
): File {
val legacyStorage = selectAnkiDroidFolder(context) != AnkiDroidFolder.APP_PRIVATE
return if (legacyStorage) {
legacyAnkiDroidDirectory(directoryName)
} else {
File(getAppSpecificExternalAnkiDroidDirectory(context), directoryName)
folder: AnkiDroidFolder = selectAnkiDroidFolder(context),
): File =
when (folder) {
AnkiDroidFolder.PUBLIC -> legacyAnkiDroidDirectory(directoryName)
AnkiDroidFolder.APP_PRIVATE -> File(getAppSpecificExternalAnkiDroidDirectory(context), directoryName)
}
}

/**
* Returns the absolute path to the AnkiDroid directory under the primary/shared external storage directory.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,26 @@
package com.ichi2.anki

import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import androidx.core.content.edit
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ichi2.anki.common.preferences.sharedPrefs
import com.ichi2.anki.common.storage.CollectionHelper
import com.ichi2.testutils.EmptyApplication
import com.ichi2.utils.Permissions
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkObject
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.contains
import org.junit.Test
import org.junit.experimental.categories.Category
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import java.io.File
import kotlin.test.assertTrue

/**
Expand Down Expand Up @@ -95,6 +105,53 @@ class SelectStoragePermissionsTest {
)
}

@SuppressLint("NewApi") // EXTERNAL_MANAGER requires R, guaranteed by @Config
@Config(sdk = [R_OR_AFTER])
@Test // #13574: no collection path is set: permissions are based on device capabilities
fun `full build - screen is required while no collection path is set`() {
context.sharedPrefs().edit { remove(CollectionHelper.PREF_COLLECTION_PATH) }
withManageExternalStorageInManifest {
assertThat(selectStoragePermissions(context), equalTo(PermissionSet.EXTERNAL_MANAGER))
}
}

@SuppressLint("NewApi") // EXTERNAL_MANAGER requires R, guaranteed by @Config
@Config(sdk = [R_OR_AFTER])
@Test // #13574: public storage which the app cannot access: the screen is required
fun `full build - screen is required when access to public storage was revoked`() {
context.sharedPrefs().edit {
putString(CollectionHelper.PREF_COLLECTION_PATH, "/storage/emulated/0/AnkiDroid")
}
withManageExternalStorageInManifest {
assertThat(selectStoragePermissions(context), equalTo(PermissionSet.EXTERNAL_MANAGER))
}
}

@Config(sdk = [R_OR_AFTER])
@Test // #13574: app-private storage can be accessed without storage permissions
fun `app-private collection path requires no storage permissions`() {
context.sharedPrefs().edit {
putString(CollectionHelper.PREF_COLLECTION_PATH, File(context.filesDir, "AnkiDroid").path)
}
withManageExternalStorageInManifest {
assertThat(selectStoragePermissions(context), equalTo(PermissionSet.APP_PRIVATE))
}
}

private val context: Context
get() = ApplicationProvider.getApplicationContext()

/** a 'full' build: `MANAGE_EXTERNAL_STORAGE` is declared in the manifest */
private fun withManageExternalStorageInManifest(block: () -> Unit) {
mockkObject(Permissions)
every { Permissions.canManageExternalStorage(any()) } returns true
try {
block()
} finally {
unmockkObject(Permissions)
}
}

/**
* Helper for [com.ichi2.anki.selectStoragePermissions], making `currentFolderIsAccessibleAndLegacy` optional
*/
Expand Down
Loading