diff --git a/README.md b/README.md
index 919bbe7..3dd1bf0 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,7 @@ FileFlow scans your files periodically and organizes them according to your rule
- **Actions** - Choose what to do to your files β
1. Copy, move, or rename files π
2. Delete stale files π
+- **Schedule** - Choose when and how often rules run β°
- **History** - Recent executions are stored (locally) β³
- **Free, open-source & private**
- No ads, subscriptions, or in-app purchases π
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0a71417..5448da9 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 = 5
- versionName = "1.3.0"
+ versionCode = 6
+ versionName = "1.4.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -93,6 +93,7 @@ dependencies {
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.ktx)
implementation(libs.aboutlibraries.compose)
+ implementation(libs.cron.utils)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
diff --git a/app/schemas/co.adityarajput.fileflow.data.FileFlowDatabase/4.json b/app/schemas/co.adityarajput.fileflow.data.FileFlowDatabase/4.json
new file mode 100644
index 0000000..e2b8cce
--- /dev/null
+++ b/app/schemas/co.adityarajput.fileflow.data.FileFlowDatabase/4.json
@@ -0,0 +1,92 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 4,
+ "identityHash": "fe2af7f88a055f816417fe0ebace9f2a",
+ "entities": [
+ {
+ "tableName": "rules",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`action` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `executions` INTEGER NOT NULL, `interval` INTEGER DEFAULT 3600000, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "action",
+ "columnName": "action",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "enabled",
+ "columnName": "enabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "executions",
+ "columnName": "executions",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "interval",
+ "columnName": "interval",
+ "affinity": "INTEGER",
+ "defaultValue": "3600000"
+ },
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "executions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileName` TEXT NOT NULL, `verb` TEXT NOT NULL DEFAULT 'MOVE', `timestamp` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "fileName",
+ "columnName": "fileName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "verb",
+ "columnName": "verb",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'MOVE'"
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fe2af7f88a055f816417fe0ebace9f2a')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/schemas/co.adityarajput.fileflow.data.FileFlowDatabase/5.json b/app/schemas/co.adityarajput.fileflow.data.FileFlowDatabase/5.json
new file mode 100644
index 0000000..054d964
--- /dev/null
+++ b/app/schemas/co.adityarajput.fileflow.data.FileFlowDatabase/5.json
@@ -0,0 +1,98 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 5,
+ "identityHash": "5fa205cd80eec0e4d09c32130309c044",
+ "entities": [
+ {
+ "tableName": "rules",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`action` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `executions` INTEGER NOT NULL, `interval` INTEGER DEFAULT 3600000, `cronString` TEXT DEFAULT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "action",
+ "columnName": "action",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "enabled",
+ "columnName": "enabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "executions",
+ "columnName": "executions",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "interval",
+ "columnName": "interval",
+ "affinity": "INTEGER",
+ "defaultValue": "3600000"
+ },
+ {
+ "fieldPath": "cronString",
+ "columnName": "cronString",
+ "affinity": "TEXT",
+ "defaultValue": "NULL"
+ },
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "executions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileName` TEXT NOT NULL, `verb` TEXT NOT NULL DEFAULT 'MOVE', `timestamp` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "fileName",
+ "columnName": "fileName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "verb",
+ "columnName": "verb",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'MOVE'"
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5fa205cd80eec0e4d09c32130309c044')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index da928e5..f6c78d3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -20,9 +20,9 @@
android:allowBackup="${allowBackup}"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
- android:requestLegacyExternalStorage="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name_launcher"
+ android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.FileFlow">
@@ -35,5 +35,12 @@
+
+
+
+
+
diff --git a/app/src/main/java/co/adityarajput/fileflow/AlarmReceiver.kt b/app/src/main/java/co/adityarajput/fileflow/AlarmReceiver.kt
new file mode 100644
index 0000000..0f0fd28
--- /dev/null
+++ b/app/src/main/java/co/adityarajput/fileflow/AlarmReceiver.kt
@@ -0,0 +1,35 @@
+package co.adityarajput.fileflow
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import co.adityarajput.fileflow.data.AppContainer
+import co.adityarajput.fileflow.data.models.Execution
+import co.adityarajput.fileflow.utils.Logger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+class AlarmReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ Logger.d("AlarmReceiver", "Received intent with action: ${intent.action}")
+
+ if (intent.action == Constants.ACTION_EXECUTE_RULE) {
+ CoroutineScope(Dispatchers.IO).launch {
+ val repository = AppContainer(context).repository
+ val rule = repository.rule(intent.getIntExtra(Constants.EXTRA_RULE_ID, -1))
+
+ if (rule == null || !rule.enabled)
+ return@launch
+
+ Logger.d("Worker", "Executing $rule")
+ rule.action.execute(context) {
+ repository.registerExecution(
+ rule,
+ Execution(it, rule.action.verb),
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/co/adityarajput/fileflow/Constants.kt b/app/src/main/java/co/adityarajput/fileflow/Constants.kt
index 40729cc..81f1236 100644
--- a/app/src/main/java/co/adityarajput/fileflow/Constants.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/Constants.kt
@@ -8,6 +8,11 @@ object Constants {
const val BRIGHTNESS = "brightness"
const val WORKER_NAME = "fileflow_worker"
+ const val ACTION_EXECUTE_RULE = "co.adityarajput.fileflow.EXECUTE_RULE"
+ const val EXTRA_RULE_ID = "extra_rule_id"
+ const val MAX_CRON_EXECUTIONS_PER_HOUR = 4
const val LOG_SIZE = 100
+
+ const val ONE_HOUR_IN_MILLIS = 3_600_000L
}
diff --git a/app/src/main/java/co/adityarajput/fileflow/FileFlowApplication.kt b/app/src/main/java/co/adityarajput/fileflow/FileFlowApplication.kt
index 4c3c088..198411b 100644
--- a/app/src/main/java/co/adityarajput/fileflow/FileFlowApplication.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/FileFlowApplication.kt
@@ -1,13 +1,12 @@
package co.adityarajput.fileflow
import android.app.Application
-import androidx.work.ExistingPeriodicWorkPolicy
-import androidx.work.PeriodicWorkRequestBuilder
-import androidx.work.WorkManager
import co.adityarajput.fileflow.data.AppContainer
-import co.adityarajput.fileflow.services.Worker
import co.adityarajput.fileflow.utils.isDebugBuild
-import java.util.concurrent.TimeUnit
+import co.adityarajput.fileflow.utils.scheduleWork
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
class FileFlowApplication : Application() {
lateinit var container: AppContainer
@@ -22,14 +21,8 @@ class FileFlowApplication : Application() {
container.seedDemoData()
}
- WorkManager.getInstance(this).enqueueUniquePeriodicWork(
- Constants.WORKER_NAME,
- ExistingPeriodicWorkPolicy.KEEP,
- PeriodicWorkRequestBuilder(
- // INFO: While debugging, use a shorter interval
- if (isDebugBuild()) 15 else 60,
- TimeUnit.MINUTES,
- ).build(),
- )
+ CoroutineScope(Dispatchers.IO).launch {
+ scheduleWork()
+ }
}
}
diff --git a/app/src/main/java/co/adityarajput/fileflow/data/AppContainer.kt b/app/src/main/java/co/adityarajput/fileflow/data/AppContainer.kt
index 95f8bf5..a4d3982 100644
--- a/app/src/main/java/co/adityarajput/fileflow/data/AppContainer.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/data/AppContainer.kt
@@ -17,10 +17,7 @@ class AppContainer(private val context: Context) {
fun seedDemoData() {
runBlocking {
- if (
- repository.rules().first().isEmpty() &&
- repository.executions().first().isEmpty()
- ) {
+ if (repository.rules().first().isEmpty()) {
repository.upsert(
Rule(
Action.MOVE(
@@ -31,6 +28,8 @@ class AppContainer(private val context: Context) {
overwriteExisting = true,
),
executions = 2,
+ interval = null,
+ cronString = "00 10 * * 0",
),
Rule(
Action.MOVE(
@@ -42,6 +41,7 @@ class AppContainer(private val context: Context) {
overwriteExisting = true,
),
executions = 3,
+ interval = null,
),
Rule(
Action.DELETE_STALE(
@@ -50,6 +50,7 @@ class AppContainer(private val context: Context) {
scanSubdirectories = true,
),
enabled = false,
+ interval = 86_400_000,
),
)
repository.upsert(
diff --git a/app/src/main/java/co/adityarajput/fileflow/data/FileFlowDatabase.kt b/app/src/main/java/co/adityarajput/fileflow/data/FileFlowDatabase.kt
index a1773c5..de3a8b7 100644
--- a/app/src/main/java/co/adityarajput/fileflow/data/FileFlowDatabase.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/data/FileFlowDatabase.kt
@@ -8,10 +8,12 @@ import co.adityarajput.fileflow.data.models.Rule
@Database(
[Rule::class, Execution::class],
- version = 3,
+ version = 5,
autoMigrations = [
AutoMigration(1, 2),
AutoMigration(2, 3, FileFlowDatabase.DeleteEColumnAV::class),
+ AutoMigration(3, 4),
+ AutoMigration(4, 5),
],
)
@TypeConverters(Converters::class)
diff --git a/app/src/main/java/co/adityarajput/fileflow/data/Repository.kt b/app/src/main/java/co/adityarajput/fileflow/data/Repository.kt
index 15ddc9b..fe88e9d 100644
--- a/app/src/main/java/co/adityarajput/fileflow/data/Repository.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/data/Repository.kt
@@ -14,6 +14,8 @@ class Repository(
fun rules() = ruleDao.list()
+ fun rule(id: Int) = ruleDao.get(id)
+
fun executions() = executionDao.list()
suspend fun registerExecution(rule: Rule, execution: Execution) {
diff --git a/app/src/main/java/co/adityarajput/fileflow/data/RuleDao.kt b/app/src/main/java/co/adityarajput/fileflow/data/RuleDao.kt
index b1f4d29..24a568a 100644
--- a/app/src/main/java/co/adityarajput/fileflow/data/RuleDao.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/data/RuleDao.kt
@@ -15,6 +15,9 @@ interface RuleDao {
@Query("SELECT * from rules ORDER BY id ASC")
fun list(): Flow>
+ @Query("SELECT * from rules WHERE id = :id")
+ fun get(id: Int): Rule?
+
@Query("UPDATE rules SET executions = executions + 1 WHERE id = :id")
suspend fun registerExecution(id: Int)
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 bf0d9be..81002c2 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
@@ -1,5 +1,6 @@
package co.adityarajput.fileflow.data.models
+import android.content.Context
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.AnnotatedString
@@ -8,11 +9,10 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import co.adityarajput.fileflow.R
import co.adityarajput.fileflow.data.Verb
-import co.adityarajput.fileflow.utils.File
-import co.adityarajput.fileflow.utils.FileSuperlative
-import co.adityarajput.fileflow.utils.getGetDirectoryFromUri
-import co.adityarajput.fileflow.utils.toShortHumanReadableTime
+import co.adityarajput.fileflow.utils.*
+import co.adityarajput.fileflow.views.dullStyle
import kotlinx.serialization.Serializable
+import java.nio.file.FileAlreadyExistsException
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@@ -29,6 +29,8 @@ sealed class Action {
@Composable
abstract fun getDescription(): AnnotatedString
+ abstract suspend fun execute(context: Context, registerExecution: suspend (String) -> Unit)
+
val base: Action
get() = when (this) {
is MOVE -> entries[0]
@@ -55,8 +57,6 @@ sealed class Action {
@Composable
override fun getDescription() = buildAnnotatedString {
- val dullStyle = SpanStyle(MaterialTheme.colorScheme.onSurfaceVariant)
-
withStyle(dullStyle) { append("from ") }
append(src.getGetDirectoryFromUri())
if (scanSubdirectories)
@@ -81,6 +81,82 @@ sealed class Action {
srcFile.parent?.name ?: "",
),
)
+
+ 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 srcFiles = File.fromPath(context, src)
+ ?.listChildren(scanSubdirectories)
+ ?.filter {
+ it.isFile
+ && it.name != null
+ && Regex(srcFileNamePattern).matches(it.name!!)
+ }
+ ?.let {
+ if (superlative == FileSuperlative.NONE) it else
+ listOf(it.maxByOrNull(superlative.selector) ?: return)
+ } ?: return
+
+ for (srcFile in srcFiles) {
+ val srcFileName = srcFile.name ?: continue
+ val destFileName = getDestFileName(srcFile)
+
+ val relativePath = srcFile.parent!!.pathRelativeTo(src)
+ val destSubDir =
+ if (!preserveStructure || relativePath == null) destDir
+ else destDir.createDirectory(relativePath)
+ if (destSubDir == null) {
+ Logger.e(
+ "Action",
+ "Failed to create subdirectory in ${destDir.path}",
+ )
+ continue
+ }
+
+ if (
+ destSubDir
+ .listChildren(false)
+ .firstOrNull { it.isFile && it.name == destFileName }
+ ?.isIdenticalTo(srcFile, context)
+ == true
+ ) {
+ Logger.i(
+ "Action",
+ "Source and destination files are identical",
+ )
+ continue
+ }
+
+ try {
+ Logger.i(
+ "Action",
+ "Moving $srcFileName to ${destSubDir.path}/$destFileName",
+ )
+ srcFile.moveTo(
+ destSubDir,
+ destFileName,
+ keepOriginal,
+ overwriteExisting,
+ context,
+ )
+ } catch (e: FileAlreadyExistsException) {
+ Logger.e("Action", "$destFileName already exists", e)
+ continue
+ } catch (e: Exception) {
+ Logger.e("Action", "Failed to move $srcFileName", e)
+ continue
+ }
+
+ registerExecution(srcFileName)
+ }
+ }
}
@Serializable
@@ -107,6 +183,39 @@ sealed class Action {
withStyle(dullStyle) { append("\nif unmodified for ") }
append((retentionTimeInMillis()).toShortHumanReadableTime())
}
+
+ override suspend fun execute(
+ context: Context,
+ registerExecution: suspend (String) -> Unit,
+ ) {
+ val srcFiles = File.fromPath(context, src)
+ ?.listChildren(scanSubdirectories)
+ ?.filter {
+ it.isFile
+ && it.name != null
+ && Regex(srcFileNamePattern).matches(it.name!!)
+ }
+ ?.filter {
+ System.currentTimeMillis() - it.lastModified() >=
+ // INFO: While debugging, treat days as seconds
+ if (context.isDebugBuild()) retentionDays * 1000L
+ else retentionTimeInMillis()
+ }
+ ?: return
+
+ for (srcFile in srcFiles) {
+ val srcFileName = srcFile.name ?: continue
+ Logger.i("Action", "Deleting $srcFileName")
+
+ val result = srcFile.delete()
+ if (!result) {
+ Logger.e("Action", "Failed to delete $srcFileName")
+ continue
+ }
+
+ registerExecution(srcFileName)
+ }
+ }
}
companion object {
diff --git a/app/src/main/java/co/adityarajput/fileflow/data/models/Rule.kt b/app/src/main/java/co/adityarajput/fileflow/data/models/Rule.kt
index db0655b..10219b1 100644
--- a/app/src/main/java/co/adityarajput/fileflow/data/models/Rule.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/data/models/Rule.kt
@@ -1,7 +1,15 @@
package co.adityarajput.fileflow.data.models
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.withStyle
+import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
+import co.adityarajput.fileflow.Constants
+import co.adityarajput.fileflow.utils.toAccurateHumanReadableTime
+import co.adityarajput.fileflow.views.dullStyle
import kotlinx.serialization.Serializable
@Serializable
@@ -13,6 +21,26 @@ data class Rule(
val executions: Int = 0,
+ @ColumnInfo(defaultValue = "3600000")
+ val interval: Long? = Constants.ONE_HOUR_IN_MILLIS,
+
+ @ColumnInfo(defaultValue = "NULL")
+ val cronString: String? = null,
+
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
-)
+) {
+ @Composable
+ fun getDescription(): AnnotatedString {
+ return action.getDescription() + buildAnnotatedString {
+ if (interval != null) {
+ withStyle(dullStyle) { append("\nevery ") }
+ append(interval.toAccurateHumanReadableTime())
+ }
+ if (cronString != null) {
+ withStyle(dullStyle) { append("\nwhen ") }
+ append(cronString)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/co/adityarajput/fileflow/services/FlowExecutor.kt b/app/src/main/java/co/adityarajput/fileflow/services/FlowExecutor.kt
deleted file mode 100644
index 2f26669..0000000
--- a/app/src/main/java/co/adityarajput/fileflow/services/FlowExecutor.kt
+++ /dev/null
@@ -1,138 +0,0 @@
-package co.adityarajput.fileflow.services
-
-import android.content.Context
-import co.adityarajput.fileflow.data.AppContainer
-import co.adityarajput.fileflow.data.models.Action
-import co.adityarajput.fileflow.data.models.Execution
-import co.adityarajput.fileflow.data.models.Rule
-import co.adityarajput.fileflow.utils.*
-import kotlinx.coroutines.flow.first
-
-class FlowExecutor(private val context: Context) {
- private val repository by lazy { AppContainer(context).repository }
-
- suspend fun run(rules: List? = null) {
- for (rule in rules ?: repository.rules().first()) {
- Logger.d("FlowExecutor", "Executing $rule")
-
- if (!rule.enabled) continue
-
- val regex = Regex(rule.action.srcFileNamePattern)
-
- when (rule.action) {
- is Action.MOVE -> {
- val destDir = File.fromPath(context, rule.action.dest)
-
- if (destDir == null) {
- Logger.e("FlowExecutor", "${rule.action.dest} is invalid")
- continue
- }
-
- 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)
- listOf(it.maxByOrNull(rule.action.superlative.selector) ?: continue)
- else
- it
- } ?: continue
-
- for (srcFile in srcFiles) {
- 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 = rule.action.getDestFileName(srcFile)
- 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")
- continue
- }
-
- if (srcFile.isIdenticalTo(destFile, context)) {
- Logger.i(
- "FlowExecutor",
- "Source and destination files are identical",
- )
- continue
- }
-
-
- Logger.i("FlowExecutor", "Deleting existing ${destFile.name}")
- destFile.delete()
- }
-
- destFile = destSubDir.createFile(srcFile.type, destFileName)
-
- if (destFile == null) {
- Logger.e("FlowExecutor", "Failed to create $destFileName")
- continue
- }
-
- val result = context.copyFile(srcFile, destFile)
- if (!result) {
- Logger.e(
- "FlowExecutor",
- "Failed to copy ${srcFile.name} to ${destFile.name}",
- )
- destFile.delete()
- continue
- }
-
- repository.registerExecution(
- rule,
- Execution(srcFile.name!!, rule.action.verb),
- )
-
- if (!rule.action.keepOriginal) {
- Logger.i("FlowExecutor", "Deleting original ${srcFile.name}")
- srcFile.delete()
- }
- }
- }
-
- is Action.DELETE_STALE -> {
- 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() >=
- // INFO: While debugging, treat days as seconds
- if (context.isDebugBuild()) rule.action.retentionDays * 1000L
- else rule.action.retentionTimeInMillis()
- }
- ?: continue
-
- for (srcFile in srcFiles) {
- val srcFileName = srcFile.name ?: continue
- Logger.i("FlowExecutor", "Deleting $srcFileName")
-
- val result = srcFile.delete()
- if (!result) {
- Logger.e("FlowExecutor", "Failed to delete $srcFileName")
- continue
- }
-
- repository.registerExecution(
- rule,
- Execution(srcFileName, rule.action.verb),
- )
- }
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/co/adityarajput/fileflow/services/Worker.kt b/app/src/main/java/co/adityarajput/fileflow/services/Worker.kt
index 2d2cd3f..3d42a3e 100644
--- a/app/src/main/java/co/adityarajput/fileflow/services/Worker.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/services/Worker.kt
@@ -3,11 +3,31 @@ package co.adityarajput.fileflow.services
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
+import co.adityarajput.fileflow.data.AppContainer
+import co.adityarajput.fileflow.data.models.Execution
+import co.adityarajput.fileflow.utils.Logger
+import co.adityarajput.fileflow.utils.scheduleAlarmsFor
class Worker(private val context: Context, params: WorkerParameters) :
CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
- FlowExecutor(context).run()
+ val repository = AppContainer(context).repository
+ val rule = repository.rule(inputData.getInt("ruleId", -1))
+
+ if (rule != null && rule.enabled) {
+ if (rule.cronString != null) {
+ context.scheduleAlarmsFor(rule)
+ } else if (rule.interval != null) {
+ Logger.d("Worker", "Executing $rule")
+ rule.action.execute(context) {
+ repository.registerExecution(
+ rule,
+ Execution(it, rule.action.verb),
+ )
+ }
+ }
+ }
+
return Result.success()
}
}
diff --git a/app/src/main/java/co/adityarajput/fileflow/utils/Background.kt b/app/src/main/java/co/adityarajput/fileflow/utils/Background.kt
new file mode 100644
index 0000000..49ec173
--- /dev/null
+++ b/app/src/main/java/co/adityarajput/fileflow/utils/Background.kt
@@ -0,0 +1,81 @@
+package co.adityarajput.fileflow.utils
+
+import android.annotation.SuppressLint
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Context.ALARM_SERVICE
+import android.content.Intent
+import android.os.SystemClock
+import androidx.core.net.toUri
+import androidx.work.*
+import co.adityarajput.fileflow.AlarmReceiver
+import co.adityarajput.fileflow.Constants
+import co.adityarajput.fileflow.data.AppContainer
+import co.adityarajput.fileflow.data.models.Rule
+import co.adityarajput.fileflow.services.Worker
+import kotlinx.coroutines.flow.first
+import java.time.Duration
+import java.time.ZonedDateTime
+import java.util.concurrent.TimeUnit
+
+suspend fun Context.scheduleWork() {
+ // INFO: Delete work scheduled by the old scheduling system
+ WorkManager.getInstance(this).cancelUniqueWork(Constants.WORKER_NAME)
+
+ AppContainer(this).repository.rules().first()
+ .forEach {
+ if (!it.enabled || (it.interval == null && it.cronString == null))
+ return@forEach
+
+ Logger.d("Background", "Scheduling work for $it")
+
+ WorkManager.getInstance(this).enqueueUniquePeriodicWork(
+ "${Constants.WORKER_NAME}_${it.id}",
+ ExistingPeriodicWorkPolicy.UPDATE,
+ PeriodicWorkRequestBuilder(
+ // INFO: For cron-like schedules, set/update alarms for upcoming executions every hour
+ it.interval ?: Constants.ONE_HOUR_IN_MILLIS,
+ TimeUnit.MILLISECONDS,
+ PeriodicWorkRequest.MIN_PERIODIC_FLEX_MILLIS,
+ TimeUnit.MILLISECONDS,
+ ).setInputData(workDataOf("ruleId" to it.id)).build(),
+ )
+ }
+}
+
+fun Context.deleteWorkFor(rule: Rule) {
+ Logger.d("Background", "Deleting work for $rule")
+
+ WorkManager.getInstance(this)
+ .cancelUniqueWork("${Constants.WORKER_NAME}_${rule.id}")
+}
+
+@SuppressLint("MissingPermission")
+fun Context.scheduleAlarmsFor(rule: Rule) {
+ val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager
+
+ Logger.d("Background", "Scheduling alarms for $rule")
+ rule.cronString
+ ?.getExecutionTimes(Constants.MAX_CRON_EXECUTIONS_PER_HOUR)
+ ?.forEach { executionTime ->
+ val delay = Duration.between(ZonedDateTime.now(), executionTime).toMillis()
+ if (delay < 1000 || delay > 5 * (Constants.ONE_HOUR_IN_MILLIS)) return@forEach
+
+ Logger.d("Background", "Setting exact alarm in ${delay}ms")
+ alarmManager.setExactAndAllowWhileIdle(
+ AlarmManager.ELAPSED_REALTIME,
+ SystemClock.elapsedRealtime() + delay,
+ PendingIntent.getBroadcast(
+ this, (rule.id to executionTime.toEpochSecond()).hashCode(),
+ Intent(this, AlarmReceiver::class.java).apply {
+ data =
+ "fileflow://execute/${rule.id}/${executionTime.toEpochSecond()}".toUri()
+ action = Constants.ACTION_EXECUTE_RULE
+ putExtra(Constants.EXTRA_RULE_ID, rule.id)
+ },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ ),
+ )
+ }
+}
diff --git a/app/src/main/java/co/adityarajput/fileflow/utils/Files.kt b/app/src/main/java/co/adityarajput/fileflow/utils/Files.kt
index d5a2ab1..2eb7cbb 100644
--- a/app/src/main/java/co/adityarajput/fileflow/utils/Files.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/utils/Files.kt
@@ -7,7 +7,12 @@ import android.os.storage.StorageManager
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import co.adityarajput.fileflow.data.models.Rule
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
import java.net.URLDecoder
+import java.nio.file.FileAlreadyExistsException
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
import java.io.File as IOFile
sealed class File {
@@ -31,9 +36,91 @@ sealed class File {
}
}
- class SAFFile(val documentFile: DocumentFile) : File()
+ class SAFFile(val documentFile: DocumentFile) : File() {
+ override suspend fun moveTo(
+ destDir: File,
+ destFileName: String,
+ keepOriginal: Boolean,
+ overwriteExisting: Boolean,
+ context: Context,
+ ) {
+ if (destDir !is SAFFile)
+ throw IllegalArgumentException("Destination directory must be a SAFFile")
+
+ destDir.documentFile.findFile(destFileName)?.run {
+ if (overwriteExisting) delete()
+ else throw Exception("$destFileName already exists")
+ }
+
+ val destFile =
+ destDir.documentFile.createFile(type ?: "application/octet-stream", destFileName)!!
+
+ val resolver = context.contentResolver
+ resolver.openInputStream(this.documentFile.uri).use { srcStream ->
+ resolver.openOutputStream(destFile.uri).use { destStream ->
+ srcStream!!.copyTo(destStream!!)
+ }
+ }
+
+ if (!keepOriginal) delete()
+ }
+ }
+
+ class FSFile(val ioFile: IOFile) : File() {
+ override suspend fun moveTo(
+ destDir: File,
+ destFileName: String,
+ keepOriginal: Boolean,
+ overwriteExisting: Boolean,
+ context: Context,
+ ) {
+ if (destDir !is FSFile)
+ throw IllegalArgumentException("Destination directory must be a FSFile")
+
+ val options = mutableListOf()
+ if (keepOriginal) options.add(StandardCopyOption.COPY_ATTRIBUTES)
+ if (overwriteExisting) options.add(StandardCopyOption.REPLACE_EXISTING)
- class FSFile(val ioFile: IOFile) : File()
+ try {
+ withContext(Dispatchers.IO) {
+ if (keepOriginal) {
+ Files.copy(
+ this@FSFile.ioFile.toPath(),
+ destDir.ioFile.toPath().resolve(destFileName),
+ *options.toTypedArray(),
+ )
+ } else {
+ Files.move(
+ this@FSFile.ioFile.toPath(),
+ destDir.ioFile.toPath().resolve(destFileName),
+ *options.toTypedArray(),
+ )
+ }
+ }
+ } catch (e: Exception) {
+ if (e is FileAlreadyExistsException) throw e
+
+ Logger.w(
+ "Files",
+ "Failed to move file, falling back to create + copy [+ delete].",
+ e,
+ )
+
+ val destFile = IOFile(destDir.ioFile, destFileName)
+ withContext(Dispatchers.IO) {
+ destFile.createNewFile()
+
+ this@FSFile.ioFile.inputStream().use { srcStream ->
+ destFile.outputStream().use { destStream ->
+ srcStream.copyTo(destStream)
+ }
+ }
+ }
+
+ if (!keepOriginal) delete()
+ }
+ }
+ }
val name
get() = when (this) {
@@ -122,17 +209,6 @@ sealed class File {
return false
}
- fun createFile(type: String?, name: String): File? {
- return when (this) {
- is SAFFile -> documentFile
- .createFile(type ?: "application/octet-stream", 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 -> {
@@ -160,40 +236,20 @@ sealed class File {
}
}
+ abstract suspend fun moveTo(
+ destDir: File,
+ destFileName: String,
+ keepOriginal: Boolean,
+ overwriteExisting: Boolean,
+ context: Context,
+ )
+
fun delete() = when (this) {
is SAFFile -> documentFile.delete()
is FSFile -> ioFile.delete()
}
}
-fun Context.copyFile(src: File, dest: File): Boolean {
- val resolver = contentResolver
-
- if (src is File.SAFFile && dest is File.SAFFile) {
- resolver.openInputStream(src.documentFile.uri).use { srcStream ->
- resolver.openOutputStream(dest.documentFile.uri).use { destStream ->
- if (srcStream == null || destStream == null) {
- Logger.e("Files", "Failed to open file(s)")
- return false
- }
- Logger.i("Files", "Copying ${src.name} to ${dest.name}")
- srcStream.copyTo(destStream)
- return true
- }
- }
- } else if (src is File.FSFile && dest is File.FSFile) {
- src.ioFile.inputStream().use { srcStream ->
- dest.ioFile.outputStream().use { destStream ->
- Logger.i("Files", "Copying ${src.name} to ${dest.name}")
- srcStream.copyTo(destStream)
- return true
- }
- }
- }
-
- return false
-}
-
fun String.getGetDirectoryFromUri() =
if (isBlank()) {
this
diff --git a/app/src/main/java/co/adityarajput/fileflow/utils/Logging.kt b/app/src/main/java/co/adityarajput/fileflow/utils/Logging.kt
index 89d07ac..fba7219 100644
--- a/app/src/main/java/co/adityarajput/fileflow/utils/Logging.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/utils/Logging.kt
@@ -20,6 +20,13 @@ object Logger {
logs.addLast("[${System.currentTimeMillis()}][$tag][INFO] $msg")
}
+ fun w(tag: String, msg: String, tr: Throwable? = null) {
+ Log.w(tag, msg, tr)
+
+ if (logs.size >= Constants.LOG_SIZE) logs.removeFirst()
+ logs.addLast("[${System.currentTimeMillis()}][$tag][WARN] $msg")
+ }
+
fun e(tag: String, msg: String, tr: Throwable? = null) {
Log.e(tag, msg, tr)
diff --git a/app/src/main/java/co/adityarajput/fileflow/utils/String.kt b/app/src/main/java/co/adityarajput/fileflow/utils/String.kt
index a9a6c6b..92367a0 100644
--- a/app/src/main/java/co/adityarajput/fileflow/utils/String.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/utils/String.kt
@@ -22,6 +22,23 @@ fun Long.toShortHumanReadableTime(): String {
}
}
+@Composable
+fun Long.toAccurateHumanReadableTime(): String {
+ val minutes = this / 60_000f
+ val hours = minutes / 60
+ val days = hours / 24
+
+ return when {
+ days > 0 && days.toInt().toFloat() == days
+ -> pluralStringResource(R.plurals.day, days.toInt(), days.toInt())
+
+ hours > 0 && hours.toInt().toFloat() == hours
+ -> pluralStringResource(R.plurals.hour, hours.toInt(), hours.toInt())
+
+ else -> pluralStringResource(R.plurals.minute, minutes.toInt(), minutes.toInt())
+ }
+}
+
@Composable
fun Boolean.getToggleString(): String =
stringResource(if (this) R.string.disable else R.string.enable)
diff --git a/app/src/main/java/co/adityarajput/fileflow/utils/Time.kt b/app/src/main/java/co/adityarajput/fileflow/utils/Time.kt
new file mode 100644
index 0000000..25d896b
--- /dev/null
+++ b/app/src/main/java/co/adityarajput/fileflow/utils/Time.kt
@@ -0,0 +1,52 @@
+package co.adityarajput.fileflow.utils
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.pluralStringResource
+import co.adityarajput.fileflow.R
+import com.cronutils.model.CronType
+import com.cronutils.model.definition.CronDefinitionBuilder
+import com.cronutils.model.time.ExecutionTime
+import com.cronutils.parser.CronParser
+import java.time.ZonedDateTime
+import java.util.concurrent.TimeUnit
+
+val TimeUnit.inMillis: Long
+ get() = when (this) {
+ TimeUnit.MINUTES -> 60_000
+ TimeUnit.HOURS -> 3_600_000
+ TimeUnit.DAYS -> 86_400_000
+ else -> throw IllegalArgumentException("Unsupported TimeUnit: $this")
+ }
+
+@Composable
+fun TimeUnit.text(value: Int) =
+ pluralStringResource(
+ when (this) {
+ TimeUnit.MINUTES -> R.plurals.minute
+ TimeUnit.HOURS -> R.plurals.hour
+ TimeUnit.DAYS -> R.plurals.day
+ else -> throw IllegalArgumentException("Unsupported TimeUnit: $this")
+ },
+ value, 0,
+ ).substringAfter(' ')
+
+val ZonedDateTime.isToday get() = toLocalDate() == ZonedDateTime.now().toLocalDate()
+
+private val cronParser = CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX))
+
+fun String.getExecutionTimes(count: Int = 1): List? {
+ try {
+ val schedule = cronParser.parse(this)
+ val executionTime = ExecutionTime.forCron(schedule)
+ var next = executionTime.nextExecution(ZonedDateTime.now()).get()
+ val executions = mutableListOf()
+ repeat(count) {
+ executions.add(next)
+ next = executionTime.nextExecution(next).get()
+ }
+ return executions
+ } catch (_: IllegalArgumentException) {
+ Logger.d("Time", "Failed to parse $this")
+ return null
+ }
+}
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 e86bfe3..965ceb6 100644
--- a/app/src/main/java/co/adityarajput/fileflow/viewmodels/RulesViewModel.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/viewmodels/RulesViewModel.kt
@@ -7,9 +7,11 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.adityarajput.fileflow.data.Repository
+import co.adityarajput.fileflow.data.models.Execution
import co.adityarajput.fileflow.data.models.Rule
-import co.adityarajput.fileflow.services.FlowExecutor
import co.adityarajput.fileflow.utils.Logger
+import co.adityarajput.fileflow.utils.deleteWorkFor
+import co.adityarajput.fileflow.utils.scheduleWork
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
@@ -31,7 +33,12 @@ class RulesViewModel(private val repository: Repository) : ViewModel() {
viewModelScope.launch {
val latestLogBeforeExecution = Logger.logs.lastOrNull()
- FlowExecutor(context).run(listOf(selectedRule!!))
+ selectedRule!!.action.execute(context) {
+ repository.registerExecution(
+ selectedRule!!,
+ Execution(it, selectedRule!!.action.verb),
+ )
+ }
val recentErrorLog = Logger.logs
.dropWhile { it != latestLogBeforeExecution }.drop(1)
@@ -42,16 +49,22 @@ class RulesViewModel(private val repository: Repository) : ViewModel() {
}
}
- fun toggleRule() {
+ fun toggleRule(context: Context) {
viewModelScope.launch {
Logger.d("RulesViewModel", "Toggling enabled state of $selectedRule")
+ if (selectedRule!!.enabled) {
+ context.deleteWorkFor(selectedRule!!)
+ } else {
+ context.scheduleWork()
+ }
repository.toggle(selectedRule!!)
}
}
- fun deleteRule() {
+ fun deleteRule(context: Context) {
viewModelScope.launch {
Logger.d("RulesViewModel", "Deleting $selectedRule")
+ context.deleteWorkFor(selectedRule!!)
repository.delete(selectedRule!!)
}
}
diff --git a/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt b/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt
index 2e232df..e508db2 100644
--- a/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt
@@ -5,21 +5,24 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
+import androidx.work.PeriodicWorkRequest
+import co.adityarajput.fileflow.Constants
import co.adityarajput.fileflow.data.Repository
import co.adityarajput.fileflow.data.models.Action
import co.adityarajput.fileflow.data.models.Rule
-import co.adityarajput.fileflow.utils.File
-import co.adityarajput.fileflow.utils.FileSuperlative
-import co.adityarajput.fileflow.utils.Logger
+import co.adityarajput.fileflow.utils.*
import co.adityarajput.fileflow.views.components.FolderPickerState
+import java.time.ZonedDateTime
+import java.util.concurrent.TimeUnit
class UpsertRuleViewModel(
rule: Rule?,
private val repository: Repository,
) : ViewModel() {
data class State(
+ val page: FormPage = FormPage.ACTION,
val values: Values = Values(),
- val error: FormError? = null,
+ val error: FormError? = FormError.from(values),
val warning: FormWarning? = null,
)
@@ -38,6 +41,9 @@ class UpsertRuleViewModel(
val currentSrcFileNames: List? = null,
val predictedDestFileNames: List? = null,
val retentionDays: Int = 30,
+ val interval: Long? = Constants.ONE_HOUR_IN_MILLIS,
+ val cronString: String? = null,
+ val predictedExecutionTimes: List? = null,
) {
companion object {
fun from(rule: Rule) = when (rule.action) {
@@ -48,6 +54,8 @@ class UpsertRuleViewModel(
rule.action.destFileNameTemplate, rule.action.superlative,
rule.action.keepOriginal, rule.action.overwriteExisting,
rule.action.scanSubdirectories, rule.action.preserveStructure,
+ interval = rule.interval,
+ cronString = rule.cronString,
)
is Action.DELETE_STALE ->
@@ -56,6 +64,8 @@ class UpsertRuleViewModel(
rule.action.srcFileNamePattern,
scanSubdirectories = rule.action.scanSubdirectories,
retentionDays = rule.action.retentionDays,
+ interval = rule.interval,
+ cronString = rule.cronString,
)
}
}
@@ -68,12 +78,16 @@ class UpsertRuleViewModel(
scanSubdirectories, keepOriginal, overwriteExisting, superlative,
preserveStructure,
),
+ interval = interval,
+ cronString = cronString,
id = ruleId,
)
is Action.DELETE_STALE ->
Rule(
Action.DELETE_STALE(src, srcFileNamePattern, retentionDays, scanSubdirectories),
+ interval = interval,
+ cronString = cronString,
id = ruleId,
)
}
@@ -81,12 +95,12 @@ class UpsertRuleViewModel(
var state by mutableStateOf(
if (rule == null) State()
- else State(Values.from(rule), null),
+ else State(values = Values.from(rule)),
)
var folderPickerState by mutableStateOf(null)
- fun updateForm(context: Context, values: Values) {
+ fun updateForm(context: Context, values: Values = state.values, page: FormPage = state.page) {
var currentSrcFiles: List? = null
try {
if (values.src.isNotBlank())
@@ -117,42 +131,79 @@ class UpsertRuleViewModel(
val values = values.copy(
currentSrcFileNames = currentSrcFiles.orEmpty().mapNotNull { it.name }.distinct(),
predictedDestFileNames = predictedDestFileNames,
+ predictedExecutionTimes = values.cronString?.getExecutionTimes(4),
)
- state = State(values, getError(values), warning)
+ state = State(page, values, warning = warning)
}
- private fun getError(values: Values): FormError? {
- try {
- if (values.src.isBlank() || values.srcFileNamePattern.isBlank())
- return FormError.BLANK_FIELDS
-
- if (Regex(values.srcFileNamePattern).pattern != values.srcFileNamePattern)
- return FormError.INVALID_REGEX
-
- if (values.actionBase is Action.MOVE) {
- if (values.dest.isBlank() || values.destFileNameTemplate.isBlank())
- return FormError.BLANK_FIELDS
- if (values.predictedDestFileNames == null)
- return FormError.INVALID_TEMPLATE
- }
- } catch (_: Exception) {
- Logger.d("UpsertRuleViewModel", "Invalid regex")
- return FormError.INVALID_REGEX
- }
- return null
- }
-
- suspend fun submitForm() {
- if (getError(state.values) == null) {
+ suspend fun submitForm(context: Context) {
+ if (FormError.from(state.values) == null) {
val rule = state.values.toRule()
Logger.d(
"UpsertRuleViewModel",
"${if (state.values.ruleId == 0) "Adding" else "Updating"} $rule",
)
repository.upsert(rule)
+ context.scheduleWork()
+ }
+ }
+}
+
+enum class FormPage {
+ ACTION, SCHEDULE;
+
+ fun isFirstPage() = this == ACTION
+
+ fun isFinalPage() = this == SCHEDULE
+
+ fun next() = entries[ordinal + 1]
+
+ fun previous() = entries[ordinal - 1]
+}
+
+enum class FormError {
+ BLANK_FIELDS, INVALID_REGEX, INVALID_TEMPLATE,
+ INTERVAL_TOO_SHORT, INTERVAL_TOO_LONG, INVALID_CRON_STRING, CRON_TOO_FREQUENT;
+
+ companion object {
+ fun from(values: UpsertRuleViewModel.Values): FormError? {
+ try {
+ if (values.src.isBlank() || values.srcFileNamePattern.isBlank())
+ return BLANK_FIELDS
+
+ if (Regex(values.srcFileNamePattern).pattern != values.srcFileNamePattern)
+ return INVALID_REGEX
+
+ if (values.actionBase is Action.MOVE) {
+ if (values.dest.isBlank() || values.destFileNameTemplate.isBlank())
+ return BLANK_FIELDS
+ if (values.predictedDestFileNames == null)
+ return INVALID_TEMPLATE
+ }
+ } catch (_: Exception) {
+ Logger.d("UpsertRuleViewModel", "Invalid regex")
+ return INVALID_REGEX
+ }
+
+ if (values.interval != null) {
+ if (values.interval < PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS)
+ return INTERVAL_TOO_SHORT
+ if (values.interval > 7 * TimeUnit.DAYS.inMillis)
+ return INTERVAL_TOO_LONG
+ }
+ if (values.cronString != null) {
+ val executionTimes =
+ values.cronString.getExecutionTimes(Constants.MAX_CRON_EXECUTIONS_PER_HOUR + 1)
+ ?: return INVALID_CRON_STRING
+
+ if (
+ executionTimes.last().toEpochSecond() - executionTimes.first().toEpochSecond()
+ < Constants.ONE_HOUR_IN_MILLIS / 1000
+ ) return CRON_TOO_FREQUENT
+ }
+ return null
}
}
}
-enum class FormError { BLANK_FIELDS, INVALID_REGEX, INVALID_TEMPLATE }
enum class FormWarning { NO_MATCHES_IN_SRC }
diff --git a/app/src/main/java/co/adityarajput/fileflow/views/Theme.kt b/app/src/main/java/co/adityarajput/fileflow/views/Theme.kt
index 455ed09..a06868e 100644
--- a/app/src/main/java/co/adityarajput/fileflow/views/Theme.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/views/Theme.kt
@@ -1,12 +1,10 @@
package co.adityarajput.fileflow.views
import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Typography
-import androidx.compose.material3.darkColorScheme
-import androidx.compose.material3.lightColorScheme
+import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -58,6 +56,15 @@ private val Typography = Typography().run {
)
}
+val dullStyle @Composable get() = SpanStyle(MaterialTheme.colorScheme.onSurfaceVariant)
+
+val textFieldColors
+ @Composable get() = OutlinedTextFieldDefaults.colors(
+ focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
+ unfocusedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
+ disabledContainerColor = MaterialTheme.colorScheme.secondaryContainer,
+ )
+
@Composable
fun Theme(brightness: Brightness = Brightness.SYSTEM, content: @Composable () -> Unit) =
MaterialTheme(
diff --git a/app/src/main/java/co/adityarajput/fileflow/views/components/ImproperRulesetDialog.kt b/app/src/main/java/co/adityarajput/fileflow/views/components/ImproperRulesetDialog.kt
index fee0c6b..ec6a6e5 100644
--- a/app/src/main/java/co/adityarajput/fileflow/views/components/ImproperRulesetDialog.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/views/components/ImproperRulesetDialog.kt
@@ -60,7 +60,7 @@ fun ImproperRulesetDialog(
),
{
Text(
- it.action.getDescription(),
+ it.getDescription(),
style = MaterialTheme.typography.bodySmall,
)
},
diff --git a/app/src/main/java/co/adityarajput/fileflow/views/components/ManageRuleDialog.kt b/app/src/main/java/co/adityarajput/fileflow/views/components/ManageRuleDialog.kt
index 3012c0e..48f93b9 100644
--- a/app/src/main/java/co/adityarajput/fileflow/views/components/ManageRuleDialog.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/views/components/ManageRuleDialog.kt
@@ -58,8 +58,8 @@ fun ManageRuleDialog(viewModel: RulesViewModel) {
TextButton(
{
when (dialogState) {
- DialogState.TOGGLE_RULE -> viewModel.toggleRule()
- DialogState.DELETE -> viewModel.deleteRule()
+ DialogState.TOGGLE_RULE -> viewModel.toggleRule(context)
+ DialogState.DELETE -> viewModel.deleteRule(context)
DialogState.EXECUTE -> viewModel.executeRule(context) {
Toast.makeText(context, it, Toast.LENGTH_LONG).show()
}
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 d787389..7322e06 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
@@ -102,7 +102,7 @@ fun RulesScreen(
),
{
Text(
- it.action.getDescription(),
+ it.getDescription(),
style = MaterialTheme.typography.bodySmall,
)
},
diff --git a/app/src/main/java/co/adityarajput/fileflow/views/screens/UpsertRuleScreen.kt b/app/src/main/java/co/adityarajput/fileflow/views/screens/UpsertRuleScreen.kt
index e58fd03..97c3398 100644
--- a/app/src/main/java/co/adityarajput/fileflow/views/screens/UpsertRuleScreen.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/views/screens/UpsertRuleScreen.kt
@@ -1,7 +1,10 @@
package co.adityarajput.fileflow.views.screens
+import android.os.Handler
+import android.os.Looper
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.*
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
@@ -22,15 +25,17 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextDecoration
import androidx.lifecycle.viewmodel.compose.viewModel
+import co.adityarajput.fileflow.Constants
import co.adityarajput.fileflow.R
import co.adityarajput.fileflow.data.models.Action
import co.adityarajput.fileflow.utils.*
-import co.adityarajput.fileflow.viewmodels.FormError
-import co.adityarajput.fileflow.viewmodels.FormWarning
-import co.adityarajput.fileflow.viewmodels.Provider
-import co.adityarajput.fileflow.viewmodels.UpsertRuleViewModel
+import co.adityarajput.fileflow.viewmodels.*
import co.adityarajput.fileflow.views.components.*
+import co.adityarajput.fileflow.views.textFieldColors
import kotlinx.coroutines.launch
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+import java.util.concurrent.TimeUnit
@Composable
fun UpsertRuleScreen(
@@ -38,6 +43,7 @@ fun UpsertRuleScreen(
goBack: () -> Unit,
viewModel: UpsertRuleViewModel = viewModel(factory = Provider.createURVM(ruleString)),
) {
+ val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
Scaffold(
@@ -56,19 +62,28 @@ fun UpsertRuleScreen(
Modifier.padding(paddingValues),
Arrangement.SpaceBetween,
) {
- Column(
+ AnimatedContent(
+ viewModel.state.page,
Modifier
- .fillMaxWidth()
- .verticalScroll(rememberScrollState())
.weight(1f)
.padding(dimensionResource(R.dimen.padding_small))
.padding(
dimensionResource(R.dimen.padding_large),
dimensionResource(R.dimen.padding_medium),
),
- Arrangement.spacedBy(dimensionResource(R.dimen.padding_medium)),
+ { fadeIn() togetherWith fadeOut() },
) {
- ActionPage(viewModel)
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()),
+ Arrangement.spacedBy(dimensionResource(R.dimen.padding_medium)),
+ ) {
+ when (it) {
+ FormPage.ACTION -> ActionPage(viewModel)
+ FormPage.SCHEDULE -> SchedulePage(viewModel)
+ }
+ }
}
Row(
Modifier
@@ -78,23 +93,40 @@ fun UpsertRuleScreen(
Alignment.Bottom,
) {
TextButton(
- goBack,
+ {
+ if (viewModel.state.page.isFirstPage()) {
+ goBack()
+ } else {
+ viewModel.updateForm(
+ context,
+ page = viewModel.state.page.previous(),
+ )
+ }
+ },
Modifier
.fillMaxWidth(0.5f)
.padding(end = dimensionResource(R.dimen.padding_small)),
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.primary),
) {
Text(
- stringResource(R.string.cancel),
+ if (viewModel.state.page.isFirstPage()) stringResource(R.string.cancel)
+ else stringResource(R.string.back),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Normal,
)
}
TextButton(
{
- coroutineScope.launch {
- viewModel.submitForm()
- goBack()
+ if (viewModel.state.page.isFinalPage()) {
+ coroutineScope.launch {
+ viewModel.submitForm(context)
+ goBack()
+ }
+ } else {
+ viewModel.updateForm(
+ context,
+ page = viewModel.state.page.next(),
+ )
}
},
Modifier
@@ -104,8 +136,10 @@ fun UpsertRuleScreen(
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.secondary),
) {
Text(
- if (viewModel.state.values.ruleId == 0) stringResource(R.string.add)
- else stringResource(R.string.save),
+ if (viewModel.state.page.isFinalPage()) {
+ if (viewModel.state.values.ruleId == 0) stringResource(R.string.add)
+ else stringResource(R.string.save)
+ } else stringResource(R.string.next),
style = MaterialTheme.typography.bodyLarge,
)
}
@@ -219,11 +253,7 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) {
),
)
},
- colors = OutlinedTextFieldDefaults.colors(
- focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
- unfocusedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
- disabledContainerColor = MaterialTheme.colorScheme.secondaryContainer,
- ),
+ colors = textFieldColors,
singleLine = true,
)
when (viewModel.state.values.actionBase) {
@@ -358,11 +388,7 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) {
)
}
},
- colors = OutlinedTextFieldDefaults.colors(
- focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
- unfocusedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
- disabledContainerColor = MaterialTheme.colorScheme.secondaryContainer,
- ),
+ colors = textFieldColors,
singleLine = true,
)
Row(
@@ -400,11 +426,7 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) {
suffix = { Text(stringResource(R.string.days)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
- colors = OutlinedTextFieldDefaults.colors(
- focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
- unfocusedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
- disabledContainerColor = MaterialTheme.colorScheme.secondaryContainer,
- ),
+ colors = textFieldColors,
)
}
}
@@ -425,3 +447,257 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) {
else if (viewModel.state.error == FormError.INVALID_TEMPLATE) ErrorText(R.string.invalid_template)
else if (viewModel.state.warning == FormWarning.NO_MATCHES_IN_SRC) WarningText(R.string.pattern_doesnt_match_src_files)
}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SchedulePage(viewModel: UpsertRuleViewModel) {
+ val context = LocalContext.current
+
+ Text(
+ stringResource(R.string.schedule),
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Normal,
+ )
+ val intervalInputEnabled = viewModel.state.values.interval != null
+ val cronInputEnabled = viewModel.state.values.cronString != null
+ // region never
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .selectable(!(intervalInputEnabled || cronInputEnabled)) {
+ viewModel.updateForm(
+ context,
+ viewModel.state.values.copy(interval = null, cronString = null),
+ )
+ },
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(
+ !(intervalInputEnabled || cronInputEnabled),
+ null,
+ Modifier.padding(horizontal = dimensionResource(R.dimen.padding_small)),
+ )
+ Text(
+ stringResource(R.string.never),
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Normal,
+ )
+ }
+ // endregion
+
+ // region periodic
+ var unit by remember { mutableStateOf(TimeUnit.MINUTES) }
+ var dropdownExpanded by remember { mutableStateOf(false) }
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .selectable(intervalInputEnabled) {
+ viewModel.updateForm(
+ context,
+ viewModel.state.values.copy(
+ interval = Constants.ONE_HOUR_IN_MILLIS,
+ cronString = null,
+ ),
+ )
+ },
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(
+ intervalInputEnabled,
+ null,
+ Modifier.padding(horizontal = dimensionResource(R.dimen.padding_small)),
+ )
+ Text(
+ stringResource(R.string.periodic),
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Normal,
+ )
+ }
+ AnimatedVisibility(intervalInputEnabled) {
+ Column(
+ Modifier.fillMaxWidth(),
+ Arrangement.spacedBy(dimensionResource(R.dimen.padding_medium)),
+ ) {
+ val displayValue =
+ ((viewModel.state.values.interval
+ ?: Constants.ONE_HOUR_IN_MILLIS) / unit.inMillis).toInt()
+ OutlinedTextField(
+ displayValue.toString(),
+ {
+ viewModel.updateForm(
+ context,
+ viewModel.state.values.copy(
+ interval = it.toIntOrNull()?.times(unit.inMillis)
+ ?: Constants.ONE_HOUR_IN_MILLIS,
+ ),
+ )
+ },
+ Modifier.fillMaxWidth(),
+ intervalInputEnabled,
+ label = { Text(stringResource(R.string.interval)) },
+ placeholder = { Text(stringResource(R.string.interval_placeholder)) },
+ trailingIcon = {
+ ExposedDropdownMenuBox(
+ dropdownExpanded,
+ { dropdownExpanded = !dropdownExpanded },
+ ) {
+ Row(
+ Modifier
+ .clickable(intervalInputEnabled) { dropdownExpanded = true },
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ unit.text(displayValue),
+ Modifier.padding(start = dimensionResource(R.dimen.padding_medium)),
+ )
+ IconButton(
+ { dropdownExpanded = true },
+ enabled = intervalInputEnabled,
+ ) {
+ Icon(
+ painterResource(R.drawable.arrow_drop_down),
+ stringResource(R.string.arrow_down),
+ )
+ }
+ }
+ ExposedDropdownMenu(dropdownExpanded, { dropdownExpanded = false }) {
+ listOf(TimeUnit.MINUTES, TimeUnit.HOURS, TimeUnit.DAYS).forEach {
+ DropdownMenuItem(
+ { Text(it.text(displayValue)) },
+ {
+ unit = it
+ dropdownExpanded = false
+ },
+ )
+ }
+ }
+ }
+ },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ singleLine = true,
+ colors = textFieldColors,
+ )
+ Text(
+ stringResource(R.string.interval_disclaimer),
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Normal,
+ )
+ if (viewModel.state.error == FormError.INTERVAL_TOO_SHORT) ErrorText(R.string.interval_too_short)
+ else if (viewModel.state.error == FormError.INTERVAL_TOO_LONG) ErrorText(R.string.interval_too_long)
+ }
+ }
+ // endregion
+
+ // region cron
+ // region ExactAlarmPermission
+ var hasExactAlarmPermission by remember { mutableStateOf(context.isGranted(Permission.UNRESTRICTED_BACKGROUND_USAGE)) }
+ val handler = remember { Handler(Looper.getMainLooper()) }
+ val watcher = object : Runnable {
+ override fun run() {
+ hasExactAlarmPermission = context.isGranted(Permission.UNRESTRICTED_BACKGROUND_USAGE)
+
+ if (!hasExactAlarmPermission)
+ handler.postDelayed(this, 500)
+ }
+ }
+ DisposableEffect(Unit) {
+ handler.post(watcher)
+ onDispose { handler.removeCallbacksAndMessages(null) }
+ }
+ // endregion
+
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .selectable(cronInputEnabled) {
+ viewModel.updateForm(
+ context,
+ viewModel.state.values.copy(
+ interval = null,
+ cronString = "00 * * * *",
+ ),
+ )
+ },
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(
+ cronInputEnabled,
+ null,
+ Modifier.padding(horizontal = dimensionResource(R.dimen.padding_small)),
+ )
+ Text(
+ stringResource(R.string.cron_like),
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Normal,
+ )
+ }
+ AnimatedVisibility(cronInputEnabled) {
+ Column(
+ Modifier.fillMaxWidth(),
+ Arrangement.spacedBy(dimensionResource(R.dimen.padding_medium)),
+ ) {
+ OutlinedTextField(
+ viewModel.state.values.cronString ?: "",
+ {
+ viewModel.updateForm(
+ context,
+ viewModel.state.values.copy(cronString = it),
+ )
+ },
+ Modifier.fillMaxWidth(),
+ cronInputEnabled,
+ label = { Text(stringResource(R.string.cron_string)) },
+ placeholder = { Text(stringResource(R.string.cron_placeholder)) },
+ supportingText = {
+ viewModel.state.values.predictedExecutionTimes?.let {
+ Text(
+ stringResource(
+ R.string.rule_will_execute_at,
+ it.joinToString(", ", limit = 3) { dt ->
+ 'β' + dt.format(
+ if (dt.isToday) DateTimeFormatter.ofLocalizedTime(
+ FormatStyle.SHORT,
+ )
+ else DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT),
+ ) + 'β'
+ },
+ ),
+ )
+ }
+ },
+ colors = textFieldColors,
+ singleLine = true,
+ )
+ Text(
+ AnnotatedString.fromHtml(
+ stringResource(R.string.cron_advice),
+ TextLinkStyles(
+ SpanStyle(
+ MaterialTheme.colorScheme.primary,
+ textDecoration = TextDecoration.Underline,
+ ),
+ ),
+ ),
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Normal,
+ )
+ if (!hasExactAlarmPermission) {
+ WarningText(R.string.exact_alarm_permission_description)
+ Button(
+ { context.request(Permission.UNRESTRICTED_BACKGROUND_USAGE) },
+ Modifier.align(Alignment.CenterHorizontally),
+ colors = ButtonDefaults.buttonColors(contentColor = MaterialTheme.colorScheme.onPrimaryContainer),
+ ) {
+ Text(
+ stringResource(R.string.disable_optimization),
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Normal,
+ )
+ }
+ }
+ if (viewModel.state.error == FormError.INVALID_CRON_STRING) ErrorText(R.string.invalid_cron_string)
+ else if (viewModel.state.error == FormError.CRON_TOO_FREQUENT) ErrorText(R.string.cron_too_frequent)
+ }
+ }
+ // endregion
+}
diff --git a/app/src/main/res/drawable/arrow_drop_down.xml b/app/src/main/res/drawable/arrow_drop_down.xml
new file mode 100644
index 0000000..b36ca1d
--- /dev/null
+++ b/app/src/main/res/drawable/arrow_drop_down.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 249bd3f..ecee441 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,7 +1,7 @@
FileFlow
FileFlow
- 1.3.0
+ 1.4.0
About
@@ -91,8 +91,10 @@
Cancel
+ Back
Add
Save
+ Next
Action:
"Source: "
select folder
@@ -119,6 +121,22 @@
Regex pattern contains errors
Regex template contains errors
"Regex pattern doesn't match any file in the source folder"
+ Schedule:
+ Never
+ Periodic
+ Interval
+ Enter an interval
+ "Interval is approximate: it is up to the OS to decide the exact execution time."
+ Due to OS restrictions, interval cannot be shorter than 15 minutes
+ To ensure proper execution, interval cannot be longer than 7 days
+ Cron-like
+ Cron string
+ Enter a crontab string
+ Rule will execute at %1$s
+ hereβ for example cron strings.]]>
+ FileFlow requires exemption from battery optimization to follow schedules exactly.
+ Cron string contains errors
+ To avoid rate-limiting by OS, rules cannot execute more than 4 times an hour
Copy or move to another folder
Delete if unmodified for a while
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 9047298..67e1242 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,13 +1,13 @@
[versions]
-aboutlibraries = "14.0.0-b02"
-agp = "9.0.1"
-kotlin = "2.3.10"
-coreKtx = "1.17.0"
+aboutlibraries = "14.0.0-b03"
+agp = "9.1.0"
+kotlin = "2.3.20"
+coreKtx = "1.18.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.10.0"
-activityCompose = "1.12.4"
+activityCompose = "1.13.0"
composeBom = "2026.02.00"
appcompat = "1.7.1"
navigationCompose = "2.9.7"
@@ -15,8 +15,9 @@ room = "2.8.4"
composeMaterial = "1.5.6"
kotlinxSerializationJson = "1.10.0"
documentfile = "1.1.0"
-workRuntimeKtx = "2.11.1"
+workRuntimeKtx = "2.11.2"
ksp = "2.3.4"
+cron = "9.2.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -44,6 +45,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntimeKtx" }
aboutlibraries-compose = { group = "com.mikepenz", name = "aboutlibraries-compose-m3", version.ref = "aboutlibraries" }
+cron-utils = { group = "com.cronutils", name = "cron-utils", version.ref = "cron" }
[plugins]
aboutlibraries-android = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutlibraries" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 8a84887..8e61ef1 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionSha256Sum=72f44c9f8ebcb1af43838f45ee5c4aa9c5444898b3468ab3f4af7b6076c5bc3f
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
+distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/metadata/en-US/changelogs/6.txt b/metadata/en-US/changelogs/6.txt
new file mode 100644
index 0000000..1708838
--- /dev/null
+++ b/metadata/en-US/changelogs/6.txt
@@ -0,0 +1,6 @@
+β’ fix: Actually copy/move files instead of (create + copy [+ delete])
+β’ feat: Let user choose execution time and frequency
+
+You can now configure each rule's individual execution schedule. Advanced users can use cron expressions to control execution more precisely.
+
+IMPORTANT: After updating, open the app at least once, to trigger the new scheduling system.
diff --git a/metadata/en-US/full_description.txt b/metadata/en-US/full_description.txt
index 090be01..06ec76e 100644
--- a/metadata/en-US/full_description.txt
+++ b/metadata/en-US/full_description.txt
@@ -1 +1 @@
-FileFlow scans your files periodically and organizes them according to your rules.
Features:
- Rules: Use regex to precisely target files.
- Actions: Choose what to do with the files - copy, move, rename, or delete.
- History: Recent executions are stored (locally).
- Private: Fully offline; your data never leaves your device.
- Lightweight: Runs in the background with minimal battery and memory usage.
+FileFlow scans your files periodically and organizes them according to your rules.
Features:
- Rules: Use regex to precisely target files.
- Actions: Choose what to do with the files - copy, move, rename, or delete.
- Schedule: Choose when and how often rules run.
- History: Recent executions are stored (locally).
- Private: Fully offline; your data never leaves your device.
- Lightweight: Runs in the background with minimal battery and memory usage.
diff --git a/metadata/en-US/images/phoneScreenshots/1.png b/metadata/en-US/images/phoneScreenshots/1.png
index 33feb3e..b711b61 100644
Binary files a/metadata/en-US/images/phoneScreenshots/1.png and b/metadata/en-US/images/phoneScreenshots/1.png differ