diff --git a/README.md b/README.md
index 141ee5d..919bbe7 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
FileFlow scans your files periodically and organizes them according to your rules.
-[
](https://apt.izzysoft.de/packages/co.adityarajput.fileflow) [
](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/BURG3R5/FileFlow)
+[
](https://f-droid.org/packages/co.adityarajput.fileflow) [
](https://apt.izzysoft.de/packages/co.adityarajput.fileflow) [
](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/BURG3R5/FileFlow)
## Screenshots
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 09d61d7..df3f715 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -22,8 +22,8 @@ android {
applicationId = "co.adityarajput.fileflow"
minSdk = 29
targetSdk = 36
- versionCode = 3
- versionName = "1.1.1"
+ versionCode = 4
+ versionName = "1.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/app/src/main/java/co/adityarajput/fileflow/data/AppContainer.kt b/app/src/main/java/co/adityarajput/fileflow/data/AppContainer.kt
index 50ab22a..1e49bfe 100644
--- a/app/src/main/java/co/adityarajput/fileflow/data/AppContainer.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/data/AppContainer.kt
@@ -48,6 +48,7 @@ class AppContainer(private val context: Context) {
Action.DELETE_STALE(
"/storage/emulated/0/Download",
"Alarmetrics_v[\\d\\.]+.apk",
+ scanSubdirectories = true,
),
enabled = false,
),
diff --git a/app/src/main/java/co/adityarajput/fileflow/data/models/Action.kt b/app/src/main/java/co/adityarajput/fileflow/data/models/Action.kt
index 8f17b1a..a28a165 100644
--- a/app/src/main/java/co/adityarajput/fileflow/data/models/Action.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/data/models/Action.kt
@@ -17,6 +17,7 @@ import kotlinx.serialization.Serializable
sealed class Action {
abstract val src: String
abstract val srcFileNamePattern: String
+ abstract val scanSubdirectories: Boolean
abstract val verb: Int
abstract val phrase: Int
@@ -38,9 +39,11 @@ sealed class Action {
override val srcFileNamePattern: String,
val dest: String,
val destFileNameTemplate: String,
+ override val scanSubdirectories: Boolean = false,
val keepOriginal: Boolean = true,
val overwriteExisting: Boolean = false,
val superlative: FileSuperlative = FileSuperlative.LATEST,
+ val preserveStructure: Boolean = scanSubdirectories,
) : Action() {
override val verb get() = if (keepOriginal) R.string.copy else R.string.move
@@ -52,8 +55,12 @@ sealed class Action {
withStyle(dullStyle) { append("from ") }
append(src.getGetDirectoryFromUri())
+ if (scanSubdirectories)
+ withStyle(dullStyle) { append(" & subfolders") }
withStyle(dullStyle) { append("\nto ") }
append(dest.getGetDirectoryFromUri())
+ if (preserveStructure)
+ withStyle(dullStyle) { append(" & subfolders") }
withStyle(dullStyle) { append("\nas ") }
append(destFileNameTemplate)
}
@@ -64,6 +71,7 @@ sealed class Action {
override val src: String,
override val srcFileNamePattern: String,
val retentionDays: Int = 30,
+ override val scanSubdirectories: Boolean = false,
) : Action() {
override val verb get() = R.string.delete_stale
@@ -77,6 +85,8 @@ sealed class Action {
withStyle(dullStyle) { append("in ") }
append(src.getGetDirectoryFromUri())
+ if (scanSubdirectories)
+ withStyle(dullStyle) { append(" & subfolders") }
withStyle(dullStyle) { append("\nif unmodified for ") }
append((retentionTimeInMillis()).toShortHumanReadableTime())
}
diff --git a/app/src/main/java/co/adityarajput/fileflow/services/FlowExecutor.kt b/app/src/main/java/co/adityarajput/fileflow/services/FlowExecutor.kt
index 39ea914..4b388a8 100644
--- a/app/src/main/java/co/adityarajput/fileflow/services/FlowExecutor.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/services/FlowExecutor.kt
@@ -28,7 +28,8 @@ class FlowExecutor(private val context: Context) {
continue
}
- val srcFiles = File.fromPath(context, rule.action.src)?.listFiles()
+ val srcFiles = File.fromPath(context, rule.action.src)
+ ?.listChildren(rule.action.scanSubdirectories)
?.filter { it.isFile && it.name != null && regex.matches(it.name!!) }
?.let {
if (rule.action.superlative != FileSuperlative.NONE)
@@ -38,14 +39,29 @@ class FlowExecutor(private val context: Context) {
} ?: continue
for (srcFile in srcFiles) {
- val destFileName = regex.replace(
- srcFile.name!!,
- rule.action.destFileNameTemplate,
- )
- var destFile = destDir.listFiles().firstOrNull {
- it.isFile && it.name == destFileName
+ val relativePath = srcFile.parent!!.pathRelativeTo(rule.action.src)
+ val destSubDir =
+ if (!rule.action.preserveStructure || relativePath == null) destDir
+ else destDir.createDirectory(relativePath)
+
+ if (destSubDir == null) {
+ Logger.e(
+ "FlowExecutor",
+ "Failed to create subdirectory in ${destDir.path}",
+ )
+ continue
}
+ val destFileName = srcFile.name!!.replace(
+ regex,
+ rule.action.destFileNameTemplate.replace(
+ $$"${folder}",
+ srcFile.parent?.name ?: "",
+ ),
+ )
+ var destFile = destSubDir.listChildren(false)
+ .firstOrNull { it.isFile && it.name == destFileName }
+
if (destFile != null) {
if (!rule.action.overwriteExisting) {
Logger.e("FlowExecutor", "${destFile.name} already exists")
@@ -65,7 +81,7 @@ class FlowExecutor(private val context: Context) {
destFile.delete()
}
- destFile = destDir.createFile(srcFile.type, destFileName)
+ destFile = destSubDir.createFile(srcFile.type, destFileName)
if (destFile == null) {
Logger.e("FlowExecutor", "Failed to create $destFileName")
@@ -95,7 +111,8 @@ class FlowExecutor(private val context: Context) {
}
is Action.DELETE_STALE -> {
- val srcFiles = File.fromPath(context, rule.action.src)?.listFiles()
+ val srcFiles = File.fromPath(context, rule.action.src)
+ ?.listChildren(rule.action.scanSubdirectories)
?.filter { it.isFile && it.name != null && regex.matches(it.name!!) }
?.filter {
System.currentTimeMillis() - it.lastModified() >=
@@ -106,17 +123,18 @@ class FlowExecutor(private val context: Context) {
?: continue
for (srcFile in srcFiles) {
- Logger.i("FlowExecutor", "Deleting ${srcFile.name}")
+ val srcFileName = srcFile.name ?: continue
+ Logger.i("FlowExecutor", "Deleting $srcFileName")
val result = srcFile.delete()
if (!result) {
- Logger.e("FlowExecutor", "Failed to delete ${srcFile.name}")
+ Logger.e("FlowExecutor", "Failed to delete $srcFileName")
continue
}
repository.registerExecution(
rule,
- Execution(srcFile.name!!, rule.action.verb),
+ Execution(srcFileName, rule.action.verb),
)
}
}
diff --git a/app/src/main/java/co/adityarajput/fileflow/utils/Files.kt b/app/src/main/java/co/adityarajput/fileflow/utils/Files.kt
index dfd33a5..c8e6e46 100644
--- a/app/src/main/java/co/adityarajput/fileflow/utils/Files.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/utils/Files.kt
@@ -3,6 +3,7 @@ package co.adityarajput.fileflow.utils
import android.content.Context
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
+import co.adityarajput.fileflow.data.models.Rule
import java.net.URLDecoder
import java.io.File as IOFile
@@ -37,12 +38,30 @@ sealed class File {
is FSFile -> ioFile.name
}
+ val path: String
+ get() = when (this) {
+ is SAFFile -> documentFile.uri.toString()
+ is FSFile -> ioFile.absolutePath
+ }
+
+ val parent
+ get() = when (this) {
+ is SAFFile -> documentFile.parentFile?.let { SAFFile(it) }
+ is FSFile -> ioFile.parentFile?.let { FSFile(it) }
+ }
+
val isFile
get() = when (this) {
is SAFFile -> documentFile.isFile
is FSFile -> ioFile.isFile
}
+ val isDirectory
+ get() = when (this) {
+ is SAFFile -> documentFile.isDirectory
+ is FSFile -> ioFile.isDirectory
+ }
+
val type
get() = when (this) {
is SAFFile -> documentFile.type
@@ -59,9 +78,25 @@ sealed class File {
is FSFile -> ioFile.length()
}
- fun listFiles() = when (this) {
- is SAFFile -> documentFile.listFiles().map { SAFFile(it) }
- is FSFile -> ioFile.listFiles()?.map { FSFile(it) } ?: emptyList()
+ fun pathRelativeTo(basePath: String) = path.getGetDirectoryFromUri()
+ .substringAfter(basePath.getGetDirectoryFromUri(), "").ifBlank { null }
+
+ fun listChildren(recurse: Boolean): List {
+ if (!isDirectory) return emptyList()
+
+ if (!recurse) {
+ return when (this) {
+ is SAFFile -> documentFile.listFiles().map { SAFFile(it) }
+ is FSFile -> ioFile.listFiles()?.map { FSFile(it) }.orEmpty()
+ }
+ }
+
+ val files = mutableListOf()
+ listChildren(false).forEach {
+ files.add(it)
+ files.addAll(it.listChildren(true))
+ }
+ return files
}
fun isIdenticalTo(other: File, context: Context): Boolean {
@@ -95,6 +130,33 @@ sealed class File {
}
}
+ fun createDirectory(relativePath: String): File? {
+ return when (this) {
+ is SAFFile -> {
+ val parts = relativePath.split('/').filter { it.isNotBlank() }
+ var currentDir: DocumentFile = documentFile
+
+ for (part in parts) {
+ val nextDir = currentDir.findFile(part)
+ ?: currentDir.createDirectory(part)
+
+ if (nextDir == null) {
+ Logger.e("Files", "Failed to create directory: $part")
+ return null
+ }
+
+ currentDir = nextDir
+ }
+
+ SAFFile(currentDir)
+ }
+
+ is FSFile -> IOFile(ioFile.path + '/' + relativePath).let {
+ if (it.exists() || it.mkdirs()) FSFile(it) else null
+ }
+ }
+ }
+
fun delete() = when (this) {
is SAFFile -> documentFile.delete()
is FSFile -> ioFile.delete()
@@ -140,3 +202,6 @@ fun String.getGetDirectoryFromUri() =
substringAfter(file.name ?: "").ifBlank { "/" }
}
+
+fun Context.findRulesToBeMigrated(rules: List) =
+ rules.filter { File.fromPath(this, it.action.src) == null }
diff --git a/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt b/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt
index bdaceb9..5cce07f 100644
--- a/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt
@@ -33,6 +33,8 @@ class UpsertRuleViewModel(
val superlative: FileSuperlative = FileSuperlative.LATEST,
val keepOriginal: Boolean = true,
val overwriteExisting: Boolean = false,
+ val scanSubdirectories: Boolean = false,
+ val preserveStructure: Boolean = false,
val currentSrcFileNames: List? = null,
val predictedDestFileNames: List? = null,
val retentionDays: Int = 30,
@@ -45,12 +47,15 @@ class UpsertRuleViewModel(
rule.action.srcFileNamePattern, rule.action.dest,
rule.action.destFileNameTemplate, rule.action.superlative,
rule.action.keepOriginal, rule.action.overwriteExisting,
+ rule.action.scanSubdirectories, rule.action.preserveStructure,
)
is Action.DELETE_STALE ->
Values(
rule.id, rule.action.base, rule.action.src,
- rule.action.srcFileNamePattern, retentionDays = rule.action.retentionDays,
+ rule.action.srcFileNamePattern,
+ scanSubdirectories = rule.action.scanSubdirectories,
+ retentionDays = rule.action.retentionDays,
)
}
}
@@ -59,14 +64,18 @@ class UpsertRuleViewModel(
is Action.MOVE ->
Rule(
Action.MOVE(
- src, srcFileNamePattern, dest, destFileNameTemplate, keepOriginal,
- overwriteExisting, superlative,
+ src, srcFileNamePattern, dest, destFileNameTemplate,
+ scanSubdirectories, keepOriginal, overwriteExisting, superlative,
+ preserveStructure,
),
id = ruleId,
)
is Action.DELETE_STALE ->
- Rule(Action.DELETE_STALE(src, srcFileNamePattern, retentionDays), id = ruleId)
+ Rule(
+ Action.DELETE_STALE(src, srcFileNamePattern, retentionDays, scanSubdirectories),
+ id = ruleId,
+ )
}
}
@@ -78,32 +87,41 @@ class UpsertRuleViewModel(
var folderPickerState by mutableStateOf(null)
fun updateForm(context: Context, values: Values) {
- var currentSrcFileNames: List? = null
+ var currentSrcFiles: List? = null
try {
if (values.src.isNotBlank())
- currentSrcFileNames = File.fromPath(context, values.src)!!.listFiles()
- .filter { it.isFile && it.name != null }.map { it.name!! }
+ currentSrcFiles = File.fromPath(context, values.src)!!
+ .listChildren(values.scanSubdirectories)
+ .filter { it.isFile && it.name != null }
} catch (e: Exception) {
Logger.e("UpsertRuleViewModel", "Couldn't fetch files in ${values.src}", e)
}
var predictedDestFileNames: List? = null
var warning: FormWarning? = null
- if (currentSrcFileNames != null && values.destFileNameTemplate.isNotBlank()) {
- try {
- val regex = Regex(values.srcFileNamePattern)
-
- predictedDestFileNames = currentSrcFileNames
- .filter { regex.matches(it) }
- .also { if (it.isEmpty()) warning = FormWarning.NO_MATCHES_IN_SRC }
- .map { regex.replace(it, values.destFileNameTemplate) }
- .distinct()
- } catch (_: Exception) {
+ try {
+ val regex = Regex(values.srcFileNamePattern)
+
+ val matchingSrcFiles = currentSrcFiles
+ ?.filter { regex.matches(it.name!!) }
+ ?.also { if (it.isEmpty()) warning = FormWarning.NO_MATCHES_IN_SRC }
+
+ if (matchingSrcFiles != null && values.destFileNameTemplate.isNotBlank()) {
+ predictedDestFileNames = matchingSrcFiles.map {
+ it.name!!.replace(
+ regex,
+ values.destFileNameTemplate.replace(
+ $$"${folder}",
+ it.parent?.name ?: "",
+ ),
+ )
+ }.distinct()
}
+ } catch (_: Exception) {
}
val values = values.copy(
- currentSrcFileNames = currentSrcFileNames,
+ currentSrcFileNames = currentSrcFiles.orEmpty().mapNotNull { it.name }.distinct(),
predictedDestFileNames = predictedDestFileNames,
)
state = State(values, getError(values), warning)
diff --git a/app/src/main/java/co/adityarajput/fileflow/views/components/FolderPickerBottomSheet.kt b/app/src/main/java/co/adityarajput/fileflow/views/components/FolderPickerBottomSheet.kt
index 7c4857a..6808d9a 100644
--- a/app/src/main/java/co/adityarajput/fileflow/views/components/FolderPickerBottomSheet.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/views/components/FolderPickerBottomSheet.kt
@@ -30,8 +30,7 @@ fun FolderPickerBottomSheet(viewModel: UpsertRuleViewModel) {
val hideSheet = { viewModel.folderPickerState = null }
var currentDir by remember { mutableStateOf(Environment.getExternalStorageDirectory()) }
val items = remember(currentDir) {
- currentDir.listFiles()?.sortedBy { it.name.lowercase() }?.sortedBy { it.isFile }
- ?: emptyList()
+ currentDir.listFiles()?.sortedBy { it.name.lowercase() }?.sortedBy { it.isFile }.orEmpty()
}
ModalBottomSheet(
diff --git a/app/src/main/java/co/adityarajput/fileflow/views/components/ImproperRulesetDialog.kt b/app/src/main/java/co/adityarajput/fileflow/views/components/ImproperRulesetDialog.kt
new file mode 100644
index 0000000..34204de
--- /dev/null
+++ b/app/src/main/java/co/adityarajput/fileflow/views/components/ImproperRulesetDialog.kt
@@ -0,0 +1,79 @@
+package co.adityarajput.fileflow.views.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import co.adityarajput.fileflow.R
+import co.adityarajput.fileflow.data.models.Rule
+import co.adityarajput.fileflow.utils.Permission
+import co.adityarajput.fileflow.utils.isGranted
+import kotlinx.serialization.json.Json
+
+@Composable
+fun ImproperRulesetDialog(
+ rulesToBeMigrated: List,
+ goToUpsertRuleScreen: (String) -> Unit,
+ hideDialog: () -> Unit,
+) {
+ val context = LocalContext.current
+ val hasAllFilesAccess = remember { context.isGranted(Permission.MANAGE_EXTERNAL_STORAGE) }
+
+ AlertDialog(
+ hideDialog,
+ title = { Text(stringResource(R.string.improper_ruleset)) },
+ text = {
+ Column {
+ Text(
+ stringResource(
+ if (hasAllFilesAccess) R.string.explain_saf_to_io_migration
+ else R.string.explain_missing_saf_access,
+ ) + " " + stringResource(R.string.please_reselect_folders),
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Normal,
+ )
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()),
+ ) {
+ rulesToBeMigrated.forEach {
+ Tile(
+ it.action.srcFileNamePattern,
+ stringResource(it.action.verb),
+ if (!it.enabled) stringResource(R.string.disabled)
+ else pluralStringResource(
+ R.plurals.execution,
+ it.executions,
+ it.executions,
+ ),
+ {
+ Text(
+ it.action.getDescription(),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ },
+ { goToUpsertRuleScreen(Json.encodeToString(it)) },
+ )
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(hideDialog) {
+ Text(stringResource(R.string.dismiss), fontWeight = FontWeight.Normal)
+ }
+ },
+ )
+}
diff --git a/app/src/main/java/co/adityarajput/fileflow/views/screens/RulesScreen.kt b/app/src/main/java/co/adityarajput/fileflow/views/screens/RulesScreen.kt
index 2ea1de3..e132810 100644
--- a/app/src/main/java/co/adityarajput/fileflow/views/screens/RulesScreen.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/views/screens/RulesScreen.kt
@@ -7,10 +7,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
@@ -19,11 +19,13 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import co.adityarajput.fileflow.R
+import co.adityarajput.fileflow.utils.findRulesToBeMigrated
import co.adityarajput.fileflow.utils.getToggleString
import co.adityarajput.fileflow.viewmodels.DialogState
import co.adityarajput.fileflow.viewmodels.Provider
import co.adityarajput.fileflow.viewmodels.RulesViewModel
import co.adityarajput.fileflow.views.components.AppBar
+import co.adityarajput.fileflow.views.components.ImproperRulesetDialog
import co.adityarajput.fileflow.views.components.ManageRuleDialog
import co.adityarajput.fileflow.views.components.Tile
import kotlinx.serialization.json.Json
@@ -35,7 +37,11 @@ fun RulesScreen(
goToSettingsScreen: () -> Unit,
viewModel: RulesViewModel = viewModel(factory = Provider.Factory),
) {
+ val context = LocalContext.current
val state = viewModel.state.collectAsState()
+ val rulesToBeMigrated =
+ remember(state.value.rules) { context.findRulesToBeMigrated(state.value.rules.orEmpty()) }
+ var hideMissingPermissionsDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
@@ -147,5 +153,10 @@ fun RulesScreen(
}
if (viewModel.selectedRule != null && viewModel.dialogState != null)
ManageRuleDialog(viewModel)
+ if (rulesToBeMigrated.isNotEmpty() && !hideMissingPermissionsDialog)
+ ImproperRulesetDialog(
+ rulesToBeMigrated,
+ goToUpsertRuleScreen,
+ ) { hideMissingPermissionsDialog = true }
}
}
diff --git a/app/src/main/java/co/adityarajput/fileflow/views/screens/UpsertRuleScreen.kt b/app/src/main/java/co/adityarajput/fileflow/views/screens/UpsertRuleScreen.kt
index 5b6e236..676cb78 100644
--- a/app/src/main/java/co/adityarajput/fileflow/views/screens/UpsertRuleScreen.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/views/screens/UpsertRuleScreen.kt
@@ -185,18 +185,30 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) {
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Normal,
)
+ Row(
+ Modifier.toggleable(viewModel.state.values.scanSubdirectories) {
+ viewModel.updateForm(context, viewModel.state.values.copy(scanSubdirectories = it))
+ },
+ Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)),
+ Alignment.CenterVertically,
+ ) {
+ Checkbox(viewModel.state.values.scanSubdirectories, null)
+ Text(
+ stringResource(R.string.scan_subdirectories),
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Normal,
+ )
+ }
OutlinedTextField(
viewModel.state.values.srcFileNamePattern,
{
viewModel.updateForm(context, viewModel.state.values.copy(srcFileNamePattern = it))
},
Modifier.fillMaxWidth(),
- label = {
- Text(stringResource(R.string.file_name_pattern))
- },
+ label = { Text(stringResource(R.string.file_name_pattern)) },
placeholder = { Text(stringResource(R.string.pattern_placeholder)) },
supportingText = {
- if (viewModel.state.values.currentSrcFileNames?.isEmpty() ?: true)
+ if (viewModel.state.values.currentSrcFileNames.isNullOrEmpty())
Text(stringResource(R.string.match_entire_filename))
else
Text(
@@ -221,7 +233,7 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) {
viewModel.updateForm(context, viewModel.state.values.copy(keepOriginal = !it))
},
Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)),
- Alignment.Top,
+ Alignment.CenterVertically,
) {
Checkbox(!viewModel.state.values.keepOriginal, null)
Text(
@@ -282,6 +294,24 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) {
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Normal,
)
+ if (viewModel.state.values.scanSubdirectories)
+ Row(
+ Modifier.toggleable(viewModel.state.values.preserveStructure) {
+ viewModel.updateForm(
+ context,
+ viewModel.state.values.copy(preserveStructure = it),
+ )
+ },
+ Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)),
+ Alignment.CenterVertically,
+ ) {
+ Checkbox(viewModel.state.values.preserveStructure, null)
+ Text(
+ stringResource(R.string.preserve_structure),
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Normal,
+ )
+ }
OutlinedTextField(
viewModel.state.values.destFileNameTemplate,
{
@@ -291,9 +321,7 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) {
)
},
Modifier.fillMaxWidth(),
- label = {
- Text(stringResource(R.string.file_name_template))
- },
+ label = { Text(stringResource(R.string.file_name_template)) },
placeholder = { Text(stringResource(R.string.template_placeholder)) },
supportingText = {
if (viewModel.state.values.predictedDestFileNames?.isNotEmpty() ?: false)
@@ -320,7 +348,7 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) {
)
},
Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)),
- Alignment.Top,
+ Alignment.CenterVertically,
) {
Checkbox(viewModel.state.values.overwriteExisting, null)
Text(
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9290b38..5944dfe 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,14 +1,14 @@
FileFlow
FileFlow
- 1.1.1
+ 1.2.0
About
App logo
" scans your files periodically and organizes them according to your rules."
GPLv3
Source code available on GitHub
Visit the project wiki for more]]>
- - REQUEST_IGNORE_BATTERY_OPTIMIZATIONS: to request exemption from background usage restrictions
- WRITE/MANAGE_EXTERNAL_STORAGE: for targeting subfolders or protected locations
Other permissions may be used by libraries to manage tasks, such as executing flows periodically.]]>
+ - REQUEST_IGNORE_BATTERY_OPTIMIZATIONS: to request exemption from background usage restrictions
- WRITE/MANAGE_EXTERNAL_STORAGE: for targeting protected folders
Other permissions may be used by libraries to manage tasks, such as executing flows periodically.]]>
BURG3R5]]>
Back button
@@ -33,7 +33,7 @@
"FileFlow scans your files periodically and organizes them according to your rules.\n\nOn some devices, the 'battery optimization' feature leads to delayed execution."
Disable optimization
Skip
- "To use advanced features such as targeting subfolders or protected locations, FileFlow will need all files access.\n\nNo data is sent off-device; FileFlow doesn't have access to the internet."
+ "To use advanced features such as targeting protected folders, FileFlow will need all files access.\n\nNo data is sent off-device; FileFlow doesn't have access to the internet."
Grant permission
@@ -55,6 +55,13 @@
Disable
Delete
+
+ Incompatible rules
+ The following rules must be migrated to broader access.
+ The following rules cannot be executed with current permissions.
+ Please tap on each tile and reselect folders.
+ Dismiss
+
COPY
MOVE
@@ -68,7 +75,7 @@
Disable optimization
Exempts the app from being restricted by the OS
All files access
- Lets flows target subfolders or protected locations
+ Lets flows target protected folders
App Appearance
App Theme
Light
@@ -89,6 +96,7 @@
Action:
"Source: "
select folder
+ Scan subfolders
File name pattern
Enter a regex pattern
Pattern should match the entire filename
@@ -98,6 +106,7 @@
"In case of multiple matches, choose: "
An arrow pointing downward
"Destination: "
+ Recreate source folder-structure in destination
File name template
Enter a regex template
Template will yield %1$s
@@ -107,7 +116,7 @@
here↗ for example patterns and templates.]]>
Regex pattern contains errors
Regex template contains errors
- Regex pattern doesn\'t match any file in the source folder
+ "Regex pattern doesn't match any file in the source folder"
Copy or move to another folder
Delete if unmodified for a while
diff --git a/metadata/en-US/changelogs/4.txt b/metadata/en-US/changelogs/4.txt
new file mode 100644
index 0000000..83f5223
--- /dev/null
+++ b/metadata/en-US/changelogs/4.txt
@@ -0,0 +1,9 @@
+• feat: Let rules target protected folders
+• feat: Add "DELETE_STALE" action
+• feat: Let rules scan subfolders and recreate them
+
+Rules can now target protected folders (like "Download") if special "all files" access is granted.
+
+The new "DELETE_STALE" action helps get rid of unused/untouched files. Note: "Staleness" is calculated based on when the file was last modified, not when it was last accessed.
+
+Finally, rules can now recursively scan (and re-create) subfolders.
diff --git a/metadata/en-US/images/phoneScreenshots/1.png b/metadata/en-US/images/phoneScreenshots/1.png
index 19d78a6..33feb3e 100644
Binary files a/metadata/en-US/images/phoneScreenshots/1.png and b/metadata/en-US/images/phoneScreenshots/1.png differ
diff --git a/metadata/en-US/images/phoneScreenshots/2.png b/metadata/en-US/images/phoneScreenshots/2.png
index b327c87..16057d9 100644
Binary files a/metadata/en-US/images/phoneScreenshots/2.png and b/metadata/en-US/images/phoneScreenshots/2.png differ
diff --git a/metadata/en-US/images/phoneScreenshots/3.png b/metadata/en-US/images/phoneScreenshots/3.png
index 38ad1f6..d698920 100644
Binary files a/metadata/en-US/images/phoneScreenshots/3.png and b/metadata/en-US/images/phoneScreenshots/3.png differ