diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index c9aa7f6e2a..e85ce85857 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -28,6 +28,7 @@ dependencies {
implementation(libs.work.runtime.ktx)
implementation(libs.preferences.datastore)
implementation(libs.appcompat)
+ implementation(libs.documentfile)
// Compose
implementation(platform(libs.compose.bom))
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5c24e86e71..1688c91aba 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -19,9 +19,6 @@
-
-
diff --git a/app/src/main/java/app/revanced/manager/data/platform/Filesystem.kt b/app/src/main/java/app/revanced/manager/data/platform/Filesystem.kt
index 64676f22f2..4e76785b58 100644
--- a/app/src/main/java/app/revanced/manager/data/platform/Filesystem.kt
+++ b/app/src/main/java/app/revanced/manager/data/platform/Filesystem.kt
@@ -1,16 +1,10 @@
package app.revanced.manager.data.platform
-import android.Manifest
import android.app.Application
import android.content.Context
-import android.content.pm.PackageManager
-import android.os.Build
-import android.os.Environment
-import androidx.activity.result.contract.ActivityResultContract
-import androidx.activity.result.contract.ActivityResultContracts
-import app.revanced.manager.util.RequestManageStorageContract
+import android.net.Uri
+import androidx.documentfile.provider.DocumentFile
import java.io.File
-import java.nio.file.Path
class Filesystem(private val app: Application) {
val contentResolver = app.contentResolver // TODO: move Content Resolver operations to here.
@@ -31,21 +25,11 @@ class Filesystem(private val app: Application) {
*/
val uiTempDir: File = File(app.filesDir, "ui_ephemeral").apply { mkdirs() }
- fun externalFilesDir(): Path = Environment.getExternalStorageDirectory().toPath()
-
- private fun usesManagePermission() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
-
- private val storagePermissionName =
- if (usesManagePermission()) Manifest.permission.MANAGE_EXTERNAL_STORAGE else Manifest.permission.READ_EXTERNAL_STORAGE
-
- fun permissionContract(): Pair, String> {
- val contract =
- if (usesManagePermission()) RequestManageStorageContract() else ActivityResultContracts.RequestPermission()
- return contract to storagePermissionName
+ fun openFileDocument(uri: Uri): DocumentFile? {
+ return DocumentFile.fromSingleUri(app, uri)
}
- fun hasStoragePermission() =
- if (usesManagePermission()) Environment.isExternalStorageManager() else app.checkSelfPermission(
- storagePermissionName
- ) == PackageManager.PERMISSION_GRANTED
+ fun openFolderDocument(uri: Uri): DocumentFile? {
+ return DocumentFile.fromTreeUri(app, uri)
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt b/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt
index 3222915584..34f8b21273 100644
--- a/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt
+++ b/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt
@@ -3,6 +3,7 @@ package app.revanced.manager.ui.component.patches
import android.app.Application
import android.os.Parcelable
import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable
@@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
@@ -27,9 +29,11 @@ import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Folder
+import androidx.compose.material.icons.outlined.InsertDriveFile
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -51,6 +55,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
@@ -59,6 +64,7 @@ import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
+import androidx.documentfile.provider.DocumentFile
import app.revanced.manager.R
import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.patcher.patch.Option
@@ -77,13 +83,18 @@ import app.revanced.manager.util.saver.snapshotStateListSaver
import app.revanced.manager.util.saver.snapshotStateSetSaver
import app.revanced.manager.util.toast
import app.revanced.manager.util.transparentListItemColors
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import org.koin.compose.koinInject
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
+import java.io.File
import java.io.Serializable
+import java.util.UUID
import kotlin.random.Random
import kotlin.reflect.typeOf
@@ -309,20 +320,76 @@ private object StringOptionEditor : OptionEditor {
val validatorFailed by remember {
derivedStateOf { !scope.option.validator(fieldValue) }
}
+ var copyingDataToCache by remember {
+ mutableStateOf(false)
+ }
val fs: Filesystem = koinInject()
- val (contract, permissionName) = fs.permissionContract()
- val permissionLauncher = rememberLauncherForActivityResult(contract = contract) {
- showFileDialog = it
+
+ val coroutineScope = rememberCoroutineScope()
+
+ suspend fun copyFile(documentFile: DocumentFile): String {
+ val filename = documentFile.name ?: UUID.randomUUID().toString()
+ withContext(Dispatchers.Main) {
+ copyingDataToCache = true
+ }
+ try {
+ return withContext(Dispatchers.IO) {
+ val tempDir = File(fs.tempDir, "options")
+ val tempFile = File(tempDir, filename)
+ if (tempFile.exists()) {
+ tempFile.deleteRecursively()
+ }
+ if (documentFile.isDirectory) {
+ tempFile.mkdirs()
+ documentFile.listFiles().forEach { documentFile ->
+ val filename = documentFile.name ?: return@forEach
+ val tempFile = File(tempFile, filename).apply {
+ createNewFile()
+ }
+ fs.contentResolver.openInputStream(documentFile.uri)?.use { input ->
+ tempFile.outputStream().use { output ->
+ input.copyTo(output)
+ }
+ }
+ }
+ } else {
+ tempDir.mkdirs()
+ tempFile.createNewFile()
+ fs.contentResolver.openInputStream(documentFile.uri)?.use { input ->
+ tempFile.outputStream().use { output ->
+ input.copyTo(output)
+ }
+ }
+ }
+
+ return@withContext tempFile.path
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return ""
+ } finally {
+ withContext(Dispatchers.Main) {
+ copyingDataToCache = false
+ }
+ }
}
- if (showFileDialog) {
- PathSelectorDialog(
- root = fs.externalFilesDir()
- ) {
- showFileDialog = false
- it?.let { path ->
- fieldValue = path.toString()
+ val filePathLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.OpenDocument()) {
+ it?.let { uri ->
+ fs.openFileDocument(uri)?.let { documentFile ->
+ coroutineScope.launch {
+ fieldValue = copyFile(documentFile)
+ }
+ }
+ }
+ }
+ val folderPathLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.OpenDocumentTree()) {
+ it?.let { uri ->
+ fs.openFolderDocument(uri)?.let { documentFile ->
+ coroutineScope.launch {
+ fieldValue = copyFile(documentFile)
+ }
}
}
}
@@ -337,9 +404,10 @@ private object StringOptionEditor : OptionEditor {
placeholder = {
Text(stringResource(R.string.dialog_input_placeholder))
},
- isError = validatorFailed,
+ enabled = !copyingDataToCache,
+ isError = validatorFailed && !copyingDataToCache,
supportingText = {
- if (validatorFailed) {
+ if (validatorFailed && !copyingDataToCache) {
Text(
stringResource(R.string.input_dialog_value_invalid),
modifier = Modifier.fillMaxWidth(),
@@ -348,37 +416,51 @@ private object StringOptionEditor : OptionEditor {
}
},
trailingIcon = {
- var showDropdownMenu by rememberSaveable { mutableStateOf(false) }
- IconButton(
- onClick = { showDropdownMenu = true },
- shapes = IconButtonDefaults.shapes(),
- ) {
- Icon(
- Icons.Outlined.MoreVert,
- stringResource(R.string.string_option_menu_description)
- )
- }
+ if (copyingDataToCache) {
+ CircularProgressIndicator(modifier = Modifier.size(16.dp))
+ } else {
+ var showDropdownMenu by rememberSaveable { mutableStateOf(false) }
+ IconButton(
+ onClick = { showDropdownMenu = true },
+ shapes = IconButtonDefaults.shapes(),
+ ) {
+ Icon(
+ Icons.Outlined.MoreVert,
+ stringResource(R.string.string_option_menu_description)
+ )
+ }
- DropdownMenu(
- expanded = showDropdownMenu,
- onDismissRequest = { showDropdownMenu = false }
- ) {
- DropdownMenuItem(
- leadingIcon = {
- Icon(Icons.Outlined.Folder, null)
- },
- text = {
- Text(stringResource(R.string.path_selector))
- },
- onClick = {
- showDropdownMenu = false
- if (fs.hasStoragePermission()) {
- showFileDialog = true
- } else {
- permissionLauncher.launch(permissionName)
+ DropdownMenu(
+ expanded = showDropdownMenu,
+ onDismissRequest = { showDropdownMenu = false }
+ ) {
+ DropdownMenuItem(
+ leadingIcon = {
+ // InsertDriveFile is the only icon that actually represents a file.
+ // I don't think we need automirroring here as suggested by a warning.
+ Icon(Icons.Outlined.InsertDriveFile, null)
+ },
+ text = {
+ Text(stringResource(R.string.path_selector_file))
+ },
+ onClick = {
+ showDropdownMenu = false
+ filePathLauncher.launch(arrayOf("*/*"))
}
- }
- )
+ )
+ DropdownMenuItem(
+ leadingIcon = {
+ Icon(Icons.Outlined.Folder, null)
+ },
+ text = {
+ Text(stringResource(R.string.path_selector_dir))
+ },
+ onClick = {
+ showDropdownMenu = false
+ folderPathLauncher.launch(null)
+ }
+ )
+ }
}
}
)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2da8fc3176..9b7ef8fb5e 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -432,6 +432,8 @@ It is only compatible with the following version(s): %2$s
Custom value
Select from storage
+ Select directory from storage
+ Select file from storage
Previous directory
Directories
Files
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 21657e7580..5c566216f9 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -39,6 +39,7 @@ hidden-api-stub = "4.4.0"
binary-compatibility-validator = "0.18.1"
semver-parser = "3.0.0"
ackpine = "0.20.6"
+documentfile = "1.1.0"
foundation-layout = "1.10.5"
[libraries]
@@ -52,6 +53,7 @@ fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work-runtime" }
preferences-datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "preferences-datastore" }
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
# Compose
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }