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

GitHub backups #2509

Merged
merged 7 commits into from
Aug 27, 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
Original file line number Diff line number Diff line change
@@ -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<GitHubBackupWorker>(
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()
},
)
}
}
}
31 changes: 20 additions & 11 deletions app/src/main/java/com/ivy/wallet/backup/github/GitHubBackup.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Boolean?>(null)
private val lastBackupTimeState = MutableStateFlow<Long?>(null)

Expand Down Expand Up @@ -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<Error, Unit> = withContext(Dispatchers.IO) {
suspend fun backupData(
isAutomatic: Boolean = false,
): Either<Error, Unit> = 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 {
Expand All @@ -97,6 +98,14 @@ class GitHubBackup @Inject constructor(
}
}

suspend fun readBackupJson(): Either<String, String> = 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

Expand Down
79 changes: 68 additions & 11 deletions app/src/main/java/com/ivy/wallet/backup/github/GitHubClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand All @@ -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<String, Unit> = 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 = "[email protected]"),
message = if (isAutomatic) {
"Automatic Ivy Wallet data backup"
} else {
"Manual Ivy Wallet data backup"
},
committer = Committer(
name = "Ivy Wallet",
email = "[email protected]"
),
sha = sha,
)

val response = httpClient.get().put(url) {
val response = httpClient.get().put(repoUrl) {
headers {
githubToken(credentials)
contentType(ContentType.Application.Json)
Expand All @@ -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<String, String> = 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<String, String> = 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<ByteArray>()

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<GitHubFileResponse>().sha
}.body<GitHubFileResponse>()

}) {
null
}

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

private fun HeadersBuilder.acceptsUtf16() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class GitHubCredentialsManager @Inject constructor(
GitHubCredentials(
owner = owner,
repo = repo,
accessToken = token,
gitHubPAT = token,
)
}
}
Expand Down
Loading