Skip to content

Commit 2be7c86

Browse files
authored
feat: Add "ZIP" action (#24)
2 parents c3ead2d + 69113e1 commit 2be7c86

File tree

10 files changed

+337
-41
lines changed

10 files changed

+337
-41
lines changed

app/src/main/java/co/adityarajput/fileflow/data/AppContainer.kt

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class AppContainer(private val context: Context) {
2222
Rule(
2323
Action.MOVE(
2424
"/storage/emulated/0/AntennaPod",
25-
"AntennaPodBackup-\\d{4}-\\d{2}-\\d{2}.db",
25+
"AntennaPodBackup-\\d{4}-\\d{2}-\\d{2}\\.db",
2626
"/storage/emulated/0/Backups",
2727
"AntennaPod.db",
2828
overwriteExisting = true,
@@ -34,7 +34,7 @@ class AppContainer(private val context: Context) {
3434
Rule(
3535
Action.MOVE(
3636
"/storage/emulated/0/Backups",
37-
"TubularData-\\d{8}_\\d{6}.zip",
37+
"TubularData-\\d{8}_\\d{6}\\.zip",
3838
"/storage/emulated/0/Backups",
3939
"Tubular.zip",
4040
keepOriginal = false,
@@ -46,12 +46,24 @@ class AppContainer(private val context: Context) {
4646
Rule(
4747
Action.DELETE_STALE(
4848
"/storage/emulated/0/Download",
49-
"Alarmetrics_v[\\d\\.]+.apk",
49+
"Alarmetrics_v[\\d\\.]+\\.apk",
5050
scanSubdirectories = true,
5151
),
5252
enabled = false,
5353
interval = 86_400_000,
5454
),
55+
Rule(
56+
Action.ZIP(
57+
"/storage/emulated/0/Documents/Notes",
58+
"(.*)\\.md",
59+
"/storage/emulated/0/Backups",
60+
"Notes.zip",
61+
true,
62+
),
63+
executions = 5,
64+
interval = null,
65+
cronString = "30 13 * * *",
66+
),
5567
)
5668
repository.upsert(
5769
Execution(
@@ -74,6 +86,11 @@ class AppContainer(private val context: Context) {
7486
Verb.COPY,
7587
System.currentTimeMillis() - 86400000L * 3,
7688
),
89+
Execution(
90+
"Notes.zip",
91+
Verb.ZIP,
92+
System.currentTimeMillis() - 86400000L * 1,
93+
),
7794
Execution(
7895
"AntennaPodBackup-2026-02-05.db",
7996
Verb.COPY,

app/src/main/java/co/adityarajput/fileflow/data/Verb.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ enum class Verb(val resource: Int) {
66
MOVE(R.string.move),
77
COPY(R.string.copy),
88
DELETE_STALE(R.string.delete_stale),
9+
ZIP(R.string.zip),
910
}

app/src/main/java/co/adityarajput/fileflow/data/models/Action.kt

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ import co.adityarajput.fileflow.data.Verb
1212
import co.adityarajput.fileflow.utils.*
1313
import co.adityarajput.fileflow.views.dullStyle
1414
import kotlinx.serialization.Serializable
15+
import java.io.BufferedOutputStream
1516
import java.nio.file.FileAlreadyExistsException
16-
import kotlin.uuid.ExperimentalUuidApi
17-
import kotlin.uuid.Uuid
17+
import java.util.zip.ZipEntry
18+
import java.util.zip.ZipOutputStream
1819

1920
@Suppress("ClassName")
2021
@Serializable
@@ -35,6 +36,7 @@ sealed class Action {
3536
get() = when (this) {
3637
is MOVE -> entries[0]
3738
is DELETE_STALE -> entries[1]
39+
is ZIP -> entries[2]
3840
}
3941

4042
infix fun isSimilarTo(other: Action) = this::class == other::class
@@ -69,16 +71,15 @@ sealed class Action {
6971
append(destFileNameTemplate)
7072
}
7173

72-
@OptIn(ExperimentalUuidApi::class)
7374
fun getDestFileName(srcFile: File) =
7475
srcFile.name!!.replace(
7576
Regex(srcFileNamePattern),
76-
destFileNameTemplate.replace(
77-
$$"${uuid}",
78-
Uuid.random().toString(),
79-
).replace(
77+
destFileNameTemplate.applyCustomReplacements().replace(
8078
$$"${folder}",
8179
srcFile.parent?.name ?: "",
80+
).replace(
81+
$$"${extension}",
82+
srcFile.extension,
8283
),
8384
)
8485

@@ -218,7 +219,105 @@ sealed class Action {
218219
}
219220
}
220221

222+
@Serializable
223+
data class ZIP(
224+
override val src: String,
225+
override val srcFileNamePattern: String,
226+
val dest: String,
227+
val destFileNameTemplate: String,
228+
override val scanSubdirectories: Boolean = false,
229+
val overwriteExisting: Boolean = false,
230+
val preserveStructure: Boolean = scanSubdirectories,
231+
) : Action() {
232+
override val verb get() = Verb.ZIP
233+
234+
override val phrase = R.string.zip_phrase
235+
236+
@Composable
237+
override fun getDescription() = buildAnnotatedString {
238+
withStyle(dullStyle) { append("from ") }
239+
append(src.getGetDirectoryFromUri())
240+
if (scanSubdirectories)
241+
withStyle(dullStyle) { append(" & subfolders") }
242+
withStyle(dullStyle) { append("\nto ") }
243+
append(dest.getGetDirectoryFromUri())
244+
withStyle(dullStyle) { append("\nas ") }
245+
append(destFileNameTemplate)
246+
}
247+
248+
fun getDestFileName() = destFileNameTemplate.applyCustomReplacements()
249+
250+
override suspend fun execute(
251+
context: Context,
252+
registerExecution: suspend (String) -> Unit,
253+
) {
254+
val destDir = File.fromPath(context, dest)
255+
if (destDir == null) {
256+
Logger.e("Action", "$dest is invalid")
257+
return
258+
}
259+
val destFileName = getDestFileName()
260+
261+
destDir.listChildren(false).firstOrNull { it.isFile && it.name == destFileName }?.run {
262+
if (!overwriteExisting) {
263+
Logger.e("Action", "$destFileName already exists")
264+
return@execute
265+
}
266+
267+
delete()
268+
}
269+
val destFile = destDir.createFile(destFileName, "application/zip") ?: run {
270+
Logger.e("Action", "Failed to create $destFileName")
271+
return@execute
272+
}
273+
274+
val srcFiles = File.fromPath(context, src)
275+
?.listChildren(scanSubdirectories)
276+
?.filter {
277+
it.isFile
278+
&& it.name != null
279+
&& Regex(srcFileNamePattern).matches(it.name!!)
280+
}
281+
?: return
282+
283+
ZipOutputStream(BufferedOutputStream(destFile.getOutputStream(context))).use { dest ->
284+
for (srcFile in srcFiles) {
285+
val srcFileName = srcFile.name ?: continue
286+
Logger.i("Action", "Adding $srcFileName to archive")
287+
288+
try {
289+
dest.putNextEntry(
290+
ZipEntry(
291+
if (!preserveStructure) srcFileName
292+
else srcFile.pathRelativeTo(src)!!,
293+
),
294+
)
295+
srcFile.getInputStream(context).use { src ->
296+
if (src == null) {
297+
Logger.e("Action", "Failed to open $srcFileName")
298+
continue
299+
}
300+
src.copyTo(dest)
301+
}
302+
dest.closeEntry()
303+
} catch (e: Exception) {
304+
Logger.e("Action", "Failed to add $srcFileName to archive", e)
305+
continue
306+
}
307+
}
308+
}
309+
310+
registerExecution(destFileName)
311+
}
312+
}
313+
221314
companion object {
222-
val entries by lazy { listOf(MOVE("", "", "", ""), DELETE_STALE("", "")) }
315+
val entries by lazy {
316+
listOf(
317+
MOVE("", "", "", ""),
318+
DELETE_STALE("", ""),
319+
ZIP("", "", "", ""),
320+
)
321+
}
223322
}
224323
}

app/src/main/java/co/adityarajput/fileflow/utils/Files.kt

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ sealed class File {
122122
}
123123
}
124124

125+
val extension
126+
get() = when (this) {
127+
is SAFFile -> documentFile.name?.substringAfterLast('.', "").orEmpty()
128+
is FSFile -> ioFile.extension
129+
}
130+
125131
val name
126132
get() = when (this) {
127133
is SAFFile -> documentFile.name
@@ -168,8 +174,19 @@ sealed class File {
168174
is FSFile -> ioFile.length()
169175
}
170176

171-
fun pathRelativeTo(basePath: String) = path.getGetDirectoryFromUri()
172-
.substringAfter(basePath.getGetDirectoryFromUri(), "").ifBlank { null }
177+
fun pathRelativeTo(basePath: String): String? =
178+
if (isDirectory) {
179+
path.getGetDirectoryFromUri()
180+
.substringAfter(basePath.getGetDirectoryFromUri(), "")
181+
.ifBlank { null }
182+
} else {
183+
parent?.pathRelativeTo(basePath)
184+
?.takeIf { it.isNotEmpty() && name != null }
185+
.let {
186+
if (it == null) name
187+
else "${it.removePrefix("/").removeSuffix("/")}/$name"
188+
}
189+
}
173190

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

192209
fun isIdenticalTo(other: File, context: Context): Boolean {
193-
val resolver = context.contentResolver
194-
195210
if (this is FSFile && other is FSFile) {
196211
return ioFile.readBytes().contentEquals(other.ioFile.readBytes())
197212
} else if (this is SAFFile && other is SAFFile) {
198-
resolver.openInputStream(documentFile.uri).use { src ->
199-
resolver.openInputStream(other.documentFile.uri).use { dest ->
213+
this.getInputStream(context).use { src ->
214+
other.getInputStream(context).use { dest ->
200215
if (src == null || dest == null) {
201216
Logger.e("Files", "Failed to open file(s)")
202217
return false
@@ -209,6 +224,14 @@ sealed class File {
209224
return false
210225
}
211226

227+
fun createFile(name: String, mimeType: String) = when (this) {
228+
is SAFFile -> documentFile.createFile(mimeType, name)?.let { SAFFile(it) }
229+
230+
is FSFile -> IOFile(ioFile, name).let {
231+
if (it.createNewFile()) FSFile(it) else null
232+
}
233+
}
234+
212235
fun createDirectory(relativePath: String): File? {
213236
return when (this) {
214237
is SAFFile -> {
@@ -236,6 +259,18 @@ sealed class File {
236259
}
237260
}
238261

262+
fun getInputStream(context: Context) = when (this) {
263+
is SAFFile -> context.contentResolver.openInputStream(documentFile.uri)
264+
265+
is FSFile -> ioFile.inputStream()
266+
}
267+
268+
fun getOutputStream(context: Context) = when (this) {
269+
is SAFFile -> context.contentResolver.openOutputStream(documentFile.uri)
270+
271+
is FSFile -> ioFile.outputStream()
272+
}
273+
239274
abstract suspend fun moveTo(
240275
destDir: File,
241276
destFileName: String,

app/src/main/java/co/adityarajput/fileflow/utils/String.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import androidx.compose.runtime.Composable
44
import androidx.compose.ui.res.pluralStringResource
55
import androidx.compose.ui.res.stringResource
66
import co.adityarajput.fileflow.R
7+
import java.time.ZonedDateTime
8+
import java.time.format.DateTimeFormatter
9+
import kotlin.uuid.ExperimentalUuidApi
10+
import kotlin.uuid.Uuid
711

812
@Composable
913
fun Long.toShortHumanReadableTime(): String {
@@ -42,3 +46,24 @@ fun Long.toAccurateHumanReadableTime(): String {
4246
@Composable
4347
fun Boolean.getToggleString(): String =
4448
stringResource(if (this) R.string.disable else R.string.enable)
49+
50+
@OptIn(ExperimentalUuidApi::class)
51+
fun String.applyCustomReplacements() = this
52+
.replace(
53+
$$"${uuid}",
54+
Uuid.random().toString(),
55+
)
56+
.replace(
57+
$$"${date}",
58+
ZonedDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE),
59+
)
60+
.replace(
61+
$$"${time}",
62+
ZonedDateTime.now().withNano(0).format(DateTimeFormatter.ISO_LOCAL_TIME).replace(":", "-"),
63+
)
64+
.replace(
65+
Regex("\\$\\{datetime:([^}]+)\\}"),
66+
{ result ->
67+
ZonedDateTime.now().format(DateTimeFormatter.ofPattern(result.groupValues[1]))
68+
},
69+
)

app/src/main/java/co/adityarajput/fileflow/utils/Time.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ val ZonedDateTime.isToday get() = toLocalDate() == ZonedDateTime.now().toLocalDa
3434

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

37-
fun String.getExecutionTimes(count: Int = 1): List<ZonedDateTime>? {
37+
fun String.getExecutionTimes(count: Int): List<ZonedDateTime>? {
3838
try {
3939
val schedule = cronParser.parse(this)
4040
val executionTime = ExecutionTime.forCron(schedule)

0 commit comments

Comments
 (0)