Skip to content
This repository has been archived by the owner on Nov 5, 2024. It is now read-only.

GitHub backups + Material3 migration #2507

Merged
merged 15 commits into from
Aug 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .idea/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 16 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ plugins {
id("com.google.firebase.crashlytics")
id("org.jetbrains.kotlin.android")
id("dagger.hilt.android.plugin")

alias(libs.plugins.kotlinx.serialization)
}

android {
Expand Down Expand Up @@ -114,7 +116,7 @@ android {
}

composeOptions {
kotlinCompilerExtensionVersion = com.ivy.wallet.buildsrc.GlobalVersions.compose
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
}

lint {
Expand All @@ -139,4 +141,17 @@ android {

dependencies {
appModuleDependencies()
implementation(project(":ivy-design"))

implementation(libs.ivy.frp.temp)
implementation(libs.bundles.kotlin)
implementation(libs.bundles.ktor)
implementation(libs.bundles.arrow)
implementation(libs.bundles.compose)
implementation(libs.bundles.glance)
implementation(libs.datastore)
implementation(libs.androidx.security)

testImplementation(libs.bundles.kotest)
testImplementation(libs.bundles.kotlin.test)
}
26 changes: 0 additions & 26 deletions app/src/main/java/com/ivy/wallet/AppModuleDI.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import com.ivy.wallet.domain.deprecated.logic.loantrasactions.LTLoanRecordMapper
import com.ivy.wallet.domain.deprecated.logic.loantrasactions.LoanTransactionsCore
import com.ivy.wallet.domain.deprecated.logic.loantrasactions.LoanTransactionsLogic
import com.ivy.wallet.domain.deprecated.logic.notification.TransactionReminderLogic
import com.ivy.wallet.domain.deprecated.logic.zip.ExportZipLogic
import com.ivy.wallet.domain.deprecated.sync.IvySync
import com.ivy.wallet.domain.deprecated.sync.item.*
import com.ivy.wallet.domain.deprecated.sync.uploader.*
Expand Down Expand Up @@ -715,31 +714,6 @@ object AppModuleDI {
)
}

@Provides
fun providesExportZipLogic(
accountDao: AccountDao,
budgetDao: BudgetDao,
categoryDao: CategoryDao,
loanRecordDao: LoanRecordDao,
loanDao: LoanDao,
plannedPaymentRuleDao: PlannedPaymentRuleDao,
settingsDao: SettingsDao,
transactionDao: TransactionDao,
sharedPrefs: SharedPrefs
): ExportZipLogic {
return ExportZipLogic(
accountDao,
budgetDao,
categoryDao,
loanRecordDao,
loanDao,
plannedPaymentRuleDao,
settingsDao,
transactionDao,
sharedPrefs
)
}

@Provides
fun provideExpImagesService(): ExpImagesService = object : ExpImagesService {
override suspend fun fetchImages(): List<String> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.ivy.wallet.backup.github

import javax.inject.Inject

class GitHubAutoBackupManager @Inject constructor() {
fun scheduleAutoBackups() {
// TODO:
}
}
113 changes: 113 additions & 0 deletions app/src/main/java/com/ivy/wallet/backup/github/GitHubBackup.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package com.ivy.wallet.backup.github

import android.content.Context
import androidx.datastore.preferences.core.edit
import arrow.core.Either
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 dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.time.Instant
import javax.inject.Inject

class GitHubBackup @Inject constructor(
private val client: GitHubClient,
private val credentialsManager: GitHubCredentialsManager,
private val exportBackupLogic: ExportBackupLogic,
@ApplicationContext
private val appContext: Context
) {
private val enabledInternalState = MutableStateFlow<Boolean?>(null)
private val lastBackupTimeState = MutableStateFlow<Long?>(null)

val enabled: Flow<Boolean> = enabledInternalState.map { state ->
state ?: credentialsManager.getCredentials().isRight().also {
enabledInternalState.value = it
}
}.distinctUntilChanged()

val lastBackupTime: Flow<Instant?> = lastBackupTimeState.map { state ->
val epochSeconds = state ?: appContext.dataStore.data
.firstOrNull()?.let { it[DatastoreKeys.GITHUB_LAST_BACKUP_EPOCH_SEC] }

epochSeconds?.let(Instant::ofEpochSecond)
}

suspend fun enable(
gitHubUrl: String,
gitHubPAT: String,
): Either<String, Unit> = either {
val regex = """https?://(?:www\.)?github\.com/([^/]+)/([^/]+)""".toRegex()
val matchResult = regex.find(gitHubUrl)

val owner = matchResult?.groups?.get(1)?.value
ensureNotNull(owner) {
"Couldn't parse 'owner' from \"$gitHubUrl.\""
}
val repo = matchResult.groups[2]?.value
ensureNotNull(repo) {
"Couldn't parse 'repo' from \"$gitHubUrl.\""
}

credentialsManager.saveCredentials(
owner = owner,
repo = repo,
gitHubPAT = gitHubPAT,
).bind()
enabledInternalState.value = true
}

suspend fun disable() {
credentialsManager.removeSaved()
enabledInternalState.value = false
}

suspend fun repoUrl(): String? {
return credentialsManager.getCredentials()
.map { "https://github.com/${it.owner}/${it.repo}" }
.getOrNull()
}

suspend fun backupData(): Either<Error, Unit> = withContext(Dispatchers.IO) {
either {
val credentials = credentialsManager.getCredentials()
.mapLeft(Error::MissingCredentials).bind()

val json = exportBackupLogic.generateJsonBackup()
client.commit(
credentials = credentials,
path = "Ivy-Wallet-backup.json",
content = json,
).mapLeft(Error::Commit).bind()

appContext.dataStore.edit {
val epochSecondsNow = Instant.now().epochSecond
it[DatastoreKeys.GITHUB_LAST_BACKUP_EPOCH_SEC] = epochSecondsNow
lastBackupTimeState.value = epochSecondsNow
}
}
}

sealed interface Error {
val humanReadable: String

data class MissingCredentials(val error: String) : Error {
override val humanReadable: String
get() = "Missing credentials: $error."
}

data class Commit(val error: String) : Error {
override val humanReadable: String
get() = "Failed to commit: $error."
}
}
}
104 changes: 104 additions & 0 deletions app/src/main/java/com/ivy/wallet/backup/github/GitHubClient.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.ivy.wallet.backup.github

import androidx.annotation.Keep
import arrow.core.Either
import arrow.core.raise.catch
import arrow.core.raise.either
import arrow.core.raise.ensure
import dagger.Lazy
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.headers
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HeadersBuilder
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.util.encodeBase64
import kotlinx.serialization.Serializable
import javax.inject.Inject


class GitHubClient @Inject constructor(
private val httpClient: Lazy<HttpClient>,
) {
@Keep
@Serializable
data class GitHubFileContent(
val content: String,
val message: String,
val committer: Committer,
val sha: String?,
)

@Keep
@Serializable
data class Committer(val name: String, val email: String)

@Keep
@Serializable
data class GitHubFileResponse(val sha: String)

suspend fun commit(
credentials: GitHubCredentials,
path: String,
content: String,
): Either<String, Unit> = either {
val url = repoUrl(credentials, path)
val sha = getExistingFileSha(credentials, url)

val encodedContent = content.toByteArray(Charsets.UTF_16).encodeBase64()

val requestBody = GitHubFileContent(
content = encodedContent,
message = "Committing from Ktor",
committer = Committer(name = "Ivy Wallet", email = "[email protected]"),
sha = sha,
)

val response = httpClient.get().put(url) {
headers {
githubToken(credentials)
contentType(ContentType.Application.Json)
acceptsUtf16()
}
setBody(requestBody)
}
ensure(response.status.isSuccess()) {
"Unsuccessful response: ${response.status}"
}
return Either.Right(Unit)
}

private suspend fun getExistingFileSha(
credentials: GitHubCredentials,
url: String
): String? = catch({
// Fetch the current file to get its SHA
httpClient.get().get(url) {
headers {
githubToken(credentials)
}
}.body<GitHubFileResponse>().sha

}) {
null
}

private fun HeadersBuilder.githubToken(credentials: GitHubCredentials) {
append("Authorization", "token ${credentials.accessToken}")
}

private fun HeadersBuilder.acceptsUtf16() {
append("Accept-Charset", "UTF-16")
}

private fun repoUrl(
credentials: GitHubCredentials,
path: String
): String {
return "https://api.github.com/repos/${credentials.owner}/${credentials.repo}/contents/$path"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.ivy.wallet.backup.github

data class GitHubCredentials(
val owner: String,
val repo: String,
val accessToken: String,
)
Loading