From 7dd57c54bc86ca85d14ef5dff0562264f1eeb982 Mon Sep 17 00:00:00 2001 From: Cuong-Tran Date: Sun, 11 May 2025 23:24:16 +0700 Subject: [PATCH] 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) --- app/src/main/AndroidManifest.xml | 3 + .../more/onboarding/StorageStep.kt | 19 ++- .../settings/screen/SettingsDataScreen.kt | 34 +++- .../storage/AndroidStorageFolderProvider.kt | 6 + .../domain/storage/service/StorageManager.kt | 151 +++++++++++++++++- .../storage/service/StoragePreferences.kt | 1 + gradle/libs.versions.toml | 2 +- 7 files changed, 207 insertions(+), 9 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6a3ba2a22..e4600cbde 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,9 @@ + + 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 <-- + } } } } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt index a2b7c6a76..dafd8cecc 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt @@ -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) } }, diff --git a/core/common/src/main/kotlin/tachiyomi/core/common/storage/AndroidStorageFolderProvider.kt b/core/common/src/main/kotlin/tachiyomi/core/common/storage/AndroidStorageFolderProvider.kt index 33335f345..5fe7e9cc5 100644 --- a/core/common/src/main/kotlin/tachiyomi/core/common/storage/AndroidStorageFolderProvider.kt +++ b/core/common/src/main/kotlin/tachiyomi/core/common/storage/AndroidStorageFolderProvider.kt @@ -11,6 +11,9 @@ class AndroidStorageFolderProvider( private val context: Context, ) : FolderProvider { + /** + * This will return File: /storage/emulated/0/ + */ override fun directory(): File { return File( Environment.getExternalStorageDirectory().absolutePath + File.separator + @@ -18,6 +21,9 @@ class AndroidStorageFolderProvider( ) } + /** + * This will return: file:///storage/emulated/0/ + */ override fun path(): String { return directory().toUri().toString() } diff --git a/domain/src/main/java/tachiyomi/domain/storage/service/StorageManager.kt b/domain/src/main/java/tachiyomi/domain/storage/service/StorageManager.kt index 4c4b60549..78718ac87 100644 --- a/domain/src/main/java/tachiyomi/domain/storage/service/StorageManager.kt +++ b/domain/src/main/java/tachiyomi/domain/storage/service/StorageManager.kt @@ -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, + 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, + ) { + 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, + ) { + 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, + ) { + 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" diff --git a/domain/src/main/java/tachiyomi/domain/storage/service/StoragePreferences.kt b/domain/src/main/java/tachiyomi/domain/storage/service/StoragePreferences.kt index f29949cff..7ed4c95e1 100644 --- a/domain/src/main/java/tachiyomi/domain/storage/service/StoragePreferences.kt +++ b/domain/src/main/java/tachiyomi/domain/storage/service/StoragePreferences.kt @@ -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()) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 05d6db87b..330d4533b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }