Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

FileFlow scans your files periodically and organizes them according to your rules.

[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" height="80" alt="Get it at IzzyOnDroid">](https://apt.izzysoft.de/packages/co.adityarajput.fileflow) [<img src="https://github.com/user-attachments/assets/713d71c5-3dec-4ec4-a3f2-8d28d025a9c6" alt="Get it on Obtainium" height="80">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/BURG3R5/FileFlow)
[<img src="https://f-droid.org/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/co.adityarajput.fileflow) [<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" height="80" alt="Get it at IzzyOnDroid">](https://apt.izzysoft.de/packages/co.adityarajput.fileflow) [<img src="https://github.com/user-attachments/assets/713d71c5-3dec-4ec4-a3f2-8d28d025a9c6" alt="Get it on Obtainium" height="80">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/BURG3R5/FileFlow)

## Screenshots

Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/co/adityarajput/fileflow/data/models/Action.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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)
}
Expand All @@ -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

Expand All @@ -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())
}
Expand Down
42 changes: 30 additions & 12 deletions app/src/main/java/co/adityarajput/fileflow/services/FlowExecutor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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() >=
Expand All @@ -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),
)
}
}
Expand Down
71 changes: 68 additions & 3 deletions app/src/main/java/co/adityarajput/fileflow/utils/Files.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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<File> {
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<File>()
listChildren(false).forEach {
files.add(it)
files.addAll(it.listChildren(true))
}
return files
}

fun isIdenticalTo(other: File, context: Context): Boolean {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -140,3 +202,6 @@ fun String.getGetDirectoryFromUri() =

substringAfter(file.name ?: "").ifBlank { "/" }
}

fun Context.findRulesToBeMigrated(rules: List<Rule>) =
rules.filter { File.fromPath(this, it.action.src) == null }
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>? = null,
val predictedDestFileNames: List<String>? = null,
val retentionDays: Int = 30,
Expand All @@ -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,
)
}
}
Expand All @@ -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,
)
}
}

Expand All @@ -78,32 +87,41 @@ class UpsertRuleViewModel(
var folderPickerState by mutableStateOf<FolderPickerState?>(null)

fun updateForm(context: Context, values: Values) {
var currentSrcFileNames: List<String>? = null
var currentSrcFiles: List<File>? = 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<String>? = 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading