diff --git a/app/src/main/java/de/dbauer/expensetracker/Constants.kt b/app/src/main/java/de/dbauer/expensetracker/Constants.kt new file mode 100644 index 00000000..7fdbeb30 --- /dev/null +++ b/app/src/main/java/de/dbauer/expensetracker/Constants.kt @@ -0,0 +1,7 @@ +package de.dbauer.expensetracker + +object Constants { + const val DATABASE_NAME = "recurring-expenses" + const val DEFAULT_BACKUP_NAME = "$DATABASE_NAME.rexp" + const val BACKUP_MIME_TYPE = "application/octet-stream" +} diff --git a/app/src/main/java/de/dbauer/expensetracker/Extensions.kt b/app/src/main/java/de/dbauer/expensetracker/Extensions.kt index 5d8ae5c8..c5f6a577 100644 --- a/app/src/main/java/de/dbauer/expensetracker/Extensions.kt +++ b/app/src/main/java/de/dbauer/expensetracker/Extensions.kt @@ -1,6 +1,8 @@ package de.dbauer.expensetracker import java.text.NumberFormat +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream fun Float.toCurrencyString(): String { return NumberFormat.getCurrencyInstance().format(this) @@ -17,3 +19,18 @@ fun String.toFloatIgnoreSeparator(): Float { val converted = replace(",", ".") return converted.toFloat() } + +fun ZipInputStream.forEachEntry(block: (entry: ZipEntry) -> Unit) { + var entry: ZipEntry? + while (run { + entry = nextEntry + entry + } != null + ) { + try { + block(entry as ZipEntry) + } finally { + this.closeEntry() + } + } +} diff --git a/app/src/main/java/de/dbauer/expensetracker/MainActivity.kt b/app/src/main/java/de/dbauer/expensetracker/MainActivity.kt index d29ee5d7..89bdf0ee 100644 --- a/app/src/main/java/de/dbauer/expensetracker/MainActivity.kt +++ b/app/src/main/java/de/dbauer/expensetracker/MainActivity.kt @@ -1,8 +1,13 @@ package de.dbauer.expensetracker +import android.content.Intent +import android.net.Uri import android.os.Bundle +import android.widget.Toast import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -46,13 +51,17 @@ import de.dbauer.expensetracker.ui.RecurringExpenseOverview import de.dbauer.expensetracker.ui.SettingsScreen import de.dbauer.expensetracker.ui.theme.ExpenseTrackerTheme import de.dbauer.expensetracker.viewmodel.MainActivityViewModel +import de.dbauer.expensetracker.viewmodel.SettingsViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf class MainActivity : ComponentActivity() { - private val viewModel: MainActivityViewModel by viewModels { + private val mainActivityViewModel: MainActivityViewModel by viewModels { MainActivityViewModel.create((application as ExpenseTrackerApplication).repository) } + private val settingsViewModel: SettingsViewModel by viewModels { + SettingsViewModel.create(getDatabasePath(Constants.DATABASE_NAME).path) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -61,18 +70,43 @@ class MainActivity : ComponentActivity() { setContent { MainActivityContent( - weeklyExpense = viewModel.weeklyExpense, - monthlyExpense = viewModel.monthlyExpense, - yearlyExpense = viewModel.yearlyExpense, - recurringExpenseData = viewModel.recurringExpenseData, + weeklyExpense = mainActivityViewModel.weeklyExpense, + monthlyExpense = mainActivityViewModel.monthlyExpense, + yearlyExpense = mainActivityViewModel.yearlyExpense, + recurringExpenseData = mainActivityViewModel.recurringExpenseData, onRecurringExpenseAdded = { - viewModel.addRecurringExpense(it) + mainActivityViewModel.addRecurringExpense(it) }, onRecurringExpenseEdited = { - viewModel.editRecurringExpense(it) + mainActivityViewModel.editRecurringExpense(it) }, onRecurringExpenseDeleted = { - viewModel.deleteRecurringExpense(it) + mainActivityViewModel.deleteRecurringExpense(it) + }, + onSelectBackupPath = { + val takeFlags: Int = + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + applicationContext.contentResolver.takePersistableUriPermission(it, takeFlags) + + val backupSuccessful = settingsViewModel.backupDatabase(it, applicationContext) + val toastStringRes = + if (backupSuccessful) { + R.string.settings_backup_created_toast + } else { + R.string.settings_backup_not_created_toast + } + Toast.makeText(this, toastStringRes, Toast.LENGTH_LONG).show() + }, + onSelectImportFile = { + val backupRestored = settingsViewModel.restoreDatabase(it, applicationContext) + val toastStringRes = + if (backupRestored) { + mainActivityViewModel.onDatabaseRestored() + R.string.settings_backup_restored_toast + } else { + R.string.settings_backup_not_restored_toast + } + Toast.makeText(this, toastStringRes, Toast.LENGTH_LONG).show() }, ) } @@ -89,6 +123,8 @@ fun MainActivityContent( onRecurringExpenseAdded: (RecurringExpenseData) -> Unit, onRecurringExpenseEdited: (RecurringExpenseData) -> Unit, onRecurringExpenseDeleted: (RecurringExpenseData) -> Unit, + onSelectBackupPath: (backupPath: Uri) -> Unit, + onSelectImportFile: (importPath: Uri) -> Unit, modifier: Modifier = Modifier, ) { val navController = rememberNavController() @@ -116,6 +152,19 @@ fun MainActivityContent( BottomNavigation.Settings, ) + val backupPathLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(Constants.BACKUP_MIME_TYPE), + ) { + if (it == null) return@rememberLauncherForActivityResult + onSelectBackupPath(it) + } + val importPathLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.OpenDocument()) { + if (it == null) return@rememberLauncherForActivityResult + onSelectImportFile(it) + } + ExpenseTrackerTheme { Surface( modifier = modifier.fillMaxSize(), @@ -207,7 +256,14 @@ fun MainActivityContent( ) } composable(BottomNavigation.Settings.route) { - SettingsScreen() + SettingsScreen( + onBackupClicked = { + backupPathLauncher.launch(Constants.DEFAULT_BACKUP_NAME) + }, + onRestoreClicked = { + importPathLauncher.launch(arrayOf(Constants.BACKUP_MIME_TYPE)) + }, + ) } } if (addRecurringExpenseVisible) { @@ -279,5 +335,7 @@ private fun MainActivityContentPreview() { onRecurringExpenseAdded = {}, onRecurringExpenseEdited = {}, onRecurringExpenseDeleted = {}, + onSelectBackupPath = { }, + onSelectImportFile = { }, ) } diff --git a/app/src/main/java/de/dbauer/expensetracker/model/DatabaseBackupRestore.kt b/app/src/main/java/de/dbauer/expensetracker/model/DatabaseBackupRestore.kt new file mode 100644 index 00000000..5e913062 --- /dev/null +++ b/app/src/main/java/de/dbauer/expensetracker/model/DatabaseBackupRestore.kt @@ -0,0 +1,85 @@ +package de.dbauer.expensetracker.model + +import android.content.Context +import android.net.Uri +import de.dbauer.expensetracker.forEachEntry +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +class DatabaseBackupRestore { + fun exportDatabaseFile( + databasePath: String, + targetUri: Uri, + applicationContext: Context, + ): Boolean { + return try { + writeFilesToZip( + listOf(databasePath, "$databasePath-shm", "$databasePath-wal"), + targetUri, + applicationContext, + ) + true + } catch (e: Exception) { + e.printStackTrace() + false + } + } + + fun importDatabaseFile( + srcZipUri: Uri, + targetPath: String, + applicationContext: Context, + ): Boolean { + return try { + extractZipToDirectory( + srcZipUri, + targetPath, + applicationContext, + ) + true + } catch (e: Exception) { + e.printStackTrace() + false + } + } + + private fun writeFilesToZip( + files: List, + targetUri: Uri, + applicationContext: Context, + ) { + val outputStream = applicationContext.contentResolver.openOutputStream(targetUri) ?: return + ZipOutputStream(outputStream).use { zipOutputStream -> + files.forEach { file -> + val inputFile = File(file) + val fileName = inputFile.name + + zipOutputStream.putNextEntry(ZipEntry(fileName)) + + inputFile.inputStream().use { input -> + input.copyTo(zipOutputStream) + } + } + } + } + + private fun extractZipToDirectory( + srcZipUri: Uri, + targetPath: String, + applicationContext: Context, + ) { + val inputStream = applicationContext.contentResolver.openInputStream(srcZipUri) ?: return + ZipInputStream(inputStream).use { zipInputStream -> + zipInputStream.forEachEntry { entry -> + val fileName = entry.name + if (fileName.contains("..")) return@forEachEntry + val outputFile = File(targetPath, fileName) + outputFile.outputStream().use { output -> + zipInputStream.copyTo(output) + } + } + } + } +} diff --git a/app/src/main/java/de/dbauer/expensetracker/ui/SettingsScreen.kt b/app/src/main/java/de/dbauer/expensetracker/ui/SettingsScreen.kt index 4da6304c..1c8d546a 100644 --- a/app/src/main/java/de/dbauer/expensetracker/ui/SettingsScreen.kt +++ b/app/src/main/java/de/dbauer/expensetracker/ui/SettingsScreen.kt @@ -1,7 +1,6 @@ package de.dbauer.expensetracker.ui import androidx.annotation.StringRes -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -12,37 +11,63 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import de.dbauer.expensetracker.R import de.dbauer.expensetracker.ui.theme.ExpenseTrackerTheme @Composable -fun SettingsScreen(modifier: Modifier = Modifier) { +fun SettingsScreen( + onBackupClicked: () -> Unit, + onRestoreClicked: () -> Unit, + modifier: Modifier = Modifier, +) { Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, modifier = modifier .verticalScroll(rememberScrollState()) .fillMaxSize(), ) { - Text( - text = "More to come soon", - modifier = Modifier.padding(16.dp), + SettingsHeaderElement( + header = R.string.settings_backup, + ) + SettingsClickableElement( + name = R.string.settings_backup_create, + onClick = onBackupClicked, + ) + SettingsClickableElement( + name = R.string.settings_backup_restore, + onClick = onRestoreClicked, ) } } +@Composable +private fun SettingsHeaderElement( + @StringRes header: Int, + modifier: Modifier = Modifier, +) { + Text( + text = stringResource(id = header), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = + modifier + .padding(16.dp) + .fillMaxWidth(), + overflow = TextOverflow.Ellipsis, + ) +} + @Composable private fun SettingsClickableElement( @StringRes name: Int, - onClick: () -> Unit, modifier: Modifier = Modifier, + onClick: () -> Unit, ) { Surface( color = Color.Transparent, @@ -67,7 +92,10 @@ private fun SettingsClickableElement( private fun SettingsScreenPreview() { ExpenseTrackerTheme { Surface(modifier = Modifier.fillMaxSize()) { - SettingsScreen() + SettingsScreen( + onBackupClicked = {}, + onRestoreClicked = {}, + ) } } } diff --git a/app/src/main/java/de/dbauer/expensetracker/viewmodel/MainActivityViewModel.kt b/app/src/main/java/de/dbauer/expensetracker/viewmodel/MainActivityViewModel.kt index 4943e737..f253f865 100644 --- a/app/src/main/java/de/dbauer/expensetracker/viewmodel/MainActivityViewModel.kt +++ b/app/src/main/java/de/dbauer/expensetracker/viewmodel/MainActivityViewModel.kt @@ -16,6 +16,7 @@ import de.dbauer.expensetracker.viewmodel.database.ExpenseRepository import de.dbauer.expensetracker.viewmodel.database.RecurringExpense import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch private enum class RecurrenceDatabase( @@ -47,22 +48,7 @@ class MainActivityViewModel( init { viewModelScope.launch { expenseRepository.allRecurringExpensesByPrice.collect { recurringExpenses -> - _recurringExpenseData.clear() - recurringExpenses.forEach { - _recurringExpenseData.add( - RecurringExpenseData( - id = it.id, - name = it.name!!, - description = it.description!!, - price = it.price!!, - monthlyPrice = it.monthlyPrice(), - everyXRecurrence = it.everyXRecurrence!!, - recurrence = getRecurrenceFromDatabaseInt(it.recurrence!!), - ), - ) - } - _recurringExpenseData.sortByDescending { it.monthlyPrice } - updateExpenseSummary() + onDatabaseUpdated(recurringExpenses) } } } @@ -112,6 +98,32 @@ class MainActivityViewModel( } } + fun onDatabaseRestored() { + viewModelScope.launch { + val recurringExpenses = expenseRepository.allRecurringExpensesByPrice.first() + onDatabaseUpdated(recurringExpenses) + } + } + + private fun onDatabaseUpdated(recurringExpenses: List) { + _recurringExpenseData.clear() + recurringExpenses.forEach { + _recurringExpenseData.add( + RecurringExpenseData( + id = it.id, + name = it.name!!, + description = it.description!!, + price = it.price!!, + monthlyPrice = it.monthlyPrice(), + everyXRecurrence = it.everyXRecurrence!!, + recurrence = getRecurrenceFromDatabaseInt(it.recurrence!!), + ), + ) + } + _recurringExpenseData.sortByDescending { it.monthlyPrice } + updateExpenseSummary() + } + private fun getRecurrenceFromDatabaseInt(recurrenceInt: Int): Recurrence { return when (recurrenceInt) { RecurrenceDatabase.Daily.value -> Recurrence.Daily diff --git a/app/src/main/java/de/dbauer/expensetracker/viewmodel/SettingsViewModel.kt b/app/src/main/java/de/dbauer/expensetracker/viewmodel/SettingsViewModel.kt new file mode 100644 index 00000000..0a436e3c --- /dev/null +++ b/app/src/main/java/de/dbauer/expensetracker/viewmodel/SettingsViewModel.kt @@ -0,0 +1,43 @@ +package de.dbauer.expensetracker.viewmodel + +import android.content.Context +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import de.dbauer.expensetracker.model.DatabaseBackupRestore +import java.io.File + +class SettingsViewModel( + private val databasePath: String, +) : ViewModel() { + private val databaseBackupRestore = DatabaseBackupRestore() + + fun backupDatabase( + targetUri: Uri, + applicationContext: Context, + ): Boolean { + return databaseBackupRestore.exportDatabaseFile(databasePath, targetUri, applicationContext) + } + + fun restoreDatabase( + srcZipUri: Uri, + applicationContext: Context, + ): Boolean { + val targetPath = File(databasePath).parent ?: return false + return databaseBackupRestore.importDatabaseFile(srcZipUri, targetPath, applicationContext) + } + + companion object { + fun create(databasePath: String): ViewModelProvider.Factory { + return viewModelFactory { + initializer { + SettingsViewModel( + databasePath = databasePath, + ) + } + } + } + } +} diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3483a2d7..1e06141b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -31,4 +31,11 @@ J Einstellungen + Sicherung + Sicherung erstellen + Sicherung Wiederherstellen + Backup erstellt + Backup fehlgeschlagen + Backup wiederhergestellt + Backup konnte nicht wiederhergestellt werden \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54e25bd4..bbe54080 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,5 +33,12 @@ Y Settings + Backup + Create backup + Restore backup + Backup successful + Backup failed + Backup restored + Backup could not be restored \ No newline at end of file