chore(storage): check valid data folder location & handle permission for device without FilePicker (#906)

* fix(storage): setting app's data folder on Android TV

Also check if set location is actually accessible then update UI compose accordingly (for all devices) in some cases (e.g. location set but actual folder was deleted or permission revoked)

(cherry picked from commit ef56b7f11ef0d243ef14816e85afa360f1137fe8)

* chore(storage): improve permission handling for older Android version & Fire TV

(cherry picked from commit 5ee009a6db73d33687e55ee8f4ad6412a9e320d4)
This commit is contained in:
Cuong-Tran 2025-05-11 23:24:16 +07:00 committed by GitHub
parent 0db7b47bcf
commit 7dd57c54bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 207 additions and 9 deletions

View file

@ -8,6 +8,9 @@
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Storage -->
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<!-- Permission to save cover into Pictures. It isn't required from API 29 -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"

View file

@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@ -20,11 +21,13 @@ import androidx.compose.ui.unit.dp
import eu.kanade.presentation.more.settings.screen.SettingsDataScreen
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.collectLatest
import tachiyomi.domain.storage.service.StorageManager.Companion.directoryAccessible
import tachiyomi.domain.storage.service.StoragePreferences
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Button
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -44,6 +47,13 @@ internal class StorageStep : OnboardingStep {
val pickStorageLocation = SettingsDataScreen.storageLocationPicker(storagePref)
// KMK -->
val storageDir by storagePref.collectAsState()
var locationValid by remember(storageDir) {
mutableStateOf(directoryAccessible(context, storageDir))
}
// KMK <--
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
@ -83,9 +93,14 @@ internal class StorageStep : OnboardingStep {
}
}
LaunchedEffect(Unit) {
LaunchedEffect(/* KMK --> */storageDir/* KMK <-- */) {
storagePref.changes()
.collectLatest { _isComplete = storagePref.isSet() }
.collectLatest {
// KMK -->
locationValid = directoryAccessible(context, storageDir)
_isComplete = locationValid
// KMK <--
}
}
}
}

View file

