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