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" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Storage --> <!-- 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 --> <!-- Permission to save cover into Pictures. It isn't required from API 29 -->
<uses-permission <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" 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.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext 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.presentation.more.settings.screen.SettingsDataScreen
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import tachiyomi.domain.storage.service.StorageManager.Companion.directoryAccessible
import tachiyomi.domain.storage.service.StoragePreferences import tachiyomi.domain.storage.service.StoragePreferences
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Button import tachiyomi.presentation.core.components.material.Button
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@ -44,6 +47,13 @@ internal class StorageStep : OnboardingStep {
val pickStorageLocation = SettingsDataScreen.storageLocationPicker(storagePref) val pickStorageLocation = SettingsDataScreen.storageLocationPicker(storagePref)
// KMK -->
val storageDir by storagePref.collectAsState()
var locationValid by remember(storageDir) {
mutableStateOf(directoryAccessible(context, storageDir))
}
// KMK <--
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
@ -83,9 +93,14 @@ internal class StorageStep : OnboardingStep {
} }
} }
LaunchedEffect(Unit) { LaunchedEffect(/* KMK --> */storageDir/* KMK <-- */) {
storagePref.changes() 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 package eu.kanade.presentation.more.settings.screen
import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
@ -68,6 +67,7 @@ import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.common.i18n.stringResource 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.library.service.LibraryPreferences
import tachiyomi.domain.manga.interactor.GetFavorites import tachiyomi.domain.manga.interactor.GetFavorites
import tachiyomi.domain.manga.model.Manga 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.domain.storage.service.StoragePreferences
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR import tachiyomi.i18n.kmk.KMR
@ -156,6 +158,7 @@ object SettingsDataScreen : SearchableSettings {
} }
UniFile.fromUri(context, uri)?.let { UniFile.fromUri(context, uri)?.let {
storageDirPref.set("") // Trigger recompose
storageDirPref.set(it.uri.toString()) storageDirPref.set(it.uri.toString())
} }
} }
@ -169,7 +172,20 @@ object SettingsDataScreen : SearchableSettings {
val context = LocalContext.current val context = LocalContext.current
val storageDir by storageDirPref.collectAsState() 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) return stringResource(MR.strings.no_location_set)
} }
@ -186,13 +202,21 @@ object SettingsDataScreen : SearchableSettings {
val context = LocalContext.current val context = LocalContext.current
val pickStorageLocation = storageLocationPicker(storagePreferences.baseStorageDirectory()) val pickStorageLocation = storageLocationPicker(storagePreferences.baseStorageDirectory())
// KMK -->
val storagePref = storagePreferences.baseStorageDirectory()
// KMK <--
return Preference.PreferenceItem.TextPreference( return Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_storage_location), title = stringResource(MR.strings.pref_storage_location),
subtitle = storageLocationText(storagePreferences.baseStorageDirectory()), subtitle = storageLocationText(/* KMK --> */storagePref/* KMK <-- */),
onClick = { onClick = {
try { try {
pickStorageLocation.launch(null) // KMK -->
} catch (e: ActivityNotFoundException) { allowAccessStorage(context, storagePref) {
// KMK <--
pickStorageLocation.launch(null)
}
} catch (e: Exception) {
context.toast(MR.strings.file_picker_error) context.toast(MR.strings.file_picker_error)
} }
}, },

View file

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

View file

@ -1,9 +1,20 @@
package tachiyomi.domain.storage.service package tachiyomi.domain.storage.service
import android.Manifest
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context 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 androidx.core.net.toUri
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
@ -14,6 +25,10 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.shareIn 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( class StorageManager(
private val context: Context, private val context: Context,
@ -48,7 +63,11 @@ class StorageManager(
private fun getBaseDir(uri: String): UniFile? { private fun getBaseDir(uri: String): UniFile? {
return UniFile.fromUri(context, uri.toUri()) return UniFile.fromUri(context, uri.toUri())
.takeIf { it?.exists() == true } .takeIf {
// KMK -->
it?.isAccessibleDirectory == true
// KMK <--
}
} }
fun getAutomaticBackupsDirectory(): UniFile? { fun getAutomaticBackupsDirectory(): UniFile? {
@ -68,6 +87,136 @@ class StorageManager(
return baseDir?.createDirectory(LOGS_PATH) return baseDir?.createDirectory(LOGS_PATH)
} }
// SY <-- // 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" private const val AUTOMATIC_BACKUPS_PATH = "autobackup"

View file

@ -9,5 +9,6 @@ class StoragePreferences(
private val preferenceStore: PreferenceStore, private val preferenceStore: PreferenceStore,
) { ) {
// Storing URI of the directory (either file:/// or storage://
fun baseStorageDirectory() = preferenceStore.getString(Preference.appStateKey("storage_dir"), folderProvider.path()) 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" jsoup = "org.jsoup:jsoup:1.19.1"
disklrucache = "com.jakewharton:disklrucache:2.0.2" 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" libarchive = "me.zhanghai.android.libarchive:library:1.1.4"
sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqlite" } sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqlite" }