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/.idea/kotlinc.xml b/.idea/kotlinc.xml index 217e5c51..fdf8d994 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 887687fb..653f6280 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,7 +2,7 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' id 'dagger.hilt.android.plugin' - id 'kotlin-kapt' + id 'com.google.devtools.ksp' id "com.mikepenz.aboutlibraries.plugin" version "10.5.2" } @@ -10,12 +10,12 @@ apply plugin: 'com.mikepenz.aboutlibraries.plugin' android { namespace 'com.starry.greenstash' - compileSdk 33 + compileSdk 34 defaultConfig { applicationId "com.starry.greenstash" minSdk 24 - targetSdk 33 + targetSdk 34 versionCode 27 versionName "2.7.0" @@ -24,11 +24,8 @@ android { useSupportLibrary true } - javaCompileOptions { - annotationProcessorOptions { - arguments += ["room.schemaLocation": - "$projectDir/schemas".toString()] - } + ksp { + arg('room.schemaLocation', "$projectDir/schemas") } } @@ -53,13 +50,15 @@ android { } kotlinOptions { jvmTarget = '17' + freeCompilerArgs = [] } buildFeatures { - buildConfig = true compose true + buildConfig = true + } composeOptions { - kotlinCompilerExtensionVersion '1.4.7' + kotlinCompilerExtensionVersion '1.5.2' } packagingOptions { resources { @@ -76,28 +75,26 @@ aboutLibraries { dependencies { - def composeBom = platform('androidx.compose:compose-bom:2023.06.01') + def composeBom = platform('androidx.compose:compose-bom:2023.08.00') implementation composeBom androidTestImplementation composeBom // Android core components. - implementation 'androidx.core:core-ktx:1.10.1' - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1' - implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.6.1" + implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2' + implementation 'androidx.activity:activity-compose:1.7.2' + implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.6.2" + implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2" // Jetpack compose. implementation "androidx.compose.ui:ui" implementation "androidx.compose.ui:ui-tooling-preview" - implementation "androidx.compose.material3:material3" implementation "androidx.compose.material:material" implementation "androidx.compose.animation:animation" - implementation "androidx.lifecycle:lifecycle-viewmodel-compose" implementation "androidx.compose.runtime:runtime-livedata" - implementation 'androidx.activity:activity-compose' - - //accompanist - implementation "com.google.accompanist:accompanist-navigation-animation:0.29.0-alpha" + implementation "androidx.compose.material3:material3" + // Accompanist compose. implementation "com.google.accompanist:accompanist-systemuicontroller:0.28.0" - + implementation "com.google.accompanist:accompanist-navigation-animation:0.33.1-alpha" // Material theme for main activity. implementation 'com.google.android.material:material:1.9.0' // Android 12+ splash API. @@ -105,17 +102,18 @@ dependencies { // Room database implementation "androidx.room:room-ktx:2.5.2" implementation 'androidx.appcompat:appcompat:1.6.1' - kapt "androidx.room:room-compiler:2.5.2" + implementation 'androidx.core:core-ktx:1.12.0' + ksp "androidx.room:room-compiler:2.5.2" androidTestImplementation "androidx.room:room-testing:2.5.2" - // Room database backup library. - implementation 'de.raphaelebner:roomdatabasebackup:1.0.0-beta12' // Dagger - Hilt. implementation "com.google.dagger:hilt-android:$hilt_version" implementation "androidx.hilt:hilt-navigation-compose:1.0.0" - kapt "com.google.dagger:hilt-android-compiler:$hilt_version" - kapt "androidx.hilt:hilt-compiler:1.0.0" - // DataStore Preferences + ksp "com.google.dagger:hilt-android-compiler:$hilt_version" + ksp "androidx.hilt:hilt-compiler:1.0.0" + // DataStore Preferences. implementation("androidx.datastore:datastore-preferences:1.0.0") + // Gson JSON parser. + implementation 'com.google.code.gson:gson:2.10.1' // Coil Image loading library. implementation "io.coil-kt:coil-compose:2.4.0" // Material 3 calender / Date picker. @@ -136,7 +134,7 @@ dependencies { testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" - debugImplementation "androidx.compose.ui:ui-tooling:$compose_version" - debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" + androidTestImplementation "androidx.compose.ui:ui-test-junit4" + debugImplementation "androidx.compose.ui:ui-tooling" + debugImplementation "androidx.compose.ui:ui-test-manifest" } \ 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 116cb2ef..f9ec6be8 100644 --- a/app/src/main/java/com/starry/greenstash/MainActivity.kt +++ b/app/src/main/java/com/starry/greenstash/MainActivity.kt @@ -25,9 +25,7 @@ package com.starry.greenstash -import android.content.Intent import android.os.Bundle -import android.widget.Toast import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity import androidx.biometric.BiometricManager @@ -49,7 +47,6 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.ViewModelProvider import com.google.accompanist.navigation.animation.rememberAnimatedNavController import com.google.accompanist.systemuicontroller.rememberSystemUiController -import com.starry.greenstash.database.core.AppDatabase import com.starry.greenstash.ui.navigation.NavGraph import com.starry.greenstash.ui.screens.settings.viewmodels.SettingsViewModel import com.starry.greenstash.ui.screens.settings.viewmodels.ThemeMode @@ -58,16 +55,16 @@ import com.starry.greenstash.utils.PreferenceUtils import com.starry.greenstash.utils.Utils import com.starry.greenstash.utils.toToast import dagger.hilt.android.AndroidEntryPoint -import de.raphaelebner.roomdatabasebackup.core.RoomBackup +import kotlinx.coroutines.ExperimentalCoroutinesApi import java.util.concurrent.Executor -import javax.inject.Inject +@ExperimentalCoroutinesApi @ExperimentalMaterialApi @ExperimentalFoundationApi -@AndroidEntryPoint @ExperimentalComposeUiApi @ExperimentalAnimationApi @ExperimentalMaterial3Api +@AndroidEntryPoint class MainActivity : AppCompatActivity() { lateinit var settingsViewModel: SettingsViewModel @@ -77,11 +74,6 @@ class MainActivity : AppCompatActivity() { private lateinit var biometricPrompt: BiometricPrompt private lateinit var promptInfo: BiometricPrompt.PromptInfo - private lateinit var roomBackup: RoomBackup - - @Inject - lateinit var appDatabase: AppDatabase - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -147,26 +139,6 @@ class MainActivity : AppCompatActivity() { } else { setAppContents() } - - // initialize & setup room backup instance - roomBackup = RoomBackup(this) - .database(appDatabase) - .enableLogDebug(true) - .backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG) - .customBackupFileName("GreenStash-${System.currentTimeMillis()}.backup") - .apply { - onCompleteListener { success, message, _ -> - if (success) restartApp( - Intent( - this@MainActivity, - MainActivity::class.java - ) - ) else Toast.makeText( - this@MainActivity, - message, Toast.LENGTH_SHORT, - ).show() - } - } } fun setAppContents() { @@ -194,20 +166,4 @@ class MainActivity : AppCompatActivity() { } } } - - fun backupDatabase() { - try { - roomBackup.backup() - } catch (exc: NullPointerException) { - exc.printStackTrace() - } - } - - fun restoreDatabase() { - try { - roomBackup.restore() - } catch (exc: NullPointerException) { - exc.printStackTrace() - } - } } \ 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 new file mode 100644 index 00000000..d5d9009b --- /dev/null +++ b/app/src/main/java/com/starry/greenstash/backup/BackupManager.kt @@ -0,0 +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 + +/** + * 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) { + + /** + * 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) } + + } + + /** + * 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 to parse backup json file! Err: ${exc.message}") + exc.printStackTrace() + null + } + + if (backupData?.data == 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/navigation/NavGraph.kt b/app/src/main/java/com/starry/greenstash/ui/navigation/NavGraph.kt index c6051d27..932bc05e 100644 --- a/app/src/main/java/com/starry/greenstash/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/starry/greenstash/ui/navigation/NavGraph.kt @@ -53,7 +53,9 @@ import com.starry.greenstash.ui.screens.settings.composables.AboutScreen import com.starry.greenstash.ui.screens.settings.composables.OSLScreen import com.starry.greenstash.ui.screens.settings.composables.SettingsScreen import com.starry.greenstash.ui.screens.welcome.composables.WelcomeScreen +import kotlinx.coroutines.ExperimentalCoroutinesApi +@ExperimentalCoroutinesApi @ExperimentalMaterialApi @ExperimentalFoundationApi @ExperimentalComposeUiApi 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 5dc665bf..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,8 +25,8 @@ package com.starry.greenstash.ui.screens.backups -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi +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 @@ -39,7 +39,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.Button @@ -48,14 +47,17 @@ 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.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -63,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 @@ -70,22 +73,24 @@ import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.LottieConstants import com.airbnb.lottie.compose.animateLottieCompositionAsState import com.airbnb.lottie.compose.rememberLottieComposition -import com.starry.greenstash.MainActivity import com.starry.greenstash.R -import com.starry.greenstash.utils.getActivity +import kotlinx.coroutines.launch +import java.io.InputStreamReader +import java.io.Reader +import java.nio.charset.StandardCharsets + @ExperimentalMaterial3Api -@ExperimentalAnimationApi -@ExperimentalComposeUiApi -@ExperimentalFoundationApi -@ExperimentalMaterialApi @Composable fun BackupScreen(navController: NavController) { val context = LocalContext.current - val activity = (context.getActivity() as MainActivity) + val viewModel = hiltViewModel() + + val snackBarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() - Scaffold( - modifier = Modifier.fillMaxSize(), + Scaffold(modifier = Modifier.fillMaxSize(), + snackbarHost = { SnackbarHost(snackBarHostState) }, topBar = { TopAppBar(modifier = Modifier.fillMaxWidth(), title = { Text( @@ -103,20 +108,50 @@ fun BackupScreen(navController: NavController) { containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) ) ) - }, - content = { - BackupScreenContent( - paddingValues = it, - onBackupClicked = { activity.backupDatabase() }, - onRestoreClicked = { activity.restoreDatabase() }) + }, 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 @@ -160,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/ui/screens/input/composables/InputScreen.kt b/app/src/main/java/com/starry/greenstash/ui/screens/input/composables/InputScreen.kt index 2f9329ab..6e64dfde 100644 --- a/app/src/main/java/com/starry/greenstash/ui/screens/input/composables/InputScreen.kt +++ b/app/src/main/java/com/starry/greenstash/ui/screens/input/composables/InputScreen.kt @@ -140,11 +140,13 @@ import com.starry.greenstash.utils.getActivity import com.starry.greenstash.utils.toToast import com.starry.greenstash.utils.validateAmount import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.time.format.DateTimeFormatter +@ExperimentalCoroutinesApi @ExperimentalAnimationApi @ExperimentalMaterialApi @ExperimentalFoundationApi @@ -597,6 +599,7 @@ fun GoalPriorityMenu(viewModel: InputViewModel) { } } +@ExperimentalCoroutinesApi @ExperimentalMaterial3Api @ExperimentalAnimationApi @ExperimentalComposeUiApi @@ -675,6 +678,7 @@ fun GoalReminderMenu( } } +@ExperimentalCoroutinesApi @ExperimentalMaterialApi @ExperimentalAnimationApi @ExperimentalFoundationApi 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 diff --git a/build.gradle b/build.gradle index dabbc5bb..fa2aed75 100644 --- a/build.gradle +++ b/build.gradle @@ -1,15 +1,15 @@ buildscript { ext { - compose_version = '1.4.3' - hilt_version = '2.47' + kotlin_version = '1.9.0' + hilt_version = '2.48' } repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.4.2' - classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.21' + classpath 'com.android.tools.build:gradle:8.1.1' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files @@ -18,7 +18,8 @@ buildscript { // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '8.1.0' apply false - id 'com.android.library' version '8.1.0' apply false - id 'org.jetbrains.kotlin.android' version '1.8.21' apply false + id 'com.android.application' version '8.1.1' apply false + id 'com.android.library' version '8.1.1' apply false + id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false + id 'com.google.devtools.ksp' version '1.9.0-1.0.13' apply false } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 3c5031eb..a2e90d87 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,6 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true +android.defaults.buildfeatures.buildconfig=true +android.nonFinalResIds=false \ No newline at end of file