diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2e14d6d..77bf2b5 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 = 10 - versionName = "1.8.0" + versionCode = 11 + versionName = "1.9.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } 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 24d0b8b..4e28c48 100644 --- a/app/src/main/java/co/adityarajput/fileflow/data/AppContainer.kt +++ b/app/src/main/java/co/adityarajput/fileflow/data/AppContainer.kt @@ -2,10 +2,12 @@ package co.adityarajput.fileflow.data import android.content.Context import co.adityarajput.fileflow.data.models.Action +import co.adityarajput.fileflow.data.models.Backup import co.adityarajput.fileflow.data.models.Execution import co.adityarajput.fileflow.data.models.Rule import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json class AppContainer(private val context: Context) { val repository: Repository by lazy { @@ -17,6 +19,26 @@ class AppContainer(private val context: Context) { ) } + suspend fun export() = Json.encodeToString( + Backup( + // INFO: May contain servers but creds can't be decrypted anyway. + repository.rules().first(), + repository.groups().first(), + repository.servers().first().map { + it.copy(encryptedPassword = null, encryptedPrivateKey = null) + }, + ), + ) + + suspend fun import(json: String) { + repository.deleteRulesGroupsAndServers() + Json.decodeFromString(json).let { (rules, groups, servers) -> + repository.upsert(*rules.toTypedArray()) + repository.upsert(*groups.toTypedArray()) + repository.upsert(*servers.toTypedArray()) + } + } + fun seedDemoData() { runBlocking { if (repository.rules().first().isEmpty()) { 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 ded08b9..0421749 100644 --- a/app/src/main/java/co/adityarajput/fileflow/data/Repository.kt +++ b/app/src/main/java/co/adityarajput/fileflow/data/Repository.kt @@ -63,4 +63,10 @@ class Repository( suspend fun delete(group: Group) = groupDao.delete(group) suspend fun delete(server: Server) = serverDao.delete(server) + + suspend fun deleteRulesGroupsAndServers() { + ruleDao.deleteAll() + groupDao.deleteAll() + serverDao.deleteAll() + } } diff --git a/app/src/main/java/co/adityarajput/fileflow/data/daos/GroupDao.kt b/app/src/main/java/co/adityarajput/fileflow/data/daos/GroupDao.kt index 69e4079..8d34b0d 100644 --- a/app/src/main/java/co/adityarajput/fileflow/data/daos/GroupDao.kt +++ b/app/src/main/java/co/adityarajput/fileflow/data/daos/GroupDao.kt @@ -20,4 +20,7 @@ interface GroupDao { @Delete suspend fun delete(group: Group) + + @Query("DELETE FROM `groups`") + suspend fun deleteAll() } diff --git a/app/src/main/java/co/adityarajput/fileflow/data/daos/RuleDao.kt b/app/src/main/java/co/adityarajput/fileflow/data/daos/RuleDao.kt index f5eb82d..da2b146 100644 --- a/app/src/main/java/co/adityarajput/fileflow/data/daos/RuleDao.kt +++ b/app/src/main/java/co/adityarajput/fileflow/data/daos/RuleDao.kt @@ -26,4 +26,7 @@ interface RuleDao { @Delete suspend fun delete(rule: Rule) + + @Query("DELETE FROM rules") + suspend fun deleteAll() } diff --git a/app/src/main/java/co/adityarajput/fileflow/data/daos/ServerDao.kt b/app/src/main/java/co/adityarajput/fileflow/data/daos/ServerDao.kt index e0a4809..ecf7523 100644 --- a/app/src/main/java/co/adityarajput/fileflow/data/daos/ServerDao.kt +++ b/app/src/main/java/co/adityarajput/fileflow/data/daos/ServerDao.kt @@ -17,4 +17,7 @@ interface ServerDao { @Delete suspend fun delete(group: Server) + + @Query("DELETE FROM servers") + suspend fun deleteAll() } diff --git a/app/src/main/java/co/adityarajput/fileflow/data/models/Backup.kt b/app/src/main/java/co/adityarajput/fileflow/data/models/Backup.kt new file mode 100644 index 0000000..c51582d --- /dev/null +++ b/app/src/main/java/co/adityarajput/fileflow/data/models/Backup.kt @@ -0,0 +1,10 @@ +package co.adityarajput.fileflow.data.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Backup( + val rules: List, + val groups: List, + val servers: List, +) 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 35bc95d..c950d9d 100644 --- a/app/src/main/java/co/adityarajput/fileflow/utils/Files.kt +++ b/app/src/main/java/co/adityarajput/fileflow/utils/Files.kt @@ -40,8 +40,7 @@ sealed class File { if (it.exists()) FSFile(it) else null }!! } catch (e2: Exception) { - Logger.w("Files", "Error while creating SAFFile from path: $path", e1) - Logger.w("Files", "Error while creating FSFile from path: $path", e2) + Logger.w("Files", "Error while creating File from path: $path", e1, e2) } return null 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 4710704..40939e4 100644 --- a/app/src/main/java/co/adityarajput/fileflow/utils/Logging.kt +++ b/app/src/main/java/co/adityarajput/fileflow/utils/Logging.kt @@ -20,16 +20,17 @@ object Logger { if (logs.size >= Constants.LOG_SIZE) logs.removeFirst() logs.addLast("[${System.currentTimeMillis()}][$tag][INFO] $msg") - ACRA.errorReporter.putCustomData("logs", logs.joinToString("\n")) + ACRA.errorReporter.putCustomData("logs", logs.toList().joinToString("\n")) } - fun w(tag: String, msg: String, tr: Throwable? = null) { + fun w(tag: String, msg: String, tr: Throwable? = null, tr2: Throwable? = null) { Log.w(tag, msg, tr) + Log.w(tag, msg, tr2) if (logs.size >= Constants.LOG_SIZE) logs.removeFirst() logs.addLast("[${System.currentTimeMillis()}][$tag][WARN] $msg") - ACRA.errorReporter.putCustomData("logs", logs.joinToString("\n")) + ACRA.errorReporter.putCustomData("logs", logs.toList().joinToString("\n")) } fun e(tag: String, msg: String, tr: Throwable? = null) { @@ -38,6 +39,6 @@ object Logger { if (logs.size >= Constants.LOG_SIZE) logs.removeFirst() logs.addLast("[${System.currentTimeMillis()}][$tag][ERROR] $msg") - ACRA.errorReporter.putCustomData("logs", logs.joinToString("\n")) + ACRA.errorReporter.putCustomData("logs", logs.toList().joinToString("\n")) } } diff --git a/app/src/main/java/co/adityarajput/fileflow/views/screens/SettingsScreen.kt b/app/src/main/java/co/adityarajput/fileflow/views/screens/SettingsScreen.kt index dca4419..ee6b157 100644 --- a/app/src/main/java/co/adityarajput/fileflow/views/screens/SettingsScreen.kt +++ b/app/src/main/java/co/adityarajput/fileflow/views/screens/SettingsScreen.kt @@ -5,6 +5,8 @@ import android.os.Build import android.os.Handler import android.os.Looper import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -22,6 +24,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import co.adityarajput.fileflow.BuildConfig import co.adityarajput.fileflow.R +import co.adityarajput.fileflow.data.AppContainer import co.adityarajput.fileflow.services.Preferences import co.adityarajput.fileflow.utils.Logger import co.adityarajput.fileflow.utils.Permission @@ -31,6 +34,9 @@ import co.adityarajput.fileflow.viewmodels.AppearanceViewModel import co.adityarajput.fileflow.views.Brightness import co.adityarajput.fileflow.views.components.AppBar import kotlinx.coroutines.launch +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter private val permissions = listOf( Permission.UNRESTRICTED_BACKGROUND_USAGE, @@ -236,6 +242,96 @@ fun SettingsScreen( } } } + Card( + Modifier + .fillMaxWidth() + .padding(dimensionResource(R.dimen.padding_small)), + ) { + Column( + Modifier + .fillMaxWidth() + .padding( + dimensionResource(R.dimen.padding_large), + dimensionResource(R.dimen.padding_medium), + ), + Arrangement.spacedBy(dimensionResource(R.dimen.padding_medium)), + ) { + Text( + stringResource(R.string.settings_section_3), + fontWeight = FontWeight.Medium, + ) + val importSuccess = stringResource(R.string.import_success) + val importLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument(), + ) { uri -> + scope.launch { + uri + ?.let { context.contentResolver.openInputStream(it) } + ?.use { + AppContainer(context).import(it.bufferedReader().readText()) + goBack() + Toast + .makeText(context, importSuccess, Toast.LENGTH_SHORT) + .show() + } + } + } + Column( + Modifier + .fillMaxWidth() + .clickable { importLauncher.launch(arrayOf("application/json")) }, + ) { + Text( + stringResource(R.string.import_backup), + style = MaterialTheme.typography.titleSmall, + ) + Text( + stringResource(R.string.import_warning), + style = MaterialTheme.typography.bodySmall, + ) + } + val exportSuccess = stringResource(R.string.export_success) + val appNameAndVersion = + "${stringResource(R.string.app_name)}_${BuildConfig.VERSION_NAME}" + val exportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json"), + ) { uri -> + scope.launch { + uri + ?.let { context.contentResolver.openOutputStream(it) } + ?.use { + it.write(AppContainer(context).export().toByteArray()) + Toast + .makeText(context, exportSuccess, Toast.LENGTH_SHORT) + .show() + } + } + } + Column( + Modifier + .fillMaxWidth() + .clickable { + exportLauncher.launch( + appNameAndVersion + "_${ + Instant.now().atZone(ZoneId.systemDefault()) + .toLocalDateTime().format( + DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"), + ) + }.json", + ) + }, + ) { + Text( + stringResource(R.string.export_backup), + style = MaterialTheme.typography.titleSmall, + ) + Text( + stringResource(R.string.export_explanation), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } Card( Modifier .fillMaxWidth() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 620fdc0..ca43497 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -121,6 +121,13 @@ Light System Dark + Data Transfer + Import backup + Overwrites your current rules and groups + Data imported successfully + Export backup + Generates a JSON export of your rules and groups + Data exported successfully Manage groups Servers Manage servers diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2dfda16..00f0bf4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] -aboutlibraries = "14.0.1" +aboutlibraries = "14.2.1" agp = "9.1.1" -kotlin = "2.3.20" +kotlin = "2.3.21" coreKtx = "1.18.0" junit = "4.13.2" junitVersion = "1.3.0" @@ -10,9 +10,9 @@ lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" composeBom = "2026.02.00" appcompat = "1.7.1" -navigationCompose = "2.9.7" +navigationCompose = "2.9.8" room = "2.8.4" -composeMaterial = "1.6.1" +composeMaterial = "1.6.2" kotlinxSerializationJson = "1.11.0" documentfile = "1.1.0" workRuntimeKtx = "2.11.2" diff --git a/metadata/en-US/changelogs/11.txt b/metadata/en-US/changelogs/11.txt new file mode 100644 index 0000000..998dcf7 --- /dev/null +++ b/metadata/en-US/changelogs/11.txt @@ -0,0 +1,2 @@ +• fix: Prevent concurrent modification while logging +• feat: Add import/export functionality