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 numberDiff line numberDiff 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 DownExpand 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
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
Comment thread
ovitrif marked this conversation as resolved.

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 DownExpand 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
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
Comment thread
ovitrif marked this conversation as resolved.

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
Comment thread
ovitrif marked this conversation as resolved.

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
Comment thread
ovitrif marked this conversation as resolved.

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 DownExpand 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
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
Comment thread
ovitrif marked this conversation as resolved.

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
Comment thread
ovitrif marked this conversation as resolved.

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
Comment thread
ovitrif marked this conversation as resolved.

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 DownExpand 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
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
Comment thread
ovitrif marked this conversation as resolved.

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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