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. -[Get it at IzzyOnDroid](https://apt.izzysoft.de/packages/co.adityarajput.fileflow) [Get it on Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/BURG3R5/FileFlow) +[Get it on F-Droid](https://f-droid.org/packages/co.adityarajput.fileflow) [Get it at IzzyOnDroid](https://apt.izzysoft.de/packages/co.adityarajput.fileflow) [Get it on Obtainium](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