From 4e0669501b42000e2146e27e627b96849f642e3d Mon Sep 17 00:00:00 2001 From: starry-shivam Date: Thu, 19 Oct 2023 12:27:32 +0530 Subject: [PATCH] Add backup manager & rework on backup related stuff Signed-off-by: starry-shivam --- .idea/deploymentTargetDropDown.xml | 2 +- app/src/main/AndroidManifest.xml | 10 ++ .../com/starry/greenstash/MainActivity.kt | 2 +- .../starry/greenstash/backup/BackupManager.kt | 119 +++++++++++++++++- .../greenstash/backup/BitmapTypeAdapter.kt | 73 +++++++++++ .../greenstash/database/goal/GoalDao.kt | 22 ++++ .../com/starry/greenstash/di/MianModule.kt | 7 ++ .../ui/screens/backups/BackupScreen.kt | 70 +++++++++-- .../ui/screens/backups/BackupViewModel.kt | 34 +++++ .../com/starry/greenstash/utils/Extensions.kt | 21 +++- app/src/main/res/values-es/strings.xml | 4 + app/src/main/res/values-zh-rCN/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + app/src/main/res/xml/provider_paths.xml | 6 + 14 files changed, 355 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/com/starry/greenstash/backup/BitmapTypeAdapter.kt create mode 100644 app/src/main/java/com/starry/greenstash/ui/screens/backups/BackupViewModel.kt create mode 100644 app/src/main/res/xml/provider_paths.xml diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml index 48edde44..5dd2abd2 100644 --- a/.idea/deploymentTargetDropDown.xml +++ b/.idea/deploymentTargetDropDown.xml @@ -12,6 +12,6 @@ - + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fd4a4273..964e3aeb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -82,6 +82,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/java/com/starry/greenstash/MainActivity.kt b/app/src/main/java/com/starry/greenstash/MainActivity.kt index 185f1afb..f9ec6be8 100644 --- a/app/src/main/java/com/starry/greenstash/MainActivity.kt +++ b/app/src/main/java/com/starry/greenstash/MainActivity.kt @@ -61,10 +61,10 @@ import java.util.concurrent.Executor @ExperimentalCoroutinesApi @ExperimentalMaterialApi @ExperimentalFoundationApi -@AndroidEntryPoint @ExperimentalComposeUiApi @ExperimentalAnimationApi @ExperimentalMaterial3Api +@AndroidEntryPoint class MainActivity : AppCompatActivity() { lateinit var settingsViewModel: SettingsViewModel 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 0a68a45a..9dac3c19 100644 --- a/app/src/main/java/com/starry/greenstash/backup/BackupManager.kt +++ b/app/src/main/java/com/starry/greenstash/backup/BackupManager.kt @@ -1,19 +1,128 @@ package com.starry.greenstash.backup +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.util.Log +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 -class BackupManager(private val goalDao: GoalDao) { +/** + * Handles all backup & restore related functionalities. + * Note: Access this class using DI instead of manually initialising. + */ +class BackupManager(private val context: Context, private val goalDao: GoalDao) { - private val gsonInstance = Gson() - suspend fun createDatabaseBackup() = withContext(Dispatchers.IO) { + /** + * 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" + } + + /** + * Model for backup json data, containing current schema version + * and timestamp when backup was created. + */ + data class BackupJsonModel( + val version: Int = BACKUP_SCHEMA_VERSION, + val timestamp: Long, + val data: List + ) + + /** + * Logger function with pre-applied tag. + */ + private fun log(message: String) { + Log.d("BackupManager", message) + } + + /** + * Creates a database backup by converting goals and transaction data into json + * then saving that json file into cache directory and retuning a chooser intent + * for the backup file. + * + * @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...") 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 (${LocalDateTime.now()}).json" + val file = File(context.cacheDir, fileName) + file.updateText(jsonString) + val uri = FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, file) + + log("Building and returning chooser intent for backup file.") + return@withContext Intent(Intent.ACTION_SEND).apply { + type = "application/json" + 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) } } - suspend fun restoreDatabaseBackup(): Nothing = withContext(Dispatchers.IO) { - TODO() + /** + * Restores a database backup by deserializing the backup json string + * and saving goals and transactions back into the database. + * + * @param jsonString a valid backup json as sting. + * @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, + onFailure: () -> Unit, + onSuccess: () -> Unit + ) = withContext(Dispatchers.IO) { + + // Parse json string. + log("Parsing backup json file...") + val backupData: BackupJsonModel? = try { + gsonInstance.fromJson(jsonString, BackupJsonModel::class.java) + } catch (exc: Exception) { + log("Failed tp parse backup json file! Err: ${exc.message}") + exc.printStackTrace() + null + } + + if (backupData == null) { + withContext(Dispatchers.Main) { onFailure() } + return@withContext + } + + // Insert goal & transaction data into database. + log("Inserting goals & transactions into the database...") + goalDao.insertGoalWithTransaction(backupData.data) + withContext(Dispatchers.Main) { onSuccess() } } } \ No newline at end of file diff --git a/app/src/main/java/com/starry/greenstash/backup/BitmapTypeAdapter.kt b/app/src/main/java/com/starry/greenstash/backup/BitmapTypeAdapter.kt new file mode 100644 index 00000000..73fd2606 --- /dev/null +++ b/app/src/main/java/com/starry/greenstash/backup/BitmapTypeAdapter.kt @@ -0,0 +1,73 @@ +package com.starry.greenstash.backup + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Base64 +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import com.google.gson.JsonPrimitive +import com.google.gson.JsonSerializationContext +import com.google.gson.JsonSerializer +import java.io.ByteArrayOutputStream +import java.lang.reflect.Type + + +/** + * Gson type adaptor used for serializing and deserializing goal image which is + * stored as [Bitmap] in the database. + * Currently used for backup & restore functionality. + */ +class BitmapTypeAdapter : JsonSerializer, JsonDeserializer { + + /** + * Gson invokes this call-back method during serialization when it encounters a field of the + * specified type. + * + * + * In the implementation of this call-back method, you should consider invoking + * [JsonSerializationContext.serialize] method to create JsonElements for any + * non-trivial field of the `src` object. However, you should never invoke it on the + * `src` object itself since that will cause an infinite loop (Gson will call your + * call-back method again). + * + * @param src the object that needs to be converted to Json. + * @param typeOfSrc the actual type (fully genericized version) of the source object. + * @return a JsonElement corresponding to the specified object. + */ + override fun serialize( + src: Bitmap?, typeOfSrc: Type?, context: JsonSerializationContext? + ): JsonElement { + val byteArrayOutputStream = ByteArrayOutputStream() + src?.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream) + return JsonPrimitive( + Base64.encodeToString( + byteArrayOutputStream.toByteArray(), Base64.NO_WRAP + ) + ) + } + + /** + * Gson invokes this call-back method during deserialization when it encounters a field of the + * specified type. + * + * In the implementation of this call-back method, you should consider invoking + * [JsonDeserializationContext.deserialize] method to create objects + * for any non-trivial field of the returned object. However, you should never invoke it on the + * the same type passing `json` since that will cause an infinite loop (Gson will call your + * call-back method again). + * + * @param json The Json data being deserialized + * @param typeOfT The type of the Object to deserialize to + * @return a deserialized object of the specified type typeOfT which is a subclass of `T` + * @throws JsonParseException if json is not in the expected format of `typeofT` + */ + override fun deserialize( + json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext? + ): Bitmap? { + if (json?.asString == null) return null + val byteArray: ByteArray = Base64.decode(json.asString, Base64.NO_WRAP) + return BitmapFactory.decodeByteArray(byteArray, 0, byteArray.count()) + } +} \ 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 c58ba6bf..41a0beab 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 @@ -37,6 +37,20 @@ interface GoalDao { @Insert suspend fun insertGoal(goal: Goal): Long + @Transaction + suspend fun insertGoalWithTransaction(goalsWithTransactions: List) { + goalsWithTransactions.forEach { goalWithTransactions -> + // Set placeholder id. + goalWithTransactions.goal.goalId = 0L + // insert goal and get actual id from database. + val goalId = insertGoal(goalWithTransactions.goal) + // map transactions with inserted goal, and insert them into database. + val transactionsWithGoalId = + goalWithTransactions.transactions.map { it.copy(ownerGoalId = goalId) } + insertTransactions(transactionsWithGoalId) + } + } + @Update suspend fun updateGoal(goal: Goal) @@ -82,4 +96,12 @@ interface GoalDao { ) fun getAllGoalsByPriority(sortOrder: Int): Flow> + /** + * For internal use with insertGoalWithTransaction() method only, + * Please use Transaction Dao for transaction related operations. + */ + @Insert + suspend fun insertTransactions( + transactions: List + ) } \ No newline at end of file diff --git a/app/src/main/java/com/starry/greenstash/di/MianModule.kt b/app/src/main/java/com/starry/greenstash/di/MianModule.kt index 2a94ae27..396e9439 100644 --- a/app/src/main/java/com/starry/greenstash/di/MianModule.kt +++ b/app/src/main/java/com/starry/greenstash/di/MianModule.kt @@ -31,7 +31,9 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.ui.ExperimentalComposeUiApi +import com.starry.greenstash.backup.BackupManager import com.starry.greenstash.database.core.AppDatabase +import com.starry.greenstash.database.goal.GoalDao import com.starry.greenstash.other.WelcomeDataStore import com.starry.greenstash.reminder.ReminderManager import com.starry.greenstash.reminder.ReminderNotificationSender @@ -78,4 +80,9 @@ class MianModule { @Singleton fun provideReminderNotificationSender(@ApplicationContext context: Context) = ReminderNotificationSender(context) + + @Provides + @Singleton + fun providebackupmanager(@ApplicationContext context: Context, goalDao: GoalDao) = + BackupManager(context = context, goalDao = goalDao) } \ No newline at end of file diff --git a/app/src/main/java/com/starry/greenstash/ui/screens/backups/BackupScreen.kt b/app/src/main/java/com/starry/greenstash/ui/screens/backups/BackupScreen.kt index 524e8f90..3b1a09db 100644 --- a/app/src/main/java/com/starry/greenstash/ui/screens/backups/BackupScreen.kt +++ b/app/src/main/java/com/starry/greenstash/ui/screens/backups/BackupScreen.kt @@ -25,6 +25,8 @@ package com.starry.greenstash.ui.screens.backups +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -45,12 +47,16 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -59,6 +65,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionResult @@ -67,15 +74,23 @@ import com.airbnb.lottie.compose.LottieConstants import com.airbnb.lottie.compose.animateLottieCompositionAsState import com.airbnb.lottie.compose.rememberLottieComposition import com.starry.greenstash.R +import kotlinx.coroutines.launch +import java.io.InputStreamReader +import java.io.Reader +import java.nio.charset.StandardCharsets @ExperimentalMaterial3Api @Composable fun BackupScreen(navController: NavController) { val context = LocalContext.current + val viewModel = hiltViewModel() - Scaffold( - modifier = Modifier.fillMaxSize(), + val snackBarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + + Scaffold(modifier = Modifier.fillMaxSize(), + snackbarHost = { SnackbarHost(snackBarHostState) }, topBar = { TopAppBar(modifier = Modifier.fillMaxWidth(), title = { Text( @@ -93,20 +108,50 @@ fun BackupScreen(navController: NavController) { containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) ) ) - }, - content = { - BackupScreenContent( - paddingValues = it, - onBackupClicked = { /* TODO */ }, - onRestoreClicked = { /* TODO */ }) + }, content = { + val backupLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.let { fileUri -> + context.contentResolver.openInputStream(fileUri)?.let { ips -> + // read json content from input stream + val bufferSize = 1024 + val buffer = CharArray(bufferSize) + val out = StringBuilder() + val reader: Reader = InputStreamReader(ips, StandardCharsets.UTF_8) + var numRead: Int + while (reader.read(buffer, 0, buffer.size) + .also { nRead -> numRead = nRead } > 0 + ) { + out.appendRange(buffer, 0, numRead) + } + + viewModel.restoreBackup(jsonString = out.toString(), + onSuccess = { + coroutineScope.launch { + snackBarHostState.showSnackbar(context.getString(R.string.backup_restore_success)) + } + }, + onFailure = { + coroutineScope.launch { + snackBarHostState.showSnackbar(context.getString(R.string.unknown_error)) + } + } + ) + } + } + + } + + BackupScreenContent(paddingValues = it, + onBackupClicked = { viewModel.takeBackup { intent -> context.startActivity(intent) } }, + onRestoreClicked = { backupLauncher.launch(arrayOf("application/json")) } + ) }) } @Composable fun BackupScreenContent( - paddingValues: PaddingValues, - onBackupClicked: () -> Unit, - onRestoreClicked: () -> Unit + paddingValues: PaddingValues, onBackupClicked: () -> Unit, onRestoreClicked: () -> Unit ) { Column( modifier = Modifier @@ -150,8 +195,7 @@ fun BackupScreenContent( } Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { Button( onClick = onBackupClicked, 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 new file mode 100644 index 00000000..39dd899b --- /dev/null +++ b/app/src/main/java/com/starry/greenstash/ui/screens/backups/BackupViewModel.kt @@ -0,0 +1,34 @@ +package com.starry.greenstash.ui.screens.backups + +import android.content.Intent +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.starry.greenstash.backup.BackupManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@HiltViewModel +class BackupViewModel @Inject constructor( + private val backupManager: BackupManager +) : ViewModel() { + + fun takeBackup(onComplete: (Intent) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + val backupIntent = backupManager.createDatabaseBackup() + withContext(Dispatchers.Main) { onComplete(backupIntent) } + } + } + + fun restoreBackup(jsonString: String, onSuccess: () -> Unit, onFailure: () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + backupManager.restoreDatabaseBackup( + jsonString = jsonString, + onSuccess = onSuccess, + onFailure = onFailure + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/starry/greenstash/utils/Extensions.kt b/app/src/main/java/com/starry/greenstash/utils/Extensions.kt index cd3ea386..3bd30554 100644 --- a/app/src/main/java/com/starry/greenstash/utils/Extensions.kt +++ b/app/src/main/java/com/starry/greenstash/utils/Extensions.kt @@ -33,9 +33,12 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import java.io.File +import java.io.PrintWriter fun Context.getActivity(): AppCompatActivity? = when (this) { is AppCompatActivity -> this @@ -45,8 +48,8 @@ fun Context.getActivity(): AppCompatActivity? = when (this) { @Composable fun LazyListState.isScrollingUp(): Boolean { - var previousIndex by remember(this) { mutableStateOf(firstVisibleItemIndex) } - var previousScrollOffset by remember(this) { mutableStateOf(firstVisibleItemScrollOffset) } + var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) } + var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) } return remember(this) { derivedStateOf { if (previousIndex != firstVisibleItemIndex) { @@ -69,4 +72,16 @@ fun String.toToast(context: Context, length: Int = Toast.LENGTH_SHORT) { fun String.validateAmount() = this.isNotEmpty() && this.isNotBlank() && !this.matches("[0.]+".toRegex()) - && !this.endsWith(".") \ No newline at end of file + && !this.endsWith(".") + +fun File.clearText() { + PrintWriter(this).also { + it.print("") + it.close() + } +} + +fun File.updateText(content: String) { + clearText() + appendText(content) +} \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 9e1bd73b..05ae81fe 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,7 +1,10 @@ GreenStash + + Confirmar Cancelar + Oops! something went wrong. Inicio @@ -101,6 +104,7 @@ Nota: La copia de seguridad no incluye la configuración de la app. Respaldar Datos Recuperar Datos + Backup restored successfully! Configuración diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index da5d48a2..a76c67e4 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,7 +1,10 @@ GreenStash + + 确认 取消 + Oops! something went wrong. 主页 @@ -102,6 +105,7 @@ 注意:备份不包括应用设置信息。 备份应用数据 恢复应用数据 + Backup restored successfully! 设置 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b21e9935..81a75510 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,7 +1,10 @@ GreenStash + + Confirm Cancel + Oops! something went wrong. Home @@ -102,6 +105,7 @@ Note: Backups does not include app settings. Backup App Data Restore App Data + Backup restored successfully! Settings diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml new file mode 100644 index 00000000..c322aec6 --- /dev/null +++ b/app/src/main/res/xml/provider_paths.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file