@ -1,6 +1,5 @@
package eu.kanade.presentation.more.settings.screen
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
@ -68,6 +67,7 @@ import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import logcat.LogPriority
import tachiyomi.core.common.i18n.stringResource
@ -79,6 +79,8 @@ import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.manga.interactor.GetFavorites
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.storage.service.StorageManager.Companion.allowAccessStorage
import tachiyomi.domain.storage.service.StorageManager.Companion.directoryAccessible
import tachiyomi.domain.storage.service.StoragePreferences
import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR
@ -156,6 +158,7 @@ object SettingsDataScreen : SearchableSettings {
}
UniFile.fromUri(context, uri)?.let {
storageDirPref.set("") // Trigger recompose
storageDirPref.set(it.uri.toString())
}
}
@ -169,7 +172,20 @@ object SettingsDataScreen : SearchableSettings {
val context = LocalContext.current
val storageDir by storageDirPref.collectAsState()
if (storageDir == storageDirPref.defaultValue()) {
// KMK -->
var locationValid by remember(storageDir) {
mutableStateOf(directoryAccessible(context, storageDir))
}
LaunchedEffect(storageDir) {
storageDirPref.changes()
.collectLatest {
locationValid = directoryAccessible(context, storageDir)
}
}
if (!locationValid) {
// KMK <--
return stringResource(MR.strings.no_location_set)
}
@ -186,13 +202,21 @@ object SettingsDataScreen : SearchableSettings {
val context = LocalContext.current
val pickStorageLocation = storageLocationPicker(storagePreferences.baseStorageDirectory())
// KMK -->
val storagePref = storagePreferences.baseStorageDirectory()
// KMK <--
return Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_storage_location),
subtitle = storageLocationText(storagePreferences.baseStorageDirectory()),
subtitle = storageLocationText(/* KMK --> */storagePref/* KMK <-- */),
onClick = {
try {
pickStorageLocation.launch(null)
} catch (e: ActivityNotFoundException) {
// KMK -->
allowAccessStorage(context, storagePref) {
// KMK <--
pickStorageLocation.launch(null)
}
} catch (e: Exception) {
context.toast(MR.strings.file_picker_error)
}
},

View file

@ -11,6 +11,9 @@ class AndroidStorageFolderProvider(
private val context: Context,
) : FolderProvider {
/**
* This will return File: /storage/emulated/0/<app_name>
*/
override fun directory(): File {
return File(
Environment.getExternalStorageDirectory().absolutePath + File.separator +
@ -18,6 +21,9 @@ class AndroidStorageFolderProvider(
)
}
/**
* This will return: file:///storage/emulated/0/<app_name>
*/
override fun path(): String {
return directory().toUri().toString()
}

View file

@ -1,9 +1,20 @@
package tachiyomi.domain.storage.service
import android.Manifest
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Environment
import android.provider.Settings
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.app.ActivityCompat
import androidx.core.net.toUri
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
@ -14,6 +25,10 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.shareIn
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.preference.Preference
import tachiyomi.i18n.MR
import java.io.File
class StorageManager(
private val context: Context,
@ -48,7 +63,11 @@ class StorageManager(
private fun getBaseDir(uri: String): UniFile? {
return UniFile.fromUri(context, uri.toUri())
.takeIf { it?.exists() == true }
.takeIf {
// KMK -->
it?.isAccessibleDirectory == true
// KMK <--
}
}
fun getAutomaticBackupsDirectory(): UniFile? {
@ -68,6 +87,136 @@ class StorageManager(
return baseDir?.createDirectory(LOGS_PATH)
}
// SY <--
companion object {
// KMK -->
/**
* Extension property to check if a UniFile is an accessible directory
*/
val UniFile.isAccessibleDirectory: Boolean
get() = exists() && isDirectory && canWrite() && canRead()
/**
* Check if a directory is accessible
*/
fun directoryAccessible(context: Context, uri: String): Boolean {
return UniFile.fromUri(context, uri.toUri())?.isAccessibleDirectory == true
}
/**
* Call FilePicker to allow access to storage or request All Files Access Permission if not available.
*/
fun allowAccessStorage(
context: Context,
storageDirPref: Preference<String>,
pickStorageLocation: () -> Unit,
) {
try {
val documentTreeIntent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if (isIntentAvailable(context, documentTreeIntent)) {
pickStorageLocation()
} else {
handleStoragePermission(context, storageDirPref)
}
} catch (e: ActivityNotFoundException) {
fallbackToScopedStorage(context, storageDirPref)
}
}
/**
* Handle storage permissions for Android R and above
*/
private fun handleStoragePermission(
context: Context,
storageDirPref: Preference<String>,
) {
if (hasManageExternalStoragePermission(context)) {
updateStoragePreference(context, storageDirPref)
} else {
requestManageExternalStoragePermission(context)
}
}
private fun hasManageExternalStoragePermission(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Environment.isExternalStorageManager()
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
context.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) ==
PackageManager.PERMISSION_GRANTED
} else {
context.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) ==
PackageManager.PERMISSION_GRANTED
}
}
private fun requestManageExternalStoragePermission(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
try {
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply {
data = "package:${context.packageName}".toUri()
}
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
context.startActivity(intent)
}
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ActivityCompat.requestPermissions(
context as Activity,
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),
1001,
)
} else {
ActivityCompat.requestPermissions(
context as Activity,
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
1001,
)
}
}
/**
* Update storage preference with the selected directory
*/
private fun updateStoragePreference(
context: Context,
storageDirPref: Preference<String>,
) {
UniFile.fromUri(context, storageDirPref.get().toUri())?.let {
it.mkdir()
storageDirPref.set("") // Trigger recompose
storageDirPref.set(it.uri.toString())
}
}
/**
* Fallback to scoped storage if no other options are available
*/
private fun fallbackToScopedStorage(
context: Context,
storageDirPref: Preference<String>,
) {
val fallbackDir = File(context.getExternalFilesDir(null), context.stringResource(MR.strings.app_name))
if (!fallbackDir.exists()) fallbackDir.mkdirs()
storageDirPref.set("") // Trigger recompose
storageDirPref.set(fallbackDir.toUri().toString())
context.toast("Using default directory: ${fallbackDir.absolutePath}")
}
/**
* Used to check if system is able to open contract [ActivityResultContracts.OpenDocumentTree]
* by checking if intent [Intent.ACTION_OPEN_DOCUMENT_TREE] is available and not being stub (on Android TV)
*/
private fun isIntentAvailable(context: Context, intent: Intent): Boolean {
val packageManager = context.packageManager
// Android TV: ResolveInfo{c236166 com.android.tv.frameworkpackagestubs/.Stubs$DocumentsStub m=0x108000 userHandle=UserHandle{0}}
val resolveInfo = packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
return resolveInfo.any {
it.activityInfo.packageName != null && it.activityInfo.packageName != "com.android.tv.frameworkpackagestubs"
}
}
// KMK <--
}
}
private const val AUTOMATIC_BACKUPS_PATH = "autobackup"

View file

@ -9,5 +9,6 @@ class StoragePreferences(
private val preferenceStore: PreferenceStore,
) {
// Storing URI of the directory (either file:/// or storage://
fun baseStorageDirectory() = preferenceStore.getString(Preference.appStateKey("storage_dir"), folderProvider.path())
}

View file

@ -30,7 +30,7 @@ quickjs-android = "app.cash.quickjs:quickjs-android:0.9.2"
jsoup = "org.jsoup:jsoup:1.19.1"
disklrucache = "com.jakewharton:disklrucache:2.0.2"
unifile = "com.github.tachiyomiorg:unifile:e0def6b3dc"
unifile = "com.github.komikku-app:UniFile:084a54140a"
libarchive = "me.zhanghai.android.libarchive:library:1.1.4"
sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqlite" }