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