Skip to content

Commit

Permalink
Add backup and restore option
Browse files Browse the repository at this point in the history
fixes #25
  • Loading branch information
DennisBauer committed Nov 4, 2023
1 parent 1368b8e commit a72e6e1
Show file tree
Hide file tree
Showing 9 changed files with 299 additions and 35 deletions.
7 changes: 7 additions & 0 deletions app/src/main/java/de/dbauer/expensetracker/Constants.kt
Original file line number Diff line number Diff line change
@@ -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"
}
17 changes: 17 additions & 0 deletions app/src/main/java/de/dbauer/expensetracker/Extensions.kt
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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()
}
}
}
76 changes: 67 additions & 9 deletions app/src/main/java/de/dbauer/expensetracker/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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()
},
)
}
Expand All @@ -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()
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -279,5 +335,7 @@ private fun MainActivityContentPreview() {
onRecurringExpenseAdded = {},
onRecurringExpenseEdited = {},
onRecurringExpenseDeleted = {},
onSelectBackupPath = { },
onSelectImportFile = { },
)
}
Original file line number Diff line number Diff line change
@@ -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<String>,
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

Check failure

Code scanning / CodeQL

Arbitrary file access during archive extraction ("Zip Slip") High

Unsanitized archive entry, which may contain '..', is used in a
file system operation
.
if (fileName.contains("..")) return@forEachEntry
val outputFile = File(targetPath, fileName)
outputFile.outputStream().use { output ->
zipInputStream.copyTo(output)
}
}
}
}
}
48 changes: 38 additions & 10 deletions app/src/main/java/de/dbauer/expensetracker/ui/SettingsScreen.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -67,7 +92,10 @@ private fun SettingsClickableElement(
private fun SettingsScreenPreview() {
ExpenseTrackerTheme {
Surface(modifier = Modifier.fillMaxSize()) {
SettingsScreen()
SettingsScreen(
onBackupClicked = {},
onRestoreClicked = {},
)
}
}
}
Loading

0 comments on commit a72e6e1

Please sign in to comment.