Skip to content
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
20 changes: 14 additions & 6 deletions app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import to.bitkit.data.keychain.Keychain
import to.bitkit.di.BgDispatcher
import to.bitkit.di.IoDispatcher
import to.bitkit.env.Env
import to.bitkit.utils.Logger
import to.bitkit.utils.ServiceError
Expand All @@ -20,13 +20,13 @@ import kotlin.time.Duration.Companion.seconds

@Singleton
class VssBackupClient @Inject constructor(
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val vssStoreIdProvider: VssStoreIdProvider,
private val keychain: Keychain,
) {
private val isSetup = CompletableDeferred<Unit>()
private var isSetup = CompletableDeferred<Unit>()

suspend fun setup(walletIndex: Int = 0) = withContext(bgDispatcher) {
suspend fun setup(walletIndex: Int = 0) = withContext(ioDispatcher) {
try {
withTimeout(30.seconds) {
Logger.debug("VSS client setting up…", context = TAG)
Expand Down Expand Up @@ -62,10 +62,18 @@ class VssBackupClient @Inject constructor(
}
}

fun reset() {
synchronized(this) {
isSetup.cancel()
isSetup = CompletableDeferred()
}
vssStoreIdProvider.clearCache()
Logger.debug("VSS client reset", context = TAG)
}
suspend fun putObject(
key: String,
data: ByteArray,
): Result<VssItem> = withContext(bgDispatcher) {
): Result<VssItem> = withContext(ioDispatcher) {
isSetup.await()
Logger.verbose("VSS 'putObject' call for '$key'", context = TAG)
runCatching {
Expand All @@ -80,7 +88,7 @@ class VssBackupClient @Inject constructor(
}
}

suspend fun getObject(key: String): Result<VssItem?> = withContext(bgDispatcher) {
suspend fun getObject(key: String): Result<VssItem?> = withContext(ioDispatcher) {
isSetup.await()
Logger.verbose("VSS 'getObject' call for '$key'", context = TAG)
runCatching {
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ class VssStoreIdProvider @Inject constructor(
}
}

fun clearCache(walletIndex: Int = 0) {
cacheMap.remove(walletIndex)
fun clearCache() {
cacheMap.clear()
}

companion object {
Expand Down
3 changes: 0 additions & 3 deletions app/src/main/java/to/bitkit/di/DispatchersModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,12 @@ import kotlinx.coroutines.Dispatchers
import javax.inject.Qualifier

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class UiDispatcher

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class BgDispatcher

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher

@Module
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/to/bitkit/ext/DateTime.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package to.bitkit.ext

import android.icu.text.DateFormat
import android.icu.util.ULocale
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
Expand All @@ -22,6 +23,8 @@ import java.util.Locale
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds

fun nowMillis(clock: Clock = Clock.System): Long = clock.now().toEpochMilliseconds()

fun nowTimestamp(): Instant = Instant.now().truncatedTo(ChronoUnit.SECONDS)

fun Instant.formatted(pattern: String = DatePattern.DATE_TIME): String {
Expand Down
8 changes: 5 additions & 3 deletions app/src/main/java/to/bitkit/models/BackupCategory.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ enum class BackupCategory(
@Serializable
data class BackupItemStatus(
val running: Boolean = false,
val synced: Long = 0L,
val required: Long = 0L,
)
val synced: Long = 0,
val required: Long = 0,
) {
val isRequired: Boolean get() = synced < required
}
67 changes: 50 additions & 17 deletions app/src/main/java/to/bitkit/repositories/BackupRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ import to.bitkit.data.WidgetsData
import to.bitkit.data.WidgetsStore
import to.bitkit.data.backup.VssBackupClient
import to.bitkit.data.resetPin
import to.bitkit.di.BgDispatcher
import to.bitkit.di.IoDispatcher
import to.bitkit.di.json
import to.bitkit.ext.formatPlural
import to.bitkit.ext.nowMillis
import to.bitkit.models.ActivityBackupV1
import to.bitkit.models.BackupCategory
import to.bitkit.models.BackupItemStatus
Expand All @@ -36,14 +37,15 @@ import to.bitkit.models.WalletBackupV1
import to.bitkit.services.LightningService
import to.bitkit.ui.shared.toast.ToastEventBus
import to.bitkit.utils.Logger
import to.bitkit.utils.jsonLogOf
import javax.inject.Inject
import javax.inject.Singleton

@Suppress("LongParameterList")
@Singleton
class BackupRepo @Inject constructor(
@ApplicationContext private val context: Context,
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val cacheStore: CacheStore,
private val vssBackupClient: VssBackupClient,
private val settingsStore: SettingsStore,
Expand All @@ -54,7 +56,7 @@ class BackupRepo @Inject constructor(
private val clock: Clock,
private val db: AppDb,
) {
private val scope = CoroutineScope(bgDispatcher + SupervisorJob())
private val scope = CoroutineScope(ioDispatcher + SupervisorJob())

private val backupJobs = mutableMapOf<BackupCategory, Job>()
private val statusObserverJobs = mutableListOf<Job>()
Expand All @@ -65,6 +67,11 @@ class BackupRepo @Inject constructor(

private var lastNotificationTime = 0L

fun reset() {
stopObservingBackups()
vssBackupClient.reset()
}

fun startObservingBackups() {
if (isObserving) return

Expand Down Expand Up @@ -112,7 +119,7 @@ class BackupRepo @Inject constructor(
old.synced == new.synced && old.required == new.required
}
.collect { status ->
if (status.synced < status.required && !status.running && !isRestoring) {
if (status.isRequired && !status.running && !isRestoring) {
scheduleBackup(category)
}
}
Expand Down Expand Up @@ -249,12 +256,22 @@ class BackupRepo @Inject constructor(
Logger.verbose("Scheduling backup for: '$category'", context = TAG)

backupJobs[category] = scope.launch {
// Set running immediately to prevent UI showing failure during debounce
cacheStore.updateBackupStatus(category) {
it.copy(running = true)
}

delay(BACKUP_DEBOUNCE)

// Double-check if backup is still needed
val status = cacheStore.backupStatuses.first()[category] ?: BackupItemStatus()
if (status.synced < status.required && !status.running && !isRestoring) {
if (status.isRequired && !isRestoring) {
triggerBackup(category)
} else {
// Backup no longer needed, reset running flag
cacheStore.updateBackupStatus(category) {
it.copy(running = false)
}
}
}
}
Expand All @@ -268,7 +285,7 @@ class BackupRepo @Inject constructor(
val hasFailedBackups = BackupCategory.entries.any { category ->
val status = backupStatuses[category] ?: BackupItemStatus()

val isPendingAndOverdue = status.synced < status.required &&
val isPendingAndOverdue = status.isRequired &&
currentTime - status.required > FAILED_BACKUP_CHECK_TIME
return@any isPendingAndOverdue
}
Expand All @@ -290,13 +307,13 @@ class BackupRepo @Inject constructor(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.settings__backup__failed_title),
description = context.getString(R.string.settings__backup__failed_message).formatPlural(
mapOf("interval" to (BACKUP_CHECK_INTERVAL / 60000)) // displayed in minutes
mapOf("interval" to (BACKUP_CHECK_INTERVAL / MINUTE_IN_MS))
),
)
}
}

suspend fun triggerBackup(category: BackupCategory) = withContext(bgDispatcher) {
suspend fun triggerBackup(category: BackupCategory) = withContext(ioDispatcher) {
Logger.debug("Backup starting for: '$category'", context = TAG)

cacheStore.updateBackupStatus(category) {
Expand Down Expand Up @@ -385,12 +402,23 @@ class BackupRepo @Inject constructor(
BackupCategory.LIGHTNING_CONNECTIONS -> throw NotImplementedError("LIGHTNING backup is managed by ldk-node")
}

suspend fun performFullRestoreFromLatestBackup(): Result<Unit> = withContext(bgDispatcher) {
suspend fun performFullRestoreFromLatestBackup(
onCacheRestored: suspend () -> Unit = {},
): Result<Unit> = withContext(ioDispatcher) {
Logger.debug("Full restore starting", context = TAG)

isRestoring = true

return@withContext try {
performRestore(BackupCategory.METADATA) { dataBytes ->
val parsed = json.decodeFromString<MetadataBackupV1>(String(dataBytes))
cacheStore.update { parsed.cache }
Logger.debug("Restored caches: ${jsonLogOf(parsed.cache.copy(cachedRates = emptyList()))}", TAG)
onCacheRestored()
db.tagMetadataDao().upsert(parsed.tagMetadata)
Logger.debug("Restored caches and ${parsed.tagMetadata.size} tags metadata records", TAG)
}

performRestore(BackupCategory.SETTINGS) { dataBytes ->
val parsed = json.decodeFromString<SettingsData>(String(dataBytes)).resetPin()
settingsStore.update { parsed }
Expand All @@ -404,12 +432,6 @@ class BackupRepo @Inject constructor(
db.transferDao().upsert(parsed.transfers)
Logger.debug("Restored ${parsed.transfers.size} transfers", context = TAG)
}
performRestore(BackupCategory.METADATA) { dataBytes ->
val parsed = json.decodeFromString<MetadataBackupV1>(String(dataBytes))
db.tagMetadataDao().upsert(parsed.tagMetadata)
cacheStore.update { parsed.cache }
Logger.debug("Restored caches and ${parsed.tagMetadata.size} tags metadata records", TAG)
}
performRestore(BackupCategory.BLOCKTANK) { dataBytes ->
val parsed = json.decodeFromString<BlocktankBackupV1>(String(dataBytes))
blocktankRepo.restoreFromBackup(parsed).onSuccess {
Expand All @@ -436,6 +458,15 @@ class BackupRepo @Inject constructor(
}
}

fun scheduleFullBackup() {
Logger.debug("Scheduling backups for all categories", context = TAG)
BackupCategory.entries
.filter { it != BackupCategory.LIGHTNING_CONNECTIONS }
.forEach {
scheduleBackup(it)
}
}

private suspend fun performRestore(
category: BackupCategory,
restoreAction: suspend (ByteArray) -> Unit,
Expand All @@ -453,16 +484,18 @@ class BackupRepo @Inject constructor(
Logger.debug("Restore error for: '$category'", context = TAG)
}

val now = currentTimeMillis()
cacheStore.updateBackupStatus(category) {
it.copy(running = false, synced = currentTimeMillis())
it.copy(running = false, synced = now, required = now)
}
}

private fun currentTimeMillis(): Long = clock.now().toEpochMilliseconds()
private fun currentTimeMillis(): Long = nowMillis(clock)

companion object {
private const val TAG = "BackupRepo"

private const val MINUTE_IN_MS = 60_000
private const val BACKUP_DEBOUNCE = 5000L // 5 seconds
private const val BACKUP_CHECK_INTERVAL = 60 * 1000L // 1 minute
private const val FAILED_BACKUP_CHECK_TIME = 30 * 60 * 1000L // 30 minutes
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/to/bitkit/repositories/HealthRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ class HealthRepo @Inject constructor(
val now = clock.now().toEpochMilliseconds()

fun isSyncOk(synced: Long, required: Long) =
synced > required || (now - required) < 5.minutes.inWholeMilliseconds
synced >= required || (now - required) < 5.minutes.inWholeMilliseconds

val isBackupSyncOk = BackupCategory.entries
.filter { it != BackupCategory.LIGHTNING_CONNECTIONS }
Expand Down
14 changes: 6 additions & 8 deletions app/src/main/java/to/bitkit/repositories/WalletRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class WalletRepo @Inject constructor(
private val cacheStore: CacheStore,
private val deriveBalanceStateUseCase: DeriveBalanceStateUseCase,
private val vssStoreIdProvider: VssStoreIdProvider,
private val backupRepo: BackupRepo,
) {
private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob())

Expand All @@ -61,11 +62,6 @@ class WalletRepo @Inject constructor(
private val _balanceState = MutableStateFlow(BalanceState())
val balanceState = _balanceState.asStateFlow()

init {
// Load from cache once on init
loadFromCache()
}

fun loadFromCache() {
// TODO try keeping in sync with cache if performant and reliable
repoScope.launch {
Expand Down Expand Up @@ -243,15 +239,17 @@ class WalletRepo @Inject constructor(

suspend fun wipeWallet(walletIndex: Int = 0): Result<Unit> = withContext(bgDispatcher) {
try {
backupRepo.reset()

_walletState.update { WalletState() }
_balanceState.update { BalanceState() }

keychain.wipe()
vssStoreIdProvider.clearCache(walletIndex)
db.clearAllTables()
settingsStore.reset()
cacheStore.reset()
// TODO CLEAN ACTIVITY'S AND UPDATE STATE. CHECK ActivityListViewModel.removeAllActivities
coreService.activity.removeAll()
_walletState.update { WalletState() }
_balanceState.update { BalanceState() }
setWalletExistsState()

return@withContext lightningRepo.wipeStorage(walletIndex = walletIndex)
Expand Down
11 changes: 5 additions & 6 deletions app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ private fun BackupSettingsScreenContent(
onBack: () -> Unit,
onClose: () -> Unit,
) {
val allSynced = uiState.categories.all { it.status.synced >= it.status.required }
val allSynced = uiState.categories.all { !it.status.isRequired }
ScreenColumn {
AppTopBar(
titleText = stringResource(R.string.settings__backup__title),
Expand Down Expand Up @@ -158,7 +158,7 @@ private fun BackupStatusItem(

val subtitle = when {
status.running -> "Running" // TODO add missing localized text
status.synced >= status.required -> stringResource(R.string.settings__backup__status_success)
!status.isRequired -> stringResource(R.string.settings__backup__status_success)
.replace("{time}", status.synced.toLocalizedTimestamp())

else -> stringResource(R.string.settings__backup__status_failed)
Expand All @@ -182,7 +182,7 @@ private fun BackupStatusItem(
CaptionB(text = subtitle, color = Colors.White64, maxLines = 1)
}

val showRetry = !uiState.disableRetry && !status.running && status.synced < status.required
val showRetry = !uiState.disableRetry && !status.running && status.isRequired
if (showRetry) {
BackupRetryButton(
onClick = { onRetryClick(uiState.category) },
Expand All @@ -204,7 +204,7 @@ private fun BackupStatusIcon(
.background(
color = when {
status.running -> Colors.Yellow16
status.synced >= status.required -> Colors.Green16
!status.isRequired -> Colors.Green16
else -> Colors.Red16
},
shape = CircleShape
Expand All @@ -215,7 +215,7 @@ private fun BackupStatusIcon(
contentDescription = null,
tint = when {
status.running -> Colors.Yellow
status.synced >= status.required -> Colors.Green
!status.isRequired -> Colors.Green
else -> Colors.Red
},
modifier = Modifier.size(16.dp)
Expand Down Expand Up @@ -251,7 +251,6 @@ private fun Preview() {
val timestamp = System.currentTimeMillis() - (minutesAgo * 60 * 1000)

when (it.category) {
BackupCategory.LIGHTNING_CONNECTIONS -> it.copy(disableRetry = true)
BackupCategory.WALLET -> it.copy(status = BackupItemStatus(running = true, required = 1))
BackupCategory.METADATA -> it.copy(status = BackupItemStatus(required = 1))
else -> it.copy(status = BackupItemStatus(synced = timestamp, required = timestamp))
Expand Down
Loading
Loading