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
23 changes: 20 additions & 3 deletions app/src/main/java/co/adityarajput/fileflow/data/AppContainer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class AppContainer(private val context: Context) {
Rule(
Action.MOVE(
"/storage/emulated/0/AntennaPod",
"AntennaPodBackup-\\d{4}-\\d{2}-\\d{2}.db",
"AntennaPodBackup-\\d{4}-\\d{2}-\\d{2}\\.db",
"/storage/emulated/0/Backups",
"AntennaPod.db",
overwriteExisting = true,
Expand All @@ -34,7 +34,7 @@ class AppContainer(private val context: Context) {
Rule(
Action.MOVE(
"/storage/emulated/0/Backups",
"TubularData-\\d{8}_\\d{6}.zip",
"TubularData-\\d{8}_\\d{6}\\.zip",
"/storage/emulated/0/Backups",
"Tubular.zip",
keepOriginal = false,
Expand All @@ -46,12 +46,24 @@ class AppContainer(private val context: Context) {
Rule(
Action.DELETE_STALE(
"/storage/emulated/0/Download",
"Alarmetrics_v[\\d\\.]+.apk",
"Alarmetrics_v[\\d\\.]+\\.apk",
scanSubdirectories = true,
),
enabled = false,
interval = 86_400_000,
),
Rule(
Action.ZIP(
"/storage/emulated/0/Documents/Notes",
"(.*)\\.md",
"/storage/emulated/0/Backups",
"Notes.zip",
true,
),
executions = 5,
interval = null,
cronString = "30 13 * * *",
),
)
repository.upsert(
Execution(
Expand All @@ -74,6 +86,11 @@ class AppContainer(private val context: Context) {
Verb.COPY,
System.currentTimeMillis() - 86400000L * 3,
),
Execution(
"Notes.zip",
Verb.ZIP,
System.currentTimeMillis() - 86400000L * 1,
),
Execution(
"AntennaPodBackup-2026-02-05.db",
Verb.COPY,
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/co/adityarajput/fileflow/data/Verb.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ enum class Verb(val resource: Int) {
MOVE(R.string.move),
COPY(R.string.copy),
DELETE_STALE(R.string.delete_stale),
ZIP(R.string.zip),
}
115 changes: 107 additions & 8 deletions app/src/main/java/co/adityarajput/fileflow/data/models/Action.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ import co.adityarajput.fileflow.data.Verb
import co.adityarajput.fileflow.utils.*
import co.adityarajput.fileflow.views.dullStyle
import kotlinx.serialization.Serializable
import java.io.BufferedOutputStream
import java.nio.file.FileAlreadyExistsException
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream

@Suppress("ClassName")
@Serializable
Expand All @@ -35,6 +36,7 @@ sealed class Action {
get() = when (this) {
is MOVE -> entries[0]
is DELETE_STALE -> entries[1]
is ZIP -> entries[2]
}

infix fun isSimilarTo(other: Action) = this::class == other::class
Expand Down Expand Up @@ -69,16 +71,15 @@ sealed class Action {
append(destFileNameTemplate)
}

@OptIn(ExperimentalUuidApi::class)
fun getDestFileName(srcFile: File) =
srcFile.name!!.replace(
Regex(srcFileNamePattern),
destFileNameTemplate.replace(
$$"${uuid}",
Uuid.random().toString(),
).replace(
destFileNameTemplate.applyCustomReplacements().replace(
$$"${folder}",
srcFile.parent?.name ?: "",
).replace(
$$"${extension}",
srcFile.extension,
),
)

Expand Down Expand Up @@ -218,7 +219,105 @@ sealed class Action {
}
}

@Serializable
data class ZIP(
override val src: String,
override val srcFileNamePattern: String,
val dest: String,
val destFileNameTemplate: String,
override val scanSubdirectories: Boolean = false,
val overwriteExisting: Boolean = false,
val preserveStructure: Boolean = scanSubdirectories,
) : Action() {
override val verb get() = Verb.ZIP

override val phrase = R.string.zip_phrase

@Composable
override fun getDescription() = buildAnnotatedString {
withStyle(dullStyle) { append("from ") }
append(src.getGetDirectoryFromUri())
if (scanSubdirectories)
withStyle(dullStyle) { append(" & subfolders") }
withStyle(dullStyle) { append("\nto ") }
append(dest.getGetDirectoryFromUri())
withStyle(dullStyle) { append("\nas ") }
append(destFileNameTemplate)
}

fun getDestFileName() = destFileNameTemplate.applyCustomReplacements()

override suspend fun execute(
context: Context,
registerExecution: suspend (String) -> Unit,
) {
val destDir = File.fromPath(context, dest)
if (destDir == null) {
Logger.e("Action", "$dest is invalid")
return
}
val destFileName = getDestFileName()

destDir.listChildren(false).firstOrNull { it.isFile && it.name == destFileName }?.run {
if (!overwriteExisting) {
Logger.e("Action", "$destFileName already exists")
return@execute
}

delete()
}
val destFile = destDir.createFile(destFileName, "application/zip") ?: run {
Logger.e("Action", "Failed to create $destFileName")
return@execute
}

val srcFiles = File.fromPath(context, src)
?.listChildren(scanSubdirectories)
?.filter {
it.isFile
&& it.name != null
&& Regex(srcFileNamePattern).matches(it.name!!)
}
?: return

ZipOutputStream(BufferedOutputStream(destFile.getOutputStream(context))).use { dest ->
for (srcFile in srcFiles) {
val srcFileName = srcFile.name ?: continue
Logger.i("Action", "Adding $srcFileName to archive")

try {
dest.putNextEntry(
ZipEntry(
if (!preserveStructure) srcFileName
else srcFile.pathRelativeTo(src)!!,
),
)
srcFile.getInputStream(context).use { src ->
if (src == null) {
Logger.e("Action", "Failed to open $srcFileName")
continue
}
src.copyTo(dest)
}
dest.closeEntry()
} catch (e: Exception) {
Logger.e("Action", "Failed to add $srcFileName to archive", e)
continue
}
}
}

registerExecution(destFileName)
}
}

companion object {
val entries by lazy { listOf(MOVE("", "", "", ""), DELETE_STALE("", "")) }
val entries by lazy {
listOf(
MOVE("", "", "", ""),
DELETE_STALE("", ""),
ZIP("", "", "", ""),
)
}
}
}
47 changes: 41 additions & 6 deletions app/src/main/java/co/adityarajput/fileflow/utils/Files.kt
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ sealed class File {
}
}

val extension
get() = when (this) {
is SAFFile -> documentFile.name?.substringAfterLast('.', "").orEmpty()
is FSFile -> ioFile.extension
}

val name
get() = when (this) {
is SAFFile -> documentFile.name
Expand Down Expand Up @@ -168,8 +174,19 @@ sealed class File {
is FSFile -> ioFile.length()
}

fun pathRelativeTo(basePath: String) = path.getGetDirectoryFromUri()
.substringAfter(basePath.getGetDirectoryFromUri(), "").ifBlank { null }
fun pathRelativeTo(basePath: String): String? =
if (isDirectory) {
path.getGetDirectoryFromUri()
.substringAfter(basePath.getGetDirectoryFromUri(), "")
.ifBlank { null }
} else {
parent?.pathRelativeTo(basePath)
?.takeIf { it.isNotEmpty() && name != null }
.let {
if (it == null) name
else "${it.removePrefix("/").removeSuffix("/")}/$name"
}
}

fun listChildren(recurse: Boolean): List<File> {
if (!isDirectory) return emptyList()
Expand All @@ -190,13 +207,11 @@ sealed class File {
}

fun isIdenticalTo(other: File, context: Context): Boolean {
val resolver = context.contentResolver

if (this is FSFile && other is FSFile) {
return ioFile.readBytes().contentEquals(other.ioFile.readBytes())
} else if (this is SAFFile && other is SAFFile) {
resolver.openInputStream(documentFile.uri).use { src ->
resolver.openInputStream(other.documentFile.uri).use { dest ->
this.getInputStream(context).use { src ->
other.getInputStream(context).use { dest ->
if (src == null || dest == null) {
Logger.e("Files", "Failed to open file(s)")
return false
Expand All @@ -209,6 +224,14 @@ sealed class File {
return false
}

fun createFile(name: String, mimeType: String) = when (this) {
is SAFFile -> documentFile.createFile(mimeType, name)?.let { SAFFile(it) }

is FSFile -> IOFile(ioFile, name).let {
if (it.createNewFile()) FSFile(it) else null
}
}

fun createDirectory(relativePath: String): File? {
return when (this) {
is SAFFile -> {
Expand Down Expand Up @@ -236,6 +259,18 @@ sealed class File {
}
}

fun getInputStream(context: Context) = when (this) {
is SAFFile -> context.contentResolver.openInputStream(documentFile.uri)

is FSFile -> ioFile.inputStream()
}

fun getOutputStream(context: Context) = when (this) {
is SAFFile -> context.contentResolver.openOutputStream(documentFile.uri)

is FSFile -> ioFile.outputStream()
}

abstract suspend fun moveTo(
destDir: File,
destFileName: String,
Expand Down
25 changes: 25 additions & 0 deletions app/src/main/java/co/adityarajput/fileflow/utils/String.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import co.adityarajput.fileflow.R
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid

@Composable
fun Long.toShortHumanReadableTime(): String {
Expand Down Expand Up @@ -42,3 +46,24 @@ fun Long.toAccurateHumanReadableTime(): String {
@Composable
fun Boolean.getToggleString(): String =
stringResource(if (this) R.string.disable else R.string.enable)

@OptIn(ExperimentalUuidApi::class)
fun String.applyCustomReplacements() = this
.replace(
$$"${uuid}",
Uuid.random().toString(),
)
.replace(
$$"${date}",
ZonedDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE),
)
.replace(
$$"${time}",
ZonedDateTime.now().withNano(0).format(DateTimeFormatter.ISO_LOCAL_TIME).replace(":", "-"),
)
.replace(
Regex("\\$\\{datetime:([^}]+)\\}"),
{ result ->
ZonedDateTime.now().format(DateTimeFormatter.ofPattern(result.groupValues[1]))
},
)
2 changes: 1 addition & 1 deletion app/src/main/java/co/adityarajput/fileflow/utils/Time.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ val ZonedDateTime.isToday get() = toLocalDate() == ZonedDateTime.now().toLocalDa

private val cronParser = CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX))

fun String.getExecutionTimes(count: Int = 1): List<ZonedDateTime>? {
fun String.getExecutionTimes(count: Int): List<ZonedDateTime>? {
try {
val schedule = cronParser.parse(this)
val executionTime = ExecutionTime.forCron(schedule)
Expand Down
Loading
Loading