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