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 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/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/TagMetadataDao.kt b/app/src/main/java/to/bitkit/data/dao/TagMetadataDao.kt index 7ac7c2833..b25bb1e56 100644 --- a/app/src/main/java/to/bitkit/data/dao/TagMetadataDao.kt +++ b/app/src/main/java/to/bitkit/data/dao/TagMetadataDao.kt @@ -5,13 +5,21 @@ 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 @Dao interface TagMetadataDao { @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun saveTagMetadata(tagMetadata: TagMetadataEntity) + suspend fun insert(tagMetadata: TagMetadataEntity) + + @Upsert + suspend fun upsert(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/dao/TransferDao.kt b/app/src/main/java/to/bitkit/data/dao/TransferDao.kt index 5107d08c3..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,9 +14,18 @@ interface TransferDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(transfer: TransferEntity) + @Upsert + suspend fun upsert(transfer: TransferEntity) + @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/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/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index f3eed9dbf..342e4eeba 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -134,6 +134,13 @@ 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'." } val path = Path(appStoragePath, network, "wallet$walletIndex", dir) 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/models/BackupCategory.kt b/app/src/main/java/to/bitkit/models/BackupCategory.kt new file mode 100644 index 000000000..7944ea710 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/BackupCategory.kt @@ -0,0 +1,62 @@ +package to.bitkit.models + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import kotlinx.serialization.Serializable +import to.bitkit.R + +@Serializable +enum class BackupCategory( + @DrawableRes val icon: Int, + @StringRes val title: Int, +) { + LIGHTNING_CONNECTIONS( + icon = R.drawable.ic_lightning, + title = R.string.settings__backup__category_connections, + ), + BLOCKTANK( + icon = R.drawable.ic_note, + title = R.string.settings__backup__category_connection_receipts, + ), + ACTIVITY( + icon = R.drawable.ic_transfer, + title = R.string.settings__backup__category_transaction_log, + ), + WALLET( + icon = R.drawable.ic_timer_alt, + title = R.string.settings__backup__category_wallet, + ), + SETTINGS( + icon = R.drawable.ic_settings, + title = R.string.settings__backup__category_settings, + ), + WIDGETS( + icon = R.drawable.ic_rectangles_two, + title = R.string.settings__backup__category_widgets, + ), + METADATA( + icon = R.drawable.ic_tag, + title = R.string.settings__backup__category_tags, + ), + // Descoped in v1, will return in v2: + // PROFILE( + // icon = R.drawable.ic_user, + // title = R.string.settings__backup__category_profile, + // ), + // CONTACTS( + // icon = R.drawable.ic_users, + // title = 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, +) 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..59b006015 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -0,0 +1,41 @@ +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.AppCacheData +import to.bitkit.data.entities.TagMetadataEntity +import to.bitkit.data.entities.TransferEntity + +@Serializable +data class WalletBackupV1( + val version: Int = 1, + val createdAt: Long, + val transfers: List, +) + +@Serializable +data class MetadataBackupV1( + val version: Int = 1, + val createdAt: Long, + val tagMetadata: List, + val cache: AppCacheData, +) + +@Serializable +data class BlocktankBackupV1( + val version: Int = 1, + val createdAt: Long, + val orders: List, + val cjitEntries: List, + val info: IBtInfo? = null, +) + +@Serializable +data class ActivityBackupV1( + val version: Int = 1, + val createdAt: Long, + val activities: List, +) diff --git a/app/src/main/java/to/bitkit/models/BackupStatus.kt b/app/src/main/java/to/bitkit/models/BackupStatus.kt deleted file mode 100644 index 8e85742b1..000000000 --- a/app/src/main/java/to/bitkit/models/BackupStatus.kt +++ /dev/null @@ -1,54 +0,0 @@ -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 { - LIGHTNING_CONNECTIONS, - BLOCKTANK, - LDK_ACTIVITY, - WALLET, - SETTINGS, - WIDGETS, - METADATA, - SLASHTAGS, -} - -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.SLASHTAGS -> R.drawable.ic_users - } -} - -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.SLASHTAGS -> R.string.settings__backup__category_contacts - } -} diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 66ad68f3c..cbd35954b 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,15 @@ class ActivityRepo @Inject constructor( private val db: AppDb, private val addressChecker: AddressChecker, private val transferRepo: TransferRepo, + private val clock: Clock, ) { val isSyncingLdkNodePayments = MutableStateFlow(false) + 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) @@ -219,6 +228,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 +455,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 +474,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 +547,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 +587,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) @@ -606,9 +636,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/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 4249b1574..17ace5b98 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -13,7 +13,9 @@ 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 import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore @@ -24,14 +26,20 @@ 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 +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 import javax.inject.Singleton +@Suppress("LongParameterList") @Singleton class BackupRepo @Inject constructor( @ApplicationContext private val context: Context, @@ -40,6 +48,11 @@ class BackupRepo @Inject constructor( private val vssBackupClient: VssBackupClient, private val settingsStore: SettingsStore, private val widgetsStore: WidgetsStore, + 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()) @@ -110,15 +123,15 @@ 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 .distinctUntilChanged() .drop(1) .collect { - if (!isRestoring) { - markBackupRequired(BackupCategory.SETTINGS) - } + if (isRestoring) return@collect + markBackupRequired(BackupCategory.SETTINGS) } } dataListenerJobs.add(settingsJob) @@ -128,13 +141,86 @@ class BackupRepo @Inject constructor( .distinctUntilChanged() .drop(1) .collect { - if (!isRestoring) { - markBackupRequired(BackupCategory.WIDGETS) - } + if (isRestoring) return@collect + markBackupRequired(BackupCategory.WIDGETS) } } dataListenerJobs.add(widgetsJob) + // WALLET - Observe transfers + val transfersJob = scope.launch { + db.transferDao().observeAll() + .distinctUntilChanged() + .drop(1) + .collect { + if (isRestoring) return@collect + markBackupRequired(BackupCategory.WALLET) + } + } + dataListenerJobs.add(transfersJob) + + // METADATA - Observe tag metadata + val tagMetadataJob = scope.launch { + db.tagMetadataDao().observeAll() + .distinctUntilChanged() + .drop(1) + .collect { + if (isRestoring) return@collect + markBackupRequired(BackupCategory.METADATA) + } + } + dataListenerJobs.add(tagMetadataJob) + + // METADATA - Observe entire CacheStore excluding backup statuses + val cacheMetadataJob = scope.launch { + cacheStore.data + .map { it.copy(backupStatuses = mapOf()) } + .distinctUntilChanged() + .drop(1) + .collect { + if (isRestoring) return@collect + markBackupRequired(BackupCategory.METADATA) + } + } + dataListenerJobs.add(cacheMetadataJob) + + // BLOCKTANK - Observe blocktank state changes (orders, cjitEntries, info) + val blocktankJob = scope.launch { + blocktankRepo.blocktankState + .drop(1) + .collect { + if (isRestoring) return@collect + markBackupRequired(BackupCategory.BLOCKTANK) + } + } + dataListenerJobs.add(blocktankJob) + + // ACTIVITY - Observe all activity changes notified by ActivityRepo on any mutation to core's activity store + val activityChangesJob = scope.launch { + activityRepo.activitiesChanged + .drop(1) + .collect { + if (isRestoring) return@collect + markBackupRequired(BackupCategory.ACTIVITY) + } + } + dataListenerJobs.add(activityChangesJob) + + // 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) return@collect + cacheStore.updateBackupStatus(BackupCategory.LIGHTNING_CONNECTIONS) { + it.copy(required = lastSync, synced = lastSync, running = false) + } + } + } + dataListenerJobs.add(lightningConnectionsJob) + Logger.debug("Started ${dataListenerJobs.size} data store listeners", context = TAG) } @@ -150,7 +236,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) } @@ -174,7 +260,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 { @@ -214,7 +300,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)) @@ -222,7 +308,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) @@ -247,28 +333,54 @@ class BackupRepo @Inject constructor( } BackupCategory.WALLET -> { - throw NotImplementedError("Wallet backup not yet implemented") + val transfers = db.transferDao().getAll() + + val payload = WalletBackupV1( + createdAt = currentTimeMillis(), + transfers = transfers + ) + + json.encodeToString(payload).toByteArray() } BackupCategory.METADATA -> { - throw NotImplementedError("Metadata backup not yet implemented") + val tagMetadata = db.tagMetadataDao().getAll() + val cacheData = cacheStore.data.first() + + val payload = MetadataBackupV1( + createdAt = currentTimeMillis(), + tagMetadata = tagMetadata, + cache = cacheData, + ) + + json.encodeToString(payload).toByteArray() } BackupCategory.BLOCKTANK -> { - throw NotImplementedError("Blocktank backup not yet implemented") - } + val blocktankState = blocktankRepo.blocktankState.first() - BackupCategory.SLASHTAGS -> { - throw NotImplementedError("Slashtags backup not yet implemented") - } + val payload = BlocktankBackupV1( + createdAt = currentTimeMillis(), + orders = blocktankState.orders, + cjitEntries = blocktankState.cjitEntries, + info = blocktankState.info, + ) - BackupCategory.LDK_ACTIVITY -> { - throw NotImplementedError("LDK activity backup not yet implemented") + json.encodeToString(payload).toByteArray() } - BackupCategory.LIGHTNING_CONNECTIONS -> { - throw NotImplementedError("Lightning connections backup not yet implemented") + BackupCategory.ACTIVITY -> { + val activities = activityRepo.getActivities().getOrDefault(emptyList()) + + val payload = ActivityBackupV1( + createdAt = currentTimeMillis(), + activities = activities, + ) + + json.encodeToString(payload).toByteArray() } + + BackupCategory.LIGHTNING_CONNECTIONS -> throw NotImplementedError("LIGHTNING backup is managed by ldk-node") } suspend fun performFullRestoreFromLatestBackup(): Result = withContext(bgDispatcher) { @@ -285,14 +397,52 @@ class BackupRepo @Inject constructor( val parsed = json.decodeFromString(String(dataBytes)) widgetsStore.update { parsed } } - // TODO: Add other backup categories as they get implemented: - // performMetadataRestore() - // performWalletRestore() - // performBlocktankRestore() - // performSlashtagsRestore() - // performLdkActivityRestore() - - Logger.info("Full restore completed", context = TAG) + performRestore(BackupCategory.WALLET) { dataBytes -> + val parsed = json.decodeFromString(String(dataBytes)) + + parsed.transfers.forEach { transfer -> + db.transferDao().upsert(transfer) + } + + Logger.debug("Restored ${parsed.transfers.size} transfers", context = TAG) + } + performRestore(BackupCategory.METADATA) { dataBytes -> + val parsed = json.decodeFromString(String(dataBytes)) + + parsed.tagMetadata.forEach { entity -> + db.tagMetadataDao().upsert(entity) + } + + cacheStore.update { parsed.cache } + + Logger.debug("Restored caches and ${parsed.tagMetadata.size} tags metadata", context = TAG) + } + performRestore(BackupCategory.BLOCKTANK) { dataBytes -> + val parsed = json.decodeFromString(String(dataBytes)) + + // 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)", + context = TAG, + ) + } + performRestore(BackupCategory.ACTIVITY) { dataBytes -> + val parsed = json.decodeFromString(String(dataBytes)) + + parsed.activities.forEach { activity -> + activityRepo.upsertActivity(activity) + } + + Logger.debug("Restored ${parsed.activities.size} activities", context = TAG) + } + + Logger.info("Full restore success", context = TAG) Result.success(Unit) } catch (e: Throwable) { Logger.warn("Full restore error", e = e, context = TAG) @@ -320,10 +470,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" diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index eddc82c26..881ad6da2 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) @@ -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) @@ -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/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 diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 3882c75cb..2296019ed 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -98,38 +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 = "", - ) - } - - // 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) } @@ -171,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 { @@ -309,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) { @@ -347,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) @@ -403,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 @@ -424,37 +451,26 @@ 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) + Logger.error("Error persisting tag metadata", e, context = TAG) } } - 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) } catch (e: Throwable) { - Logger.error("deleteExpiredInvoices error", e, context = TAG) + Logger.error("Error deleting expired tag metadata records", e, context = TAG) } } 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/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index bdb22854b..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, @@ -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) @@ -278,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 { 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) 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..c14ec3ac2 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.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) } @@ -253,7 +251,7 @@ private fun Preview() { val timestamp = System.currentTimeMillis() - (minutesAgo * 60 * 1000) when (it.category) { - BackupCategory.LDK_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/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 } } diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index d72543a46..37a69d1f5 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 @@ -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/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, ) } diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 27259bb42..6811494ab 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( @@ -458,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( 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