Skip to content
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
3 changes: 0 additions & 3 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,6 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ENFORCE_UPDATE_OWNERSHIP" />

Expand Down
30 changes: 7 additions & 23 deletions app/src/main/java/app/revanced/manager/data/platform/Filesystem.kt
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<ActivityResultContract<String, Boolean>, 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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -309,20 +320,76 @@ private object StringOptionEditor : OptionEditor<String> {
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)
}
}
}
}
Expand All @@ -337,9 +404,10 @@ private object StringOptionEditor : OptionEditor<String> {
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(),
Expand All @@ -348,37 +416,51 @@ private object StringOptionEditor : OptionEditor<String> {
}
},
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)
}
)
}
}
}
)
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,8 @@ It is only compatible with the following version(s): %2$s</string>
<string name="option_preset_custom_value">Custom value</string>

<string name="path_selector">Select from storage</string>
<string name="path_selector_dir">Select directory from storage</string>
<string name="path_selector_file">Select file from storage</string>
<string name="path_selector_parent_dir">Previous directory</string>
<string name="path_selector_dirs">Directories</string>
<string name="path_selector_files">Files</string>
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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" }
Expand Down