From ba4f4a0ba34ab88ddae630786d360aac2c976fa7 Mon Sep 17 00:00:00 2001
From: Aditya Rajput
Date: Sat, 7 Mar 2026 12:41:10 +0530
Subject: [PATCH 1/3] Don't toast outdated error logs
---
.../java/co/adityarajput/fileflow/viewmodels/RulesViewModel.kt | 1 -
1 file changed, 1 deletion(-)
diff --git a/app/src/main/java/co/adityarajput/fileflow/viewmodels/RulesViewModel.kt b/app/src/main/java/co/adityarajput/fileflow/viewmodels/RulesViewModel.kt
index abc4e1a..e86bfe3 100644
--- a/app/src/main/java/co/adityarajput/fileflow/viewmodels/RulesViewModel.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/viewmodels/RulesViewModel.kt
@@ -36,7 +36,6 @@ class RulesViewModel(private val repository: Repository) : ViewModel() {
val recentErrorLog = Logger.logs
.dropWhile { it != latestLogBeforeExecution }.drop(1)
.firstOrNull { it.contains("[ERROR]") }
- ?: Logger.logs.lastOrNull { it.contains("[ERROR]") }
if (recentErrorLog != null) {
showToast("Error:" + recentErrorLog.substringAfter("[ERROR]"))
}
From 1ddb69faa6982673a105ea8fe24a559ec0ef1a47 Mon Sep 17 00:00:00 2001
From: Aditya Rajput
Date: Sun, 8 Mar 2026 13:00:00 +0530
Subject: [PATCH 2/3] Add "DELETE_STALE" action
---
.../fileflow/data/AppContainer.kt | 15 +-
.../adityarajput/fileflow/data/Converters.kt | 2 +-
.../fileflow/data/models/Action.kt | 82 +++--
.../fileflow/services/FlowExecutor.kt | 156 ++++++----
.../co/adityarajput/fileflow/utils/String.kt | 7 +-
.../fileflow/utils/Superlatives.kt | 1 +
.../viewmodels/UpsertRuleViewModel.kt | 78 +++--
.../views/screens/ExecutionsScreen.kt | 5 +-
.../fileflow/views/screens/RulesScreen.kt | 4 +-
.../views/screens/UpsertRuleScreen.kt | 293 +++++++++++-------
app/src/main/res/values/plurals.xml | 16 +-
app/src/main/res/values/strings.xml | 14 +-
12 files changed, 435 insertions(+), 238 deletions(-)
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 7e49846..50ab22a 100644
--- a/app/src/main/java/co/adityarajput/fileflow/data/AppContainer.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/data/AppContainer.kt
@@ -25,9 +25,9 @@ class AppContainer(private val context: Context) {
repository.upsert(
Rule(
Action.MOVE(
- "content://com.android.externalstorage.documents/tree/primary%3AAntennaPod",
+ "/storage/emulated/0/AntennaPod",
"AntennaPodBackup-\\d{4}-\\d{2}-\\d{2}.db",
- "content://com.android.externalstorage.documents/tree/primary%3ABackups",
+ "/storage/emulated/0/Backups",
"AntennaPod.db",
overwriteExisting = true,
),
@@ -35,15 +35,22 @@ class AppContainer(private val context: Context) {
),
Rule(
Action.MOVE(
- "content://com.android.externalstorage.documents/tree/primary%3ABackups",
+ "/storage/emulated/0/Backups",
"TubularData-\\d{8}_\\d{6}.zip",
- "content://com.android.externalstorage.documents/tree/primary%3ABackups",
+ "/storage/emulated/0/Backups",
"Tubular.zip",
keepOriginal = false,
overwriteExisting = true,
),
executions = 3,
),
+ Rule(
+ Action.DELETE_STALE(
+ "/storage/emulated/0/Download",
+ "Alarmetrics_v[\\d\\.]+.apk",
+ ),
+ enabled = false,
+ ),
)
repository.upsert(
Execution(
diff --git a/app/src/main/java/co/adityarajput/fileflow/data/Converters.kt b/app/src/main/java/co/adityarajput/fileflow/data/Converters.kt
index 7de5702..763a907 100644
--- a/app/src/main/java/co/adityarajput/fileflow/data/Converters.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/data/Converters.kt
@@ -12,6 +12,6 @@ class Converters {
fun toAction(value: String) = try {
Json.decodeFromString(value)
} catch (_: Exception) {
- Action.MOVE("", "", "", "")
+ Action.entries[0]
}
}
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 adec208..8f17b1a 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
@@ -2,43 +2,87 @@ package co.adityarajput.fileflow.data.models
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
+import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import co.adityarajput.fileflow.R
import co.adityarajput.fileflow.utils.FileSuperlative
import co.adityarajput.fileflow.utils.getGetDirectoryFromUri
+import co.adityarajput.fileflow.utils.toShortHumanReadableTime
import kotlinx.serialization.Serializable
+@Suppress("ClassName")
@Serializable
-sealed class Action(val title: String) {
+sealed class Action {
+ abstract val src: String
+ abstract val srcFileNamePattern: String
+
+ abstract val verb: Int
+ abstract val phrase: Int
+
+ @Composable
+ abstract fun getDescription(): AnnotatedString
+
+ val base: Action
+ get() = when (this) {
+ is MOVE -> entries[0]
+ is DELETE_STALE -> entries[1]
+ }
+
+ infix fun isSimilarTo(other: Action) = this::class == other::class
+
@Serializable
data class MOVE(
- val src: String,
- val srcFileNamePattern: String,
+ override val src: String,
+ override val srcFileNamePattern: String,
val dest: String,
val destFileNameTemplate: String,
val keepOriginal: Boolean = true,
val overwriteExisting: Boolean = false,
val superlative: FileSuperlative = FileSuperlative.LATEST,
- ) : Action(srcFileNamePattern)
+ ) : Action() {
+ override val verb get() = if (keepOriginal) R.string.copy else R.string.move
- val verb
- get() = when (this) {
- is MOVE -> if (keepOriginal) R.string.copy else R.string.move
+ override val phrase = R.string.move_phrase
+
+ @Composable
+ override fun getDescription() = buildAnnotatedString {
+ val dullStyle = SpanStyle(MaterialTheme.colorScheme.onSurfaceVariant)
+
+ withStyle(dullStyle) { append("from ") }
+ append(src.getGetDirectoryFromUri())
+ withStyle(dullStyle) { append("\nto ") }
+ append(dest.getGetDirectoryFromUri())
+ withStyle(dullStyle) { append("\nas ") }
+ append(destFileNameTemplate)
}
+ }
+
+ @Serializable
+ data class DELETE_STALE(
+ override val src: String,
+ override val srcFileNamePattern: String,
+ val retentionDays: Int = 30,
+ ) : Action() {
+ override val verb get() = R.string.delete_stale
- val description
- @Composable get() = when (this) {
- is MOVE -> buildAnnotatedString {
- val dullStyle = SpanStyle(MaterialTheme.colorScheme.onSurfaceVariant)
-
- withStyle(dullStyle) { append("from ") }
- append(src.getGetDirectoryFromUri())
- withStyle(dullStyle) { append("\nto ") }
- append(dest.getGetDirectoryFromUri())
- withStyle(dullStyle) { append("\nas ") }
- append(destFileNameTemplate)
- }
+ override val phrase = R.string.delete_stale_phrase
+
+ fun retentionTimeInMillis() = retentionDays * 86_400_000L
+
+ @Composable
+ override fun getDescription() = buildAnnotatedString {
+ val dullStyle = SpanStyle(MaterialTheme.colorScheme.onSurfaceVariant)
+
+ withStyle(dullStyle) { append("in ") }
+ append(src.getGetDirectoryFromUri())
+ withStyle(dullStyle) { append("\nif unmodified for ") }
+ append((retentionTimeInMillis()).toShortHumanReadableTime())
}
+ }
+
+ companion object {
+ val entries by lazy { listOf(MOVE("", "", "", ""), DELETE_STALE("", "")) }
+ }
}
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 8e2c4ec..39ea914 100644
--- a/app/src/main/java/co/adityarajput/fileflow/services/FlowExecutor.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/services/FlowExecutor.kt
@@ -5,9 +5,7 @@ import co.adityarajput.fileflow.data.AppContainer
import co.adityarajput.fileflow.data.models.Action
import co.adityarajput.fileflow.data.models.Execution
import co.adityarajput.fileflow.data.models.Rule
-import co.adityarajput.fileflow.utils.File
-import co.adityarajput.fileflow.utils.Logger
-import co.adityarajput.fileflow.utils.copyFile
+import co.adityarajput.fileflow.utils.*
import kotlinx.coroutines.flow.first
class FlowExecutor(private val context: Context) {
@@ -17,65 +15,111 @@ class FlowExecutor(private val context: Context) {
for (rule in rules ?: repository.rules().first()) {
Logger.d("FlowExecutor", "Executing $rule")
- if (!rule.enabled || rule.action !is Action.MOVE) continue
+ if (!rule.enabled) continue
val regex = Regex(rule.action.srcFileNamePattern)
- val destDir = File.fromPath(context, rule.action.dest)
- if (destDir == null) {
- Logger.e("FlowExecutor", "${rule.action.dest} is invalid")
- continue
- }
-
- val srcFile = File.fromPath(context, rule.action.src)?.listFiles()
- ?.filter { it.isFile && it.name != null && regex.matches(it.name!!) }
- ?.maxByOrNull(rule.action.superlative.selector)
- ?: continue
-
- val destFileName = regex.replace(srcFile.name!!, rule.action.destFileNameTemplate)
- var destFile = destDir.listFiles().firstOrNull { it.isFile && it.name == destFileName }
-
- if (destFile != null) {
- if (!rule.action.overwriteExisting) {
- Logger.e("FlowExecutor", "${destFile.name} already exists")
- continue
+ when (rule.action) {
+ is Action.MOVE -> {
+ val destDir = File.fromPath(context, rule.action.dest)
+
+ if (destDir == null) {
+ Logger.e("FlowExecutor", "${rule.action.dest} is invalid")
+ continue
+ }
+
+ val srcFiles = File.fromPath(context, rule.action.src)?.listFiles()
+ ?.filter { it.isFile && it.name != null && regex.matches(it.name!!) }
+ ?.let {
+ if (rule.action.superlative != FileSuperlative.NONE)
+ listOf(it.maxByOrNull(rule.action.superlative.selector) ?: continue)
+ else
+ it
+ } ?: continue
+
+ for (srcFile in srcFiles) {
+ val destFileName = regex.replace(
+ srcFile.name!!,
+ rule.action.destFileNameTemplate,
+ )
+ var destFile = destDir.listFiles().firstOrNull {
+ it.isFile && it.name == destFileName
+ }
+
+ if (destFile != null) {
+ if (!rule.action.overwriteExisting) {
+ Logger.e("FlowExecutor", "${destFile.name} already exists")
+ continue
+ }
+
+ if (srcFile.isIdenticalTo(destFile, context)) {
+ Logger.i(
+ "FlowExecutor",
+ "Source and destination files are identical",
+ )
+ continue
+ }
+
+
+ Logger.i("FlowExecutor", "Deleting existing ${destFile.name}")
+ destFile.delete()
+ }
+
+ destFile = destDir.createFile(srcFile.type, destFileName)
+
+ if (destFile == null) {
+ Logger.e("FlowExecutor", "Failed to create $destFileName")
+ continue
+ }
+
+ val result = context.copyFile(srcFile, destFile)
+ if (!result) {
+ Logger.e(
+ "FlowExecutor",
+ "Failed to copy ${srcFile.name} to ${destFile.name}",
+ )
+ destFile.delete()
+ continue
+ }
+
+ repository.registerExecution(
+ rule,
+ Execution(srcFile.name!!, rule.action.verb),
+ )
+
+ if (!rule.action.keepOriginal) {
+ Logger.i("FlowExecutor", "Deleting original ${srcFile.name}")
+ srcFile.delete()
+ }
+ }
}
- if (srcFile.isIdenticalTo(destFile, context)) {
- Logger.i(
- "FlowExecutor",
- "Source and destination files are identical",
- )
- continue
+ is Action.DELETE_STALE -> {
+ val srcFiles = File.fromPath(context, rule.action.src)?.listFiles()
+ ?.filter { it.isFile && it.name != null && regex.matches(it.name!!) }
+ ?.filter {
+ System.currentTimeMillis() - it.lastModified() >=
+ // INFO: While debugging, treat days as seconds
+ if (context.isDebugBuild()) rule.action.retentionDays * 1000L
+ else rule.action.retentionTimeInMillis()
+ }
+ ?: continue
+
+ for (srcFile in srcFiles) {
+ Logger.i("FlowExecutor", "Deleting ${srcFile.name}")
+
+ val result = srcFile.delete()
+ if (!result) {
+ Logger.e("FlowExecutor", "Failed to delete ${srcFile.name}")
+ continue
+ }
+
+ repository.registerExecution(
+ rule,
+ Execution(srcFile.name!!, rule.action.verb),
+ )
+ }
}
-
-
- Logger.i("FlowExecutor", "Deleting existing ${destFile.name}")
- destFile.delete()
- }
-
- destFile = destDir.createFile(srcFile.type, destFileName)
-
- if (destFile == null) {
- Logger.e("FlowExecutor", "Failed to create $destFileName")
- continue
- }
-
- val result = context.copyFile(srcFile, destFile)
- if (!result) {
- Logger.e("FlowExecutor", "Failed to copy ${srcFile.name} to ${destFile.name}")
- destFile.delete()
- continue
- }
-
- repository.registerExecution(
- rule,
- Execution(srcFile.name!!, rule.action.verb),
- )
-
- if (!rule.action.keepOriginal) {
- Logger.i("FlowExecutor", "Deleting original ${srcFile.name}")
- srcFile.delete()
}
}
}
diff --git a/app/src/main/java/co/adityarajput/fileflow/utils/String.kt b/app/src/main/java/co/adityarajput/fileflow/utils/String.kt
index a7baa65..a9a6c6b 100644
--- a/app/src/main/java/co/adityarajput/fileflow/utils/String.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/utils/String.kt
@@ -7,16 +7,13 @@ import co.adityarajput.fileflow.R
@Composable
fun Long.toShortHumanReadableTime(): String {
- val now = System.currentTimeMillis()
- val delta = now - this
-
- val seconds = delta / 1000
+ val seconds = this / 1000
val minutes = seconds / 60
val hours = minutes / 60
val days = hours / 24
return when {
- days > 1000 -> stringResource(R.string.many_days_ago)
+ days > 1000 -> stringResource(R.string.many_days)
days > 0 -> pluralStringResource(R.plurals.day, days.toInt(), days)
hours > 0 -> pluralStringResource(R.plurals.hour, hours.toInt(), hours)
minutes > 0 -> pluralStringResource(R.plurals.minute, minutes.toInt(), minutes)
diff --git a/app/src/main/java/co/adityarajput/fileflow/utils/Superlatives.kt b/app/src/main/java/co/adityarajput/fileflow/utils/Superlatives.kt
index 28c4233..c3cd7f8 100644
--- a/app/src/main/java/co/adityarajput/fileflow/utils/Superlatives.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/utils/Superlatives.kt
@@ -7,4 +7,5 @@ enum class FileSuperlative(val displayName: Int, val selector: (File) -> Long) {
LATEST(R.string.latest, { it.lastModified() }),
SMALLEST(R.string.smallest, { -it.length() }),
LARGEST(R.string.largest, { it.length() }),
+ NONE(R.string.all, { 0L })
}
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 a9eab40..bdaceb9 100644
--- a/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt
@@ -25,6 +25,7 @@ class UpsertRuleViewModel(
data class Values(
val ruleId: Int = 0,
+ val actionBase: Action = Action.entries[0],
val src: String = "",
val srcFileNamePattern: String = "",
val dest: String = "",
@@ -34,25 +35,44 @@ class UpsertRuleViewModel(
val overwriteExisting: Boolean = false,
val currentSrcFileNames: List? = null,
val predictedDestFileNames: List? = null,
+ val retentionDays: Int = 30,
) {
- constructor(rule: Rule) : this(
- rule.id, (rule.action as Action.MOVE).src, rule.action.srcFileNamePattern,
- rule.action.dest, rule.action.destFileNameTemplate, rule.action.superlative,
- rule.action.keepOriginal, rule.action.overwriteExisting,
- )
+ companion object {
+ fun from(rule: Rule) = when (rule.action) {
+ is Action.MOVE ->
+ Values(
+ rule.id, rule.action.base, rule.action.src,
+ rule.action.srcFileNamePattern, rule.action.dest,
+ rule.action.destFileNameTemplate, rule.action.superlative,
+ rule.action.keepOriginal, rule.action.overwriteExisting,
+ )
- fun toRule() = Rule(
- Action.MOVE(
- src, srcFileNamePattern, dest, destFileNameTemplate,
- keepOriginal, overwriteExisting, superlative,
- ),
- id = ruleId,
- )
+ is Action.DELETE_STALE ->
+ Values(
+ rule.id, rule.action.base, rule.action.src,
+ rule.action.srcFileNamePattern, retentionDays = rule.action.retentionDays,
+ )
+ }
+ }
+
+ fun toRule() = when (actionBase) {
+ is Action.MOVE ->
+ Rule(
+ Action.MOVE(
+ src, srcFileNamePattern, dest, destFileNameTemplate, keepOriginal,
+ overwriteExisting, superlative,
+ ),
+ id = ruleId,
+ )
+
+ is Action.DELETE_STALE ->
+ Rule(Action.DELETE_STALE(src, srcFileNamePattern, retentionDays), id = ruleId)
+ }
}
var state by mutableStateOf(
if (rule == null) State()
- else State(Values(rule), null),
+ else State(Values.from(rule), null),
)
var folderPickerState by mutableStateOf(null)
@@ -69,16 +89,17 @@ class UpsertRuleViewModel(
var predictedDestFileNames: List? = null
var warning: FormWarning? = null
- try {
- val regex = Regex(values.srcFileNamePattern)
+ if (currentSrcFileNames != null && values.destFileNameTemplate.isNotBlank()) {
+ try {
+ val regex = Regex(values.srcFileNamePattern)
- if (values.destFileNameTemplate.isNotBlank())
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) {
+ .filter { regex.matches(it) }
+ .also { if (it.isEmpty()) warning = FormWarning.NO_MATCHES_IN_SRC }
+ .map { regex.replace(it, values.destFileNameTemplate) }
+ .distinct()
+ } catch (_: Exception) {
+ }
}
val values = values.copy(
@@ -90,17 +111,18 @@ class UpsertRuleViewModel(
private fun getError(values: Values): FormError? {
try {
- if (
- values.src.isBlank() ||
- values.srcFileNamePattern.isBlank() ||
- values.dest.isBlank() ||
- values.destFileNameTemplate.isBlank()
- ) return FormError.BLANK_FIELDS
+ if (values.src.isBlank() || values.srcFileNamePattern.isBlank())
+ return FormError.BLANK_FIELDS
if (Regex(values.srcFileNamePattern).pattern != values.srcFileNamePattern)
return FormError.INVALID_REGEX
- if (values.predictedDestFileNames == null) return FormError.INVALID_TEMPLATE
+ if (values.actionBase is Action.MOVE) {
+ if (values.dest.isBlank() || values.destFileNameTemplate.isBlank())
+ return FormError.BLANK_FIELDS
+ if (values.predictedDestFileNames == null)
+ return FormError.INVALID_TEMPLATE
+ }
} catch (_: Exception) {
Logger.d("UpsertRuleViewModel", "Invalid regex")
return FormError.INVALID_REGEX
diff --git a/app/src/main/java/co/adityarajput/fileflow/views/screens/ExecutionsScreen.kt b/app/src/main/java/co/adityarajput/fileflow/views/screens/ExecutionsScreen.kt
index b5338ff..a4d0b51 100644
--- a/app/src/main/java/co/adityarajput/fileflow/views/screens/ExecutionsScreen.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/views/screens/ExecutionsScreen.kt
@@ -64,7 +64,10 @@ fun ExecutionsScreen(
Tile(
it.fileName,
stringResource(it.actionVerb),
- it.timestamp.toShortHumanReadableTime(),
+ stringResource(
+ R.string.ago,
+ (System.currentTimeMillis() - it.timestamp).toShortHumanReadableTime(),
+ ),
)
}
}
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 594dd56..2ea1de3 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
@@ -86,7 +86,7 @@ fun RulesScreen(
) {
items(state.value.rules!!, { it.id }) {
Tile(
- it.action.title,
+ it.action.srcFileNamePattern,
stringResource(it.action.verb),
if (!it.enabled) stringResource(R.string.disabled)
else pluralStringResource(
@@ -96,7 +96,7 @@ fun RulesScreen(
),
{
Text(
- it.action.description,
+ it.action.getDescription(),
style = MaterialTheme.typography.bodySmall,
)
},
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 dcea548..5b6e236 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
@@ -5,7 +5,9 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -17,9 +19,11 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextDecoration
import androidx.lifecycle.viewmodel.compose.viewModel
import co.adityarajput.fileflow.R
+import co.adityarajput.fileflow.data.models.Action
import co.adityarajput.fileflow.utils.*
import co.adityarajput.fileflow.viewmodels.FormError
import co.adityarajput.fileflow.viewmodels.FormWarning
@@ -118,23 +122,50 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) {
var superlativeDropdownExpanded by remember { mutableStateOf(false) }
- val srcPicker =
- rememberLauncherForActivityResult(
- ActivityResultContracts.OpenDocumentTree(),
- ) { uri ->
- uri ?: return@rememberLauncherForActivityResult
- context.requestPersistableFolderPermission(uri)
- viewModel.updateForm(context, viewModel.state.values.copy(src = uri.toString()))
- }
- val destPicker =
- rememberLauncherForActivityResult(
- ActivityResultContracts.OpenDocumentTree(),
- ) { uri ->
- uri ?: return@rememberLauncherForActivityResult
- context.requestPersistableFolderPermission(uri)
- viewModel.updateForm(context, viewModel.state.values.copy(dest = uri.toString()))
- }
+ val srcPicker = rememberLauncherForActivityResult(
+ ActivityResultContracts.OpenDocumentTree(),
+ ) { uri ->
+ uri ?: return@rememberLauncherForActivityResult
+ context.requestPersistableFolderPermission(uri)
+ viewModel.updateForm(context, viewModel.state.values.copy(src = uri.toString()))
+ }
+ val destPicker = rememberLauncherForActivityResult(
+ ActivityResultContracts.OpenDocumentTree(),
+ ) { uri ->
+ uri ?: return@rememberLauncherForActivityResult
+ context.requestPersistableFolderPermission(uri)
+ viewModel.updateForm(context, viewModel.state.values.copy(dest = uri.toString()))
+ }
+ Text(
+ stringResource(R.string.action),
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Normal,
+ )
+ Action.entries.forEach {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .selectable(it isSimilarTo viewModel.state.values.actionBase) {
+ viewModel.updateForm(
+ context,
+ viewModel.state.values.copy(actionBase = it),
+ )
+ },
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(
+ it isSimilarTo viewModel.state.values.actionBase,
+ null,
+ Modifier.padding(horizontal = dimensionResource(R.dimen.padding_small)),
+ )
+ Text(
+ stringResource(it.phrase),
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Normal,
+ )
+ }
+ }
Text(
buildAnnotatedString {
append(stringResource(R.string.source))
@@ -183,108 +214,146 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) {
),
singleLine = true,
)
- Row(
- Modifier.toggleable(!viewModel.state.values.keepOriginal) {
- viewModel.updateForm(context, viewModel.state.values.copy(keepOriginal = !it))
- },
- Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)),
- Alignment.Top,
- ) {
- Checkbox(!viewModel.state.values.keepOriginal, null)
- Text(
- stringResource(R.string.delete_original),
- style = MaterialTheme.typography.labelMedium,
- fontWeight = FontWeight.Normal,
- )
- }
- Box {
- Text(
- buildAnnotatedString {
- append(stringResource(R.string.choose_superlative))
- withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) {
- append(stringResource(viewModel.state.values.superlative.displayName))
- }
- },
- Modifier.clickable { superlativeDropdownExpanded = true },
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = FontWeight.Normal,
- )
- DropdownMenu(superlativeDropdownExpanded, { superlativeDropdownExpanded = false }) {
- FileSuperlative.entries.forEach {
- DropdownMenuItem(
- { Text(stringResource(it.displayName)) },
- {
- viewModel.updateForm(context, viewModel.state.values.copy(superlative = it))
- superlativeDropdownExpanded = false
- },
+ when (viewModel.state.values.actionBase) {
+ is Action.MOVE -> {
+ Row(
+ Modifier.toggleable(!viewModel.state.values.keepOriginal) {
+ viewModel.updateForm(context, viewModel.state.values.copy(keepOriginal = !it))
+ },
+ Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)),
+ Alignment.Top,
+ ) {
+ Checkbox(!viewModel.state.values.keepOriginal, null)
+ Text(
+ stringResource(R.string.delete_original),
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Normal,
)
}
- }
- }
- Icon(
- painterResource(R.drawable.arrow_down),
- stringResource(R.string.arrow_down),
- Modifier.align(Alignment.CenterHorizontally),
- )
- Text(
- buildAnnotatedString {
- append(stringResource(R.string.destination))
- withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) {
- append(
- viewModel.state.values.dest.getGetDirectoryFromUri()
- .ifBlank { stringResource(R.string.select_folder) },
+ Box {
+ Text(
+ buildAnnotatedString {
+ append(stringResource(R.string.choose_superlative))
+ withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) {
+ append(stringResource(viewModel.state.values.superlative.displayName))
+ }
+ },
+ Modifier.clickable { superlativeDropdownExpanded = true },
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Normal,
)
+ DropdownMenu(superlativeDropdownExpanded, { superlativeDropdownExpanded = false }) {
+ FileSuperlative.entries.forEach {
+ DropdownMenuItem(
+ { Text(stringResource(it.displayName)) },
+ {
+ viewModel.updateForm(
+ context,
+ viewModel.state.values.copy(superlative = it),
+ )
+ superlativeDropdownExpanded = false
+ },
+ )
+ }
+ }
}
- },
- Modifier
- .fillMaxWidth()
- .clickable {
- if (shouldUseCustomPicker) viewModel.folderPickerState = FolderPickerState.DEST
- else destPicker.launch(null)
- },
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = FontWeight.Normal,
- )
- OutlinedTextField(
- viewModel.state.values.destFileNameTemplate,
- {
- viewModel.updateForm(context, viewModel.state.values.copy(destFileNameTemplate = it))
- },
- Modifier.fillMaxWidth(),
- label = {
- Text(stringResource(R.string.file_name_template))
- },
- placeholder = { Text(stringResource(R.string.template_placeholder)) },
- supportingText = {
- if (viewModel.state.values.predictedDestFileNames?.isNotEmpty() ?: false)
+ Icon(
+ painterResource(R.drawable.arrow_down),
+ stringResource(R.string.arrow_down),
+ Modifier.align(Alignment.CenterHorizontally),
+ )
+ Text(
+ buildAnnotatedString {
+ append(stringResource(R.string.destination))
+ withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) {
+ append(
+ viewModel.state.values.dest.getGetDirectoryFromUri()
+ .ifBlank { stringResource(R.string.select_folder) },
+ )
+ }
+ },
+ Modifier
+ .fillMaxWidth()
+ .clickable {
+ if (shouldUseCustomPicker) viewModel.folderPickerState =
+ FolderPickerState.DEST
+ else destPicker.launch(null)
+ },
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Normal,
+ )
+ OutlinedTextField(
+ viewModel.state.values.destFileNameTemplate,
+ {
+ viewModel.updateForm(
+ context,
+ viewModel.state.values.copy(destFileNameTemplate = it),
+ )
+ },
+ Modifier.fillMaxWidth(),
+ label = {
+ Text(stringResource(R.string.file_name_template))
+ },
+ placeholder = { Text(stringResource(R.string.template_placeholder)) },
+ supportingText = {
+ if (viewModel.state.values.predictedDestFileNames?.isNotEmpty() ?: false)
+ Text(
+ stringResource(
+ R.string.template_will_yield,
+ viewModel.state.values.predictedDestFileNames!!
+ .joinToString(stringResource(R.string.or), limit = 3),
+ ),
+ )
+ },
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
+ unfocusedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
+ disabledContainerColor = MaterialTheme.colorScheme.secondaryContainer,
+ ),
+ singleLine = true,
+ )
+ Row(
+ Modifier.toggleable(viewModel.state.values.overwriteExisting) {
+ viewModel.updateForm(
+ context,
+ viewModel.state.values.copy(overwriteExisting = it),
+ )
+ },
+ Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)),
+ Alignment.Top,
+ ) {
+ Checkbox(viewModel.state.values.overwriteExisting, null)
Text(
- stringResource(
- R.string.template_will_yield,
- viewModel.state.values.predictedDestFileNames!!
- .joinToString(stringResource(R.string.or), limit = 3),
- ),
+ stringResource(R.string.overwrite_existing),
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Normal,
)
- },
- colors = OutlinedTextFieldDefaults.colors(
- focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
- unfocusedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
- disabledContainerColor = MaterialTheme.colorScheme.secondaryContainer,
- ),
- singleLine = true,
- )
- Row(
- Modifier.toggleable(viewModel.state.values.overwriteExisting) {
- viewModel.updateForm(context, viewModel.state.values.copy(overwriteExisting = it))
- },
- Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)),
- Alignment.Top,
- ) {
- Checkbox(viewModel.state.values.overwriteExisting, null)
- Text(
- stringResource(R.string.overwrite_existing),
- style = MaterialTheme.typography.labelMedium,
- fontWeight = FontWeight.Normal,
- )
+ }
+ }
+
+ is Action.DELETE_STALE -> {
+ OutlinedTextField(
+ viewModel.state.values.retentionDays.toString(),
+ {
+ it.toIntOrNull()?.let { days ->
+ viewModel.updateForm(
+ context,
+ viewModel.state.values.copy(retentionDays = days),
+ )
+ }
+ },
+ Modifier.fillMaxWidth(),
+ label = { Text(stringResource(R.string.retention_days)) },
+ suffix = { Text(stringResource(R.string.days)) },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ singleLine = true,
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
+ unfocusedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
+ disabledContainerColor = MaterialTheme.colorScheme.secondaryContainer,
+ ),
+ )
+ }
}
Text(
AnnotatedString.fromHtml(
diff --git a/app/src/main/res/values/plurals.xml b/app/src/main/res/values/plurals.xml
index 85f734a..1cf6872 100644
--- a/app/src/main/res/values/plurals.xml
+++ b/app/src/main/res/values/plurals.xml
@@ -6,19 +6,19 @@
- - %1$d day ago
- - %1$d days ago
+ - %1$d day
+ - %1$d days
- - %1$d hour ago
- - %1$d hours ago
+ - %1$d hour
+ - %1$d hours
- - %1$d minute ago
- - %1$d minutes ago
+ - %1$d minute
+ - %1$d minutes
- - %1$d second ago
- - %1$d seconds ago
+ - %1$d second
+ - %1$d seconds
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 54cbfe6..9290b38 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -18,8 +18,9 @@
History
No executions yet.
+ %1$s ago
- "1k+ days ago"
+ "1k+ days"
just now
@@ -55,8 +56,9 @@
Delete
- MOVE
COPY
+ MOVE
+ DELETE
@@ -84,6 +86,7 @@
Cancel
Add
Save
+ Action:
"Source: "
select folder
File name pattern
@@ -99,10 +102,16 @@
Enter a regex template
Template will yield %1$s
Overwrite existing files in the destination, in case of conflict
+ Mark stale if unmodified for
+ days
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
+
+ Copy or move to another folder
+ Delete if unmodified for a while
+
Back
(Empty)
@@ -115,6 +124,7 @@
latest
smallest
largest
+ all
From 35297880781b280a9a292673a0f61285934c2cdd Mon Sep 17 00:00:00 2001
From: Aditya Rajput
Date: Sun, 8 Mar 2026 13:12:59 +0530
Subject: [PATCH 3/3] Update metadata
---
README.md | 5 ++++-
metadata/en-US/full_description.txt | 2 +-
2 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 559904a..141ee5d 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,10 @@ FileFlow scans your files periodically and organizes them according to your rule
## Features
- **Rules** - Use [regex](https://github.com/BURG3R5/FileFlow/wiki/Examples) to precisely target
- files and template strings to rename them π―
+ files π―
+- **Actions** - Choose what to do to your files β
+ 1. Copy, move, or rename files π
+ 2. Delete stale files π
- **History** - Recent executions are stored (locally) β³
- **Free, open-source & private**
- No ads, subscriptions, or in-app purchases π
diff --git a/metadata/en-US/full_description.txt b/metadata/en-US/full_description.txt
index 8d20d85..090be01 100644
--- a/metadata/en-US/full_description.txt
+++ b/metadata/en-US/full_description.txt
@@ -1 +1 @@
-FileFlow scans your files periodically and organizes them according to your rules.
Features:
- Rules: Use regex to precisely target files and template strings to rename them.
- History: Recent executions are stored (locally).
- Private: Fully offline; your data never leaves your device.
- Lightweight: Runs in the background with minimal battery and memory usage.
\ No newline at end of file
+FileFlow scans your files periodically and organizes them according to your rules.
Features:
- Rules: Use regex to precisely target files.
- Actions: Choose what to do with the files - copy, move, rename, or delete.
- History: Recent executions are stored (locally).
- Private: Fully offline; your data never leaves your device.
- Lightweight: Runs in the background with minimal battery and memory usage.