From e8f921edbf1c601948893432edac07a04b17ed9e Mon Sep 17 00:00:00 2001 From: ILIYANGERMANOV Date: Mon, 28 Aug 2023 01:44:03 +0300 Subject: [PATCH] GitHub auto-backups MVP (not tested) (#2509) * Support JSON file import * WIP: GitHub backups * WIP: GitHub backups UX * WIP: `GitHubAutoBackupManager` * POC of the GitHub auto-backups * WIP: GitHubBackupCard screen * Import GitHub backup imports --- .../backup/github/GitHubAutoBackupManager.kt | 87 +++++++- .../ivy/wallet/backup/github/GitHubBackup.kt | 31 +-- .../ivy/wallet/backup/github/GitHubClient.kt | 79 ++++++-- .../wallet/backup/github/GitHubCredentials.kt | 2 +- .../backup/github/GitHubCredentialsManager.kt | 2 +- .../backup/github/ui/GitHubBackupCard.kt | 185 +++++++++--------- .../backup/github/ui/GitHubBackupScreen.kt | 60 +++++- .../backup/github/ui/GitHubBackupStatus.kt | 74 +++++++ .../backup/github/ui/GitHubBackupViewModel.kt | 81 ++++++-- .../backup/github/ui/GitHubBackupViewState.kt | 13 ++ .../com/ivy/wallet/backup/ktor/KtorClient.kt | 2 + .../{ExportBackupLogic.kt => BackupLogic.kt} | 106 +++++----- .../wallet/ui/csvimport/ImportViewModel.kt | 21 +- .../ui/serverstop/ServerStopViewModel.kt | 6 +- .../wallet/ui/settings/SettingsViewModel.kt | 6 +- 15 files changed, 543 insertions(+), 212 deletions(-) create mode 100644 app/src/main/java/com/ivy/wallet/backup/github/ui/GitHubBackupStatus.kt create mode 100644 app/src/main/java/com/ivy/wallet/backup/github/ui/GitHubBackupViewState.kt rename app/src/main/java/com/ivy/wallet/domain/deprecated/logic/zip/{ExportBackupLogic.kt => BackupLogic.kt} (81%) diff --git a/app/src/main/java/com/ivy/wallet/backup/github/GitHubAutoBackupManager.kt b/app/src/main/java/com/ivy/wallet/backup/github/GitHubAutoBackupManager.kt index 7bc95b872e..7b3ac7e9c9 100644 --- a/app/src/main/java/com/ivy/wallet/backup/github/GitHubAutoBackupManager.kt +++ b/app/src/main/java/com/ivy/wallet/backup/github/GitHubAutoBackupManager.kt @@ -1,9 +1,90 @@ package com.ivy.wallet.backup.github +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.Calendar +import java.util.concurrent.TimeUnit import javax.inject.Inject -class GitHubAutoBackupManager @Inject constructor() { +class GitHubAutoBackupManager @Inject constructor( + @ApplicationContext + private val context: Context +) { + + private val uniqueWorkName = "GITHUB_AUTO_BACKUP_WORK" + fun scheduleAutoBackups() { - // TODO: + val initialDelay = calculateInitialDelayMillis() + + val dailyWorkRequest = PeriodicWorkRequestBuilder( + 24, TimeUnit.HOURS + ).setInitialDelay(initialDelay, TimeUnit.MILLISECONDS) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .build() + + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork( + uniqueWorkName, + ExistingPeriodicWorkPolicy.REPLACE, + dailyWorkRequest + ) + } + + private fun calculateInitialDelayMillis(): Long { + val lunchTime = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 12) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + } + + val currentTime = Calendar.getInstance() + + return if (currentTime.after(lunchTime)) { + // If current time is after 12 pm, schedule for next day + lunchTime.add(Calendar.DAY_OF_YEAR, 1) + lunchTime.timeInMillis - currentTime.timeInMillis + } else { + // If it's before 12 pm, set delay to reach 12 pm + lunchTime.timeInMillis - currentTime.timeInMillis + } + } + + fun cancelAutoBackups() { + WorkManager.getInstance(context).cancelUniqueWork(uniqueWorkName) + } +} + +@HiltWorker +class GitHubBackupWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val gitHubBackup: GitHubBackup, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + return gitHubBackup.backupData( + isAutomatic = true + ).fold( + ifLeft = { + Result.failure() + }, + ifRight = { + Result.success() + }, + ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/ivy/wallet/backup/github/GitHubBackup.kt b/app/src/main/java/com/ivy/wallet/backup/github/GitHubBackup.kt index c9e7237c28..49bd83e8fa 100644 --- a/app/src/main/java/com/ivy/wallet/backup/github/GitHubBackup.kt +++ b/app/src/main/java/com/ivy/wallet/backup/github/GitHubBackup.kt @@ -7,7 +7,7 @@ import arrow.core.raise.either import arrow.core.raise.ensureNotNull import com.ivy.wallet.data.DatastoreKeys import com.ivy.wallet.data.dataStore -import com.ivy.wallet.domain.deprecated.logic.zip.ExportBackupLogic +import com.ivy.wallet.domain.deprecated.logic.zip.BackupLogic import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -22,10 +22,14 @@ import javax.inject.Inject class GitHubBackup @Inject constructor( private val client: GitHubClient, private val credentialsManager: GitHubCredentialsManager, - private val exportBackupLogic: ExportBackupLogic, + private val backupLogic: BackupLogic, @ApplicationContext private val appContext: Context ) { + companion object { + const val BACKUP_FILE_PATH = "Ivy-Wallet-backup.json" + } + private val enabledInternalState = MutableStateFlow(null) private val lastBackupTimeState = MutableStateFlow(null) @@ -71,22 +75,19 @@ class GitHubBackup @Inject constructor( enabledInternalState.value = false } - suspend fun repoUrl(): String? { - return credentialsManager.getCredentials() - .map { "https://github.com/${it.owner}/${it.repo}" } - .getOrNull() - } - - suspend fun backupData(): Either = withContext(Dispatchers.IO) { + suspend fun backupData( + isAutomatic: Boolean = false, + ): Either = withContext(Dispatchers.IO) { either { val credentials = credentialsManager.getCredentials() .mapLeft(Error::MissingCredentials).bind() - val json = exportBackupLogic.generateJsonBackup() + val json = backupLogic.generateJsonBackup() client.commit( credentials = credentials, - path = "Ivy-Wallet-backup.json", + path = BACKUP_FILE_PATH, content = json, + isAutomatic = isAutomatic, ).mapLeft(Error::Commit).bind() appContext.dataStore.edit { @@ -97,6 +98,14 @@ class GitHubBackup @Inject constructor( } } + suspend fun readBackupJson(): Either = withContext(Dispatchers.IO) { + either { + val credentials = credentialsManager.getCredentials().bind() + client.readFileContent(credentials, BACKUP_FILE_PATH) + .mapLeft { "GitHub backup error: $it" }.bind() + } + } + sealed interface Error { val humanReadable: String diff --git a/app/src/main/java/com/ivy/wallet/backup/github/GitHubClient.kt b/app/src/main/java/com/ivy/wallet/backup/github/GitHubClient.kt index 8dc71c64e3..8def71700a 100644 --- a/app/src/main/java/com/ivy/wallet/backup/github/GitHubClient.kt +++ b/app/src/main/java/com/ivy/wallet/backup/github/GitHubClient.kt @@ -5,6 +5,7 @@ import arrow.core.Either import arrow.core.raise.catch import arrow.core.raise.either import arrow.core.raise.ensure +import arrow.core.raise.ensureNotNull import dagger.Lazy import io.ktor.client.HttpClient import io.ktor.client.call.body @@ -17,7 +18,9 @@ import io.ktor.http.HeadersBuilder import io.ktor.http.contentType import io.ktor.http.isSuccess import io.ktor.util.encodeBase64 +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import timber.log.Timber import javax.inject.Inject @@ -39,26 +42,38 @@ class GitHubClient @Inject constructor( @Keep @Serializable - data class GitHubFileResponse(val sha: String) + data class GitHubFileResponse( + val sha: String, + @SerialName("download_url") + val downloadUrl: String, + ) suspend fun commit( credentials: GitHubCredentials, path: String, content: String, + isAutomatic: Boolean = false, ): Either = either { - val url = repoUrl(credentials, path) - val sha = getExistingFileSha(credentials, url) + val repoUrl = repoUrl(credentials, path) + val sha = getExistingFile(credentials, repoUrl)?.sha val encodedContent = content.toByteArray(Charsets.UTF_16).encodeBase64() val requestBody = GitHubFileContent( content = encodedContent, - message = "Committing from Ktor", - committer = Committer(name = "Ivy Wallet", email = "ivywalelt@ivy-bot.com"), + message = if (isAutomatic) { + "Automatic Ivy Wallet data backup" + } else { + "Manual Ivy Wallet data backup" + }, + committer = Committer( + name = "Ivy Wallet", + email = "automation@ivywallet.com" + ), sha = sha, ) - val response = httpClient.get().put(url) { + val response = httpClient.get().put(repoUrl) { headers { githubToken(credentials) contentType(ContentType.Application.Json) @@ -67,28 +82,70 @@ class GitHubClient @Inject constructor( setBody(requestBody) } ensure(response.status.isSuccess()) { - "Unsuccessful response: ${response.status}" + when (response.status.value) { + 404 -> "Invalid GitHub repo url." + 403 -> "Invalid GitHub PAT (Personal Access Token). Check your PAT permissions and expiration day." + else -> "Unsuccessful response: '${response.status}' $response." + } } return Either.Right(Unit) } - private suspend fun getExistingFileSha( + suspend fun readFileContent( + credentials: GitHubCredentials, + path: String, + ): Either = either { + val repoUrl = repoUrl(credentials, path) + val file = getExistingFile(credentials, repoUrl) + ensureNotNull(file) { + "Failed to fetch GitHub file '$repoUrl'." + } + val fileContent = downloadFileContent(credentials, file.downloadUrl).bind() + ensure(fileContent.isNotBlank()) { + "GitHub file content is blank!" + } + fileContent + } + + private suspend fun downloadFileContent( + credentials: GitHubCredentials, + downloadUrl: String + ): Either = catch({ + val client = httpClient.get() + val response = client.get(downloadUrl) { + headers { + githubToken(credentials) + } + } + if (!response.status.isSuccess()) { + error("Failed to download file with ${response.status} $response") + } + val byteArray = response.body() + + val content = byteArray.toString(Charsets.UTF_16) + Either.Right(content) + }) { + Timber.e("GitHub file download: $it") + Either.Left("Failed to GitHub backup file because $it.") + } + + private suspend fun getExistingFile( credentials: GitHubCredentials, url: String - ): String? = catch({ + ): GitHubFileResponse? = catch({ // Fetch the current file to get its SHA httpClient.get().get(url) { headers { githubToken(credentials) } - }.body().sha + }.body() }) { null } private fun HeadersBuilder.githubToken(credentials: GitHubCredentials) { - append("Authorization", "token ${credentials.accessToken}") + append("Authorization", "token ${credentials.gitHubPAT}") } private fun HeadersBuilder.acceptsUtf16() { diff --git a/app/src/main/java/com/ivy/wallet/backup/github/GitHubCredentials.kt b/app/src/main/java/com/ivy/wallet/backup/github/GitHubCredentials.kt index 6aefe8e598..1a9957c2a7 100644 --- a/app/src/main/java/com/ivy/wallet/backup/github/GitHubCredentials.kt +++ b/app/src/main/java/com/ivy/wallet/backup/github/GitHubCredentials.kt @@ -3,5 +3,5 @@ package com.ivy.wallet.backup.github data class GitHubCredentials( val owner: String, val repo: String, - val accessToken: String, + val gitHubPAT: String, ) \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/backup/github/GitHubCredentialsManager.kt b/app/src/main/java/com/ivy/wallet/backup/github/GitHubCredentialsManager.kt index 577ebe6095..46fed52c7a 100644 --- a/app/src/main/java/com/ivy/wallet/backup/github/GitHubCredentialsManager.kt +++ b/app/src/main/java/com/ivy/wallet/backup/github/GitHubCredentialsManager.kt @@ -46,7 +46,7 @@ class GitHubCredentialsManager @Inject constructor( GitHubCredentials( owner = owner, repo = repo, - accessToken = token, + gitHubPAT = token, ) } } diff --git a/app/src/main/java/com/ivy/wallet/backup/github/ui/GitHubBackupCard.kt b/app/src/main/java/com/ivy/wallet/backup/github/ui/GitHubBackupCard.kt index 66c2b9e8f5..49e144a62b 100644 --- a/app/src/main/java/com/ivy/wallet/backup/github/ui/GitHubBackupCard.kt +++ b/app/src/main/java/com/ivy/wallet/backup/github/ui/GitHubBackupCard.kt @@ -13,15 +13,17 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter @@ -35,7 +37,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.ivy.design.l0_system.UI import com.ivy.frp.view.navigation.navigation import com.ivy.wallet.R -import com.ivy.wallet.ui.theme.Orange +import com.ivy.wallet.ui.theme.White @Composable fun GitHubBackupCard( @@ -56,49 +58,82 @@ fun GitHubBackupCard( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun BackupEnabled( viewModel: GitHubBackupViewModel, modifier: Modifier = Modifier, ) { + val nav = navigation() Card( modifier = modifier, - shape = RoundedCornerShape(12.dp), + shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( containerColor = UI.colors.medium, contentColor = UI.colors.mediumInverse, - ) + ), + onClick = { + nav.navigateTo(GitHubBackupScreen) + } ) { Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = Modifier.width(4.dp)) + GitHubIcon() + Spacer(modifier = Modifier.width(16.dp)) + Text( + modifier = Modifier, + text = "GitHub auto-backups", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Start, + ) + } + Spacer(modifier = Modifier.height(16.dp)) Text( modifier = Modifier, - text = "GitHub auto-backups", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, + text = "Ivy Wallet will perform an automatic backup of your data every day at 12:00 PM.", + style = MaterialTheme.typography.labelMedium, textAlign = TextAlign.Start, + fontWeight = FontWeight.Normal, ) - GitHubBackupStatus(viewModel) LastBackup(viewModel) + GitHubBackupStatus(viewModel) Spacer(modifier = Modifier.height(8.dp)) Row( verticalAlignment = Alignment.CenterVertically, ) { - ElevatedButton( - onClick = viewModel::backupData + Button( + onClick = viewModel::backupData, + colors = ButtonDefaults.buttonColors( + containerColor = UI.colors.green, + contentColor = White, + ) ) { - Text(text = "Backup now") + Text("Backup now") } - Spacer(modifier = Modifier.width(16.dp)) - OutlinedButton( - onClick = viewModel::disableBackups, - colors = ButtonDefaults.outlinedButtonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, + Spacer(modifier = Modifier.weight(1f)) + var confirmDisable by remember { + mutableStateOf(false) + } + TextButton( + onClick = { + confirmDisable = if (confirmDisable) { + viewModel.disableBackups() + false + } else { + true + } + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, ) ) { - Text("Disable") + Text(if (confirmDisable) "Confirm disable?" else "Disable") } } } @@ -106,83 +141,31 @@ private fun BackupEnabled( } @Composable -private fun GitHubBackupStatus( +private fun LastBackup( viewModel: GitHubBackupViewModel, ) { - val status by viewModel.backupStatus.collectAsState() - if (status == null) return - - - when (val stat = status) { - is GitHubBackupStatus.Error -> { - Spacer(modifier = Modifier.height(8.dp)) + val lastBackupTime by viewModel.lastBackupTime.collectAsState(initial = null) + if (lastBackupTime != null) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { Text( - modifier = Modifier, - text = stat.error, + text = "Last Backup: $lastBackupTime", + fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Start, ) - } - - GitHubBackupStatus.Loading -> { - Spacer(modifier = Modifier.height(8.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, + Spacer(modifier = Modifier.width(12.dp)) + val uriHandler = LocalUriHandler.current + TextButton( + onClick = { + viewModel.viewBackup(uriHandler::openUri) + } ) { - CircularProgressIndicator( - modifier = Modifier, - color = Orange, - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - modifier = Modifier, - text = "Backing up...", - color = Orange, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Start, - ) + Text("View") } } - - GitHubBackupStatus.Success -> { - Spacer(modifier = Modifier.height(8.dp)) - Text( - modifier = Modifier, - text = "Hurray! Backup successful!", - style = MaterialTheme.typography.bodyMedium, - color = UI.colors.green, - textAlign = TextAlign.Start, - ) - } - - null -> {} - } -} - -@Composable -private fun LastBackup( - viewModel: GitHubBackupViewModel, -) { - val lastBackupTime by viewModel.lastBackupTime.collectAsState(initial = null) - if (lastBackupTime != null) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - modifier = Modifier, - text = "Last backup at $lastBackupTime", - color = UI.colors.gray, - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Start, - ) - Spacer(modifier = Modifier.height(8.dp)) - val uriHandler = LocalUriHandler.current - OutlinedButton( - onClick = { - viewModel.viewBackup(uriHandler::openUri) - } - ) { - Text("View backup") - } } } @@ -196,18 +179,26 @@ private fun BackupDisabled( onClick = { nav.navigateTo(GitHubBackupScreen) }, - shape = RoundedCornerShape(12.dp), + shape = RoundedCornerShape(16.dp), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp) ) { Spacer(modifier = Modifier.width(4.dp)) - Image( - painter = painterResource(id = R.drawable.github_logo), - contentDescription = null, - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current) - ) + GitHubIcon() Spacer(modifier = Modifier.width(16.dp)) - Text("Enable GitHub auto backups") + Text("Enable GitHub auto-backups") Spacer(modifier = Modifier.weight(1f)) } +} + +@Composable +private fun GitHubIcon( + modifier: Modifier = Modifier, +) { + Image( + modifier = modifier, + painter = painterResource(id = R.drawable.github_logo), + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(LocalContentColor.current) + ) } \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/backup/github/ui/GitHubBackupScreen.kt b/app/src/main/java/com/ivy/wallet/backup/github/ui/GitHubBackupScreen.kt index a23e688af9..adeb76c91e 100644 --- a/app/src/main/java/com/ivy/wallet/backup/github/ui/GitHubBackupScreen.kt +++ b/app/src/main/java/com/ivy/wallet/backup/github/ui/GitHubBackupScreen.kt @@ -3,9 +3,12 @@ package com.ivy.wallet.backup.github.ui import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.outlined.Info @@ -15,10 +18,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -82,12 +88,21 @@ private fun Content( val viewModel = viewModel() Column( - modifier = modifier.padding(horizontal = 16.dp), + modifier = modifier + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), ) { Spacer(modifier = Modifier.height(24.dp)) var repoUrl by rememberSaveable { mutableStateOf("") } - var accessToken by rememberSaveable { mutableStateOf("") } + var gitHubPAT by rememberSaveable { mutableStateOf("") } + + LaunchedEffect(Unit) { + viewModel.getCredentials()?.let { + repoUrl = it.repoUrl + gitHubPAT = it.gitHubPAT + } + } Row( verticalAlignment = Alignment.CenterVertically, @@ -109,8 +124,8 @@ private fun Content( ) { OutlinedTextField( modifier = Modifier.weight(1f), - value = accessToken, - onValueChange = { accessToken = it }, + value = gitHubPAT, + onValueChange = { gitHubPAT = it }, label = { Text("GitHub PAT") } ) Spacer(modifier = Modifier.width(12.dp)) @@ -118,14 +133,45 @@ private fun Content( } Spacer(modifier = Modifier.height(24.dp)) + val enabled by viewModel.enabled.collectAsState(initial = false) ElevatedButton( + modifier = Modifier.fillMaxWidth(), onClick = { - viewModel.enableBackups(repoUrl, accessToken) + viewModel.enableBackups(repoUrl, gitHubPAT) }, - enabled = repoUrl.isNotBlank() && accessToken.isNotBlank() + enabled = repoUrl.isNotBlank() && gitHubPAT.isNotBlank() ) { - Text(text = "Connect") + Text(text = if (!enabled) "Connect" else "Update connection") } + if (enabled) { + Spacer(modifier = Modifier.height(12.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = viewModel::backupData + ) { + Text("Backup now") + } + Spacer(modifier = Modifier.width(16.dp)) + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = viewModel::importFromGitHub + ) { + Text("Import from GitHub") + } + } + } + Spacer(modifier = Modifier.height(24.dp)) + Column( + modifier = Modifier.padding(horizontal = 16.dp), + ) { + GitHubBackupStatus(viewModel = viewModel) + } + + // Scroll fix + Spacer(modifier = Modifier.height(320.dp)) } } diff --git a/app/src/main/java/com/ivy/wallet/backup/github/ui/GitHubBackupStatus.kt b/app/src/main/java/com/ivy/wallet/backup/github/ui/GitHubBackupStatus.kt new file mode 100644 index 0000000000..32d46ae7c4 --- /dev/null +++ b/app/src/main/java/com/ivy/wallet/backup/github/ui/GitHubBackupStatus.kt @@ -0,0 +1,74 @@ +package com.ivy.wallet.backup.github.ui + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.wallet.ui.theme.Orange + +@Composable +fun GitHubBackupStatus( + viewModel: GitHubBackupViewModel, +) { + val status by viewModel.backupStatus.collectAsState() + if (status == null) return + + + when (val stat = status) { + is GitHubBackupStatus.Error -> { + Spacer(modifier = Modifier.height(8.dp)) + Text( + modifier = Modifier, + text = stat.error, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Start, + ) + } + + GitHubBackupStatus.Loading -> { + Spacer(modifier = Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier, + color = Orange, + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + modifier = Modifier, + text = "Backing up...", + color = Orange, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } + + GitHubBackupStatus.Success -> { + Spacer(modifier = Modifier.height(8.dp)) + Text( + modifier = Modifier, + text = "Hurray! Backup successful!", + style = MaterialTheme.typography.bodyMedium, + color = UI.colors.green, + textAlign = TextAlign.Start, + ) + } + + null -> {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/backup/github/ui/GitHubBackupViewModel.kt b/app/src/main/java/com/ivy/wallet/backup/github/ui/GitHubBackupViewModel.kt index 4decc4bb8b..6ef504bade 100644 --- a/app/src/main/java/com/ivy/wallet/backup/github/ui/GitHubBackupViewModel.kt +++ b/app/src/main/java/com/ivy/wallet/backup/github/ui/GitHubBackupViewModel.kt @@ -1,32 +1,41 @@ package com.ivy.wallet.backup.github.ui +import android.annotation.SuppressLint +import android.content.Context +import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.ivy.frp.view.navigation.Navigation import com.ivy.wallet.backup.github.GitHubAutoBackupManager import com.ivy.wallet.backup.github.GitHubBackup -import com.ivy.wallet.datetime.format +import com.ivy.wallet.backup.github.GitHubCredentials +import com.ivy.wallet.backup.github.GitHubCredentialsManager import com.ivy.wallet.datetime.toLocal +import com.ivy.wallet.domain.deprecated.logic.zip.BackupLogic import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import java.time.format.FormatStyle +import java.time.format.DateTimeFormatter import javax.inject.Inject +@SuppressLint("StaticFieldLeak") @HiltViewModel class GitHubBackupViewModel @Inject constructor( private val gitHubBackup: GitHubBackup, - private val navigation: Navigation, private val gitHubAutoBackupManager: GitHubAutoBackupManager, + private val gitHubCredentialsManager: GitHubCredentialsManager, + @ApplicationContext + private val context: Context, + private val backupLogic: BackupLogic, ) : ViewModel() { val enabled = gitHubBackup.enabled val lastBackupTime: Flow = gitHubBackup.lastBackupTime.map { instant -> - instant?.toLocal()?.format(FormatStyle.MEDIUM) + instant?.toLocal()?.format(DateTimeFormatter.ofPattern("dd MMM, HH:mm")) } val backupStatus = MutableStateFlow(null) @@ -55,7 +64,6 @@ class GitHubBackupViewModel @Inject constructor( gitHubPAT = gitHubPAT.trim(), ).onRight { gitHubAutoBackupManager.scheduleAutoBackups() - navigation.back() } } } @@ -63,20 +71,67 @@ class GitHubBackupViewModel @Inject constructor( fun disableBackups() { viewModelScope.launch { gitHubBackup.disable() + gitHubAutoBackupManager.cancelAutoBackups() } } fun viewBackup(onOpenUrl: (String) -> Unit) { viewModelScope.launch { - gitHubBackup.repoUrl()?.let { - onOpenUrl(it) + gitHubCredentialsManager.getCredentials().onRight { + onOpenUrl(it.toRepoUrl()) } } } -} -sealed interface GitHubBackupStatus { - object Loading : GitHubBackupStatus - data class Error(val error: String) : GitHubBackupStatus - data object Success : GitHubBackupStatus + suspend fun getCredentials(): GitHubBackupInput? { + return gitHubCredentialsManager.getCredentials().getOrNull() + ?.let { + GitHubBackupInput( + repoUrl = it.toRepoUrl(), + gitHubPAT = it.gitHubPAT + ) + } + } + + private fun GitHubCredentials.toRepoUrl(): String { + return "https://github.com/${owner}/${repo}" + } + + private var backupImportInProgress = false + + fun importFromGitHub() { + viewModelScope.launch { + showToast("Importing backup... Be patient!") + if (backupImportInProgress) return@launch + + backupImportInProgress = true + gitHubBackup.readBackupJson() + .onRight { json -> + val result = backupLogic.importJson(json) + val toast = if (result.transactionsImported > 0) { + "Success! Imported ${result.transactionsImported} transactions." + } else { + "Import failed :/" + } + showToast(toast) + backupImportInProgress = false + } + .onLeft { + showToast(it) + backupImportInProgress = false + } + } + } + + private fun showToast( + text: String, + duration: Int = Toast.LENGTH_LONG, + ) { + Toast.makeText( + context, + text, + duration + ).show() + } + } \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/backup/github/ui/GitHubBackupViewState.kt b/app/src/main/java/com/ivy/wallet/backup/github/ui/GitHubBackupViewState.kt new file mode 100644 index 0000000000..aef533ed5c --- /dev/null +++ b/app/src/main/java/com/ivy/wallet/backup/github/ui/GitHubBackupViewState.kt @@ -0,0 +1,13 @@ +package com.ivy.wallet.backup.github.ui + + +sealed interface GitHubBackupStatus { + object Loading : GitHubBackupStatus + data class Error(val error: String) : GitHubBackupStatus + data object Success : GitHubBackupStatus +} + +data class GitHubBackupInput( + val repoUrl: String, + val gitHubPAT: String, +) \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/backup/ktor/KtorClient.kt b/app/src/main/java/com/ivy/wallet/backup/ktor/KtorClient.kt index b7b34b6ea6..7b2ca75c33 100644 --- a/app/src/main/java/com/ivy/wallet/backup/ktor/KtorClient.kt +++ b/app/src/main/java/com/ivy/wallet/backup/ktor/KtorClient.kt @@ -2,6 +2,7 @@ package com.ivy.wallet.backup.ktor import io.ktor.client.HttpClient import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging import io.ktor.serialization.kotlinx.json.json @@ -18,6 +19,7 @@ fun newKtorClient(): HttpClient { } install(Logging) { + level = LogLevel.BODY logger = object : Logger { override fun log(message: String) { Timber.d(message) diff --git a/app/src/main/java/com/ivy/wallet/domain/deprecated/logic/zip/ExportBackupLogic.kt b/app/src/main/java/com/ivy/wallet/domain/deprecated/logic/zip/BackupLogic.kt similarity index 81% rename from app/src/main/java/com/ivy/wallet/domain/deprecated/logic/zip/ExportBackupLogic.kt rename to app/src/main/java/com/ivy/wallet/domain/deprecated/logic/zip/BackupLogic.kt index 307c31e4ca..353abda43e 100644 --- a/app/src/main/java/com/ivy/wallet/domain/deprecated/logic/zip/ExportBackupLogic.kt +++ b/app/src/main/java/com/ivy/wallet/domain/deprecated/logic/zip/BackupLogic.kt @@ -13,6 +13,7 @@ import com.ivy.wallet.utils.ioThread import com.ivy.wallet.utils.readFile import com.ivy.wallet.utils.scopedIOThread import com.ivy.wallet.utils.toEpochMilli +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.async import timber.log.Timber import java.io.File @@ -24,7 +25,7 @@ import java.util.* import javax.inject.Inject -class ExportBackupLogic @Inject constructor( +class BackupLogic @Inject constructor( private val accountDao: AccountDao, private val budgetDao: BudgetDao, private val categoryDao: CategoryDao, @@ -34,18 +35,19 @@ class ExportBackupLogic @Inject constructor( private val settingsDao: SettingsDao, private val transactionDao: TransactionDao, private val sharedPrefs: SharedPrefs, + @ApplicationContext + private val context: Context, ) { suspend fun exportToFile( - context: Context, zipFileUri: Uri ) { val jsonString = generateJsonBackup() - val file = createJsonDataFile(context, jsonString) + val file = createJsonDataFile(jsonString) zip(context = context, zipFileUri, listOf(file)) - clearCacheDir(context) + clearCacheDir() } - private fun createJsonDataFile(context: Context, jsonString: String): File { + private fun createJsonDataFile(jsonString: String): File { val fileNamePrefix = "data" val fileNameSuffix = ".json" val outputDir = context.cacheDir @@ -115,63 +117,39 @@ class ExportBackupLogic @Inject constructor( } suspend fun import( - context: Context, - zipFileUri: Uri, + backupFileUri: Uri, onProgress: suspend (progressPercent: Double) -> Unit ): ImportResult { return ioThread { return@ioThread try { - val folderName = "backup" + System.currentTimeMillis() - val cacheFolderPath = File(context.cacheDir, folderName) + val jsonString = try { + val folderName = "backup" + System.currentTimeMillis() + val cacheFolderPath = File(context.cacheDir, folderName) - unzip(context, zipFileUri, cacheFolderPath) + unzip(context, backupFileUri, cacheFolderPath) - val filesArray = cacheFolderPath.listFiles() + val filesArray = cacheFolderPath.listFiles() - onProgress(0.05) + onProgress(0.05) - if (filesArray == null || filesArray.isEmpty()) - ImportResult( - rowsFound = 0, - transactionsImported = 0, - accountsImported = 0, - categoriesImported = 0, - failedRows = emptyList() - ) + if (filesArray == null || filesArray.isEmpty()) + error("Couldn't unzip") - val filesList = filesArray!!.toList().filter { - hasJsonExtension(it) - } - - onProgress(0.1) - - if (filesList.size != 1) - ImportResult( - rowsFound = 0, - transactionsImported = 0, - accountsImported = 0, - categoriesImported = 0, - failedRows = emptyList() - ) - - val jsonString = readFile(context, filesList[0].toUri(), Charsets.UTF_16) - val modifiedJsonString = accommodateExistingAccountsAndCategories(jsonString) - val ivyWalletCompleteData = getIvyWalletCompleteData(modifiedJsonString) + val filesList = filesArray.toList().filter { + hasJsonExtension(it) + } - onProgress(0.4) - insertDataToDb(completeData = ivyWalletCompleteData, onProgress = onProgress) - onProgress(1.0) + onProgress(0.1) - clearCacheDir(context) + if (filesList.size != 1) + error("Didn't unzip exactly one file.") - ImportResult( - rowsFound = ivyWalletCompleteData.transactions.size, - transactionsImported = ivyWalletCompleteData.transactions.size, - accountsImported = ivyWalletCompleteData.accounts.size, - categoriesImported = ivyWalletCompleteData.categories.size, - failedRows = emptyList() - ) + readFile(context, filesList[0].toUri(), Charsets.UTF_16) + } catch (e: Exception) { + readFile(context, backupFileUri, Charsets.UTF_16) + } ?: "" + importJson(jsonString, onProgress, clearCacheDir = true) } catch (e: Exception) { Timber.e("Import error: $e") ImportResult( @@ -185,6 +163,31 @@ class ExportBackupLogic @Inject constructor( } } + suspend fun importJson( + jsonString: String, + onProgress: suspend (Double) -> Unit = {}, + clearCacheDir: Boolean = false, + ): ImportResult { + val modifiedJsonString = accommodateExistingAccountsAndCategories(jsonString) + val ivyWalletCompleteData = getIvyWalletCompleteData(modifiedJsonString) + + onProgress(0.4) + insertDataToDb(completeData = ivyWalletCompleteData, onProgress = onProgress) + onProgress(1.0) + + if (clearCacheDir) { + clearCacheDir() + } + + return ImportResult( + rowsFound = ivyWalletCompleteData.transactions.size, + transactionsImported = ivyWalletCompleteData.transactions.size, + accountsImported = ivyWalletCompleteData.accounts.size, + categoriesImported = ivyWalletCompleteData.categories.size, + failedRows = emptyList() + ) + } + private suspend fun accommodateExistingAccountsAndCategories(jsonString: String?): String? { val ivyWalletCompleteData = getIvyWalletCompleteData(jsonString) val replacementPairs = getReplacementPairs(ivyWalletCompleteData) @@ -269,7 +272,8 @@ class ExportBackupLogic @Inject constructor( sharedPrefs.putBoolean( SharedPrefs.TRANSFERS_AS_INCOME_EXPENSE, - (completeData.sharedPrefs[SharedPrefs.TRANSFERS_AS_INCOME_EXPENSE] ?: "false").toBoolean() + (completeData.sharedPrefs[SharedPrefs.TRANSFERS_AS_INCOME_EXPENSE] + ?: "false").toBoolean() ) plannedPayments.await() @@ -337,7 +341,7 @@ class ExportBackupLogic @Inject constructor( return (name.substring(lastIndexOf).equals(".json", true)) } - private fun clearCacheDir(context: Context) { + private fun clearCacheDir() { context.cacheDir.deleteRecursively() } } \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/ui/csvimport/ImportViewModel.kt b/app/src/main/java/com/ivy/wallet/ui/csvimport/ImportViewModel.kt index 9e4c6c0615..a6d1f3f5d5 100644 --- a/app/src/main/java/com/ivy/wallet/ui/csvimport/ImportViewModel.kt +++ b/app/src/main/java/com/ivy/wallet/ui/csvimport/ImportViewModel.kt @@ -13,7 +13,7 @@ import com.ivy.wallet.domain.deprecated.logic.csv.CSVNormalizer import com.ivy.wallet.domain.deprecated.logic.csv.IvyFileReader import com.ivy.wallet.domain.deprecated.logic.csv.model.ImportResult import com.ivy.wallet.domain.deprecated.logic.csv.model.ImportType -import com.ivy.wallet.domain.deprecated.logic.zip.ExportBackupLogic +import com.ivy.wallet.domain.deprecated.logic.zip.BackupLogic import com.ivy.wallet.ui.Import import com.ivy.wallet.ui.IvyWalletCtx import com.ivy.wallet.ui.onboarding.viewmodel.OnboardingViewModel @@ -35,7 +35,7 @@ class ImportViewModel @Inject constructor( private val csvNormalizer: CSVNormalizer, private val csvMapper: CSVMapper, private val csvImporter: CSVImporter, - private val exportBackupLogic: ExportBackupLogic + private val backupLogic: BackupLogic ) : ViewModel() { private val _importStep = MutableLiveData() val importStep = _importStep.asLiveData() @@ -83,15 +83,14 @@ class ImportViewModel @Inject constructor( _importResult.value = if (hasCSVExtension(context, fileUri)) restoreCSVFile(fileUri = fileUri, importType = importType) else { - exportBackupLogic.import( - context = context, - zipFileUri = fileUri, - onProgress = { progressPercent -> - uiThread { - _importProgressPercent.value = - (progressPercent * 100).roundToInt() - } - }) + backupLogic.import( + backupFileUri = fileUri + ) { progressPercent -> + uiThread { + _importProgressPercent.value = + (progressPercent * 100).roundToInt() + } + } } _importStep.value = ImportStep.RESULT diff --git a/app/src/main/java/com/ivy/wallet/ui/serverstop/ServerStopViewModel.kt b/app/src/main/java/com/ivy/wallet/ui/serverstop/ServerStopViewModel.kt index ef130a1b97..e89462a1e1 100644 --- a/app/src/main/java/com/ivy/wallet/ui/serverstop/ServerStopViewModel.kt +++ b/app/src/main/java/com/ivy/wallet/ui/serverstop/ServerStopViewModel.kt @@ -5,7 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ivy.frp.test.TestIdlingResource import com.ivy.frp.view.navigation.Navigation -import com.ivy.wallet.domain.deprecated.logic.zip.ExportBackupLogic +import com.ivy.wallet.domain.deprecated.logic.zip.BackupLogic import com.ivy.wallet.io.persistence.SharedPrefs import com.ivy.wallet.ui.IvyWalletCtx import com.ivy.wallet.ui.RootActivity @@ -23,7 +23,7 @@ import javax.inject.Inject class ServerStopViewModel @Inject constructor( private val ivyContext: IvyWalletCtx, private val sharedPrefs: SharedPrefs, - private val exportBackupLogic: ExportBackupLogic, + private val backupLogic: BackupLogic, private val navigation: Navigation, ) : ViewModel() { private val exportInProgress = MutableStateFlow(false) @@ -48,7 +48,7 @@ class ServerStopViewModel @Inject constructor( TestIdlingResource.increment() exportInProgress.value = true - exportBackupLogic.exportToFile(context = context, zipFileUri = fileUri) + backupLogic.exportToFile(zipFileUri = fileUri) exportInProgress.value = false sharedPrefs.putBoolean(SharedPrefs.DATA_BACKUP_COMPLETED, true) diff --git a/app/src/main/java/com/ivy/wallet/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/ivy/wallet/ui/settings/SettingsViewModel.kt index f5151a65d6..670b6522ca 100644 --- a/app/src/main/java/com/ivy/wallet/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/ivy/wallet/ui/settings/SettingsViewModel.kt @@ -16,7 +16,7 @@ import com.ivy.wallet.domain.data.core.User import com.ivy.wallet.domain.deprecated.logic.LogoutLogic import com.ivy.wallet.domain.deprecated.logic.csv.ExportCSVLogic import com.ivy.wallet.domain.deprecated.logic.currency.ExchangeRatesLogic -import com.ivy.wallet.domain.deprecated.logic.zip.ExportBackupLogic +import com.ivy.wallet.domain.deprecated.logic.zip.BackupLogic import com.ivy.wallet.domain.deprecated.sync.IvySync import com.ivy.wallet.io.network.FCMClient import com.ivy.wallet.io.network.IvyAnalytics @@ -61,7 +61,7 @@ class SettingsViewModel @Inject constructor( private val exchangeRatesLogic: ExchangeRatesLogic, private val logoutLogic: LogoutLogic, private val sharedPrefs: SharedPrefs, - private val exportBackupLogic: ExportBackupLogic, + private val backupLogic: BackupLogic, private val startDayOfMonthAct: StartDayOfMonthAct, private val updateStartDayOfMonthAct: UpdateStartDayOfMonthAct, private val fetchAllTrnsFromServerAct: FetchAllTrnsFromServerAct, @@ -223,7 +223,7 @@ class SettingsViewModel @Inject constructor( TestIdlingResource.increment() _progressState.value = true - exportBackupLogic.exportToFile(context = context, zipFileUri = fileUri) + backupLogic.exportToFile(zipFileUri = fileUri) _progressState.value = false sharedPrefs.putBoolean(SharedPrefs.DATA_BACKUP_COMPLETED, true)