From 8dfd9cd9011efc1626ba22d328e9dc992b28a794 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 31 Oct 2025 12:55:43 +0100 Subject: [PATCH 01/29] feat: wallet backup boosts & transfers --- .../main/java/to/bitkit/data/SettingsStore.kt | 2 +- .../main/java/to/bitkit/data/WidgetsStore.kt | 2 +- .../java/to/bitkit/data/dao/TransferDao.kt | 6 + app/src/main/java/to/bitkit/env/Env.kt | 8 + .../java/to/bitkit/models/BackupPayloads.kt | 21 + .../java/to/bitkit/repositories/BackupRepo.kt | 62 +- docs/backups-plan.md | 737 ++++++++++++++++++ 7 files changed, 834 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/to/bitkit/models/BackupPayloads.kt create mode 100644 docs/backups-plan.md diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index e10fd4942..ea3c10b53 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -20,7 +20,7 @@ import javax.inject.Singleton private val Context.settingsDataStore: DataStore by dataStore( fileName = "settings.json", - serializer = SettingsSerializer + serializer = SettingsSerializer, ) @Singleton diff --git a/app/src/main/java/to/bitkit/data/WidgetsStore.kt b/app/src/main/java/to/bitkit/data/WidgetsStore.kt index 27f5dfee2..2a7fab9ae 100644 --- a/app/src/main/java/to/bitkit/data/WidgetsStore.kt +++ b/app/src/main/java/to/bitkit/data/WidgetsStore.kt @@ -27,7 +27,7 @@ import javax.inject.Singleton private val Context.widgetsDataStore: DataStore by dataStore( fileName = "widgets.json", - serializer = WidgetsSerializer + serializer = WidgetsSerializer, ) @Singleton diff --git a/app/src/main/java/to/bitkit/data/dao/TransferDao.kt b/app/src/main/java/to/bitkit/data/dao/TransferDao.kt index 5107d08c3..389024be5 100644 --- a/app/src/main/java/to/bitkit/data/dao/TransferDao.kt +++ b/app/src/main/java/to/bitkit/data/dao/TransferDao.kt @@ -16,6 +16,12 @@ interface TransferDao { @Update suspend fun update(transfer: TransferEntity) + @Query("SELECT * FROM transfers") + suspend fun getAll(): List + + @Query("SELECT * FROM transfers") + fun observeAll(): Flow> + @Query("SELECT * FROM transfers WHERE isSettled = 0") fun getActiveTransfers(): Flow> diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index f3eed9dbf..76f7de5fd 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -134,8 +134,16 @@ internal object Env { return "$BIT_REFILL_URL/$page/$BITREFILL_PARAMS" } + /** + * Generates the storage path for a specified wallet index, network, and directory. + * + * Output format: + * + * `appStoragePath/network/walletN/dir` + */ private fun storagePathOf(walletIndex: Int, network: String, dir: String): String { require(::appStoragePath.isInitialized) { "App storage path should be 'context.filesDir.absolutePath'." } + // appStoragePath/network/walletN/dir val path = Path(appStoragePath, network, "wallet$walletIndex", dir) .toFile() .ensureDir() diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt new file mode 100644 index 000000000..3869f5fe3 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -0,0 +1,21 @@ +package to.bitkit.models + +import kotlinx.serialization.Serializable +import to.bitkit.data.dto.PendingBoostActivity +import to.bitkit.data.entities.TransferEntity + +/** + * Wallet backup payload (v1) + * + * Contains: + * - Boosted transaction activities from CacheStore + * - Transfer entities from Room database + */ +@Serializable +data class WalletBackupV1( + val version: Int = 1, + val createdAt: Long, + val boostedActivities: List, + val transfers: List, +) + diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 4249b1574..7d4863a80 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import to.bitkit.R +import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore @@ -27,6 +28,7 @@ import to.bitkit.ext.formatPlural import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus import to.bitkit.models.Toast +import to.bitkit.models.WalletBackupV1 import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject @@ -40,6 +42,7 @@ class BackupRepo @Inject constructor( private val vssBackupClient: VssBackupClient, private val settingsStore: SettingsStore, private val widgetsStore: WidgetsStore, + private val db: AppDb, ) { private val scope = CoroutineScope(bgDispatcher + SupervisorJob()) @@ -135,6 +138,35 @@ class BackupRepo @Inject constructor( } dataListenerJobs.add(widgetsJob) + // WALLET - Observe boosted activities + val boostJob = scope.launch { + // TODO concat into one job using combine of boosts + transfers + cacheStore.data + .map { it.pendingBoostActivities } + .distinctUntilChanged() + .drop(1) + .collect { + if (!isRestoring) { + markBackupRequired(BackupCategory.WALLET) + } + } + } + dataListenerJobs.add(boostJob) + + // WALLET - Observe transfers + val transfersJob = scope.launch { + // TODO concat into one job using combine of boosts + transfers + db.transferDao().observeAll() + .distinctUntilChanged() + .drop(1) + .collect { + if (!isRestoring) { + markBackupRequired(BackupCategory.WALLET) + } + } + } + dataListenerJobs.add(transfersJob) + Logger.debug("Started ${dataListenerJobs.size} data store listeners", context = TAG) } @@ -247,7 +279,16 @@ class BackupRepo @Inject constructor( } BackupCategory.WALLET -> { - throw NotImplementedError("Wallet backup not yet implemented") + val boostedActivities = cacheStore.data.first().pendingBoostActivities + val transfers = db.transferDao().getAll() + + val payload = WalletBackupV1( + createdAt = System.currentTimeMillis(), + boostedActivities = boostedActivities, + transfers = transfers + ) + + json.encodeToString(payload).toByteArray() } BackupCategory.METADATA -> { @@ -285,9 +326,26 @@ class BackupRepo @Inject constructor( val parsed = json.decodeFromString(String(dataBytes)) widgetsStore.update { parsed } } + performRestore(BackupCategory.WALLET) { dataBytes -> + val parsed = json.decodeFromString(String(dataBytes)) + + parsed.transfers.forEach { transfer -> + // TODO add transferDao().upsert() and use it instead + db.transferDao().insert(transfer) + } + + // Restore boosted activities (idempotent via txId) + parsed.boostedActivities.forEach { activity -> + cacheStore.addActivityToPendingBoost(activity) + } + + Logger.debug( + "Restored ${parsed.transfers.size} transfers and ${parsed.boostedActivities.size} boosted activities", + context = TAG + ) + } // TODO: Add other backup categories as they get implemented: // performMetadataRestore() - // performWalletRestore() // performBlocktankRestore() // performSlashtagsRestore() // performLdkActivityRestore() diff --git a/docs/backups-plan.md b/docs/backups-plan.md new file mode 100644 index 000000000..158260556 --- /dev/null +++ b/docs/backups-plan.md @@ -0,0 +1,737 @@ +# Backup Implementation Plan + +## Overview + +This document outlines the implementation plan for completing the backup system in bitkit-android. Currently, only **SETTINGS** and **WIDGETS** categories are fully implemented. This plan covers the remaining categories: **WALLET**, **METADATA**, **BLOCKTANK**, **ACTIVITY** (formerly LDK_ACTIVITY), and **LIGHTNING_CONNECTIONS** (display-only). + +**Last Updated:** 2025-10-30 + +--- + +## Goals + +1. **Backup all data stores comprehensively** - CacheStore, SettingsStore, WidgetsStore, Room DB (AppDb), and CoreService-managed data +2. **Use JSON serialization** (not raw database files) for maintainability, idempotency, and version migration +3. **Implement idempotent restore** - safe to restore multiple times without duplicates +4. **Category independence** - each category backs up and restores independently +5. **Display-only status for LIGHTNING_CONNECTIONS** - show ldk-node's native backup timing +6. **Descope SLASHTAGS** - comment out for v1, plan for v2 + +--- + +## Architecture + +### Data Stores in bitkit-android + +| Store | Type | Contents | Backed Up By | +|-------|------|----------|--------------| +| **SettingsStore** | DataStore | User preferences, UI state | SETTINGS ✅ | +| **WidgetsStore** | DataStore | Widget config, cached widget data | WIDGETS ✅ | +| **CacheStore** | DataStore | Boosted activities, tx metadata, paid orders, balance cache | WALLET, METADATA, BLOCKTANK | +| **AppDb** (Room) | SQLite | Transfers, tag metadata, config | WALLET, METADATA | +| **activity.db** (CoreService) | SQLite | All activities (onchain + lightning) | ACTIVITY | +| **blocktank.db** (CoreService) | SQLite | Orders, CJIT entries | BLOCKTANK | +| **LDK storage** | Native | Channel state, monitors | LDK (native backup) | + +### Backup Categories + +| Category | Status | Data Source | Backup Method | Change Detection | +|----------|--------|-------------|---------------|------------------| +| SETTINGS | ✅ Implemented | SettingsStore | JSON serialization | DataStore flow observer | +| WIDGETS | ✅ Implemented | WidgetsStore | JSON serialization | DataStore flow observer | +| WALLET | ⏳ To implement | CacheStore + TransferDao | JSON serialization | CacheStore + Room flows | +| METADATA | ⏳ To implement | CacheStore + TagMetadataDao | JSON serialization | CacheStore + Room flows | +| BLOCKTANK | ⏳ To implement | CacheStore + CoreService | JSON serialization | CacheStore + event callbacks | +| ACTIVITY | ⏳ To implement | CoreService.activity (ALL) | JSON serialization | Manual trigger only | +| LIGHTNING_CONNECTIONS | ⏳ Display-only | LightningService.status | N/A (ldk-node native) | Status timestamp observer | +| ~~SLASHTAGS~~ | 🚫 Descoped | N/A | N/A | N/A | + +--- + +## Design Decisions + +### Why JSON Serialization (Not Raw DB Files)? + +**Advantages:** +- ✅ **No file locking issues** - read data via DAOs/APIs, not file I/O +- ✅ **Schema evolution** - easy to migrate between versions +- ✅ **Selective restore** - validate and transform data before mutation +- ✅ **Smaller size** - only serialize needed data, not DB overhead +- ✅ **Idempotency** - upsert by stable keys prevents duplicates +- ✅ **Testability** - can inspect and mock JSON payloads +- ✅ **Cross-version compatibility** - handles schema changes gracefully + +**Rejected Alternative:** Copying raw `activity.db` and `blocktank.db` files +- ❌ Requires database locks/close +- ❌ Schema changes break restores +- ❌ Harder to deduplicate on restore +- ❌ Larger backup sizes + +### Rename LDK_ACTIVITY → ACTIVITY + +The category now backs up **ALL** activities (onchain + lightning), not just lightning activities. The name change reflects this scope accurately. + +--- + +## Implementation Details + +### 1. Payload Schemas + +All payloads include version and timestamp for migration support. + +```kotlin +@Serializable +data class WalletBackupV1( + val version: Int = 1, + val createdAt: Long = System.currentTimeMillis(), + val boostedActivities: List, + val transfers: List, +) + +@Serializable +data class MetadataBackupV1( + val version: Int = 1, + val createdAt: Long = System.currentTimeMillis(), + val tagMetadata: List, + val transactionsMetadata: List, +) + +@Serializable +data class BlocktankBackupV1( + val version: Int = 1, + val createdAt: Long = System.currentTimeMillis(), + val paidOrders: Map, // orderId -> txId + val orders: List, + val cjitEntries: List, +) + +@Serializable +data class SerializableOrder( + val id: String, + val state: String, + // Essential fields only +) + +@Serializable +data class SerializableCjitEntry( + val channelSizeSat: ULong, + val invoice: String, + // Essential fields only +) + +@Serializable +data class ActivityBackupV1( + val version: Int = 1, + val createdAt: Long = System.currentTimeMillis(), + val activities: List, // ALL activities (onchain + lightning) +) +``` + +### 2. WALLET Backup Implementation + +**Data sources:** +- `CacheStore.pendingBoostActivities` +- `TransferDao.getAll()` + +**Backup:** +```kotlin +BackupCategory.WALLET -> { + val boostedActivities = cacheStore.data.first().pendingBoostActivities + val transfers = transferDao.getAll() + + val payload = WalletBackupV1( + boostedActivities = boostedActivities, + transfers = transfers + ) + + json.encodeToString(payload).toByteArray() +} +``` + +**Restore:** +```kotlin +performRestore(BackupCategory.WALLET) { dataBytes -> + val payload = json.decodeFromString(String(dataBytes)) + + // Restore transfers (idempotent via primary key) + db.withTransaction { + payload.transfers.forEach { transfer -> + transferDao.upsert(transfer) + } + } + + // Restore boosted activities (idempotent via txId) + payload.boostedActivities.forEach { activity -> + cacheStore.addActivityToPendingBoost(activity) + } +} +``` + +**Change detection:** +```kotlin +// Observe boosted activities +val boostJob = scope.launch { + cacheStore.data + .map { it.pendingBoostActivities } + .distinctUntilChanged() + .drop(1) + .collect { + if (!isRestoring) { + markBackupRequired(BackupCategory.WALLET) + } + } +} + +// Observe transfers +val transfersJob = scope.launch { + transferDao.observeAll() + .distinctUntilChanged() + .drop(1) + .collect { + if (!isRestoring) { + markBackupRequired(BackupCategory.WALLET) + } + } +} +``` + +**New DAO methods needed:** +```kotlin +@Dao +interface TransferDao { + @Query("SELECT * FROM transfers") + suspend fun getAll(): List + + @Query("SELECT * FROM transfers") + fun observeAll(): Flow> + + @Upsert + suspend fun upsert(entity: TransferEntity) +} +``` + +--- + +### 3. METADATA Backup Implementation + +**Data sources:** +- `TagMetadataDao.getAll()` +- `CacheStore.transactionsMetadata` + +**Backup:** +```kotlin +BackupCategory.METADATA -> { + val tagMetadata = db.tagMetadataDao().getAll() + val txMetadata = cacheStore.data.first().transactionsMetadata + + val payload = MetadataBackupV1( + tagMetadata = tagMetadata, + transactionsMetadata = txMetadata + ) + + json.encodeToString(payload).toByteArray() +} +``` + +**Restore:** +```kotlin +performRestore(BackupCategory.METADATA) { dataBytes -> + val payload = json.decodeFromString(String(dataBytes)) + + // Restore tag metadata (idempotent via primary key) + db.withTransaction { + payload.tagMetadata.forEach { entity -> + db.tagMetadataDao().upsert(entity) + } + } + + // Restore transaction metadata (idempotent via txId) + payload.transactionsMetadata.forEach { metadata -> + cacheStore.addTransactionMetadata(metadata) + } +} +``` + +**Change detection:** +```kotlin +// Observe tag metadata +val metadataJob = scope.launch { + db.tagMetadataDao().observeAll() + .distinctUntilChanged() + .drop(1) + .collect { + if (!isRestoring) { + markBackupRequired(BackupCategory.METADATA) + } + } +} + +// Observe transaction metadata +val txMetadataJob = scope.launch { + cacheStore.data + .map { it.transactionsMetadata } + .distinctUntilChanged() + .drop(1) + .collect { + if (!isRestoring) { + markBackupRequired(BackupCategory.METADATA) + } + } +} +``` + +**New DAO methods needed:** +```kotlin +@Dao +interface TagMetadataDao { + @Query("SELECT * FROM tag_metadata") + suspend fun getAll(): List + + @Query("SELECT * FROM tag_metadata") + fun observeAll(): Flow> + + @Upsert + suspend fun upsert(entity: TagMetadataEntity) +} +``` + +--- + +### 4. BLOCKTANK Backup Implementation + +**Data sources:** +- `CacheStore.paidOrders` +- `CoreService.blocktank.orders(refresh = false)` +- `CoreService.blocktank.cjitEntries(refresh = false)` + +**Backup:** +```kotlin +BackupCategory.BLOCKTANK -> { + val paidOrders = cacheStore.data.first().paidOrders + val orders = coreService.blocktank.orders(refresh = false) + val cjitEntries = coreService.blocktank.cjitEntries(refresh = false) + + val payload = BlocktankBackupV1( + paidOrders = paidOrders, + orders = orders.map { it.toSerializable() }, + cjitEntries = cjitEntries.map { it.toSerializable() } + ) + + json.encodeToString(payload).toByteArray() +} +``` + +**Restore:** +```kotlin +performRestore(BackupCategory.BLOCKTANK) { dataBytes -> + val payload = json.decodeFromString(String(dataBytes)) + + // Restore paid orders (idempotent via orderId) + payload.paidOrders.forEach { (orderId, txId) -> + cacheStore.addPaidOrder(orderId, txId) + } + + // Note: Orders and CJIT entries are refreshed from server + // We mainly need paidOrders to track payment status locally +} +``` + +**Change detection:** +```kotlin +// Observe paid orders +val blocktankJob = scope.launch { + cacheStore.data + .map { it.paidOrders } + .distinctUntilChanged() + .drop(1) + .collect { + if (!isRestoring) { + markBackupRequired(BackupCategory.BLOCKTANK) + } + } +} +``` + +**Serializable representations:** +```kotlin +fun IBtOrder.toSerializable() = SerializableOrder( + id = this.id, + state = this.state2.name, + // Add other essential fields +) + +fun IcJitEntry.toSerializable() = SerializableCjitEntry( + channelSizeSat = this.channelSizeSat, + invoice = this.invoice.request, + // Add other essential fields +) +``` + +--- + +### 5. ACTIVITY Backup Implementation + +**Data source:** +- `CoreService.activity.get(filter = ActivityFilter.ALL)` - **ALL activities** + +**Backup:** +```kotlin +BackupCategory.ACTIVITY -> { + val allActivities = coreService.activity.get( + filter = ActivityFilter.ALL, + txType = null, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = null, + sortDirection = null + ) + + val payload = ActivityBackupV1( + activities = allActivities + ) + + json.encodeToString(payload).toByteArray() +} +``` + +**Restore:** +```kotlin +performRestore(BackupCategory.ACTIVITY) { dataBytes -> + val payload = json.decodeFromString(String(dataBytes)) + + // Restore all activities (idempotent via activity ID) + payload.activities.forEach { activity -> + runCatching { + // Try to insert; if exists, skip or update + coreService.activity.insert(activity) + }.onFailure { e -> + // Activity might already exist; log and continue + Logger.debug("Activity already exists or failed: ${e.message}") + } + } +} +``` + +**Change detection:** +- **Manual backup only** (no auto-trigger) +- Reason: Activity list can be large; user initiates backup manually +- Keep `disableRetry = true` in BackupsViewModel for this category + +**Note:** Ensure `Activity` (both `Activity.Lightning` and `Activity.Onchain`) are `@Serializable` from bitkit-core. + +--- + +### 6. LIGHTNING_CONNECTIONS (Display-Only) + +**Purpose:** Display ldk-node's native backup status, not perform manual backup + +**Data source:** +- `lightningService.status.latestLightningWalletSyncTimestamp` + +**Implementation:** +```kotlin +// In BackupRepo.getBackupDataBytes() +BackupCategory.LIGHTNING_CONNECTIONS -> { + throw NotImplementedError( + "LIGHTNING_CONNECTIONS backup is handled by ldk-node's native backup system" + ) +} + +// In BackupRepo.startDataStoreListeners() +private fun observeLdkBackupStatus() { + val ldkStatusJob = scope.launch { + lightningRepo.lightningState + .map { it.status.latestLightningWalletSyncTimestamp } + .distinctUntilChanged() + .collect { syncTimestamp -> + // Update backup status to display LDK's sync time + cacheStore.updateBackupStatus(BackupCategory.LIGHTNING_CONNECTIONS) { + it.copy( + synced = syncTimestamp, + required = syncTimestamp, // Always "synced" + running = false + ) + } + } + } + statusObserverJobs.add(ldkStatusJob) +} +``` + +**UI behavior:** +- Show sync timestamp from ldk-node +- No "Backup Now" button +- No retry or running states +- Display as "Automatically backed up by Lightning" + +--- + +### 7. SLASHTAGS (Descoped) + +**Status:** Descoped for v1, planned for v2 + +**Implementation:** +```kotlin +// In BackupStatus.kt +@Serializable +enum class BackupCategory { + LIGHTNING_CONNECTIONS, + BLOCKTANK, + ACTIVITY, // Renamed from LDK_ACTIVITY + WALLET, + SETTINGS, + WIDGETS, + METADATA, + // SLASHTAGS, // Descoped for v1, will return in v2 +} +``` + +Remove from: +- BackupRepo.getBackupDataBytes() +- performFullRestoreFromLatestBackup() +- UI screens (BackupSettingsScreen, etc.) + +--- + +## Restore Orchestration + +### Restore Order + +Recommended order for dependencies: +1. **METADATA** (tags and tx metadata) +2. **WALLET** (transfers and boosts) +3. **BLOCKTANK** (orders and paid orders) +4. **ACTIVITY** (all activities) + +### Idempotency Strategy + +Each category uses stable keys for upsert: +- **WALLET**: Transfer by `id` (primary key), boost by `txId` +- **METADATA**: TagMetadata by `id` or composite key, tx metadata by `txId` +- **BLOCKTANK**: Paid orders by `orderId` +- **ACTIVITY**: Activity by `id` field + +Use Room's `@Upsert` (or `onConflict = REPLACE`) and CacheStore deduplication. + +### Error Handling + +```kotlin +suspend fun performFullRestoreFromLatestBackup(): Result = withContext(bgDispatcher) { + isRestoring = true + + val results = mutableMapOf>() + + val categories = listOf( + BackupCategory.METADATA, + BackupCategory.WALLET, + BackupCategory.BLOCKTANK, + BackupCategory.ACTIVITY + ) + + for (category in categories) { + val result = runCatching { + performRestore(category) { dataBytes -> + // Category-specific restore logic + } + } + results[category] = result + + if (result.isFailure) { + Logger.warn("Restore failed for $category", result.exceptionOrNull()) + // Continue with other categories + } + } + + isRestoring = false + + // Return success if at least one category restored + val anySuccess = results.values.any { it.isSuccess } + if (anySuccess) Result.success(Unit) else Result.failure(Exception("All restores failed")) +} +``` + +--- + +## Testing Strategy + +### Unit Tests + +For each category, test: +1. **Serialization roundtrip** - backup → deserialize → verify equality +2. **Empty data** - backup with no data returns valid empty payload +3. **Large datasets** - 1000+ items serialize/deserialize correctly +4. **Version migration** - older payload version can be migrated + +Example: +```kotlin +@Test +fun `wallet backup serialization roundtrip`() = runTest { + // Create test data + val transfers = listOf( + TransferEntity(id = "1", type = TransferType.TO_SAVINGS, ...), + TransferEntity(id = "2", type = TransferType.TO_SPENDING, ...) + ) + val boosts = listOf( + PendingBoostActivity(txId = "abc", ...) + ) + + // Serialize + val payload = WalletBackupV1( + boostedActivities = boosts, + transfers = transfers + ) + val json = Json.encodeToString(payload) + + // Deserialize + val restored = Json.decodeFromString(json) + + // Verify + assertEquals(payload.transfers.size, restored.transfers.size) + assertEquals(payload.boostedActivities.size, restored.boostedActivities.size) +} +``` + +### Integration Tests + +For each category, test: +1. **Backup → restore → verify** - data persists correctly +2. **Idempotent restore** - restore twice, no duplicates +3. **Partial data** - restore with some missing data succeeds +4. **Failed restore** - one category fails, others continue + +Example: +```kotlin +@Test +fun `wallet restore is idempotent`() = runTest { + // Insert initial data + transferDao.insert(TransferEntity(id = "1", ...)) + + // Backup + val backupBytes = backupRepo.triggerBackup(BackupCategory.WALLET) + + // Restore twice + backupRepo.performRestore(BackupCategory.WALLET) { backupBytes } + backupRepo.performRestore(BackupCategory.WALLET) { backupBytes } + + // Verify no duplicates + val transfers = transferDao.getAll() + assertEquals(1, transfers.size) +} +``` + +### Manual Tests + +- [ ] Create wallet data, backup, wipe app data, restore, verify +- [ ] Add tags, backup, restore on another device, verify +- [ ] Create blocktank order, pay, backup, restore, verify paid status +- [ ] Generate activities, backup, restore, verify count and details +- [ ] Check LIGHTNING_CONNECTIONS displays correct timestamp +- [ ] Trigger backup failure (network error), verify UI shows error +- [ ] Restore with missing category backup, verify graceful handling + +--- + +## Implementation Checklist + +### Phase 1: Setup (1 day) +- [ ] Create `docs/backups-plan.md` and commit +- [ ] Rename `BackupCategory.LDK_ACTIVITY` → `ACTIVITY` +- [ ] Comment out `SLASHTAGS` from enum +- [ ] Define all payload data classes (`WalletBackupV1`, etc.) + +### Phase 2: DAO Extensions (0.5 day) +- [ ] Add `TransferDao.getAll()` and `observeAll()` +- [ ] Add `TransferDao.upsert()` +- [ ] Add `TagMetadataDao.getAll()` and `observeAll()` +- [ ] Add `TagMetadataDao.upsert()` +- [ ] Ensure all entities are `@Serializable` + +### Phase 3: WALLET Implementation (1 day) +- [ ] Implement `getBackupDataBytes` for WALLET +- [ ] Implement `performRestore` for WALLET +- [ ] Add change detection listeners +- [ ] Write unit tests +- [ ] Write integration tests + +### Phase 4: METADATA Implementation (1 day) +- [ ] Implement `getBackupDataBytes` for METADATA +- [ ] Implement `performRestore` for METADATA +- [ ] Add change detection listeners +- [ ] Write unit tests +- [ ] Write integration tests + +### Phase 5: BLOCKTANK Implementation (1 day) +- [ ] Create `SerializableOrder` and `SerializableCjitEntry` +- [ ] Implement `getBackupDataBytes` for BLOCKTANK +- [ ] Implement `performRestore` for BLOCKTANK +- [ ] Add change detection listener +- [ ] Write unit tests +- [ ] Write integration tests + +### Phase 6: ACTIVITY Implementation (1 day) +- [ ] Ensure `Activity` types are `@Serializable` in bitkit-core +- [ ] Implement `getBackupDataBytes` for ACTIVITY (ALL activities) +- [ ] Implement `performRestore` for ACTIVITY +- [ ] Keep manual-only (no auto-trigger) +- [ ] Write unit tests +- [ ] Write integration tests + +### Phase 7: LIGHTNING_CONNECTIONS Display (0.5 day) +- [ ] Add LDK status observer +- [ ] Implement display-only status updates +- [ ] Throw `NotImplementedError` in `getBackupDataBytes` +- [ ] Update UI to show "Automatically backed up" +- [ ] Test timestamp updates + +### Phase 8: Restore Orchestration (1 day) +- [ ] Implement `performFullRestoreFromLatestBackup` with ordering +- [ ] Add per-category error handling +- [ ] Test partial restore scenarios +- [ ] Test failure isolation + +### Phase 9: Polish & Documentation (1 day) +- [ ] Add code comments documenting payloads and behavior +- [ ] Update UI strings for renamed category +- [ ] Test all categories end-to-end +- [ ] Performance test with large datasets +- [ ] Update this document with any changes + +**Total Estimated Time:** 8-9 days + +--- + +## Open Questions + +1. **Activity serialization:** Are `Activity.Lightning` and `Activity.Onchain` already `@Serializable` in bitkit-core? +2. **Blocktank restore:** Do we need to restore full order details, or just `paidOrders`? +3. **Activity backup size:** Should we limit the number of activities backed up (e.g., last 1000)? +4. **Restore conflicts:** If local data is newer than backup, should we skip or overwrite? +5. **Backup frequency:** Should ACTIVITY be manual-only, or add auto-backup with longer debounce? + +--- + +## References + +- **Current implementation:** `app/src/main/java/to/bitkit/repositories/BackupRepo.kt` +- **Backup categories:** `app/src/main/java/to/bitkit/models/BackupStatus.kt` +- **VSS client:** `app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt` +- **Data stores:** + - `app/src/main/java/to/bitkit/data/SettingsStore.kt` + - `app/src/main/java/to/bitkit/data/WidgetsStore.kt` + - `app/src/main/java/to/bitkit/data/CacheStore.kt` + - `app/src/main/java/to/bitkit/data/AppDb.kt` +- **CoreService:** `app/src/main/java/to/bitkit/services/CoreService.kt` + +--- + +## Changelog + +| Date | Author | Changes | +|------|--------|---------| +| 2025-10-30 | AI Assistant | Initial plan created | + +--- + +**End of Plan** From 6f839ff416a00f6c20510a288dd30e985d98ee69 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 31 Oct 2025 13:12:23 +0100 Subject: [PATCH 02/29] feat: metadata backup tags & tx --- .../java/to/bitkit/data/dao/TagMetadataDao.kt | 4 ++ .../bitkit/data/entities/TagMetadataEntity.kt | 2 + .../java/to/bitkit/models/BackupPayloads.kt | 16 +++++ .../java/to/bitkit/repositories/BackupRepo.kt | 63 ++++++++++++++++++- .../to/bitkit/repositories/TransferRepo.kt | 2 + 5 files changed, 85 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/dao/TagMetadataDao.kt b/app/src/main/java/to/bitkit/data/dao/TagMetadataDao.kt index 7ac7c2833..685f2c6b9 100644 --- a/app/src/main/java/to/bitkit/data/dao/TagMetadataDao.kt +++ b/app/src/main/java/to/bitkit/data/dao/TagMetadataDao.kt @@ -5,6 +5,7 @@ import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import kotlinx.coroutines.flow.Flow import to.bitkit.data.entities.TagMetadataEntity @Dao @@ -13,6 +14,9 @@ interface TagMetadataDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun saveTagMetadata(tagMetadata: TagMetadataEntity) + @Query("SELECT * FROM tag_metadata") + fun observeAll(): Flow> + @Query("SELECT * FROM tag_metadata") suspend fun getAll(): List diff --git a/app/src/main/java/to/bitkit/data/entities/TagMetadataEntity.kt b/app/src/main/java/to/bitkit/data/entities/TagMetadataEntity.kt index 374f9c5cf..e4f8acb7d 100644 --- a/app/src/main/java/to/bitkit/data/entities/TagMetadataEntity.kt +++ b/app/src/main/java/to/bitkit/data/entities/TagMetadataEntity.kt @@ -2,7 +2,9 @@ package to.bitkit.data.entities import androidx.room.Entity import androidx.room.PrimaryKey +import kotlinx.serialization.Serializable +@Serializable @Entity(tableName = "tag_metadata") /** * @param id This will be paymentHash, txId, or address depending on context diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt index 3869f5fe3..da942e784 100644 --- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -2,6 +2,7 @@ package to.bitkit.models import kotlinx.serialization.Serializable import to.bitkit.data.dto.PendingBoostActivity +import to.bitkit.data.entities.TagMetadataEntity import to.bitkit.data.entities.TransferEntity /** @@ -19,3 +20,18 @@ data class WalletBackupV1( val transfers: List, ) +/** + * Metadata backup payload (v1) + * + * Contains: + * - Tag metadata entities from Room database + * - Transaction metadata from CacheStore + */ +@Serializable +data class MetadataBackupV1( + val version: Int = 1, + val createdAt: Long, + val tagMetadata: List, + val transactionsMetadata: List, +) + diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 7d4863a80..f7411bd17 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -27,6 +27,7 @@ import to.bitkit.di.json import to.bitkit.ext.formatPlural import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus +import to.bitkit.models.MetadataBackupV1 import to.bitkit.models.Toast import to.bitkit.models.WalletBackupV1 import to.bitkit.ui.shared.toast.ToastEventBus @@ -167,6 +168,35 @@ class BackupRepo @Inject constructor( } dataListenerJobs.add(transfersJob) + // METADATA - Observe tag metadata + val tagMetadataJob = scope.launch { + // TODO concat into one job using combine of tagMetadataDao + transactionsMetadata + db.tagMetadataDao().observeAll() + .distinctUntilChanged() + .drop(1) + .collect { + if (!isRestoring) { + markBackupRequired(BackupCategory.METADATA) + } + } + } + dataListenerJobs.add(tagMetadataJob) + + // METADATA - Observe transaction metadata + val txMetadataJob = scope.launch { + // TODO concat into one job using combine of tagMetadataDao + transactionsMetadata + cacheStore.data + .map { it.transactionsMetadata } + .distinctUntilChanged() + .drop(1) + .collect { + if (!isRestoring) { + markBackupRequired(BackupCategory.METADATA) + } + } + } + dataListenerJobs.add(txMetadataJob) + Logger.debug("Started ${dataListenerJobs.size} data store listeners", context = TAG) } @@ -292,7 +322,16 @@ class BackupRepo @Inject constructor( } BackupCategory.METADATA -> { - throw NotImplementedError("Metadata backup not yet implemented") + val tagMetadata = db.tagMetadataDao().getAll() + val txMetadata = cacheStore.data.first().transactionsMetadata + + val payload = MetadataBackupV1( + createdAt = System.currentTimeMillis(), + tagMetadata = tagMetadata, + transactionsMetadata = txMetadata + ) + + json.encodeToString(payload).toByteArray() } BackupCategory.BLOCKTANK -> { @@ -336,6 +375,7 @@ class BackupRepo @Inject constructor( // Restore boosted activities (idempotent via txId) parsed.boostedActivities.forEach { activity -> + // TODO add addActivityToPendingBoost(vararg) and use it instead cacheStore.addActivityToPendingBoost(activity) } @@ -344,8 +384,27 @@ class BackupRepo @Inject constructor( context = TAG ) } + performRestore(BackupCategory.METADATA) { dataBytes -> + val parsed = json.decodeFromString(String(dataBytes)) + + // Restore tag metadata (idempotent via primary key with INSERT OR REPLACE) + parsed.tagMetadata.forEach { entity -> + // TODO add tagMetadataDao().upsert() and use it instead + db.tagMetadataDao().saveTagMetadata(entity) + } + + // Restore transaction metadata (idempotent via txId) + parsed.transactionsMetadata.forEach { metadata -> + // TODO add addTransactionMetadata(vararg) and use it instead + cacheStore.addTransactionMetadata(metadata) + } + + Logger.debug( + "Restored ${parsed.tagMetadata.size} tag metadata entries and ${parsed.transactionsMetadata.size} transaction metadata", + context = TAG + ) + } // TODO: Add other backup categories as they get implemented: - // performMetadataRestore() // performBlocktankRestore() // performSlashtagsRestore() // performLdkActivityRestore() diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index a386c2394..4e4596ddf 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -54,6 +54,8 @@ class TransferRepo @Inject constructor( } } + // TODO maybe replace with delete, or call delete once activity item was augmented with the transfer's data. + // Likely no clear reason to keep persisting transfers afterwards. suspend fun markSettled(id: String): Result = withContext(bgDispatcher) { runCatching { val settledAt = clock.now().epochSeconds From 7d396886b535528e87525f46195dab234d4a83fc Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 31 Oct 2025 15:04:19 +0100 Subject: [PATCH 03/29] feat: metadata backup tags & tx --- .../java/to/bitkit/models/BackupPayloads.kt | 23 +++++++++ .../java/to/bitkit/repositories/BackupRepo.kt | 51 +++++++++++++++++-- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt index da942e784..09367436e 100644 --- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -1,5 +1,9 @@ package to.bitkit.models +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.IBtInfo +import com.synonym.bitkitcore.IBtOrder +import com.synonym.bitkitcore.IcJitEntry import kotlinx.serialization.Serializable import to.bitkit.data.dto.PendingBoostActivity import to.bitkit.data.entities.TagMetadataEntity @@ -35,3 +39,22 @@ data class MetadataBackupV1( val transactionsMetadata: List, ) +/** + * Blocktank backup payload (v1) + * + * Contains: + * - Paid orders map from CacheStore + * - List of IBtOrder from bitkit-core + * - List of IcJitEntry from bitkit-core + * - IBtInfo from bitkit-core + */ +@Serializable +data class BlocktankBackupV1( + val version: Int = 1, + val createdAt: Long, + val paidOrders: Map, // orderId -> txId + val orders: List, + val cjitEntries: List, + val info: IBtInfo? = null, +) + diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index f7411bd17..2db9e5838 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -27,6 +27,7 @@ import to.bitkit.di.json import to.bitkit.ext.formatPlural import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus +import to.bitkit.models.BlocktankBackupV1 import to.bitkit.models.MetadataBackupV1 import to.bitkit.models.Toast import to.bitkit.models.WalletBackupV1 @@ -43,6 +44,7 @@ class BackupRepo @Inject constructor( private val vssBackupClient: VssBackupClient, private val settingsStore: SettingsStore, private val widgetsStore: WidgetsStore, + private val blocktankRepo: BlocktankRepo, private val db: AppDb, ) { private val scope = CoroutineScope(bgDispatcher + SupervisorJob()) @@ -197,6 +199,20 @@ class BackupRepo @Inject constructor( } dataListenerJobs.add(txMetadataJob) + // BLOCKTANK - Observe paid orders + val blocktankJob = scope.launch { + cacheStore.data + .map { it.paidOrders } + .distinctUntilChanged() + .drop(1) + .collect { + if (!isRestoring) { + markBackupRequired(BackupCategory.BLOCKTANK) + } + } + } + dataListenerJobs.add(blocktankJob) + Logger.debug("Started ${dataListenerJobs.size} data store listeners", context = TAG) } @@ -335,8 +351,17 @@ class BackupRepo @Inject constructor( } BackupCategory.BLOCKTANK -> { - throw NotImplementedError("Blocktank backup not yet implemented") - } + val paidOrders = cacheStore.data.first().paidOrders + // Fetch all orders, CJIT entries, and info from BlocktankRepo state + val blocktankState = blocktankRepo.blocktankState.first() + + val payload = BlocktankBackupV1( + createdAt = System.currentTimeMillis(), + paidOrders = paidOrders, + orders = blocktankState.orders, + cjitEntries = blocktankState.cjitEntries, + info = blocktankState.info, + ) BackupCategory.SLASHTAGS -> { throw NotImplementedError("Slashtags backup not yet implemented") @@ -404,8 +429,28 @@ class BackupRepo @Inject constructor( context = TAG ) } + performRestore(BackupCategory.BLOCKTANK) { dataBytes -> + val parsed = json.decodeFromString(String(dataBytes)) + + // Restore paid orders (idempotent via orderId) + parsed.paidOrders.forEach { (orderId, txId) -> + // TODO add addPaidOrder(vararg) and use it instead + cacheStore.addPaidOrder(orderId, txId) + } + + // TODO: Restore orders, CJIT entries, and info to bitkit-core storage + // This requires bitkit-core to expose an API for restoring orders/cjitEntries/info + // For now, trigger a refresh from the Blocktank server to sync the data + // Data is preserved in backup: ${parsed.orders.size} orders, ${parsed.cjitEntries.size} CJIT entries, info=${parsed.info != null} + blocktankRepo.refreshInfo() + blocktankRepo.refreshOrders() + + Logger.debug( + "Restored ${parsed.paidOrders.size} paid orders (${parsed.orders.size} orders, ${parsed.cjitEntries.size} CJIT entries, info=${parsed.info != null} backed up)", + context = TAG, + ) + } // TODO: Add other backup categories as they get implemented: - // performBlocktankRestore() // performSlashtagsRestore() // performLdkActivityRestore() From 3356cd8aa74bbb0c9fc8f7e688aa012c9f331199 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 31 Oct 2025 16:01:53 +0100 Subject: [PATCH 04/29] feat: activity backup --- .../java/to/bitkit/models/BackupPayloads.kt | 16 ++++ .../to/bitkit/repositories/ActivityRepo.kt | 35 ++++++++ .../java/to/bitkit/repositories/BackupRepo.kt | 89 ++++++++++++++++++- .../java/to/bitkit/services/CoreService.kt | 6 ++ 4 files changed, 143 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt index 09367436e..48e684b06 100644 --- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -58,3 +58,19 @@ data class BlocktankBackupV1( val info: IBtInfo? = null, ) +/** + * Activity backup payload (v1) + * + * Contains: + * - ALL activities (onchain + lightning) from bitkit-core + * - Deleted activity IDs from CacheStore + * - Activities pending deletion from CacheStore + */ +@Serializable +data class ActivityBackupV1( + val version: Int = 1, + val createdAt: Long, + val activities: List, + val deletedActivities: List, + val activitiesPendingDelete: List, +) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 66ad68f3c..d2c76d59b 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -12,9 +12,12 @@ import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import kotlinx.datetime.Clock import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.PaymentDetails import to.bitkit.data.AppDb @@ -44,9 +47,19 @@ class ActivityRepo @Inject constructor( private val db: AppDb, private val addressChecker: AddressChecker, private val transferRepo: TransferRepo, + private val clock: Clock, ) { val isSyncingLdkNodePayments = MutableStateFlow(false) + /** + * Emits a timestamp whenever activities are modified (insert, update, delete, tag changes, etc.) + * Used to trigger backups and UI updates. + */ + private val _activitiesChanged = MutableStateFlow(0L) + val activitiesChanged: StateFlow = _activitiesChanged + + private fun notifyActivitiesChanged() = _activitiesChanged.update { clock.now().toEpochMilliseconds() } + suspend fun syncActivities(): Result = withContext(bgDispatcher) { Logger.debug("syncActivities called", context = TAG) @@ -70,6 +83,7 @@ class ActivityRepo @Inject constructor( boostPendingActivities() transferRepo.syncTransferStates() isSyncingLdkNodePayments.value = false + notifyActivitiesChanged() return@withContext Result.success(Unit) }.onFailure { e -> Logger.error("Failed to sync ldk-node payments", e, context = TAG) @@ -219,6 +233,7 @@ class ActivityRepo @Inject constructor( ) } coreService.activity.update(id, activity) + notifyActivitiesChanged() }.onFailure { e -> Logger.error("updateActivity error for ID: $id", e, context = TAG) } @@ -445,6 +460,7 @@ class ActivityRepo @Inject constructor( val deleted = coreService.activity.delete(id) if (deleted) { cacheStore.addActivityToDeletedList(id) + notifyActivitiesChanged() } else { return@withContext Result.failure(Exception("Activity not deleted")) } @@ -463,11 +479,28 @@ class ActivityRepo @Inject constructor( return@withContext Result.failure(Exception("Activity ${activity.rawId()} was deleted")) } coreService.activity.insert(activity) + notifyActivitiesChanged() }.onFailure { e -> Logger.error("insertActivity error", e, context = TAG) } } + /** + * Upserts an activity (insert or update if exists) + */ + suspend fun upsertActivity(activity: Activity): Result = withContext(bgDispatcher) { + return@withContext runCatching { + if (activity.rawId() in cacheStore.data.first().deletedActivities) { + Logger.debug("Activity ${activity.rawId()} was deleted, skipping", context = TAG) + return@withContext Result.failure(Exception("Activity ${activity.rawId()} was deleted")) + } + coreService.activity.upsert(activity) + notifyActivitiesChanged() + }.onFailure { e -> + Logger.error("upsertActivity error", e, context = TAG) + } + } + /** * Inserts a new activity for a fulfilled (channel ready) cjit channel order */ @@ -519,6 +552,7 @@ class ActivityRepo @Inject constructor( if (newTags.isNotEmpty()) { coreService.activity.appendTags(activityId, newTags).getOrThrow() + notifyActivitiesChanged() Logger.info("Added ${newTags.size} new tags to activity $activityId", context = TAG) } else { Logger.info("No new tags to add to activity $activityId", context = TAG) @@ -558,6 +592,7 @@ class ActivityRepo @Inject constructor( checkNotNull(coreService.activity.getActivity(activityId)) { "Activity with ID $activityId not found" } coreService.activity.dropTags(activityId, tags) + notifyActivitiesChanged() Logger.info("Removed ${tags.size} tags from activity $activityId", context = TAG) }.onFailure { e -> Logger.error("removeTagsFromActivity error for activity $activityId", e, context = TAG) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 2db9e5838..69d6aa0da 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -25,6 +25,7 @@ import to.bitkit.data.resetPin import to.bitkit.di.BgDispatcher import to.bitkit.di.json import to.bitkit.ext.formatPlural +import to.bitkit.models.ActivityBackupV1 import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus import to.bitkit.models.BlocktankBackupV1 @@ -45,6 +46,7 @@ class BackupRepo @Inject constructor( private val settingsStore: SettingsStore, private val widgetsStore: WidgetsStore, private val blocktankRepo: BlocktankRepo, + private val activityRepo: ActivityRepo, private val db: AppDb, ) { private val scope = CoroutineScope(bgDispatcher + SupervisorJob()) @@ -213,6 +215,49 @@ class BackupRepo @Inject constructor( } dataListenerJobs.add(blocktankJob) + // ACTIVITY - Observe all activity changes + // ActivityRepo notifies on insert, update, delete, tag changes, and sync completion + val activityChangesJob = scope.launch { + activityRepo.activitiesChanged + .drop(1) + .collect { + if (!isRestoring) { + markBackupRequired(BackupCategory.LDK_ACTIVITY) + } + } + } + dataListenerJobs.add(activityChangesJob) + + // ACTIVITY - Also observe deleted activities list from CacheStore + val deletedActivitiesJob = scope.launch { + // TODO concat into one job using combine of deletedActivities + activitiesPendingDelete + cacheStore.data + .map { it.deletedActivities } + .distinctUntilChanged() + .drop(1) + .collect { + if (!isRestoring) { + markBackupRequired(BackupCategory.LDK_ACTIVITY) + } + } + } + dataListenerJobs.add(deletedActivitiesJob) + + // ACTIVITY - Also observe activities pending delete from CacheStore + val activitiesPendingDeleteJob = scope.launch { + // TODO concat into one job using combine of deletedActivities + activitiesPendingDelete + cacheStore.data + .map { it.activitiesPendingDelete } + .distinctUntilChanged() + .drop(1) + .collect { + if (!isRestoring) { + markBackupRequired(BackupCategory.LDK_ACTIVITY) + } + } + } + dataListenerJobs.add(activitiesPendingDeleteJob) + Logger.debug("Started ${dataListenerJobs.size} data store listeners", context = TAG) } @@ -363,12 +408,25 @@ class BackupRepo @Inject constructor( info = blocktankState.info, ) + json.encodeToString(payload).toByteArray() + } + BackupCategory.SLASHTAGS -> { throw NotImplementedError("Slashtags backup not yet implemented") } BackupCategory.LDK_ACTIVITY -> { - throw NotImplementedError("LDK activity backup not yet implemented") + val activities = activityRepo.getActivities().getOrDefault(emptyList()) + val cacheData = cacheStore.data.first() + + val payload = ActivityBackupV1( + createdAt = System.currentTimeMillis(), + activities = activities, + deletedActivities = cacheData.deletedActivities, + activitiesPendingDelete = cacheData.activitiesPendingDelete, + ) + + json.encodeToString(payload).toByteArray() } BackupCategory.LIGHTNING_CONNECTIONS -> { @@ -433,8 +491,8 @@ class BackupRepo @Inject constructor( val parsed = json.decodeFromString(String(dataBytes)) // Restore paid orders (idempotent via orderId) + // TODO add addPaidOrder(vararg) and use it instead parsed.paidOrders.forEach { (orderId, txId) -> - // TODO add addPaidOrder(vararg) and use it instead cacheStore.addPaidOrder(orderId, txId) } @@ -450,9 +508,34 @@ class BackupRepo @Inject constructor( context = TAG, ) } + performRestore(BackupCategory.LDK_ACTIVITY) { dataBytes -> + val parsed = json.decodeFromString(String(dataBytes)) + + // Restore activities using upsertActivity (idempotent - insert or update) + parsed.activities.forEach { activity -> + activityRepo.upsertActivity(activity) + } + + // Restore deleted activities list (idempotent via addActivityToDeletedList) + // TODO add addActivityToDeletedList(vararg) and use it instead + parsed.deletedActivities.forEach { activityId -> + cacheStore.addActivityToDeletedList(activityId) + } + + // Restore activities pending deletion (idempotent via addActivityToPendingDelete) + // TODO add addActivityToPendingDelete(vararg) and use it instead + parsed.activitiesPendingDelete.forEach { activityId -> + cacheStore.addActivityToPendingDelete(activityId) + } + + Logger.debug( + "Restored ${parsed.activities.size} activities, ${parsed.deletedActivities.size} deleted, ${parsed.activitiesPendingDelete.size} pending delete", + context = TAG, + ) + } + // TODO: Add other backup categories as they get implemented: // performSlashtagsRestore() - // performLdkActivityRestore() Logger.info("Full restore completed", context = TAG) Result.success(Unit) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index bdb22854b..30978a3e3 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -199,6 +199,12 @@ class ActivityService( } } + suspend fun upsert(activity: Activity) { + ServiceQueue.CORE.background { + upsertActivity(activity) + } + } + suspend fun getActivity(id: String): Activity? { return ServiceQueue.CORE.background { getActivityById(id) From 3b9e33099de44f5b4776ed417a59ffe47f5df9c9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 31 Oct 2025 16:26:33 +0100 Subject: [PATCH 05/29] feat: lightning connections status sync time --- .../java/to/bitkit/repositories/BackupRepo.kt | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 69d6aa0da..2434e6829 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -32,6 +32,7 @@ import to.bitkit.models.BlocktankBackupV1 import to.bitkit.models.MetadataBackupV1 import to.bitkit.models.Toast import to.bitkit.models.WalletBackupV1 +import to.bitkit.services.LightningService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject @@ -47,6 +48,7 @@ class BackupRepo @Inject constructor( private val widgetsStore: WidgetsStore, private val blocktankRepo: BlocktankRepo, private val activityRepo: ActivityRepo, + private val lightningService: LightningService, private val db: AppDb, ) { private val scope = CoroutineScope(bgDispatcher + SupervisorJob()) @@ -258,6 +260,23 @@ class BackupRepo @Inject constructor( } dataListenerJobs.add(activitiesPendingDeleteJob) + // LIGHTNING_CONNECTIONS - Only display sync timestamp, ldk-node manages its own backups + val lightningConnectionsJob = scope.launch { + lightningService.syncFlow() + .collect { + val lastSync = lightningService.status?.latestLightningWalletSyncTimestamp?.toLong() + ?.let { it * 1000 } // Convert seconds to millis + ?: return@collect + if (!isRestoring) { + cacheStore.updateBackupStatus(BackupCategory.LIGHTNING_CONNECTIONS) { + it.copy(required = lastSync, synced = lastSync, running = false) + } + Logger.verbose("Updated lightning backup timestamp to: '$lastSync'", context = TAG) + } + } + } + dataListenerJobs.add(lightningConnectionsJob) + Logger.debug("Started ${dataListenerJobs.size} data store listeners", context = TAG) } @@ -397,7 +416,6 @@ class BackupRepo @Inject constructor( BackupCategory.BLOCKTANK -> { val paidOrders = cacheStore.data.first().paidOrders - // Fetch all orders, CJIT entries, and info from BlocktankRepo state val blocktankState = blocktankRepo.blocktankState.first() val payload = BlocktankBackupV1( @@ -429,9 +447,7 @@ class BackupRepo @Inject constructor( json.encodeToString(payload).toByteArray() } - BackupCategory.LIGHTNING_CONNECTIONS -> { - throw NotImplementedError("Lightning connections backup not yet implemented") - } + BackupCategory.LIGHTNING_CONNECTIONS -> throw NotImplementedError("LIGHTNING backup is managed by ldk-node") } suspend fun performFullRestoreFromLatestBackup(): Result = withContext(bgDispatcher) { From f696a1fe5beea4d8171326c85b24bf26ea0b5558 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 31 Oct 2025 16:28:15 +0100 Subject: [PATCH 06/29] fix: disable retry for ldk instead of activities --- app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt index 5f602c668..5abe7ad31 100644 --- a/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt @@ -37,7 +37,7 @@ class BackupsViewModel @Inject constructor( val cachedStatus = cachedStatuses[category] ?: BackupItemStatus(synced = 0, required = 1) category.toUiState(cachedStatus).let { uiState -> when (category) { - BackupCategory.LDK_ACTIVITY -> uiState.copy(disableRetry = true) + BackupCategory.LIGHTNING_CONNECTIONS -> uiState.copy(disableRetry = true) else -> uiState } } From d1a2b84d91abe569558ae04661b6c433f6ebdf5a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 31 Oct 2025 16:31:21 +0100 Subject: [PATCH 07/29] feat: comment out contacts backup category --- app/src/main/java/to/bitkit/models/BackupStatus.kt | 9 ++++++--- app/src/main/java/to/bitkit/repositories/BackupRepo.kt | 4 ---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/BackupStatus.kt b/app/src/main/java/to/bitkit/models/BackupStatus.kt index 8e85742b1..6361e6028 100644 --- a/app/src/main/java/to/bitkit/models/BackupStatus.kt +++ b/app/src/main/java/to/bitkit/models/BackupStatus.kt @@ -24,7 +24,8 @@ enum class BackupCategory { SETTINGS, WIDGETS, METADATA, - SLASHTAGS, + // PROFILE, // descoped in v1, will return in v2 + // CONTACTS, // descoped in v1, will return in v2 } fun BackupCategory.uiIcon(): Int { @@ -36,7 +37,8 @@ fun BackupCategory.uiIcon(): Int { BackupCategory.SETTINGS -> R.drawable.ic_settings BackupCategory.WIDGETS -> R.drawable.ic_rectangles_two BackupCategory.METADATA -> R.drawable.ic_tag - BackupCategory.SLASHTAGS -> R.drawable.ic_users + // BackupCategory.PROFILE -> R.drawable.ic_user // descoped in v1 + // BackupCategory.SLASHTAGS -> R.drawable.ic_users // descoped in v1 } } @@ -49,6 +51,7 @@ fun BackupCategory.uiTitle(): Int { BackupCategory.SETTINGS -> R.string.settings__backup__category_settings BackupCategory.WIDGETS -> R.string.settings__backup__category_widgets BackupCategory.METADATA -> R.string.settings__backup__category_tags - BackupCategory.SLASHTAGS -> R.string.settings__backup__category_contacts + // BackupCategory.PROFILE -> R.string.settings__backup__category_profile // descoped in v1 + // BackupCategory.SLASHTAGS -> R.string.settings__backup__category_contacts // descoped in v1 } } diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 2434e6829..9fb6a703b 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -429,10 +429,6 @@ class BackupRepo @Inject constructor( json.encodeToString(payload).toByteArray() } - BackupCategory.SLASHTAGS -> { - throw NotImplementedError("Slashtags backup not yet implemented") - } - BackupCategory.LDK_ACTIVITY -> { val activities = activityRepo.getActivities().getOrDefault(emptyList()) val cacheData = cacheStore.data.first() From 2cc9e5c0bb4c6e0b33f3c19be566b0b66bcc6501 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 31 Oct 2025 16:44:50 +0100 Subject: [PATCH 08/29] test: fix activity repo tests --- app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 93ed2aa77..c1612aad4 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -6,6 +6,7 @@ import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection import kotlinx.coroutines.flow.flowOf +import kotlinx.datetime.Clock import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.PaymentDetails @@ -36,6 +37,7 @@ class ActivityRepoTest : BaseUnitTest() { private val cacheStore: CacheStore = mock() private val addressChecker: AddressChecker = mock() private val db: AppDb = mock() + private val clock: Clock = mock() private lateinit var sut: ActivityRepo @@ -55,6 +57,7 @@ class ActivityRepoTest : BaseUnitTest() { fun setUp() { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) whenever(coreService.activity).thenReturn(mock()) + whenever(clock.now()).thenReturn(Clock.System.now()) sut = ActivityRepo( bgDispatcher = testDispatcher, @@ -64,6 +67,7 @@ class ActivityRepoTest : BaseUnitTest() { addressChecker = addressChecker, db = db, transferRepo = mock(), + clock = clock, ) } From a3012f67c2eab53c9414a204a621934642cf88cf Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 31 Oct 2025 16:48:05 +0100 Subject: [PATCH 09/29] refactor: use clock for currentTimeMillis --- .../java/to/bitkit/repositories/BackupRepo.kt | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 9fb6a703b..07ad326ea 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock import to.bitkit.R import to.bitkit.data.AppDb import to.bitkit.data.CacheStore @@ -49,6 +50,7 @@ class BackupRepo @Inject constructor( private val blocktankRepo: BlocktankRepo, private val activityRepo: ActivityRepo, private val lightningService: LightningService, + private val clock: Clock, private val db: AppDb, ) { private val scope = CoroutineScope(bgDispatcher + SupervisorJob()) @@ -271,7 +273,6 @@ class BackupRepo @Inject constructor( cacheStore.updateBackupStatus(BackupCategory.LIGHTNING_CONNECTIONS) { it.copy(required = lastSync, synced = lastSync, running = false) } - Logger.verbose("Updated lightning backup timestamp to: '$lastSync'", context = TAG) } } } @@ -292,7 +293,7 @@ class BackupRepo @Inject constructor( private fun markBackupRequired(category: BackupCategory) { scope.launch { cacheStore.updateBackupStatus(category) { - it.copy(required = System.currentTimeMillis()) + it.copy(required = currentTimeMillis()) } Logger.verbose("Marked backup required for: '$category'", context = TAG) } @@ -316,7 +317,7 @@ class BackupRepo @Inject constructor( } private fun checkForFailedBackups() { - val currentTime = System.currentTimeMillis() + val currentTime = currentTimeMillis() // find if there are any backup categories that have been failing for more than 30 minutes scope.launch { @@ -356,7 +357,7 @@ class BackupRepo @Inject constructor( Logger.debug("Backup starting for: '$category'", context = TAG) cacheStore.updateBackupStatus(category) { - it.copy(running = true, required = System.currentTimeMillis()) + it.copy(running = true, required = currentTimeMillis()) } vssBackupClient.putObject(key = category.name, data = getBackupDataBytes(category)) @@ -364,7 +365,7 @@ class BackupRepo @Inject constructor( cacheStore.updateBackupStatus(category) { it.copy( running = false, - synced = System.currentTimeMillis(), + synced = currentTimeMillis(), ) } Logger.info("Backup succeeded for: '$category'", context = TAG) @@ -393,7 +394,7 @@ class BackupRepo @Inject constructor( val transfers = db.transferDao().getAll() val payload = WalletBackupV1( - createdAt = System.currentTimeMillis(), + createdAt = currentTimeMillis(), boostedActivities = boostedActivities, transfers = transfers ) @@ -406,7 +407,7 @@ class BackupRepo @Inject constructor( val txMetadata = cacheStore.data.first().transactionsMetadata val payload = MetadataBackupV1( - createdAt = System.currentTimeMillis(), + createdAt = currentTimeMillis(), tagMetadata = tagMetadata, transactionsMetadata = txMetadata ) @@ -419,7 +420,7 @@ class BackupRepo @Inject constructor( val blocktankState = blocktankRepo.blocktankState.first() val payload = BlocktankBackupV1( - createdAt = System.currentTimeMillis(), + createdAt = currentTimeMillis(), paidOrders = paidOrders, orders = blocktankState.orders, cjitEntries = blocktankState.cjitEntries, @@ -434,7 +435,7 @@ class BackupRepo @Inject constructor( val cacheData = cacheStore.data.first() val payload = ActivityBackupV1( - createdAt = System.currentTimeMillis(), + createdAt = currentTimeMillis(), activities = activities, deletedActivities = cacheData.deletedActivities, activitiesPendingDelete = cacheData.activitiesPendingDelete, @@ -577,10 +578,12 @@ class BackupRepo @Inject constructor( } cacheStore.updateBackupStatus(category) { - it.copy(running = false, synced = System.currentTimeMillis()) + it.copy(running = false, synced = currentTimeMillis()) } } + private fun currentTimeMillis(): Long = clock.now().toEpochMilliseconds() + companion object { private const val TAG = "BackupRepo" From f5104809eb6b7b620af35257efe03045556570ce Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 31 Oct 2025 17:03:15 +0100 Subject: [PATCH 10/29] fix: remove notifyActivitiesChanged from sync --- app/src/main/java/to/bitkit/repositories/ActivityRepo.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index d2c76d59b..9a91a20de 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -83,7 +83,7 @@ class ActivityRepo @Inject constructor( boostPendingActivities() transferRepo.syncTransferStates() isSyncingLdkNodePayments.value = false - notifyActivitiesChanged() + // Note: We don't call notifyActivitiesChanged() here to avoid backups on every sync. return@withContext Result.success(Unit) }.onFailure { e -> Logger.error("Failed to sync ldk-node payments", e, context = TAG) From 3f2275d717e14b10e66f6e9e95c1e9158ddfca38 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 31 Oct 2025 17:26:58 +0100 Subject: [PATCH 11/29] refactor: encapsulate backup categories props --- .../java/to/bitkit/models/BackupStatus.kt | 79 ++++++++++--------- .../ui/settings/BackupSettingsScreen.kt | 6 +- 2 files changed, 43 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/BackupStatus.kt b/app/src/main/java/to/bitkit/models/BackupStatus.kt index 6361e6028..ddbd310e5 100644 --- a/app/src/main/java/to/bitkit/models/BackupStatus.kt +++ b/app/src/main/java/to/bitkit/models/BackupStatus.kt @@ -16,42 +16,45 @@ data class BackupItemStatus( ) @Serializable -enum class BackupCategory { - LIGHTNING_CONNECTIONS, - BLOCKTANK, - LDK_ACTIVITY, - WALLET, - SETTINGS, - WIDGETS, - METADATA, - // PROFILE, // descoped in v1, will return in v2 - // CONTACTS, // descoped in v1, will return in v2 -} - -fun BackupCategory.uiIcon(): Int { - return when (this) { - BackupCategory.LIGHTNING_CONNECTIONS -> R.drawable.ic_lightning - BackupCategory.BLOCKTANK -> R.drawable.ic_note - BackupCategory.LDK_ACTIVITY -> R.drawable.ic_transfer - BackupCategory.WALLET -> R.drawable.ic_timer_alt - BackupCategory.SETTINGS -> R.drawable.ic_settings - BackupCategory.WIDGETS -> R.drawable.ic_rectangles_two - BackupCategory.METADATA -> R.drawable.ic_tag - // BackupCategory.PROFILE -> R.drawable.ic_user // descoped in v1 - // BackupCategory.SLASHTAGS -> R.drawable.ic_users // descoped in v1 - } -} - -fun BackupCategory.uiTitle(): Int { - return when (this) { - BackupCategory.LIGHTNING_CONNECTIONS -> R.string.settings__backup__category_connections - BackupCategory.BLOCKTANK -> R.string.settings__backup__category_connection_receipts - BackupCategory.LDK_ACTIVITY -> R.string.settings__backup__category_transaction_log - BackupCategory.WALLET -> R.string.settings__backup__category_wallet - BackupCategory.SETTINGS -> R.string.settings__backup__category_settings - BackupCategory.WIDGETS -> R.string.settings__backup__category_widgets - BackupCategory.METADATA -> R.string.settings__backup__category_tags - // BackupCategory.PROFILE -> R.string.settings__backup__category_profile // descoped in v1 - // BackupCategory.SLASHTAGS -> R.string.settings__backup__category_contacts // descoped in v1 - } +enum class BackupCategory( + val uiIcon: Int, + val uiTitle: Int, +) { + LIGHTNING_CONNECTIONS( + uiIcon = R.drawable.ic_lightning, + uiTitle = R.string.settings__backup__category_connections, + ), + BLOCKTANK( + uiIcon = R.drawable.ic_note, + uiTitle = R.string.settings__backup__category_connection_receipts, + ), + LDK_ACTIVITY( + uiIcon = R.drawable.ic_transfer, + uiTitle = R.string.settings__backup__category_transaction_log, + ), + WALLET( + uiIcon = R.drawable.ic_timer_alt, + uiTitle = R.string.settings__backup__category_wallet, + ), + SETTINGS( + uiIcon = R.drawable.ic_settings, + uiTitle = R.string.settings__backup__category_settings, + ), + WIDGETS( + uiIcon = R.drawable.ic_rectangles_two, + uiTitle = R.string.settings__backup__category_widgets, + ), + METADATA( + uiIcon = R.drawable.ic_tag, + uiTitle = R.string.settings__backup__category_tags, + ), + // Descoped in v1, will return in v2: + // PROFILE( + // uiIcon = R.drawable.ic_user, + // uiTitle = R.string.settings__backup__category_profile, + // ), + // CONTACTS( + // uiIcon = R.drawable.ic_users, + // uiTitle = R.string.settings__backup__category_contacts, + // ), } diff --git a/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt index 0fb44bfb9..092742217 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt @@ -31,8 +31,6 @@ import to.bitkit.env.Env import to.bitkit.ext.toLocalizedTimestamp import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus -import to.bitkit.models.uiIcon -import to.bitkit.models.uiTitle import to.bitkit.ui.Routes import to.bitkit.ui.appViewModel import to.bitkit.ui.backupsViewModel @@ -176,11 +174,11 @@ private fun BackupStatusItem( ) { BackupStatusIcon( status = uiState.status, - iconRes = uiState.category.uiIcon(), + iconRes = uiState.category.uiIcon, ) Column(modifier = Modifier.weight(1f)) { - BodyMSB(text = stringResource(uiState.category.uiTitle())) + BodyMSB(text = stringResource(uiState.category.uiTitle)) CaptionB(text = subtitle, color = Colors.White64, maxLines = 1) } From ca140c1de271ed34a143d23c2c11a8815240d94d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 31 Oct 2025 17:27:43 +0100 Subject: [PATCH 12/29] refactor: rename to BackupCategory --- .../{BackupStatus.kt => BackupCategory.kt} | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) rename app/src/main/java/to/bitkit/models/{BackupStatus.kt => BackupCategory.kt} (100%) diff --git a/app/src/main/java/to/bitkit/models/BackupStatus.kt b/app/src/main/java/to/bitkit/models/BackupCategory.kt similarity index 100% rename from app/src/main/java/to/bitkit/models/BackupStatus.kt rename to app/src/main/java/to/bitkit/models/BackupCategory.kt index ddbd310e5..01307c8f0 100644 --- a/app/src/main/java/to/bitkit/models/BackupStatus.kt +++ b/app/src/main/java/to/bitkit/models/BackupCategory.kt @@ -3,18 +3,6 @@ package to.bitkit.models import kotlinx.serialization.Serializable import to.bitkit.R -/** - * @property running In progress - * @property synced Timestamp in ms of last time this backup was synced - * @property required Timestamp in ms of last time this backup was required - */ -@Serializable -data class BackupItemStatus( - val running: Boolean = false, - val synced: Long = 0L, - val required: Long = 0L, -) - @Serializable enum class BackupCategory( val uiIcon: Int, @@ -58,3 +46,15 @@ enum class BackupCategory( // uiTitle = R.string.settings__backup__category_contacts, // ), } + +/** + * @property running In progress + * @property synced Timestamp in ms of last time this backup was synced + * @property required Timestamp in ms of last time this backup was required + */ +@Serializable +data class BackupItemStatus( + val running: Boolean = false, + val synced: Long = 0L, + val required: Long = 0L, +) From 63912aa4ddaaf07e5db4be57ab694385f95621b3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 31 Oct 2025 17:49:27 +0100 Subject: [PATCH 13/29] refactor: backup & restore all caches --- .../main/java/to/bitkit/data/CacheStore.kt | 1 - .../java/to/bitkit/models/BackupPayloads.kt | 14 +- .../java/to/bitkit/repositories/BackupRepo.kt | 128 ++---------------- 3 files changed, 18 insertions(+), 125 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index aeb09ac35..00eb1fc15 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -31,7 +31,6 @@ class CacheStore @Inject constructor( private val store = context.appCacheDataStore val data: Flow = store.data - val backupStatuses: Flow> = data.map { it.backupStatuses } suspend fun update(transform: (AppCacheData) -> AppCacheData) { diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt index 48e684b06..7621e1180 100644 --- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -5,7 +5,7 @@ import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.IcJitEntry import kotlinx.serialization.Serializable -import to.bitkit.data.dto.PendingBoostActivity +import to.bitkit.data.AppCacheData import to.bitkit.data.entities.TagMetadataEntity import to.bitkit.data.entities.TransferEntity @@ -13,14 +13,12 @@ import to.bitkit.data.entities.TransferEntity * Wallet backup payload (v1) * * Contains: - * - Boosted transaction activities from CacheStore * - Transfer entities from Room database */ @Serializable data class WalletBackupV1( val version: Int = 1, val createdAt: Long, - val boostedActivities: List, val transfers: List, ) @@ -29,21 +27,20 @@ data class WalletBackupV1( * * Contains: * - Tag metadata entities from Room database - * - Transaction metadata from CacheStore + * - Entire AppCacheData from CacheStore */ @Serializable data class MetadataBackupV1( val version: Int = 1, val createdAt: Long, val tagMetadata: List, - val transactionsMetadata: List, + val cache: AppCacheData, ) /** * Blocktank backup payload (v1) * * Contains: - * - Paid orders map from CacheStore * - List of IBtOrder from bitkit-core * - List of IcJitEntry from bitkit-core * - IBtInfo from bitkit-core @@ -52,7 +49,6 @@ data class MetadataBackupV1( data class BlocktankBackupV1( val version: Int = 1, val createdAt: Long, - val paidOrders: Map, // orderId -> txId val orders: List, val cjitEntries: List, val info: IBtInfo? = null, @@ -63,14 +59,10 @@ data class BlocktankBackupV1( * * Contains: * - ALL activities (onchain + lightning) from bitkit-core - * - Deleted activity IDs from CacheStore - * - Activities pending deletion from CacheStore */ @Serializable data class ActivityBackupV1( val version: Int = 1, val createdAt: Long, val activities: List, - val deletedActivities: List, - val activitiesPendingDelete: List, ) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 07ad326ea..803585a79 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -147,21 +147,6 @@ class BackupRepo @Inject constructor( } dataListenerJobs.add(widgetsJob) - // WALLET - Observe boosted activities - val boostJob = scope.launch { - // TODO concat into one job using combine of boosts + transfers - cacheStore.data - .map { it.pendingBoostActivities } - .distinctUntilChanged() - .drop(1) - .collect { - if (!isRestoring) { - markBackupRequired(BackupCategory.WALLET) - } - } - } - dataListenerJobs.add(boostJob) - // WALLET - Observe transfers val transfersJob = scope.launch { // TODO concat into one job using combine of boosts + transfers @@ -178,7 +163,6 @@ class BackupRepo @Inject constructor( // METADATA - Observe tag metadata val tagMetadataJob = scope.launch { - // TODO concat into one job using combine of tagMetadataDao + transactionsMetadata db.tagMetadataDao().observeAll() .distinctUntilChanged() .drop(1) @@ -190,11 +174,9 @@ class BackupRepo @Inject constructor( } dataListenerJobs.add(tagMetadataJob) - // METADATA - Observe transaction metadata - val txMetadataJob = scope.launch { - // TODO concat into one job using combine of tagMetadataDao + transactionsMetadata + // METADATA - Observe entire CacheStore + val cacheMetadataJob = scope.launch { cacheStore.data - .map { it.transactionsMetadata } .distinctUntilChanged() .drop(1) .collect { @@ -203,13 +185,11 @@ class BackupRepo @Inject constructor( } } } - dataListenerJobs.add(txMetadataJob) + dataListenerJobs.add(cacheMetadataJob) - // BLOCKTANK - Observe paid orders + // BLOCKTANK - Observe blocktank state changes (orders, cjitEntries, info) val blocktankJob = scope.launch { - cacheStore.data - .map { it.paidOrders } - .distinctUntilChanged() + blocktankRepo.blocktankState .drop(1) .collect { if (!isRestoring) { @@ -219,8 +199,7 @@ class BackupRepo @Inject constructor( } dataListenerJobs.add(blocktankJob) - // ACTIVITY - Observe all activity changes - // ActivityRepo notifies on insert, update, delete, tag changes, and sync completion + // ACTIVITY - Observe all activity changes notified by ActivityRepo on any mutation to core's activity store val activityChangesJob = scope.launch { activityRepo.activitiesChanged .drop(1) @@ -232,36 +211,6 @@ class BackupRepo @Inject constructor( } dataListenerJobs.add(activityChangesJob) - // ACTIVITY - Also observe deleted activities list from CacheStore - val deletedActivitiesJob = scope.launch { - // TODO concat into one job using combine of deletedActivities + activitiesPendingDelete - cacheStore.data - .map { it.deletedActivities } - .distinctUntilChanged() - .drop(1) - .collect { - if (!isRestoring) { - markBackupRequired(BackupCategory.LDK_ACTIVITY) - } - } - } - dataListenerJobs.add(deletedActivitiesJob) - - // ACTIVITY - Also observe activities pending delete from CacheStore - val activitiesPendingDeleteJob = scope.launch { - // TODO concat into one job using combine of deletedActivities + activitiesPendingDelete - cacheStore.data - .map { it.activitiesPendingDelete } - .distinctUntilChanged() - .drop(1) - .collect { - if (!isRestoring) { - markBackupRequired(BackupCategory.LDK_ACTIVITY) - } - } - } - dataListenerJobs.add(activitiesPendingDeleteJob) - // LIGHTNING_CONNECTIONS - Only display sync timestamp, ldk-node manages its own backups val lightningConnectionsJob = scope.launch { lightningService.syncFlow() @@ -390,12 +339,10 @@ class BackupRepo @Inject constructor( } BackupCategory.WALLET -> { - val boostedActivities = cacheStore.data.first().pendingBoostActivities val transfers = db.transferDao().getAll() val payload = WalletBackupV1( createdAt = currentTimeMillis(), - boostedActivities = boostedActivities, transfers = transfers ) @@ -404,24 +351,22 @@ class BackupRepo @Inject constructor( BackupCategory.METADATA -> { val tagMetadata = db.tagMetadataDao().getAll() - val txMetadata = cacheStore.data.first().transactionsMetadata + val cacheData = cacheStore.data.first() val payload = MetadataBackupV1( createdAt = currentTimeMillis(), tagMetadata = tagMetadata, - transactionsMetadata = txMetadata + cache = cacheData, ) json.encodeToString(payload).toByteArray() } BackupCategory.BLOCKTANK -> { - val paidOrders = cacheStore.data.first().paidOrders val blocktankState = blocktankRepo.blocktankState.first() val payload = BlocktankBackupV1( createdAt = currentTimeMillis(), - paidOrders = paidOrders, orders = blocktankState.orders, cjitEntries = blocktankState.cjitEntries, info = blocktankState.info, @@ -432,13 +377,10 @@ class BackupRepo @Inject constructor( BackupCategory.LDK_ACTIVITY -> { val activities = activityRepo.getActivities().getOrDefault(emptyList()) - val cacheData = cacheStore.data.first() val payload = ActivityBackupV1( createdAt = currentTimeMillis(), activities = activities, - deletedActivities = cacheData.deletedActivities, - activitiesPendingDelete = cacheData.activitiesPendingDelete, ) json.encodeToString(payload).toByteArray() @@ -469,16 +411,7 @@ class BackupRepo @Inject constructor( db.transferDao().insert(transfer) } - // Restore boosted activities (idempotent via txId) - parsed.boostedActivities.forEach { activity -> - // TODO add addActivityToPendingBoost(vararg) and use it instead - cacheStore.addActivityToPendingBoost(activity) - } - - Logger.debug( - "Restored ${parsed.transfers.size} transfers and ${parsed.boostedActivities.size} boosted activities", - context = TAG - ) + Logger.debug("Restored ${parsed.transfers.size} transfers", context = TAG) } performRestore(BackupCategory.METADATA) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) @@ -489,27 +422,14 @@ class BackupRepo @Inject constructor( db.tagMetadataDao().saveTagMetadata(entity) } - // Restore transaction metadata (idempotent via txId) - parsed.transactionsMetadata.forEach { metadata -> - // TODO add addTransactionMetadata(vararg) and use it instead - cacheStore.addTransactionMetadata(metadata) - } + cacheStore.update { parsed.cache } - Logger.debug( - "Restored ${parsed.tagMetadata.size} tag metadata entries and ${parsed.transactionsMetadata.size} transaction metadata", - context = TAG - ) + Logger.debug("Restored ${parsed.tagMetadata.size} tags and complete cache data", context = TAG) } performRestore(BackupCategory.BLOCKTANK) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) - // Restore paid orders (idempotent via orderId) - // TODO add addPaidOrder(vararg) and use it instead - parsed.paidOrders.forEach { (orderId, txId) -> - cacheStore.addPaidOrder(orderId, txId) - } - - // TODO: Restore orders, CJIT entries, and info to bitkit-core storage + // TODO: Restore orders, CJIT entries, and info in bitkit-core // This requires bitkit-core to expose an API for restoring orders/cjitEntries/info // For now, trigger a refresh from the Blocktank server to sync the data // Data is preserved in backup: ${parsed.orders.size} orders, ${parsed.cjitEntries.size} CJIT entries, info=${parsed.info != null} @@ -517,7 +437,7 @@ class BackupRepo @Inject constructor( blocktankRepo.refreshOrders() Logger.debug( - "Restored ${parsed.paidOrders.size} paid orders (${parsed.orders.size} orders, ${parsed.cjitEntries.size} CJIT entries, info=${parsed.info != null} backed up)", + "Triggered Blocktank refresh (${parsed.orders.size} orders, ${parsed.cjitEntries.size} CJIT entries, info=${parsed.info != null} backed up)", context = TAG, ) } @@ -529,28 +449,10 @@ class BackupRepo @Inject constructor( activityRepo.upsertActivity(activity) } - // Restore deleted activities list (idempotent via addActivityToDeletedList) - // TODO add addActivityToDeletedList(vararg) and use it instead - parsed.deletedActivities.forEach { activityId -> - cacheStore.addActivityToDeletedList(activityId) - } - - // Restore activities pending deletion (idempotent via addActivityToPendingDelete) - // TODO add addActivityToPendingDelete(vararg) and use it instead - parsed.activitiesPendingDelete.forEach { activityId -> - cacheStore.addActivityToPendingDelete(activityId) - } - - Logger.debug( - "Restored ${parsed.activities.size} activities, ${parsed.deletedActivities.size} deleted, ${parsed.activitiesPendingDelete.size} pending delete", - context = TAG, - ) + Logger.debug("Restored ${parsed.activities.size} activities", context = TAG) } - // TODO: Add other backup categories as they get implemented: - // performSlashtagsRestore() - - Logger.info("Full restore completed", context = TAG) + Logger.info("Full restore success", context = TAG) Result.success(Unit) } catch (e: Throwable) { Logger.warn("Full restore error", e = e, context = TAG) From a8658c7af8463443be1527db26ea091b4b28507a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 31 Oct 2025 17:50:43 +0100 Subject: [PATCH 14/29] refactor: rename LDK_ACTIVITY to ACTIVITY --- app/src/main/java/to/bitkit/models/BackupCategory.kt | 2 +- app/src/main/java/to/bitkit/repositories/BackupRepo.kt | 6 +++--- .../main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/BackupCategory.kt b/app/src/main/java/to/bitkit/models/BackupCategory.kt index 01307c8f0..70a9c29ed 100644 --- a/app/src/main/java/to/bitkit/models/BackupCategory.kt +++ b/app/src/main/java/to/bitkit/models/BackupCategory.kt @@ -16,7 +16,7 @@ enum class BackupCategory( uiIcon = R.drawable.ic_note, uiTitle = R.string.settings__backup__category_connection_receipts, ), - LDK_ACTIVITY( + ACTIVITY( uiIcon = R.drawable.ic_transfer, uiTitle = R.string.settings__backup__category_transaction_log, ), diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 803585a79..d2206b921 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -205,7 +205,7 @@ class BackupRepo @Inject constructor( .drop(1) .collect { if (!isRestoring) { - markBackupRequired(BackupCategory.LDK_ACTIVITY) + markBackupRequired(BackupCategory.ACTIVITY) } } } @@ -375,7 +375,7 @@ class BackupRepo @Inject constructor( json.encodeToString(payload).toByteArray() } - BackupCategory.LDK_ACTIVITY -> { + BackupCategory.ACTIVITY -> { val activities = activityRepo.getActivities().getOrDefault(emptyList()) val payload = ActivityBackupV1( @@ -441,7 +441,7 @@ class BackupRepo @Inject constructor( context = TAG, ) } - performRestore(BackupCategory.LDK_ACTIVITY) { dataBytes -> + performRestore(BackupCategory.ACTIVITY) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) // Restore activities using upsertActivity (idempotent - insert or update) diff --git a/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt index 092742217..09082f815 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt @@ -251,7 +251,7 @@ private fun Preview() { val timestamp = System.currentTimeMillis() - (minutesAgo * 60 * 1000) when (it.category) { - BackupCategory.LDK_ACTIVITY -> it.copy(disableRetry = true) + BackupCategory.ACTIVITY -> 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)) From 0655c69188f15045faf0bc8a1d4167efe4e4567f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 31 Oct 2025 18:15:34 +0100 Subject: [PATCH 15/29] chore: lint --- app/src/main/java/to/bitkit/repositories/BackupRepo.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index d2206b921..e07390629 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -429,15 +429,15 @@ class BackupRepo @Inject constructor( performRestore(BackupCategory.BLOCKTANK) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) - // TODO: Restore orders, CJIT entries, and info in bitkit-core - // This requires bitkit-core to expose an API for restoring orders/cjitEntries/info - // For now, trigger a refresh from the Blocktank server to sync the data - // Data is preserved in backup: ${parsed.orders.size} orders, ${parsed.cjitEntries.size} CJIT entries, info=${parsed.info != null} + // TODO: Restore orders, CJIT entries, and info to bitkit-core using synonymdev/bitkit-core#46 + // For now, trigger a refresh from the server to sync the data blocktankRepo.refreshInfo() blocktankRepo.refreshOrders() Logger.debug( - "Triggered Blocktank refresh (${parsed.orders.size} orders, ${parsed.cjitEntries.size} CJIT entries, info=${parsed.info != null} backed up)", + "Triggered Blocktank refresh (${parsed.orders.size} orders," + + "${parsed.cjitEntries.size} CJIT entries," + + "info=${parsed.info != null} backed up)", context = TAG, ) } From 1b19a519e002c491ce087c3e8e2eece89e3c83f4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 31 Oct 2025 18:20:03 +0100 Subject: [PATCH 16/29] refactor: use upsert --- app/src/main/java/to/bitkit/data/dao/TagMetadataDao.kt | 4 ++++ app/src/main/java/to/bitkit/data/dao/TransferDao.kt | 4 ++++ app/src/main/java/to/bitkit/repositories/BackupRepo.kt | 7 ++----- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/dao/TagMetadataDao.kt b/app/src/main/java/to/bitkit/data/dao/TagMetadataDao.kt index 685f2c6b9..3861c888b 100644 --- a/app/src/main/java/to/bitkit/data/dao/TagMetadataDao.kt +++ b/app/src/main/java/to/bitkit/data/dao/TagMetadataDao.kt @@ -5,6 +5,7 @@ import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Upsert import kotlinx.coroutines.flow.Flow import to.bitkit.data.entities.TagMetadataEntity @@ -14,6 +15,9 @@ interface TagMetadataDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun saveTagMetadata(tagMetadata: TagMetadataEntity) + @Upsert + suspend fun upsert(tagMetadata: TagMetadataEntity) + @Query("SELECT * FROM tag_metadata") fun observeAll(): Flow> diff --git a/app/src/main/java/to/bitkit/data/dao/TransferDao.kt b/app/src/main/java/to/bitkit/data/dao/TransferDao.kt index 389024be5..b37b777c9 100644 --- a/app/src/main/java/to/bitkit/data/dao/TransferDao.kt +++ b/app/src/main/java/to/bitkit/data/dao/TransferDao.kt @@ -5,6 +5,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update +import androidx.room.Upsert import kotlinx.coroutines.flow.Flow import to.bitkit.data.entities.TransferEntity @@ -13,6 +14,9 @@ interface TransferDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(transfer: TransferEntity) + @Upsert + suspend fun upsert(transfer: TransferEntity) + @Update suspend fun update(transfer: TransferEntity) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index e07390629..3eea6668f 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -149,7 +149,6 @@ class BackupRepo @Inject constructor( // WALLET - Observe transfers val transfersJob = scope.launch { - // TODO concat into one job using combine of boosts + transfers db.transferDao().observeAll() .distinctUntilChanged() .drop(1) @@ -407,8 +406,7 @@ class BackupRepo @Inject constructor( val parsed = json.decodeFromString(String(dataBytes)) parsed.transfers.forEach { transfer -> - // TODO add transferDao().upsert() and use it instead - db.transferDao().insert(transfer) + db.transferDao().upsert(transfer) } Logger.debug("Restored ${parsed.transfers.size} transfers", context = TAG) @@ -418,8 +416,7 @@ class BackupRepo @Inject constructor( // Restore tag metadata (idempotent via primary key with INSERT OR REPLACE) parsed.tagMetadata.forEach { entity -> - // TODO add tagMetadataDao().upsert() and use it instead - db.tagMetadataDao().saveTagMetadata(entity) + db.tagMetadataDao().upsert(entity) } cacheStore.update { parsed.cache } From 446ee64a2cba7fda19b1c76dc0d5a0eb77d7fe70 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 31 Oct 2025 18:21:11 +0100 Subject: [PATCH 17/29] =?UTF-8?q?refactor=E2=80=A6=20rename=20saveTagMetad?= =?UTF-8?q?ata=20to=20insert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/java/to/bitkit/data/dao/TagMetadataDao.kt | 2 +- app/src/main/java/to/bitkit/repositories/ActivityRepo.kt | 4 +--- app/src/main/java/to/bitkit/repositories/WalletRepo.kt | 4 +--- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/dao/TagMetadataDao.kt b/app/src/main/java/to/bitkit/data/dao/TagMetadataDao.kt index 3861c888b..b25bb1e56 100644 --- a/app/src/main/java/to/bitkit/data/dao/TagMetadataDao.kt +++ b/app/src/main/java/to/bitkit/data/dao/TagMetadataDao.kt @@ -13,7 +13,7 @@ import to.bitkit.data.entities.TagMetadataEntity interface TagMetadataDao { @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun saveTagMetadata(tagMetadata: TagMetadataEntity) + suspend fun insert(tagMetadata: TagMetadataEntity) @Upsert suspend fun upsert(tagMetadata: TagMetadataEntity) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 9a91a20de..42ddafd74 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -641,9 +641,7 @@ class ActivityRepo @Inject constructor( tags = tags, createdAt = nowTimestamp().toEpochMilli() ) - db.tagMetadataDao().saveTagMetadata( - tagMetadata = entity - ) + db.tagMetadataDao().insert(tagMetadata = entity) Logger.debug("Tag metadata saved: $entity", context = TAG) }.onFailure { e -> Logger.error("getAllAvailableTags error", e, context = TAG) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 3882c75cb..9dc2609a4 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -424,9 +424,7 @@ class WalletRepo @Inject constructor( isReceive = true, createdAt = nowTimestamp().toEpochMilli() ) - db.tagMetadataDao().saveTagMetadata( - tagMetadata = entity - ) + db.tagMetadataDao().insert(tagMetadata = entity) Logger.debug("Tag metadata saved: $entity", context = TAG) } catch (e: Throwable) { Logger.error("saveInvoice error", e, context = TAG) From bee7811884ae4929f0f542d3f746e689cce96353 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 1 Nov 2025 02:00:49 +0100 Subject: [PATCH 18/29] refactor: use if guards --- .../java/to/bitkit/repositories/BackupRepo.kt | 42 ++++++++----------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 3eea6668f..4099a5d0d 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -128,9 +128,8 @@ class BackupRepo @Inject constructor( .distinctUntilChanged() .drop(1) .collect { - if (!isRestoring) { - markBackupRequired(BackupCategory.SETTINGS) - } + if (isRestoring) return@collect + markBackupRequired(BackupCategory.SETTINGS) } } dataListenerJobs.add(settingsJob) @@ -140,9 +139,8 @@ class BackupRepo @Inject constructor( .distinctUntilChanged() .drop(1) .collect { - if (!isRestoring) { - markBackupRequired(BackupCategory.WIDGETS) - } + if (isRestoring) return@collect + markBackupRequired(BackupCategory.WIDGETS) } } dataListenerJobs.add(widgetsJob) @@ -153,9 +151,8 @@ class BackupRepo @Inject constructor( .distinctUntilChanged() .drop(1) .collect { - if (!isRestoring) { - markBackupRequired(BackupCategory.WALLET) - } + if (isRestoring) return@collect + markBackupRequired(BackupCategory.WALLET) } } dataListenerJobs.add(transfersJob) @@ -166,9 +163,8 @@ class BackupRepo @Inject constructor( .distinctUntilChanged() .drop(1) .collect { - if (!isRestoring) { - markBackupRequired(BackupCategory.METADATA) - } + if (isRestoring) return@collect + markBackupRequired(BackupCategory.METADATA) } } dataListenerJobs.add(tagMetadataJob) @@ -179,9 +175,8 @@ class BackupRepo @Inject constructor( .distinctUntilChanged() .drop(1) .collect { - if (!isRestoring) { - markBackupRequired(BackupCategory.METADATA) - } + if (isRestoring) return@collect + markBackupRequired(BackupCategory.METADATA) } } dataListenerJobs.add(cacheMetadataJob) @@ -191,9 +186,8 @@ class BackupRepo @Inject constructor( blocktankRepo.blocktankState .drop(1) .collect { - if (!isRestoring) { - markBackupRequired(BackupCategory.BLOCKTANK) - } + if (isRestoring) return@collect + markBackupRequired(BackupCategory.BLOCKTANK) } } dataListenerJobs.add(blocktankJob) @@ -203,9 +197,8 @@ class BackupRepo @Inject constructor( activityRepo.activitiesChanged .drop(1) .collect { - if (!isRestoring) { - markBackupRequired(BackupCategory.ACTIVITY) - } + if (isRestoring) return@collect + markBackupRequired(BackupCategory.ACTIVITY) } } dataListenerJobs.add(activityChangesJob) @@ -217,10 +210,9 @@ class BackupRepo @Inject constructor( val lastSync = lightningService.status?.latestLightningWalletSyncTimestamp?.toLong() ?.let { it * 1000 } // Convert seconds to millis ?: return@collect - if (!isRestoring) { - cacheStore.updateBackupStatus(BackupCategory.LIGHTNING_CONNECTIONS) { - it.copy(required = lastSync, synced = lastSync, running = false) - } + if (isRestoring) return@collect + cacheStore.updateBackupStatus(BackupCategory.LIGHTNING_CONNECTIONS) { + it.copy(required = lastSync, synced = lastSync, running = false) } } } From 3f9ea1f4855b092f4a5813e4bd163e5341a27c80 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 1 Nov 2025 02:33:45 +0100 Subject: [PATCH 19/29] chore: cleanup --- app/src/main/java/to/bitkit/env/Env.kt | 1 - .../java/to/bitkit/models/BackupCategory.kt | 42 +- .../java/to/bitkit/models/BackupPayloads.kt | 27 - .../to/bitkit/repositories/ActivityRepo.kt | 5 - .../ui/settings/BackupSettingsScreen.kt | 6 +- docs/backups-plan.md | 737 ------------------ 6 files changed, 25 insertions(+), 793 deletions(-) delete mode 100644 docs/backups-plan.md diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 76f7de5fd..342e4eeba 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -143,7 +143,6 @@ internal object Env { */ private fun storagePathOf(walletIndex: Int, network: String, dir: String): String { require(::appStoragePath.isInitialized) { "App storage path should be 'context.filesDir.absolutePath'." } - // appStoragePath/network/walletN/dir val path = Path(appStoragePath, network, "wallet$walletIndex", dir) .toFile() .ensureDir() diff --git a/app/src/main/java/to/bitkit/models/BackupCategory.kt b/app/src/main/java/to/bitkit/models/BackupCategory.kt index 70a9c29ed..7944ea710 100644 --- a/app/src/main/java/to/bitkit/models/BackupCategory.kt +++ b/app/src/main/java/to/bitkit/models/BackupCategory.kt @@ -1,49 +1,51 @@ package to.bitkit.models +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import kotlinx.serialization.Serializable import to.bitkit.R @Serializable enum class BackupCategory( - val uiIcon: Int, - val uiTitle: Int, + @DrawableRes val icon: Int, + @StringRes val title: Int, ) { LIGHTNING_CONNECTIONS( - uiIcon = R.drawable.ic_lightning, - uiTitle = R.string.settings__backup__category_connections, + icon = R.drawable.ic_lightning, + title = R.string.settings__backup__category_connections, ), BLOCKTANK( - uiIcon = R.drawable.ic_note, - uiTitle = R.string.settings__backup__category_connection_receipts, + icon = R.drawable.ic_note, + title = R.string.settings__backup__category_connection_receipts, ), ACTIVITY( - uiIcon = R.drawable.ic_transfer, - uiTitle = R.string.settings__backup__category_transaction_log, + icon = R.drawable.ic_transfer, + title = R.string.settings__backup__category_transaction_log, ), WALLET( - uiIcon = R.drawable.ic_timer_alt, - uiTitle = R.string.settings__backup__category_wallet, + icon = R.drawable.ic_timer_alt, + title = R.string.settings__backup__category_wallet, ), SETTINGS( - uiIcon = R.drawable.ic_settings, - uiTitle = R.string.settings__backup__category_settings, + icon = R.drawable.ic_settings, + title = R.string.settings__backup__category_settings, ), WIDGETS( - uiIcon = R.drawable.ic_rectangles_two, - uiTitle = R.string.settings__backup__category_widgets, + icon = R.drawable.ic_rectangles_two, + title = R.string.settings__backup__category_widgets, ), METADATA( - uiIcon = R.drawable.ic_tag, - uiTitle = R.string.settings__backup__category_tags, + icon = R.drawable.ic_tag, + title = R.string.settings__backup__category_tags, ), // Descoped in v1, will return in v2: // PROFILE( - // uiIcon = R.drawable.ic_user, - // uiTitle = R.string.settings__backup__category_profile, + // icon = R.drawable.ic_user, + // title = R.string.settings__backup__category_profile, // ), // CONTACTS( - // uiIcon = R.drawable.ic_users, - // uiTitle = R.string.settings__backup__category_contacts, + // icon = R.drawable.ic_users, + // title = R.string.settings__backup__category_contacts, // ), } diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt index 7621e1180..59b006015 100644 --- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -9,12 +9,6 @@ import to.bitkit.data.AppCacheData import to.bitkit.data.entities.TagMetadataEntity import to.bitkit.data.entities.TransferEntity -/** - * Wallet backup payload (v1) - * - * Contains: - * - Transfer entities from Room database - */ @Serializable data class WalletBackupV1( val version: Int = 1, @@ -22,13 +16,6 @@ data class WalletBackupV1( val transfers: List, ) -/** - * Metadata backup payload (v1) - * - * Contains: - * - Tag metadata entities from Room database - * - Entire AppCacheData from CacheStore - */ @Serializable data class MetadataBackupV1( val version: Int = 1, @@ -37,14 +24,6 @@ data class MetadataBackupV1( val cache: AppCacheData, ) -/** - * Blocktank backup payload (v1) - * - * Contains: - * - List of IBtOrder from bitkit-core - * - List of IcJitEntry from bitkit-core - * - IBtInfo from bitkit-core - */ @Serializable data class BlocktankBackupV1( val version: Int = 1, @@ -54,12 +33,6 @@ data class BlocktankBackupV1( val info: IBtInfo? = null, ) -/** - * Activity backup payload (v1) - * - * Contains: - * - ALL activities (onchain + lightning) from bitkit-core - */ @Serializable data class ActivityBackupV1( val version: Int = 1, diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 42ddafd74..cbd35954b 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -51,10 +51,6 @@ class ActivityRepo @Inject constructor( ) { val isSyncingLdkNodePayments = MutableStateFlow(false) - /** - * Emits a timestamp whenever activities are modified (insert, update, delete, tag changes, etc.) - * Used to trigger backups and UI updates. - */ private val _activitiesChanged = MutableStateFlow(0L) val activitiesChanged: StateFlow = _activitiesChanged @@ -83,7 +79,6 @@ class ActivityRepo @Inject constructor( boostPendingActivities() transferRepo.syncTransferStates() isSyncingLdkNodePayments.value = false - // Note: We don't call notifyActivitiesChanged() here to avoid backups on every sync. return@withContext Result.success(Unit) }.onFailure { e -> Logger.error("Failed to sync ldk-node payments", e, context = TAG) diff --git a/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt index 09082f815..c14ec3ac2 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt @@ -174,11 +174,11 @@ private fun BackupStatusItem( ) { BackupStatusIcon( status = uiState.status, - iconRes = uiState.category.uiIcon, + iconRes = uiState.category.icon, ) Column(modifier = Modifier.weight(1f)) { - BodyMSB(text = stringResource(uiState.category.uiTitle)) + BodyMSB(text = stringResource(uiState.category.title)) CaptionB(text = subtitle, color = Colors.White64, maxLines = 1) } @@ -251,7 +251,7 @@ private fun Preview() { val timestamp = System.currentTimeMillis() - (minutesAgo * 60 * 1000) when (it.category) { - BackupCategory.ACTIVITY -> it.copy(disableRetry = true) + 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)) diff --git a/docs/backups-plan.md b/docs/backups-plan.md deleted file mode 100644 index 158260556..000000000 --- a/docs/backups-plan.md +++ /dev/null @@ -1,737 +0,0 @@ -# Backup Implementation Plan - -## Overview - -This document outlines the implementation plan for completing the backup system in bitkit-android. Currently, only **SETTINGS** and **WIDGETS** categories are fully implemented. This plan covers the remaining categories: **WALLET**, **METADATA**, **BLOCKTANK**, **ACTIVITY** (formerly LDK_ACTIVITY), and **LIGHTNING_CONNECTIONS** (display-only). - -**Last Updated:** 2025-10-30 - ---- - -## Goals - -1. **Backup all data stores comprehensively** - CacheStore, SettingsStore, WidgetsStore, Room DB (AppDb), and CoreService-managed data -2. **Use JSON serialization** (not raw database files) for maintainability, idempotency, and version migration -3. **Implement idempotent restore** - safe to restore multiple times without duplicates -4. **Category independence** - each category backs up and restores independently -5. **Display-only status for LIGHTNING_CONNECTIONS** - show ldk-node's native backup timing -6. **Descope SLASHTAGS** - comment out for v1, plan for v2 - ---- - -## Architecture - -### Data Stores in bitkit-android - -| Store | Type | Contents | Backed Up By | -|-------|------|----------|--------------| -| **SettingsStore** | DataStore | User preferences, UI state | SETTINGS ✅ | -| **WidgetsStore** | DataStore | Widget config, cached widget data | WIDGETS ✅ | -| **CacheStore** | DataStore | Boosted activities, tx metadata, paid orders, balance cache | WALLET, METADATA, BLOCKTANK | -| **AppDb** (Room) | SQLite | Transfers, tag metadata, config | WALLET, METADATA | -| **activity.db** (CoreService) | SQLite | All activities (onchain + lightning) | ACTIVITY | -| **blocktank.db** (CoreService) | SQLite | Orders, CJIT entries | BLOCKTANK | -| **LDK storage** | Native | Channel state, monitors | LDK (native backup) | - -### Backup Categories - -| Category | Status | Data Source | Backup Method | Change Detection | -|----------|--------|-------------|---------------|------------------| -| SETTINGS | ✅ Implemented | SettingsStore | JSON serialization | DataStore flow observer | -| WIDGETS | ✅ Implemented | WidgetsStore | JSON serialization | DataStore flow observer | -| WALLET | ⏳ To implement | CacheStore + TransferDao | JSON serialization | CacheStore + Room flows | -| METADATA | ⏳ To implement | CacheStore + TagMetadataDao | JSON serialization | CacheStore + Room flows | -| BLOCKTANK | ⏳ To implement | CacheStore + CoreService | JSON serialization | CacheStore + event callbacks | -| ACTIVITY | ⏳ To implement | CoreService.activity (ALL) | JSON serialization | Manual trigger only | -| LIGHTNING_CONNECTIONS | ⏳ Display-only | LightningService.status | N/A (ldk-node native) | Status timestamp observer | -| ~~SLASHTAGS~~ | 🚫 Descoped | N/A | N/A | N/A | - ---- - -## Design Decisions - -### Why JSON Serialization (Not Raw DB Files)? - -**Advantages:** -- ✅ **No file locking issues** - read data via DAOs/APIs, not file I/O -- ✅ **Schema evolution** - easy to migrate between versions -- ✅ **Selective restore** - validate and transform data before mutation -- ✅ **Smaller size** - only serialize needed data, not DB overhead -- ✅ **Idempotency** - upsert by stable keys prevents duplicates -- ✅ **Testability** - can inspect and mock JSON payloads -- ✅ **Cross-version compatibility** - handles schema changes gracefully - -**Rejected Alternative:** Copying raw `activity.db` and `blocktank.db` files -- ❌ Requires database locks/close -- ❌ Schema changes break restores -- ❌ Harder to deduplicate on restore -- ❌ Larger backup sizes - -### Rename LDK_ACTIVITY → ACTIVITY - -The category now backs up **ALL** activities (onchain + lightning), not just lightning activities. The name change reflects this scope accurately. - ---- - -## Implementation Details - -### 1. Payload Schemas - -All payloads include version and timestamp for migration support. - -```kotlin -@Serializable -data class WalletBackupV1( - val version: Int = 1, - val createdAt: Long = System.currentTimeMillis(), - val boostedActivities: List, - val transfers: List, -) - -@Serializable -data class MetadataBackupV1( - val version: Int = 1, - val createdAt: Long = System.currentTimeMillis(), - val tagMetadata: List, - val transactionsMetadata: List, -) - -@Serializable -data class BlocktankBackupV1( - val version: Int = 1, - val createdAt: Long = System.currentTimeMillis(), - val paidOrders: Map, // orderId -> txId - val orders: List, - val cjitEntries: List, -) - -@Serializable -data class SerializableOrder( - val id: String, - val state: String, - // Essential fields only -) - -@Serializable -data class SerializableCjitEntry( - val channelSizeSat: ULong, - val invoice: String, - // Essential fields only -) - -@Serializable -data class ActivityBackupV1( - val version: Int = 1, - val createdAt: Long = System.currentTimeMillis(), - val activities: List, // ALL activities (onchain + lightning) -) -``` - -### 2. WALLET Backup Implementation - -**Data sources:** -- `CacheStore.pendingBoostActivities` -- `TransferDao.getAll()` - -**Backup:** -```kotlin -BackupCategory.WALLET -> { - val boostedActivities = cacheStore.data.first().pendingBoostActivities - val transfers = transferDao.getAll() - - val payload = WalletBackupV1( - boostedActivities = boostedActivities, - transfers = transfers - ) - - json.encodeToString(payload).toByteArray() -} -``` - -**Restore:** -```kotlin -performRestore(BackupCategory.WALLET) { dataBytes -> - val payload = json.decodeFromString(String(dataBytes)) - - // Restore transfers (idempotent via primary key) - db.withTransaction { - payload.transfers.forEach { transfer -> - transferDao.upsert(transfer) - } - } - - // Restore boosted activities (idempotent via txId) - payload.boostedActivities.forEach { activity -> - cacheStore.addActivityToPendingBoost(activity) - } -} -``` - -**Change detection:** -```kotlin -// Observe boosted activities -val boostJob = scope.launch { - cacheStore.data - .map { it.pendingBoostActivities } - .distinctUntilChanged() - .drop(1) - .collect { - if (!isRestoring) { - markBackupRequired(BackupCategory.WALLET) - } - } -} - -// Observe transfers -val transfersJob = scope.launch { - transferDao.observeAll() - .distinctUntilChanged() - .drop(1) - .collect { - if (!isRestoring) { - markBackupRequired(BackupCategory.WALLET) - } - } -} -``` - -**New DAO methods needed:** -```kotlin -@Dao -interface TransferDao { - @Query("SELECT * FROM transfers") - suspend fun getAll(): List - - @Query("SELECT * FROM transfers") - fun observeAll(): Flow> - - @Upsert - suspend fun upsert(entity: TransferEntity) -} -``` - ---- - -### 3. METADATA Backup Implementation - -**Data sources:** -- `TagMetadataDao.getAll()` -- `CacheStore.transactionsMetadata` - -**Backup:** -```kotlin -BackupCategory.METADATA -> { - val tagMetadata = db.tagMetadataDao().getAll() - val txMetadata = cacheStore.data.first().transactionsMetadata - - val payload = MetadataBackupV1( - tagMetadata = tagMetadata, - transactionsMetadata = txMetadata - ) - - json.encodeToString(payload).toByteArray() -} -``` - -**Restore:** -```kotlin -performRestore(BackupCategory.METADATA) { dataBytes -> - val payload = json.decodeFromString(String(dataBytes)) - - // Restore tag metadata (idempotent via primary key) - db.withTransaction { - payload.tagMetadata.forEach { entity -> - db.tagMetadataDao().upsert(entity) - } - } - - // Restore transaction metadata (idempotent via txId) - payload.transactionsMetadata.forEach { metadata -> - cacheStore.addTransactionMetadata(metadata) - } -} -``` - -**Change detection:** -```kotlin -// Observe tag metadata -val metadataJob = scope.launch { - db.tagMetadataDao().observeAll() - .distinctUntilChanged() - .drop(1) - .collect { - if (!isRestoring) { - markBackupRequired(BackupCategory.METADATA) - } - } -} - -// Observe transaction metadata -val txMetadataJob = scope.launch { - cacheStore.data - .map { it.transactionsMetadata } - .distinctUntilChanged() - .drop(1) - .collect { - if (!isRestoring) { - markBackupRequired(BackupCategory.METADATA) - } - } -} -``` - -**New DAO methods needed:** -```kotlin -@Dao -interface TagMetadataDao { - @Query("SELECT * FROM tag_metadata") - suspend fun getAll(): List - - @Query("SELECT * FROM tag_metadata") - fun observeAll(): Flow> - - @Upsert - suspend fun upsert(entity: TagMetadataEntity) -} -``` - ---- - -### 4. BLOCKTANK Backup Implementation - -**Data sources:** -- `CacheStore.paidOrders` -- `CoreService.blocktank.orders(refresh = false)` -- `CoreService.blocktank.cjitEntries(refresh = false)` - -**Backup:** -```kotlin -BackupCategory.BLOCKTANK -> { - val paidOrders = cacheStore.data.first().paidOrders - val orders = coreService.blocktank.orders(refresh = false) - val cjitEntries = coreService.blocktank.cjitEntries(refresh = false) - - val payload = BlocktankBackupV1( - paidOrders = paidOrders, - orders = orders.map { it.toSerializable() }, - cjitEntries = cjitEntries.map { it.toSerializable() } - ) - - json.encodeToString(payload).toByteArray() -} -``` - -**Restore:** -```kotlin -performRestore(BackupCategory.BLOCKTANK) { dataBytes -> - val payload = json.decodeFromString(String(dataBytes)) - - // Restore paid orders (idempotent via orderId) - payload.paidOrders.forEach { (orderId, txId) -> - cacheStore.addPaidOrder(orderId, txId) - } - - // Note: Orders and CJIT entries are refreshed from server - // We mainly need paidOrders to track payment status locally -} -``` - -**Change detection:** -```kotlin -// Observe paid orders -val blocktankJob = scope.launch { - cacheStore.data - .map { it.paidOrders } - .distinctUntilChanged() - .drop(1) - .collect { - if (!isRestoring) { - markBackupRequired(BackupCategory.BLOCKTANK) - } - } -} -``` - -**Serializable representations:** -```kotlin -fun IBtOrder.toSerializable() = SerializableOrder( - id = this.id, - state = this.state2.name, - // Add other essential fields -) - -fun IcJitEntry.toSerializable() = SerializableCjitEntry( - channelSizeSat = this.channelSizeSat, - invoice = this.invoice.request, - // Add other essential fields -) -``` - ---- - -### 5. ACTIVITY Backup Implementation - -**Data source:** -- `CoreService.activity.get(filter = ActivityFilter.ALL)` - **ALL activities** - -**Backup:** -```kotlin -BackupCategory.ACTIVITY -> { - val allActivities = coreService.activity.get( - filter = ActivityFilter.ALL, - txType = null, - tags = null, - search = null, - minDate = null, - maxDate = null, - limit = null, - sortDirection = null - ) - - val payload = ActivityBackupV1( - activities = allActivities - ) - - json.encodeToString(payload).toByteArray() -} -``` - -**Restore:** -```kotlin -performRestore(BackupCategory.ACTIVITY) { dataBytes -> - val payload = json.decodeFromString(String(dataBytes)) - - // Restore all activities (idempotent via activity ID) - payload.activities.forEach { activity -> - runCatching { - // Try to insert; if exists, skip or update - coreService.activity.insert(activity) - }.onFailure { e -> - // Activity might already exist; log and continue - Logger.debug("Activity already exists or failed: ${e.message}") - } - } -} -``` - -**Change detection:** -- **Manual backup only** (no auto-trigger) -- Reason: Activity list can be large; user initiates backup manually -- Keep `disableRetry = true` in BackupsViewModel for this category - -**Note:** Ensure `Activity` (both `Activity.Lightning` and `Activity.Onchain`) are `@Serializable` from bitkit-core. - ---- - -### 6. LIGHTNING_CONNECTIONS (Display-Only) - -**Purpose:** Display ldk-node's native backup status, not perform manual backup - -**Data source:** -- `lightningService.status.latestLightningWalletSyncTimestamp` - -**Implementation:** -```kotlin -// In BackupRepo.getBackupDataBytes() -BackupCategory.LIGHTNING_CONNECTIONS -> { - throw NotImplementedError( - "LIGHTNING_CONNECTIONS backup is handled by ldk-node's native backup system" - ) -} - -// In BackupRepo.startDataStoreListeners() -private fun observeLdkBackupStatus() { - val ldkStatusJob = scope.launch { - lightningRepo.lightningState - .map { it.status.latestLightningWalletSyncTimestamp } - .distinctUntilChanged() - .collect { syncTimestamp -> - // Update backup status to display LDK's sync time - cacheStore.updateBackupStatus(BackupCategory.LIGHTNING_CONNECTIONS) { - it.copy( - synced = syncTimestamp, - required = syncTimestamp, // Always "synced" - running = false - ) - } - } - } - statusObserverJobs.add(ldkStatusJob) -} -``` - -**UI behavior:** -- Show sync timestamp from ldk-node -- No "Backup Now" button -- No retry or running states -- Display as "Automatically backed up by Lightning" - ---- - -### 7. SLASHTAGS (Descoped) - -**Status:** Descoped for v1, planned for v2 - -**Implementation:** -```kotlin -// In BackupStatus.kt -@Serializable -enum class BackupCategory { - LIGHTNING_CONNECTIONS, - BLOCKTANK, - ACTIVITY, // Renamed from LDK_ACTIVITY - WALLET, - SETTINGS, - WIDGETS, - METADATA, - // SLASHTAGS, // Descoped for v1, will return in v2 -} -``` - -Remove from: -- BackupRepo.getBackupDataBytes() -- performFullRestoreFromLatestBackup() -- UI screens (BackupSettingsScreen, etc.) - ---- - -## Restore Orchestration - -### Restore Order - -Recommended order for dependencies: -1. **METADATA** (tags and tx metadata) -2. **WALLET** (transfers and boosts) -3. **BLOCKTANK** (orders and paid orders) -4. **ACTIVITY** (all activities) - -### Idempotency Strategy - -Each category uses stable keys for upsert: -- **WALLET**: Transfer by `id` (primary key), boost by `txId` -- **METADATA**: TagMetadata by `id` or composite key, tx metadata by `txId` -- **BLOCKTANK**: Paid orders by `orderId` -- **ACTIVITY**: Activity by `id` field - -Use Room's `@Upsert` (or `onConflict = REPLACE`) and CacheStore deduplication. - -### Error Handling - -```kotlin -suspend fun performFullRestoreFromLatestBackup(): Result = withContext(bgDispatcher) { - isRestoring = true - - val results = mutableMapOf>() - - val categories = listOf( - BackupCategory.METADATA, - BackupCategory.WALLET, - BackupCategory.BLOCKTANK, - BackupCategory.ACTIVITY - ) - - for (category in categories) { - val result = runCatching { - performRestore(category) { dataBytes -> - // Category-specific restore logic - } - } - results[category] = result - - if (result.isFailure) { - Logger.warn("Restore failed for $category", result.exceptionOrNull()) - // Continue with other categories - } - } - - isRestoring = false - - // Return success if at least one category restored - val anySuccess = results.values.any { it.isSuccess } - if (anySuccess) Result.success(Unit) else Result.failure(Exception("All restores failed")) -} -``` - ---- - -## Testing Strategy - -### Unit Tests - -For each category, test: -1. **Serialization roundtrip** - backup → deserialize → verify equality -2. **Empty data** - backup with no data returns valid empty payload -3. **Large datasets** - 1000+ items serialize/deserialize correctly -4. **Version migration** - older payload version can be migrated - -Example: -```kotlin -@Test -fun `wallet backup serialization roundtrip`() = runTest { - // Create test data - val transfers = listOf( - TransferEntity(id = "1", type = TransferType.TO_SAVINGS, ...), - TransferEntity(id = "2", type = TransferType.TO_SPENDING, ...) - ) - val boosts = listOf( - PendingBoostActivity(txId = "abc", ...) - ) - - // Serialize - val payload = WalletBackupV1( - boostedActivities = boosts, - transfers = transfers - ) - val json = Json.encodeToString(payload) - - // Deserialize - val restored = Json.decodeFromString(json) - - // Verify - assertEquals(payload.transfers.size, restored.transfers.size) - assertEquals(payload.boostedActivities.size, restored.boostedActivities.size) -} -``` - -### Integration Tests - -For each category, test: -1. **Backup → restore → verify** - data persists correctly -2. **Idempotent restore** - restore twice, no duplicates -3. **Partial data** - restore with some missing data succeeds -4. **Failed restore** - one category fails, others continue - -Example: -```kotlin -@Test -fun `wallet restore is idempotent`() = runTest { - // Insert initial data - transferDao.insert(TransferEntity(id = "1", ...)) - - // Backup - val backupBytes = backupRepo.triggerBackup(BackupCategory.WALLET) - - // Restore twice - backupRepo.performRestore(BackupCategory.WALLET) { backupBytes } - backupRepo.performRestore(BackupCategory.WALLET) { backupBytes } - - // Verify no duplicates - val transfers = transferDao.getAll() - assertEquals(1, transfers.size) -} -``` - -### Manual Tests - -- [ ] Create wallet data, backup, wipe app data, restore, verify -- [ ] Add tags, backup, restore on another device, verify -- [ ] Create blocktank order, pay, backup, restore, verify paid status -- [ ] Generate activities, backup, restore, verify count and details -- [ ] Check LIGHTNING_CONNECTIONS displays correct timestamp -- [ ] Trigger backup failure (network error), verify UI shows error -- [ ] Restore with missing category backup, verify graceful handling - ---- - -## Implementation Checklist - -### Phase 1: Setup (1 day) -- [ ] Create `docs/backups-plan.md` and commit -- [ ] Rename `BackupCategory.LDK_ACTIVITY` → `ACTIVITY` -- [ ] Comment out `SLASHTAGS` from enum -- [ ] Define all payload data classes (`WalletBackupV1`, etc.) - -### Phase 2: DAO Extensions (0.5 day) -- [ ] Add `TransferDao.getAll()` and `observeAll()` -- [ ] Add `TransferDao.upsert()` -- [ ] Add `TagMetadataDao.getAll()` and `observeAll()` -- [ ] Add `TagMetadataDao.upsert()` -- [ ] Ensure all entities are `@Serializable` - -### Phase 3: WALLET Implementation (1 day) -- [ ] Implement `getBackupDataBytes` for WALLET -- [ ] Implement `performRestore` for WALLET -- [ ] Add change detection listeners -- [ ] Write unit tests -- [ ] Write integration tests - -### Phase 4: METADATA Implementation (1 day) -- [ ] Implement `getBackupDataBytes` for METADATA -- [ ] Implement `performRestore` for METADATA -- [ ] Add change detection listeners -- [ ] Write unit tests -- [ ] Write integration tests - -### Phase 5: BLOCKTANK Implementation (1 day) -- [ ] Create `SerializableOrder` and `SerializableCjitEntry` -- [ ] Implement `getBackupDataBytes` for BLOCKTANK -- [ ] Implement `performRestore` for BLOCKTANK -- [ ] Add change detection listener -- [ ] Write unit tests -- [ ] Write integration tests - -### Phase 6: ACTIVITY Implementation (1 day) -- [ ] Ensure `Activity` types are `@Serializable` in bitkit-core -- [ ] Implement `getBackupDataBytes` for ACTIVITY (ALL activities) -- [ ] Implement `performRestore` for ACTIVITY -- [ ] Keep manual-only (no auto-trigger) -- [ ] Write unit tests -- [ ] Write integration tests - -### Phase 7: LIGHTNING_CONNECTIONS Display (0.5 day) -- [ ] Add LDK status observer -- [ ] Implement display-only status updates -- [ ] Throw `NotImplementedError` in `getBackupDataBytes` -- [ ] Update UI to show "Automatically backed up" -- [ ] Test timestamp updates - -### Phase 8: Restore Orchestration (1 day) -- [ ] Implement `performFullRestoreFromLatestBackup` with ordering -- [ ] Add per-category error handling -- [ ] Test partial restore scenarios -- [ ] Test failure isolation - -### Phase 9: Polish & Documentation (1 day) -- [ ] Add code comments documenting payloads and behavior -- [ ] Update UI strings for renamed category -- [ ] Test all categories end-to-end -- [ ] Performance test with large datasets -- [ ] Update this document with any changes - -**Total Estimated Time:** 8-9 days - ---- - -## Open Questions - -1. **Activity serialization:** Are `Activity.Lightning` and `Activity.Onchain` already `@Serializable` in bitkit-core? -2. **Blocktank restore:** Do we need to restore full order details, or just `paidOrders`? -3. **Activity backup size:** Should we limit the number of activities backed up (e.g., last 1000)? -4. **Restore conflicts:** If local data is newer than backup, should we skip or overwrite? -5. **Backup frequency:** Should ACTIVITY be manual-only, or add auto-backup with longer debounce? - ---- - -## References - -- **Current implementation:** `app/src/main/java/to/bitkit/repositories/BackupRepo.kt` -- **Backup categories:** `app/src/main/java/to/bitkit/models/BackupStatus.kt` -- **VSS client:** `app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt` -- **Data stores:** - - `app/src/main/java/to/bitkit/data/SettingsStore.kt` - - `app/src/main/java/to/bitkit/data/WidgetsStore.kt` - - `app/src/main/java/to/bitkit/data/CacheStore.kt` - - `app/src/main/java/to/bitkit/data/AppDb.kt` -- **CoreService:** `app/src/main/java/to/bitkit/services/CoreService.kt` - ---- - -## Changelog - -| Date | Author | Changes | -|------|--------|---------| -| 2025-10-30 | AI Assistant | Initial plan created | - ---- - -**End of Plan** From f8cb8342aec778e40cade1453dc253e4b47d7397 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 2 Nov 2025 00:52:30 +0100 Subject: [PATCH 20/29] refactor: move node start callback last --- .../java/to/bitkit/repositories/LightningRepo.kt | 12 ++++++------ app/src/main/java/to/bitkit/services/CoreService.kt | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index eddc82c26..65c9971d6 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -166,9 +166,9 @@ class LightningRepo @Inject constructor( walletIndex: Int = 0, timeout: Duration? = null, shouldRetry: Boolean = true, - eventHandler: NodeEventHandler? = null, customServerUrl: String? = null, customRgsServerUrl: String? = null, + eventHandler: NodeEventHandler? = null, ): Result = withContext(bgDispatcher) { if (_isRecoveryMode.value) { return@withContext Result.failure( @@ -239,9 +239,9 @@ class LightningRepo @Inject constructor( walletIndex = walletIndex, timeout = timeout, shouldRetry = false, - eventHandler = eventHandler, customServerUrl = customServerUrl, customRgsServerUrl = customRgsServerUrl, + eventHandler = eventHandler, ) } else { Logger.error("Node start error", e, context = TAG) @@ -337,9 +337,9 @@ class LightningRepo @Inject constructor( Logger.debug("Starting node with new electrum server: '$newServerUrl'") start( - eventHandler = cachedEventHandler, - customServerUrl = newServerUrl, shouldRetry = false, + customServerUrl = newServerUrl, + eventHandler = cachedEventHandler, ).onFailure { startError -> Logger.warn("Failed ldk-node config change, attempting recovery…") restartWithPreviousConfig() @@ -364,9 +364,9 @@ class LightningRepo @Inject constructor( Logger.debug("Starting node with new RGS server: '$newRgsUrl'") start( - eventHandler = cachedEventHandler, shouldRetry = false, customRgsServerUrl = newRgsUrl, + eventHandler = cachedEventHandler, ).onFailure { startError -> Logger.warn("Failed ldk-node config change, attempting recovery…") restartWithPreviousConfig() @@ -390,8 +390,8 @@ class LightningRepo @Inject constructor( Logger.debug("Starting node with previous config for recovery") start( - eventHandler = cachedEventHandler, shouldRetry = false, + eventHandler = cachedEventHandler, ).onSuccess { Logger.debug("Successfully started node with previous config") }.onFailure { e -> diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 30978a3e3..c875b3199 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -164,7 +164,7 @@ class CoreService @Inject constructor( // endregion // region Activity -private const val CHUNCK_SIZE = 50 +private const val CHUNK_SIZE = 50 class ActivityService( private val coreService: CoreService, @@ -284,7 +284,7 @@ class ActivityService( ServiceQueue.CORE.background { val allResults = mutableListOf>() - payments.chunked(CHUNCK_SIZE).forEach { chunk -> + payments.chunked(CHUNK_SIZE).forEach { chunk -> val results = chunk.map { payment -> async { runCatching { From 91e0a8bb263633bfea03d855d0f5152c381dbc5d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 2 Nov 2025 22:14:30 +0100 Subject: [PATCH 21/29] fix: use main dispatcher to collect state --- app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index d72543a46..83864670e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -72,8 +72,8 @@ class WalletViewModel @Inject constructor( collectStates() } - private fun collectStates() { // This is necessary to avoid a bigger refactor in all application - viewModelScope.launch(bgDispatcher) { + private fun collectStates() { + viewModelScope.launch { walletState.collect { state -> walletExists = state.walletExists _uiState.update { @@ -93,7 +93,7 @@ class WalletViewModel @Inject constructor( } } - viewModelScope.launch(bgDispatcher) { + viewModelScope.launch { lightningState.collect { state -> _uiState.update { it.copy( @@ -111,7 +111,7 @@ class WalletViewModel @Inject constructor( private fun triggerBackupRestore() { restoreState = RestoreState.RestoringBackups - viewModelScope.launch(bgDispatcher) { + viewModelScope.launch { backupRepo.performFullRestoreFromLatestBackup() // data backup is not critical and mostly for user convenience so there is no reason to propagate errors up restoreState = RestoreState.BackupRestoreCompleted From 366a8bce3cb9b44fe6dd846aeec275e201a9a40d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 2 Nov 2025 22:19:27 +0100 Subject: [PATCH 22/29] chore: cleanup comments --- app/src/main/java/to/bitkit/repositories/BackupRepo.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 4099a5d0d..e874cabd1 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -406,7 +406,6 @@ class BackupRepo @Inject constructor( performRestore(BackupCategory.METADATA) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) - // Restore tag metadata (idempotent via primary key with INSERT OR REPLACE) parsed.tagMetadata.forEach { entity -> db.tagMetadataDao().upsert(entity) } @@ -433,7 +432,6 @@ class BackupRepo @Inject constructor( performRestore(BackupCategory.ACTIVITY) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) - // Restore activities using upsertActivity (idempotent - insert or update) parsed.activities.forEach { activity -> activityRepo.upsertActivity(activity) } From 00c17a9bbf33cc65aa84804aa0e3e000de34691c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 3 Nov 2025 10:59:38 +0100 Subject: [PATCH 23/29] chore: update gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c8d9099da..43ecfebbd 100644 --- a/.gitignore +++ b/.gitignore @@ -8,10 +8,11 @@ .externalNativeBuild .cxx local.properties +# AI +.ai .cursor *.local.* CLAUDE.md - # Secrets google-services.json .env From a5109929ba6293337f5c5ff1d27d3f9d626dbf9b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 3 Nov 2025 13:28:09 +0100 Subject: [PATCH 24/29] fix: remove dupe `updateInvoice` call on receive confirm screen --- .../java/to/bitkit/repositories/WalletRepo.kt | 1 + .../screens/wallets/receive/EditInvoiceScreen.kt | 5 +---- .../wallets/receive/ReceiveConfirmScreen.kt | 2 +- .../ui/screens/wallets/receive/ReceiveSheet.kt | 16 ++++------------ 4 files changed, 7 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 9dc2609a4..060c46d28 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -112,6 +112,7 @@ class WalletRepo @Inject constructor( selectedTags = emptyList(), bip21Description = "", bip21 = "", + bip21AmountSats = null, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt index da3f65e5e..660d311a1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt @@ -139,10 +139,7 @@ fun EditInvoiceScreen( } }, onContinueKeyboard = { keyboardVisible = false }, - onContinueGeneral = { - updateInvoice(amountInputUiState.sats.toULong()) - editInvoiceVM.onClickContinue() - }, + onContinueGeneral = { editInvoiceVM.onClickContinue() }, onClickAddTag = onClickAddTag, onClickTag = onClickTag, isSoftKeyboardVisible = isSoftKeyboardVisible diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt index dd6d3180e..0dcd6cc64 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt @@ -46,7 +46,7 @@ import to.bitkit.ui.utils.NotificationUtils import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.SettingsViewModel -// TODO pass these to nav? +// TODO replace with direct use of the now serializable IcJitEntry @Serializable data class CjitEntryDetails( val networkFeeSat: Long, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index 342cddafb..16cde5a53 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -180,18 +180,10 @@ fun ReceiveSheet( amountInputViewModel = editInvoiceAmountViewModel, walletUiState = walletUiState, onBack = { navController.popBackStack() }, - updateInvoice = { sats -> - wallet.updateBip21Invoice(amountSats = sats) - }, - onClickAddTag = { - navController.navigate(ReceiveRoute.AddTag) - }, - onClickTag = { tagToRemove -> - wallet.removeTag(tagToRemove) - }, - onDescriptionUpdate = { newText -> - wallet.updateBip21Description(newText = newText) - }, + updateInvoice = wallet::updateBip21Invoice, + onClickAddTag = { navController.navigate(ReceiveRoute.AddTag) }, + onClickTag = wallet::removeTag, + onDescriptionUpdate = wallet::updateBip21Description, navigateReceiveConfirm = { entry -> cjitEntryDetails.value = entry navController.navigate(ReceiveRoute.ConfirmIncreaseInbound) From 945085724ea9acd275e247cf72e97ee832b4f197 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 2 Nov 2025 22:30:29 +0100 Subject: [PATCH 25/29] fix: bip21 wallet state management on events --- .../to/bitkit/repositories/LightningRepo.kt | 2 +- .../java/to/bitkit/repositories/WalletRepo.kt | 161 ++++++++++-------- .../to/bitkit/viewmodels/WalletViewModel.kt | 43 ++--- .../to/bitkit/repositories/WalletRepoTest.kt | 13 -- .../java/to/bitkit/ui/WalletViewModelTest.kt | 21 +-- 5 files changed, 107 insertions(+), 133 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 65c9971d6..881ad6da2 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -257,7 +257,7 @@ class LightningRepo @Inject constructor( _isRecoveryMode.value = enabled } - suspend fun updateGeoBlockState() { + suspend fun updateGeoBlockState() = withContext(bgDispatcher) { val (isGeoBlocked, shouldBlockLightning) = coreService.checkGeoBlock() _lightningState.update { it.copy(isGeoBlocked = isGeoBlocked, shouldBlockLightningReceive = shouldBlockLightning) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 060c46d28..dbcbe5d2d 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -98,39 +98,15 @@ class WalletRepo @Inject constructor( } } - suspend fun refreshBip21(force: Boolean = false): Result = withContext(bgDispatcher) { - Logger.debug("Refreshing bip21 (force: $force)", context = TAG) + suspend fun refreshBip21(): Result = withContext(bgDispatcher) { + Logger.debug("Refreshing bip21", context = TAG) - val shouldBlockLightningReceive = coreService.checkGeoBlock().second + val (_, shouldBlockLightningReceive) = coreService.checkGeoBlock() _walletState.update { it.copy(receiveOnSpendingBalance = !shouldBlockLightningReceive) } - - // Reset invoice state - _walletState.update { - it.copy( - selectedTags = emptyList(), - bip21Description = "", - bip21 = "", - bip21AmountSats = null, - ) - } - - // Check current address or generate new one - val currentAddress = getOnchainAddress() - if (force || currentAddress.isEmpty()) { - newAddress() - } else { - // Check if current address has been used - checkAddressUsage(currentAddress) - .onSuccess { hasTransactions -> - if (hasTransactions) { - // Address has been used, generate a new one - newAddress() - } - } - } - + clearBip21State() + refreshAddressIfNeeded() updateBip21Invoice() return@withContext Result.success(Unit) } @@ -172,11 +148,66 @@ class WalletRepo @Inject constructor( suspend fun refreshBip21ForEvent(event: Event) { when (event) { - is Event.PaymentReceived, is Event.ChannelReady, is Event.ChannelClosed -> refreshBip21() + is Event.ChannelReady -> { + // Only refresh bolt11 if we can now receive on lightning + if (lightningRepo.canReceive()) { + lightningRepo.createInvoice( + amountSats = _walletState.value.bip21AmountSats, + description = _walletState.value.bip21Description, + ).onSuccess { bolt11 -> + setBolt11(bolt11) + updateBip21Url() + } + } + } + + is Event.ChannelClosed -> { + // Clear bolt11 if we can no longer receive on lightning + if (!lightningRepo.canReceive()) { + setBolt11("") + updateBip21Url() + } + } + + is Event.PaymentReceived -> { + // Check if onchain address was used, generate new one if needed + refreshAddressIfNeeded() + updateBip21Url() + } + else -> Unit } } + private suspend fun refreshAddressIfNeeded() = withContext(bgDispatcher) { + val address = getOnchainAddress() + if (address.isEmpty()) { + newAddress() + } else { + checkAddressUsage(address).onSuccess { wasUsed -> + if (wasUsed) { + newAddress() + } + } + } + } + + private suspend fun updateBip21Url( + amountSats: ULong? = _walletState.value.bip21AmountSats, + message: String = _walletState.value.bip21Description, + ): String { + val address = getOnchainAddress() + val newBip21 = buildBip21Url( + bitcoinAddress = address, + amountSats = amountSats, + message = message.ifBlank { Env.DEFAULT_INVOICE_MESSAGE }, + lightningInvoice = getBolt11(), + ) + setBip21(newBip21) + + return newBip21 + } + suspend fun createWallet(bip39Passphrase: String?): Result = withContext(bgDispatcher) { lightningRepo.setRecoveryMode(enabled = false) try { @@ -310,12 +341,19 @@ class WalletRepo @Inject constructor( } // BIP21 state management - fun updateBip21AmountSats(amount: ULong?) { - _walletState.update { it.copy(bip21AmountSats = amount) } - } + fun setBip21AmountSats(amount: ULong?) = _walletState.update { it.copy(bip21AmountSats = amount) } + + fun setBip21Description(description: String) = _walletState.update { it.copy(bip21Description = description) } - fun updateBip21Description(description: String) { - _walletState.update { it.copy(bip21Description = description) } + fun clearBip21State() { + _walletState.update { + it.copy( + bip21 = "", + selectedTags = emptyList(), + bip21AmountSats = null, + bip21Description = "", + ) + } } suspend fun toggleReceiveOnSpendingBalance(): Result = withContext(bgDispatcher) { @@ -348,37 +386,23 @@ class WalletRepo @Inject constructor( // BIP21 invoice creation suspend fun updateBip21Invoice( - amountSats: ULong? = null, - description: String = "", + amountSats: ULong? = walletState.value.bip21AmountSats, + description: String = walletState.value.bip21Description, ): Result = withContext(bgDispatcher) { try { - updateBip21AmountSats(amountSats) - updateBip21Description(description) + setBip21AmountSats(amountSats) + setBip21Description(description) val canReceive = lightningRepo.canReceive() if (canReceive && _walletState.value.receiveOnSpendingBalance) { - lightningRepo.createInvoice( - amountSats = _walletState.value.bip21AmountSats, - description = _walletState.value.bip21Description, - ).onSuccess { bolt11 -> - setBolt11(bolt11) + lightningRepo.createInvoice(amountSats, description).onSuccess { + setBolt11(it) } } else { setBolt11("") } - val address = getOnchainAddress() - val newBip21 = buildBip21Url( - bitcoinAddress = address, - amountSats = _walletState.value.bip21AmountSats, - message = description.ifBlank { Env.DEFAULT_INVOICE_MESSAGE }, - lightningInvoice = getBolt11() - ) - setBip21(newBip21) - saveInvoiceWithTags( - bip21Invoice = newBip21, - onChainAddress = address, - tags = _walletState.value.selectedTags - ) + val newBip21Url = updateBip21Url(amountSats, description) + persistTagsMetadata(newBip21Url) Result.success(Unit) } catch (e: Throwable) { Logger.error("Update BIP21 invoice error", e, context = TAG) @@ -404,14 +428,16 @@ class WalletRepo @Inject constructor( } } - suspend fun saveInvoiceWithTags(bip21Invoice: String, onChainAddress: String, tags: List) = + private suspend fun persistTagsMetadata(bip21Url: String) = withContext(bgDispatcher) { + val tags = _walletState.value.selectedTags if (tags.isEmpty()) return@withContext + val onChainAddress = getOnchainAddress() + try { - deleteExpiredInvoices() - val decoded = decode(bip21Invoice) - val paymentHash = when (decoded) { + deleteExpiredTagMetadata() + val paymentHash = when (val decoded = decode(bip21Url)) { is Scanner.Lightning -> decoded.invoice.paymentHash.toHex() is Scanner.OnChain -> decoded.extractLightningHash() else -> null @@ -432,15 +458,7 @@ class WalletRepo @Inject constructor( } } - suspend fun deleteAllInvoices() = withContext(bgDispatcher) { - try { - db.tagMetadataDao().deleteAll() - } catch (e: Throwable) { - Logger.error("deleteAllInvoices error", e, context = TAG) - } - } - - suspend fun deleteExpiredInvoices() = withContext(bgDispatcher) { + private suspend fun deleteExpiredTagMetadata() = withContext(bgDispatcher) { try { val twoDaysAgoMillis = Clock.System.now().minus(2.days).toEpochMilliseconds() db.tagMetadataDao().deleteExpired(expirationTimeStamp = twoDaysAgoMillis) @@ -451,9 +469,8 @@ class WalletRepo @Inject constructor( private suspend fun Scanner.OnChain.extractLightningHash(): String? { val lightningInvoice: String = this.invoice.params?.get("lightning") ?: return null - val decoded = decode(lightningInvoice) - return when (decoded) { + return when (val decoded = decode(lightningInvoice)) { is Scanner.Lightning -> decoded.invoice.paymentHash.toHex() else -> null } diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 83864670e..37a69d1f5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -202,13 +202,10 @@ class WalletViewModel @Inject constructor( } fun updateBip21Invoice( - amountSats: ULong? = null, + amountSats: ULong? = walletState.value.bip21AmountSats, ) { viewModelScope.launch { - walletRepo.updateBip21Invoice( - amountSats = amountSats, - description = walletState.value.bip21Description, - ).onFailure { error -> + walletRepo.updateBip21Invoice(amountSats).onFailure { error -> ToastEventBus.send( type = Toast.ToastType.ERROR, title = "Error updating invoice", @@ -220,33 +217,23 @@ class WalletViewModel @Inject constructor( fun toggleReceiveOnSpending() { viewModelScope.launch { - walletRepo.toggleReceiveOnSpendingBalance().onSuccess { - updateBip21Invoice( - amountSats = walletState.value.bip21AmountSats, - ) - }.onFailure { e -> - if (e is ServiceError.GeoBlocked) { - walletEffect(WalletViewModelEffects.NavigateGeoBlockScreen) - return@launch + walletRepo.toggleReceiveOnSpendingBalance() + .onSuccess { + updateBip21Invoice() + }.onFailure { e -> + if (e is ServiceError.GeoBlocked) { + walletEffect(WalletViewModelEffects.NavigateGeoBlockScreen) + return@launch + } + updateBip21Invoice() } - - updateBip21Invoice( - amountSats = walletState.value.bip21AmountSats, - ) - } } } - fun refreshReceiveState() = viewModelScope.launch(bgDispatcher) { - launch { lightningRepo.updateGeoBlockState() } - launch { walletRepo.refreshBip21() } + fun refreshReceiveState() = viewModelScope.launch { launch { blocktankRepo.refreshInfo() } - } - - fun refreshBip21() { - viewModelScope.launch { - walletRepo.refreshBip21() - } + lightningRepo.updateGeoBlockState() + walletRepo.refreshBip21() } fun wipeWallet() { @@ -290,7 +277,7 @@ class WalletViewModel @Inject constructor( if (newText.isEmpty()) { Logger.warn("Empty") } - walletRepo.updateBip21Description(newText) + walletRepo.setBip21Description(newText) } suspend fun handleHideBalanceOnOpen() { diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 27259bb42..55dc430b5 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -200,19 +200,6 @@ class WalletRepoTest : BaseUnitTest() { verify(lightningRepo, never()).newAddress() } - @Test - fun `refreshBip21 forced should always generate new address`() = test { - val existingAddress = "existingAddress" - whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = existingAddress))) - whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) - whenever(addressChecker.getAddressInfo(any())).thenReturn(mockAddressInfo()) - - val result = sut.refreshBip21(force = true) - - assertTrue(result.isSuccess) - verify(lightningRepo).newAddress() - } - @Test fun `syncBalances should update balance cache and state`() = test { val expectedState = BalanceState( diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 2a92cc864..0b5e30c14 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -69,9 +69,9 @@ class WalletViewModelTest : BaseUnitTest() { fun `refreshReceiveState should refresh receive state`() = test { sut.refreshReceiveState() + verify(blocktankRepo).refreshInfo() verify(lightningRepo).updateGeoBlockState() verify(walletRepo).refreshBip21() - verify(blocktankRepo).refreshInfo() } @Test @@ -104,23 +104,6 @@ class WalletViewModelTest : BaseUnitTest() { // Add verification for ToastEventBus.send if you have a way to capture those events } - @Test - fun `updateBip21Invoice should call walletRepo updateBip21Invoice and send failure toast`() = test { - val testError = Exception("Test error") - whenever(walletRepo.updateBip21Invoice(anyOrNull(), any())).thenReturn(Result.failure(testError)) - - sut.updateBip21Invoice() - - verify(walletRepo).updateBip21Invoice(anyOrNull(), any()) - // Add verification for ToastEventBus.send - } - - @Test - fun `refreshBip21 should call walletRepo refreshBip21`() = test { - sut.refreshBip21() - verify(walletRepo).refreshBip21() - } - @Test fun `wipeWallet should call walletRepo wipeWallet`() = test { @@ -170,7 +153,7 @@ class WalletViewModelTest : BaseUnitTest() { fun `updateBip21Description should call walletRepo updateBip21Description`() = test { sut.updateBip21Description("test_description") - verify(walletRepo).updateBip21Description("test_description") + verify(walletRepo).setBip21Description("test_description") } @Test From 8dea165141a088781ee5f328078f1143d49616d6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 3 Nov 2025 17:04:14 +0100 Subject: [PATCH 26/29] test: bip21 wallet state management fix --- .../to/bitkit/repositories/WalletRepoTest.kt | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 55dc430b5..6811494ab 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -445,6 +445,159 @@ class WalletRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertFalse(result.getOrThrow()) } + + @Test + fun `clearBip21State should clear all bip21 related state`() = test { + sut.addTagToSelected("tag1") + sut.updateBip21Invoice(amountSats = 1000uL, description = "test") + + sut.clearBip21State() + + assertEquals("", sut.walletState.value.bip21) + assertEquals(null, sut.walletState.value.bip21AmountSats) + assertEquals("", sut.walletState.value.bip21Description) + assertTrue(sut.walletState.value.selectedTags.isEmpty()) + } + + @Test + fun `setBip21AmountSats should update state`() = test { + val testAmount = 5000uL + + sut.setBip21AmountSats(testAmount) + + assertEquals(testAmount, sut.walletState.value.bip21AmountSats) + } + + @Test + fun `setBip21Description should update state`() = test { + val testDescription = "test description" + + sut.setBip21Description(testDescription) + + assertEquals(testDescription, sut.walletState.value.bip21Description) + } + + @Test + fun `refreshBip21ForEvent ChannelReady should update bolt11 and preserve amount`() = test { + val testAmount = 1000uL + val testDescription = "test" + val testInvoice = "newInvoice" + sut.setBip21AmountSats(testAmount) + sut.setBip21Description(testDescription) + whenever(lightningRepo.canReceive()).thenReturn(true) + whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())).thenReturn(Result.success(testInvoice)) + + sut.refreshBip21ForEvent( + Event.ChannelReady( + channelId = "testChannelId", + userChannelId = "testUserChannelId", + counterpartyNodeId = null + ) + ) + + assertEquals(testInvoice, sut.walletState.value.bolt11) + assertEquals(testAmount, sut.walletState.value.bip21AmountSats) + assertEquals(testDescription, sut.walletState.value.bip21Description) + } + + @Test + fun `refreshBip21ForEvent ChannelReady should not create invoice when cannot receive`() = test { + sut.setBip21AmountSats(1000uL) + whenever(lightningRepo.canReceive()).thenReturn(false) + + sut.refreshBip21ForEvent( + Event.ChannelReady( + channelId = "testChannelId", + userChannelId = "testUserChannelId", + counterpartyNodeId = null + ) + ) + + verify(lightningRepo, never()).createInvoice(anyOrNull(), any(), any()) + } + + @Test + fun `refreshBip21ForEvent ChannelClosed should clear bolt11 when cannot receive`() = test { + val testAddress = "testAddress" + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = testAddress))) + sut = createSut() + sut.setBolt11("existingInvoice") + whenever(lightningRepo.canReceive()).thenReturn(false) + + sut.refreshBip21ForEvent( + Event.ChannelClosed( + channelId = "testChannelId", + userChannelId = "testUserChannelId", + counterpartyNodeId = null, + reason = null + ) + ) + + assertEquals("", sut.walletState.value.bolt11) + } + + @Test + fun `refreshBip21ForEvent ChannelClosed should not clear bolt11 when can still receive`() = test { + val testInvoice = "existingInvoice" + sut.setBolt11(testInvoice) + whenever(lightningRepo.canReceive()).thenReturn(true) + + sut.refreshBip21ForEvent( + Event.ChannelClosed( + channelId = "testChannelId", + userChannelId = "testUserChannelId", + counterpartyNodeId = null, + reason = null + ) + ) + + assertEquals(testInvoice, sut.walletState.value.bolt11) + } + + @Test + fun `refreshBip21ForEvent PaymentReceived should refresh address if used`() = test { + val testAddress = "testAddress" + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = testAddress))) + whenever(addressChecker.getAddressInfo(any())).thenReturn( + mockAddressInfo().let { addressInfo -> + addressInfo.copy( + chain_stats = addressInfo.chain_stats.copy(tx_count = 1) + ) + } + ) + whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) + sut = createSut() + + sut.refreshBip21ForEvent( + Event.PaymentReceived( + paymentId = "testPaymentId", + paymentHash = "testPaymentHash", + amountMsat = 1000uL, + customRecords = emptyList() + ) + ) + + verify(lightningRepo).newAddress() + } + + @Test + fun `refreshBip21ForEvent PaymentReceived should not refresh address if not used`() = test { + val testAddress = "testAddress" + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = testAddress))) + whenever(addressChecker.getAddressInfo(any())).thenReturn(mockAddressInfo()) + sut = createSut() + + sut.refreshBip21ForEvent( + Event.PaymentReceived( + paymentId = "testPaymentId", + paymentHash = "testPaymentHash", + amountMsat = 1000uL, + customRecords = emptyList() + ) + ) + + verify(lightningRepo, never()).newAddress() + } } private fun mockAddressInfo() = AddressInfo( From eb0b963c0f88463738d924aeaf0b3deb20ab8df5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 3 Nov 2025 17:04:20 +0100 Subject: [PATCH 27/29] chore: lint --- app/src/main/java/to/bitkit/ext/DateTime.kt | 2 ++ app/src/main/java/to/bitkit/repositories/BackupRepo.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index 347a6d880..c746ba7be 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -1,3 +1,5 @@ +@file:Suppress("TooManyFunctions") + package to.bitkit.ext import android.icu.text.DateFormat diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index e874cabd1..c231afe8a 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -39,6 +39,7 @@ import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton +@Suppress("LongParameterList") @Singleton class BackupRepo @Inject constructor( @ApplicationContext private val context: Context, @@ -122,6 +123,7 @@ class BackupRepo @Inject constructor( Logger.debug("Started ${statusObserverJobs.size} backup status observers", context = TAG) } + @Suppress("LongMethod") private fun startDataStoreListeners() { val settingsJob = scope.launch { settingsStore.data From fbcc17dedb67041386c659e345b5e8caf50f021c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 3 Nov 2025 17:06:04 +0100 Subject: [PATCH 28/29] fix: log messages after refactors --- app/src/main/java/to/bitkit/repositories/WalletRepo.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index dbcbe5d2d..2296019ed 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -454,7 +454,7 @@ class WalletRepo @Inject constructor( db.tagMetadataDao().insert(tagMetadata = entity) Logger.debug("Tag metadata saved: $entity", context = TAG) } catch (e: Throwable) { - Logger.error("saveInvoice error", e, context = TAG) + Logger.error("Error persisting tag metadata", e, context = TAG) } } @@ -463,7 +463,7 @@ class WalletRepo @Inject constructor( val twoDaysAgoMillis = Clock.System.now().minus(2.days).toEpochMilliseconds() db.tagMetadataDao().deleteExpired(expirationTimeStamp = twoDaysAgoMillis) } catch (e: Throwable) { - Logger.error("deleteExpiredInvoices error", e, context = TAG) + Logger.error("Error deleting expired tag metadata records", e, context = TAG) } } From 3fdd2f34efaa0b23b7b45ce1da43ef0199bd6356 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 3 Nov 2025 20:42:53 +0100 Subject: [PATCH 29/29] fix: remove backup statuses from observer to avoid infinite loop --- app/src/main/java/to/bitkit/repositories/BackupRepo.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index c231afe8a..17ace5b98 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -171,9 +171,10 @@ class BackupRepo @Inject constructor( } dataListenerJobs.add(tagMetadataJob) - // METADATA - Observe entire CacheStore + // METADATA - Observe entire CacheStore excluding backup statuses val cacheMetadataJob = scope.launch { cacheStore.data + .map { it.copy(backupStatuses = mapOf()) } .distinctUntilChanged() .drop(1) .collect { @@ -414,7 +415,7 @@ class BackupRepo @Inject constructor( cacheStore.update { parsed.cache } - Logger.debug("Restored ${parsed.tagMetadata.size} tags and complete cache data", context = TAG) + Logger.debug("Restored caches and ${parsed.tagMetadata.size} tags metadata", context = TAG) } performRestore(BackupCategory.BLOCKTANK) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes))