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" }