From abcf6ba65542f04a8cc06323d59765673aa2671a Mon Sep 17 00:00:00 2001 From: starry-shivam Date: Wed, 7 Aug 2024 13:35:05 +0530 Subject: [PATCH] Add CSV backend & seperate JSON and CSV logic from backup manager Signed-off-by: starry-shivam --- .idea/deploymentTargetSelector.xml | 8 - .idea/other.xml | 252 ++++++++++++++++++ .../starry/greenstash/backup/BackupManager.kt | 126 +++++---- .../greenstash/backup/GoalToCsvConverter.kt | 142 ++++++++++ .../greenstash/backup/GoalToJsonConverter.kt | 48 ++++ .../greenstash/database/goal/GoalDao.kt | 2 +- .../ui/screens/backups/BackupViewModel.kt | 2 +- 7 files changed, 514 insertions(+), 66 deletions(-) create mode 100644 .idea/other.xml create mode 100644 app/src/main/java/com/starry/greenstash/backup/GoalToCsvConverter.kt create mode 100644 app/src/main/java/com/starry/greenstash/backup/GoalToJsonConverter.kt diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 251f6f11..b268ef36 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,14 +4,6 @@ diff --git a/.idea/other.xml b/.idea/other.xml new file mode 100644 index 00000000..4604c446 --- /dev/null +++ b/.idea/other.xml @@ -0,0 +1,252 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/starry/greenstash/backup/BackupManager.kt b/app/src/main/java/com/starry/greenstash/backup/BackupManager.kt index dad8a20f..1b85db9a 100644 --- a/app/src/main/java/com/starry/greenstash/backup/BackupManager.kt +++ b/app/src/main/java/com/starry/greenstash/backup/BackupManager.kt @@ -27,20 +27,17 @@ package com.starry.greenstash.backup import android.content.Context import android.content.Intent -import android.graphics.Bitmap import android.util.Log -import androidx.annotation.Keep import androidx.core.content.FileProvider -import com.google.gson.Gson -import com.google.gson.GsonBuilder import com.starry.greenstash.BuildConfig -import com.starry.greenstash.database.core.GoalWithTransactions import com.starry.greenstash.database.goal.GoalDao import com.starry.greenstash.utils.updateText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File import java.time.LocalDateTime +import java.util.Locale +import java.util.UUID /** * Handles all backup & restore related functionalities. @@ -51,43 +48,23 @@ import java.time.LocalDateTime */ class BackupManager(private val context: Context, private val goalDao: GoalDao) { - /** - * Instance of [Gson] with custom type adaptor applied for serializing - * and deserializing [Bitmap] fields. - */ - private val gsonInstance = GsonBuilder() - .registerTypeAdapter(Bitmap::class.java, BitmapTypeAdapter()) - .setDateFormat(ISO8601_DATE_FORMAT) - .create() - companion object { - /** Backup schema version. */ - const val BACKUP_SCHEMA_VERSION = 1 /** Authority for using file provider API. */ private const val FILE_PROVIDER_AUTHORITY = "${BuildConfig.APPLICATION_ID}.provider" - /** An ISO-8601 date format for Gson */ - private const val ISO8601_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" - /** Backup folder name inside cache directory. */ private const val BACKUP_FOLDER_NAME = "backups" } /** - * Model for backup json data, containing current schema version - * and timestamp when backup was created. - * - * @param version backup schema version. - * @param timestamp timestamp when backup was created. - * @param data list of [GoalWithTransactions] to be backed up. + * Type of backup file. */ - @Keep - data class BackupJsonModel( - val version: Int = BACKUP_SCHEMA_VERSION, - val timestamp: Long, - val data: List - ) + enum class BackupType { JSON, CSV } + + // Converters for different backup types. + private val goalToJsonConverter = GoalToJsonConverter() + private val goalToCsvConverter = GoalToCsvConverter() /** * Logger function with pre-applied tag. @@ -103,51 +80,64 @@ class BackupManager(private val context: Context, private val goalDao: GoalDao) * * @return a chooser [Intent] for newly created backup file. */ - suspend fun createDatabaseBackup(): Intent = withContext(Dispatchers.IO) { - log("Fetching goals from database and serialising into json...") + suspend fun createDatabaseBackup(backupType: BackupType = BackupType.JSON): Intent = withContext(Dispatchers.IO) { + log("Fetching goals from database and serialising into ${backupType.name}...") val goalsWithTransactions = goalDao.getAllGoals() - val jsonString = gsonInstance.toJson( - BackupJsonModel( - timestamp = System.currentTimeMillis(), - data = goalsWithTransactions - ) - ) - - log("Creating backup json file inside cache directory...") - val fileName = "GreenStash-Backup(${System.currentTimeMillis()}).json" + val backupString = when (backupType) { + BackupType.JSON -> goalToJsonConverter.convertToJson(goalsWithTransactions) + BackupType.CSV -> goalToCsvConverter.convertToCSV(goalsWithTransactions) + } + + log("Creating a ${backupType.name} file inside cache directory...") + val fileName = "GreenStash-(${UUID.randomUUID()}).${backupType.name.lowercase(Locale.US)}" val file = File(File(context.cacheDir, BACKUP_FOLDER_NAME).apply { mkdir() }, fileName) - file.updateText(jsonString) + file.updateText(backupString) val uri = FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, file) log("Building and returning chooser intent for backup file.") + val intentType = when (backupType) { + BackupType.JSON -> "application/json" + BackupType.CSV -> "text/csv" + } return@withContext Intent(Intent.ACTION_SEND).apply { - type = "application/json" + type = intentType addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) putExtra(Intent.EXTRA_STREAM, uri) putExtra(Intent.EXTRA_SUBJECT, "Greenstash Backup") putExtra(Intent.EXTRA_TEXT, "Created at ${LocalDateTime.now()}") }.let { intent -> Intent.createChooser(intent, fileName) } - } /** - * Restores a database backup by deserializing the backup json string + * Restores a database backup by deserializing the backup json or csv string * and saving goals and transactions back into the database. * - * @param jsonString a valid backup json as sting. + * @param backupString a valid backup json or csv string. * @param onFailure callback to be called if [BackupManager] failed parse the json string. * @param onSuccess callback to be called after backup was successfully restored. */ suspend fun restoreDatabaseBackup( - jsonString: String, + backupString: String, + backupType: BackupType = BackupType.JSON, onFailure: () -> Unit, - onSuccess: () -> Unit + onSuccess: () -> Unit, ) = withContext(Dispatchers.IO) { + log("Parsing backup file...") + when (backupType) { + BackupType.JSON -> restoreJsonBackup(backupString, onFailure, onSuccess) + BackupType.CSV -> restoreCsvBackup(backupString, onFailure, onSuccess) + } + } - // Parse json string. - log("Parsing backup json file...") - val backupData: BackupJsonModel? = try { - gsonInstance.fromJson(jsonString, BackupJsonModel::class.java) + // Restores json backup by converting json string into [BackupJsonModel] and + // then inserting goals and transactions into the database. + private suspend fun restoreJsonBackup( + backupString: String, + onFailure: () -> Unit, + onSuccess: () -> Unit + ) { + val backupData = try { + goalToJsonConverter.convertFromJson(backupString) } catch (exc: Exception) { log("Failed to parse backup json file! Err: ${exc.message}") exc.printStackTrace() @@ -156,12 +146,36 @@ class BackupManager(private val context: Context, private val goalDao: GoalDao) if (backupData?.data == null) { withContext(Dispatchers.Main) { onFailure() } - return@withContext + return } - // Insert goal & transaction data into database. log("Inserting goals & transactions into the database...") - goalDao.insertGoalWithTransaction(backupData.data) + goalDao.insertGoalWithTransactions(backupData.data) withContext(Dispatchers.Main) { onSuccess() } } + + // Restores csv backup by converting csv string into [GoalWithTransactions] list. + private suspend fun restoreCsvBackup( + backupString: String, + onFailure: () -> Unit, + onSuccess: () -> Unit + ) { + val backupData = try { + goalToCsvConverter.convertFromCSV(backupString) + } catch (exc: Exception) { + log("Failed to parse backup csv file! Err: ${exc.message}") + exc.printStackTrace() + null + } + + if (backupData.isNullOrEmpty()) { + withContext(Dispatchers.Main) { onFailure() } + return + } + + log("Inserting goals & transactions into the database...") + goalDao.insertGoalWithTransactions(backupData) + withContext(Dispatchers.Main) { onSuccess() } + } + } \ No newline at end of file diff --git a/app/src/main/java/com/starry/greenstash/backup/GoalToCsvConverter.kt b/app/src/main/java/com/starry/greenstash/backup/GoalToCsvConverter.kt new file mode 100644 index 00000000..6a9e2a82 --- /dev/null +++ b/app/src/main/java/com/starry/greenstash/backup/GoalToCsvConverter.kt @@ -0,0 +1,142 @@ +package com.starry.greenstash.backup + +import com.starry.greenstash.database.core.GoalWithTransactions +import com.starry.greenstash.database.goal.Goal +import com.starry.greenstash.database.goal.GoalPriority +import com.starry.greenstash.database.transaction.Transaction +import com.starry.greenstash.database.transaction.TransactionType +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class GoalToCsvConverter { + + companion object { + private const val CSV_DELIMITER = "," + private val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd", Locale.US) + } + + fun convertToCSV(goalsWithTransactions: List): String { + val headers = arrayOf( + "Goal Title", "Goal Target Amount", "Goal Deadline", "Goal Priority", "Goal Reminder", + "Goal Icon ID", "Transaction Type", "Transaction Date", "Transaction Amount", "Transaction Notes" + ) + + val stringBuilder = StringBuilder() + + // Write headers + stringBuilder.append(headers.joinToString(CSV_DELIMITER)) + stringBuilder.append("\n") + + // Write data rows for all goals and their transactions + goalsWithTransactions.forEach { goalWithTransactions -> + val rows = goalWithTransactions.toCSVRows() + rows.forEach { row -> + stringBuilder.append(row.joinToString(CSV_DELIMITER)) + stringBuilder.append("\n") + } + } + + return stringBuilder.toString() + } + + fun convertFromCSV(csvData: String): List { + val lines = csvData.trim().split("\n") + if (lines.size < 2) return emptyList() + + val headers = lines[0].split(CSV_DELIMITER) + val records = lines.drop(1).map { it.split(CSV_DELIMITER) } + + val result = mutableListOf() + var currentGoal: Goal? = null + var currentTransactions = mutableListOf() + + records.forEach { record -> + if (record[0].isNotBlank()) { + // This is a new goal + if (currentGoal != null) { + result.add(GoalWithTransactions(currentGoal, currentTransactions)) + currentTransactions = mutableListOf() + } + currentGoal = createGoalFromCSVRecord(record, headers) + } else { + // This is a transaction for the current goal + val transaction = createTransactionFromCSVRecord(record, headers) + currentTransactions.add(transaction) + } + } + + // Add the last goal + if (currentGoal != null) { + result.add(GoalWithTransactions(currentGoal, currentTransactions)) + } + + return result + } + + private fun GoalWithTransactions.toCSVRows(): List> { + val rows = mutableListOf>() + + val goalRow = listOf( + goal.title, + goal.targetAmount.toString(), + goal.deadline, + goal.priority.name, + goal.reminder.toString(), + goal.goalIconId ?: "", + "", "", "", "" + ) + rows.add(goalRow) + + for (transaction in transactions) { + val transactionRow = listOf( + "", "", "", "", "", "", + transaction.type.name, + DATE_FORMAT.format(Date(transaction.timeStamp)), + transaction.amount.toString(), + transaction.notes + ) + rows.add(transactionRow) + } + + return rows + } + + private fun createGoalFromCSVRecord(record: List, headers: List): Goal { + val title = record[headers.indexOf("Goal Title")] + val targetAmount = record[headers.indexOf("Goal Target Amount")].toDoubleOrNull() ?: throw Exception("Invalid target amount") + val deadline = record[headers.indexOf("Goal Deadline")] + val priority = try { + GoalPriority.valueOf(record[headers.indexOf("Goal Priority")]) + } catch (e: IllegalArgumentException) { + throw Exception("Invalid priority value: ${record[headers.indexOf("Goal Priority")]}") + } + val reminder = try { + record[headers.indexOf("Goal Reminder")].toBoolean() + } catch (e: Exception) { + throw Exception("Invalid reminder value: ${record[headers.indexOf("Goal Reminder")]}") + } + val goalIconId = record[headers.indexOf("Goal Icon ID")].takeIf { it.isNotBlank() } + + return Goal(title, targetAmount, deadline, null, "", priority, reminder, goalIconId) + } + + private fun createTransactionFromCSVRecord(record: List, headers: List): Transaction { + val type = try { + TransactionType.valueOf(record[headers.indexOf("Transaction Type")]) + } catch (e: IllegalArgumentException) { + throw Exception("Invalid transaction type: ${record[headers.indexOf("Transaction Type")]}") + } + val dateString = record[headers.indexOf("Transaction Date")] + val date = try { + DATE_FORMAT.parse(dateString)?.time ?: throw Exception("Invalid date format: $dateString") + } catch (e: ParseException) { + throw Exception("Invalid date format: $dateString") + } + val amount = record[headers.indexOf("Transaction Amount")].toDoubleOrNull() ?: throw Exception("Invalid amount") + val notes = record[headers.indexOf("Transaction Notes")] + + return Transaction(0L, type, date, amount, notes) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/starry/greenstash/backup/GoalToJsonConverter.kt b/app/src/main/java/com/starry/greenstash/backup/GoalToJsonConverter.kt new file mode 100644 index 00000000..afd84bdc --- /dev/null +++ b/app/src/main/java/com/starry/greenstash/backup/GoalToJsonConverter.kt @@ -0,0 +1,48 @@ +package com.starry.greenstash.backup + +import android.graphics.Bitmap +import androidx.annotation.Keep +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.starry.greenstash.database.core.GoalWithTransactions + +class GoalToJsonConverter { + + /** + * Instance of [Gson] with custom type adaptor applied for serializing + * and deserializing [Bitmap] fields. + */ + private val gsonInstance = GsonBuilder() + .registerTypeAdapter(Bitmap::class.java, BitmapTypeAdapter()) + .setDateFormat(ISO8601_DATE_FORMAT) + .create() + + companion object { + /** Backup schema version. */ + const val BACKUP_SCHEMA_VERSION = 1 + + /** An ISO-8601 date format for Gson */ + private const val ISO8601_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + } + + /** + * Model for backup json data, containing current schema version + * and timestamp when backup was created. + * + * @param version backup schema version. + * @param timestamp timestamp when backup was created. + * @param data list of [GoalWithTransactions] to be backed up. + */ + @Keep + data class BackupJsonModel( + val version: Int = BACKUP_SCHEMA_VERSION, + val timestamp: Long, + val data: List + ) + + fun convertToJson(goalWithTransactions: List): String = gsonInstance.toJson( + BackupJsonModel(timestamp = System.currentTimeMillis(), data = goalWithTransactions) + ) + + fun convertFromJson(json: String): BackupJsonModel = gsonInstance.fromJson(json, BackupJsonModel::class.java) +} \ No newline at end of file diff --git a/app/src/main/java/com/starry/greenstash/database/goal/GoalDao.kt b/app/src/main/java/com/starry/greenstash/database/goal/GoalDao.kt index 56a570fd..32fe1b0f 100644 --- a/app/src/main/java/com/starry/greenstash/database/goal/GoalDao.kt +++ b/app/src/main/java/com/starry/greenstash/database/goal/GoalDao.kt @@ -53,7 +53,7 @@ interface GoalDao { * @param goalsWithTransactions List of GoalWithTransactions. */ @Transaction - suspend fun insertGoalWithTransaction(goalsWithTransactions: List) { + suspend fun insertGoalWithTransactions(goalsWithTransactions: List) { goalsWithTransactions.forEach { goalWithTransactions -> // Set placeholder id. goalWithTransactions.goal.goalId = 0L diff --git a/app/src/main/java/com/starry/greenstash/ui/screens/backups/BackupViewModel.kt b/app/src/main/java/com/starry/greenstash/ui/screens/backups/BackupViewModel.kt index 39dd899b..31d8f84d 100644 --- a/app/src/main/java/com/starry/greenstash/ui/screens/backups/BackupViewModel.kt +++ b/app/src/main/java/com/starry/greenstash/ui/screens/backups/BackupViewModel.kt @@ -25,7 +25,7 @@ class BackupViewModel @Inject constructor( fun restoreBackup(jsonString: String, onSuccess: () -> Unit, onFailure: () -> Unit) { viewModelScope.launch(Dispatchers.IO) { backupManager.restoreDatabaseBackup( - jsonString = jsonString, + backupString = jsonString, onSuccess = onSuccess, onFailure = onFailure )