diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
new file mode 100644
index 0000000..6b41cd7
--- /dev/null
+++ b/.github/workflows/test.yaml
@@ -0,0 +1,23 @@
+name: test
+
+on:
+ push:
+ branches:
+ - dev
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v7
+
+ - name: Setup JDK
+ uses: actions/setup-java@v6
+ with:
+ java-version: 17
+ distribution: "temurin"
+ cache: "gradle"
+
+ - name: Run tests
+ run: ./gradlew test
diff --git a/.gitignore b/.gitignore
index 8d10870..5bc82a8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
.gradle
/local.properties
.idea/*
+!.idea/runConfigurations
!.idea/icon.png
.DS_Store
/build
diff --git a/.idea/runConfigurations/app.xml b/.idea/runConfigurations/app.xml
new file mode 100644
index 0000000..4162a33
--- /dev/null
+++ b/.idea/runConfigurations/app.xml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/test.xml b/.idea/runConfigurations/test.xml
new file mode 100644
index 0000000..0a71e64
--- /dev/null
+++ b/.idea/runConfigurations/test.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+ false
+
+
+
+
+
+
+
+
+ false
+ true
+ false
+ false
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 77bf2b5..6479d93 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 = 11
- versionName = "1.9.0"
+ versionCode = 12
+ versionName = "1.10.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
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 c1a0afe..a10dfd7 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
@@ -155,7 +155,7 @@ sealed class Action {
val srcFileName = srcFile.name ?: continue
var destFileName = getDestFileName(srcFile)
- val relativePath = srcFile.parent!!.pathRelativeTo(src)
+ val relativePath = srcFile.parent?.pathRelativeTo(src)
val destSubDir =
if (!preserveStructure || relativePath == null) destDir
else destDir.createDirectory(relativePath)
diff --git a/app/src/main/java/co/adityarajput/fileflow/data/models/RemoteAction.kt b/app/src/main/java/co/adityarajput/fileflow/data/models/RemoteAction.kt
index 1572945..8d279d2 100644
--- a/app/src/main/java/co/adityarajput/fileflow/data/models/RemoteAction.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/data/models/RemoteAction.kt
@@ -180,7 +180,7 @@ sealed class RemoteAction : Action() {
for ((srcFile, _) in srcFiles) {
val srcFileName = srcFile.name ?: continue
var destFileName = getDestFileName(srcFile)
- val relativePath = srcFile.parent!!.pathRelativeTo(src)
+ val relativePath = srcFile.parent?.pathRelativeTo(src)
if (destServer == null) {
val destSubDir =
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 52086fb..fa2124c 100644
--- a/app/src/main/java/co/adityarajput/fileflow/viewmodels/RulesViewModel.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/viewmodels/RulesViewModel.kt
@@ -31,24 +31,35 @@ class RulesViewModel(private val repository: Repository) : ViewModel() {
var selectedRule by mutableStateOf(null)
+ var executingRuleIds by mutableStateOf(emptySet())
+ private set
+
fun executeRule(context: Context, showToast: (String) -> Unit) {
+ val rule = selectedRule ?: return
+ if (rule.id in executingRuleIds) return
+ executingRuleIds += rule.id
+
viewModelScope.launch {
- val latestLogBeforeExecution = Logger.logs.lastOrNull()
+ try {
+ val latestLogBeforeExecution = Logger.logs.lastOrNull()
- withContext(Dispatchers.IO) {
- selectedRule!!.action.execute(context) {
- repository.registerExecution(
- selectedRule!!,
- Execution(it, selectedRule!!.action.verb),
- )
+ withContext(Dispatchers.IO) {
+ rule.action.execute(context) {
+ repository.registerExecution(
+ rule,
+ Execution(it, rule.action.verb),
+ )
+ }
}
- }
- val recentErrorLog = Logger.logs
- .dropWhile { it != latestLogBeforeExecution }.drop(1)
- .firstOrNull { it.contains("[ERROR]") }
- if (recentErrorLog != null) {
- showToast("Error:" + recentErrorLog.substringAfter("[ERROR]"))
+ val recentErrorLog = Logger.logs
+ .dropWhile { it != latestLogBeforeExecution }.drop(1)
+ .firstOrNull { it.contains("[ERROR]") }
+ if (recentErrorLog != null) {
+ showToast("Error:" + recentErrorLog.substringAfter("[ERROR]"))
+ }
+ } finally {
+ executingRuleIds -= rule.id
}
}
}
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 0784de8..35b6b55 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
@@ -1,9 +1,6 @@
package co.adityarajput.fileflow.views.screens
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
@@ -97,11 +94,16 @@ fun RulesScreen(
else viewModel.selectedRule = it
},
) {
- IconButton({ viewModel.dialogState = DialogState.EXECUTE }) {
- Icon(
- painterResource(R.drawable.play_circle),
- stringResource(R.string.execute_rule),
- )
+ IconButton(
+ { viewModel.dialogState = DialogState.EXECUTE },
+ enabled = it.id !in viewModel.executingRuleIds,
+ ) {
+ if (it.id !in viewModel.executingRuleIds)
+ Icon(
+ painterResource(R.drawable.play_circle),
+ stringResource(R.string.execute_rule),
+ )
+ else CircularProgressIndicator(Modifier.size(24.dp))
}
IconButton({ viewModel.dialogState = DialogState.TOGGLE_RULE }) {
Icon(
diff --git a/app/src/test/kotlin/co/adityarajput/fileflow/data/ConvertersTest.kt b/app/src/test/kotlin/co/adityarajput/fileflow/data/ConvertersTest.kt
new file mode 100644
index 0000000..d6ce278
--- /dev/null
+++ b/app/src/test/kotlin/co/adityarajput/fileflow/data/ConvertersTest.kt
@@ -0,0 +1,139 @@
+package co.adityarajput.fileflow.data
+
+import co.adityarajput.fileflow.data.models.Action
+import co.adityarajput.fileflow.data.models.RemoteAction
+import co.adityarajput.fileflow.data.models.Server
+import co.adityarajput.fileflow.utils.FileSuperlative
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ConvertersTest {
+ @Test
+ fun Converters_Action() {
+ val actions = Action.entries + RemoteAction.entries + listOf(
+ Action.MOVE(
+ "/storage/emulated/0/AntennaPod",
+ "AntennaPodBackup-\\d{4}-\\d{2}-\\d{2}\\.db",
+ "/storage/emulated/0/Backups",
+ "AntennaPod.db",
+ scanSubdirectories = true,
+ overwriteExisting = true,
+ preserveStructure = false,
+ ),
+ Action.MOVE(
+ "/storage/emulated/0/Backups",
+ "PipePipeData-\\d{8}_\\d{6}\\.zip",
+ "/storage/emulated/0/Backups",
+ $$"PipePipe-${uuid}.zip",
+ keepOriginal = false,
+ superlative = FileSuperlative.LARGEST,
+ deleteEmptySrcSubdirectories = true,
+ ),
+ Action.DELETE_STALE(
+ "/storage/emulated/0/Download",
+ "(NotiFilter|Alarmetrics|FileFlow|MinCal)_v[\\d\\.]+\\.apk",
+ 90,
+ scanSubdirectories = true,
+ deleteEmptySrcSubdirectories = true,
+ ),
+ Action.ZIP(
+ "/storage/emulated/0/Documents/Notes",
+ "(.*)\\.md",
+ "/storage/emulated/0/Backups",
+ "Notes.zip",
+ scanSubdirectories = true,
+ overwriteExisting = true,
+ preserveStructure = false,
+ ),
+ Action.EMIT_CHANGES(
+ "/storage/emulated/0/Movies",
+ ".*\\.mp4",
+ "org.amoradi.syncopoli.SYNC_PROFILE",
+ "org.amoradi.syncopoli",
+ """{"profile_name": "Movies Backup"}""",
+ true,
+ 3_600_000L * 3,
+ ),
+ RemoteAction.MOVE(
+ Server(
+ "192.168.1.12",
+ 3456,
+ "admin",
+ "hunter2",
+ null,
+ 1,
+ ),
+ "backups/navidrome",
+ "navidrome_backup_[\\d_\\.]{19}\\.db",
+ Server(
+ "192.168.1.78",
+ 9012,
+ "sftp",
+ null,
+ "passkey",
+ 2,
+ ),
+ "backups/glacial",
+ "navidrome.db",
+ overwriteExisting = true,
+ ),
+ )
+
+ val serializedActions = listOf(
+ """{"type":"co.adityarajput.fileflow.data.models.Action.MOVE","src":"","srcFileNamePattern":"","dest":"","destFileNameTemplate":""}""",
+ """{"type":"co.adityarajput.fileflow.data.models.Action.DELETE_STALE","src":"","srcFileNamePattern":""}""",
+ """{"type":"co.adityarajput.fileflow.data.models.Action.ZIP","src":"","srcFileNamePattern":"","dest":"","destFileNameTemplate":""}""",
+ """{"type":"co.adityarajput.fileflow.data.models.Action.EMIT_CHANGES","src":"","srcFileNamePattern":"","intent":"","packageName":""}""",
+ """{"type":"co.adityarajput.fileflow.data.models.RemoteAction.MOVE","srcServer":null,"src":"","srcFileNamePattern":"","destServer":null,"dest":"","destFileNameTemplate":""}""",
+ """{"type":"co.adityarajput.fileflow.data.models.RemoteAction.DELETE_STALE","srcServer":{"host":"","port":0,"username":"","encryptedPassword":null,"encryptedPrivateKey":null,"id":0},"src":"","srcFileNamePattern":""}""",
+ """{"type":"co.adityarajput.fileflow.data.models.RemoteAction.ZIP","srcServer":null,"src":"","srcFileNamePattern":"","destServer":null,"dest":"","destFileNameTemplate":""}""",
+ """{"type":"co.adityarajput.fileflow.data.models.Action.EMIT_CHANGES","src":"","srcFileNamePattern":"","intent":"","packageName":""}""",
+ """{"type":"co.adityarajput.fileflow.data.models.Action.MOVE","src":"/storage/emulated/0/AntennaPod","srcFileNamePattern":"AntennaPodBackup-\\d{4}-\\d{2}-\\d{2}\\.db","dest":"/storage/emulated/0/Backups","destFileNameTemplate":"AntennaPod.db","scanSubdirectories":true,"overwriteExisting":true,"preserveStructure":false}""",
+ $$"""{"type":"co.adityarajput.fileflow.data.models.Action.MOVE","src":"/storage/emulated/0/Backups","srcFileNamePattern":"PipePipeData-\\d{8}_\\d{6}\\.zip","dest":"/storage/emulated/0/Backups","destFileNameTemplate":"PipePipe-${uuid}.zip","keepOriginal":false,"superlative":"LARGEST","deleteEmptySrcSubdirectories":true}""",
+ """{"type":"co.adityarajput.fileflow.data.models.Action.DELETE_STALE","src":"/storage/emulated/0/Download","srcFileNamePattern":"(NotiFilter|Alarmetrics|FileFlow|MinCal)_v[\\d\\.]+\\.apk","retentionDays":90,"scanSubdirectories":true,"deleteEmptySrcSubdirectories":true}""",
+ """{"type":"co.adityarajput.fileflow.data.models.Action.ZIP","src":"/storage/emulated/0/Documents/Notes","srcFileNamePattern":"(.*)\\.md","dest":"/storage/emulated/0/Backups","destFileNameTemplate":"Notes.zip","scanSubdirectories":true,"overwriteExisting":true,"preserveStructure":false}""",
+ """{"type":"co.adityarajput.fileflow.data.models.Action.EMIT_CHANGES","src":"/storage/emulated/0/Movies","srcFileNamePattern":".*\\.mp4","intent":"org.amoradi.syncopoli.SYNC_PROFILE","packageName":"org.amoradi.syncopoli","extras":"{\"profile_name\": \"Movies Backup\"}","scanSubdirectories":true,"modifiedWithin":10800000}""",
+ """{"type":"co.adityarajput.fileflow.data.models.RemoteAction.MOVE","srcServer":{"host":"192.168.1.12","port":3456,"username":"admin","encryptedPassword":"hunter2","encryptedPrivateKey":null,"id":1},"src":"backups/navidrome","srcFileNamePattern":"navidrome_backup_[\\d_\\.]{19}\\.db","destServer":{"host":"192.168.1.78","port":9012,"username":"sftp","encryptedPassword":null,"encryptedPrivateKey":"passkey","id":2},"dest":"backups/glacial","destFileNameTemplate":"navidrome.db","overwriteExisting":true}""",
+ )
+
+ val incorrectSerializations =
+ listOf("", "{", "}", "{}", serializedActions[0].substring(0, 10))
+
+ Converters().run {
+ for (i in actions.indices) {
+ assertEquals(serializedActions[i], fromAction(actions[i]))
+ assertEquals(actions[i], toAction(serializedActions[i]))
+ assertEquals(
+ actions[i],
+ toAction(serializedActions[i].replaceFirst("{", """{"title": "deprecated",""")),
+ )
+ }
+ for (string in incorrectSerializations) {
+ assertEquals(Action.MOVE("", "", "", ""), toAction(string))
+ }
+ }
+ }
+
+ @Test
+ fun Converters_IntList() {
+ val intLists = listOf(
+ listOf(1, 2), listOf(2, 1), listOf(2, 4, 6), listOf(2, 3, 5),
+ listOf(0, 1, 2, 3, 4, 5, 6),
+ )
+
+ val incorrectSerializations =
+ listOf("", "null", "null,")
+
+ Converters().run {
+ for (list in intLists) {
+ assertEquals(list, toIntList(fromIntList(list)))
+ }
+
+ for (string in incorrectSerializations) {
+ assertEquals(emptyList(), toIntList(string))
+ }
+
+ assertEquals(listOf(1), toIntList("1,null"))
+ }
+ }
+}
diff --git a/metadata/en-US/changelogs/12.txt b/metadata/en-US/changelogs/12.txt
new file mode 100644
index 0000000..0350a07
--- /dev/null
+++ b/metadata/en-US/changelogs/12.txt
@@ -0,0 +1,5 @@
+• fix: Fail gracefully for issues with encryption
+• fix: Compare files in chunks to prevent OutOfMemoryError
+• feat: Create subfolders if destination file name contains path separators
+• feat: Let users choose to override OS-suggested extensions
+• feat: Display spinner when executing rule