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