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:
parent
0db7b47bcf
commit
7dd57c54bc
7 changed files with 207 additions and 9 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 <--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Reference in a new issue