From 1c217840aa3014008b1d3fefb36de3b252fd6fe5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Jan 2026 13:28:13 +0100 Subject: [PATCH 01/10] fix: pickup migrated pending orders to fulfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes scenario: User pays for a channel order in RN, migrates to Native before the channel opens → App doesn't know the order is paid & pending fulfillment → channel never opens. --- .../java/to/bitkit/services/MigrationService.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index ca804b284..65414d99e 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -29,6 +29,7 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put +import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.WidgetsStore import to.bitkit.data.keychain.Keychain @@ -101,7 +102,7 @@ private data class RNRemoteBoostedTx( @Serializable private data class RNRemoteBlocktankBackup( val orders: List? = null, - val paidOrders: List? = null, + val paidOrders: Map? = null, ) @Serializable @@ -114,13 +115,14 @@ private data class RNRemoteBlocktankOrder( val createdAt: String? = null, ) -@Suppress("LargeClass", "TooManyFunctions") +@Suppress("LargeClass", "TooManyFunctions", "LongParameterList") @Singleton class MigrationService @Inject constructor( @ApplicationContext private val context: Context, private val keychain: Keychain, private val settingsStore: SettingsStore, private val widgetsStore: WidgetsStore, + private val cacheStore: CacheStore, private val activityRepo: ActivityRepo, private val coreService: CoreService, private val rnBackupClient: RNBackupClient, @@ -1263,8 +1265,11 @@ class MigrationService @Inject constructor( orderIds.addAll(orders.map { it.id }) } - backup.paidOrders?.let { paidOrderIds -> - orderIds.addAll(paidOrderIds) + backup.paidOrders?.let { paidOrdersMap -> + orderIds.addAll(paidOrdersMap.keys) + paidOrdersMap.forEach { (orderId, txId) -> + cacheStore.addPaidOrder(orderId, txId) + } } if (orderIds.isNotEmpty()) { From 20eca47ac109ea2d718b97e4fb13b87429b1bddd Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Jan 2026 13:31:25 +0100 Subject: [PATCH 02/10] chore: move class to top of file --- .../to/bitkit/services/MigrationService.kt | 142 ++++++++++-------- 1 file changed, 78 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index 65414d99e..b1593b701 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -51,69 +51,6 @@ import java.security.KeyStore import javax.inject.Inject import javax.inject.Singleton -private val Context.rnMigrationDataStore: DataStore by preferencesDataStore( - name = "rn_migration" -) - -private val Context.rnKeychainDataStore: DataStore by preferencesDataStore( - name = "RN_KEYCHAIN" -) - -@Serializable -private data class RNRemoteActivityItem( - val id: String, - val activityType: String, - val txType: String, - val txId: String? = null, - val value: Long, - val fee: Long? = null, - val feeRate: Long? = null, - val address: String? = null, - val confirmed: Boolean? = null, - val timestamp: Long, - val isBoosted: Boolean? = null, - val isTransfer: Boolean? = null, - val exists: Boolean? = null, - val confirmTimestamp: Long? = null, - val channelId: String? = null, - val transferTxId: String? = null, - val status: String? = null, - val message: String? = null, - val preimage: String? = null, - val boostedParents: List? = null, -) - -@Serializable -private data class RNRemoteWalletBackup( - val transfers: Map>? = null, - val boostedTransactions: Map>? = null, -) - -@Serializable -private data class RNRemoteTransfer(val txId: String? = null, val type: String? = null) - -@Serializable -private data class RNRemoteBoostedTx( - val oldTxId: String? = null, - val newTxId: String? = null, - val childTransaction: String? = null, -) - -@Serializable -private data class RNRemoteBlocktankBackup( - val orders: List? = null, - val paidOrders: Map? = null, -) - -@Serializable -private data class RNRemoteBlocktankOrder( - val id: String, - val state: String? = null, - val lspBalanceSat: ULong? = null, - val clientBalanceSat: ULong? = null, - val channelExpiryWeeks: Int? = null, - val createdAt: String? = null, -) @Suppress("LargeClass", "TooManyFunctions", "LongParameterList") @Singleton @@ -1626,10 +1563,87 @@ class MigrationService @Inject constructor( } } +private val Context.rnMigrationDataStore: DataStore by preferencesDataStore("rn_migration") +private val Context.rnKeychainDataStore: DataStore by preferencesDataStore("RN_KEYCHAIN") + +@Serializable +private data class RNRemoteActivityItem( + val id: String, + val activityType: String, + val txType: String, + val txId: String? = null, + val value: Long, + val fee: Long? = null, + val feeRate: Long? = null, + val address: String? = null, + val confirmed: Boolean? = null, + val timestamp: Long, + val isBoosted: Boolean? = null, + val isTransfer: Boolean? = null, + val exists: Boolean? = null, + val confirmTimestamp: Long? = null, + val channelId: String? = null, + val transferTxId: String? = null, + val status: String? = null, + val message: String? = null, + val preimage: String? = null, + val boostedParents: List? = null, +) + +@Serializable +private data class RNRemoteWalletBackup( + val transfers: Map>? = null, + val boostedTransactions: Map>? = null, +) + +@Serializable +private data class RNRemoteTransfer(val txId: String? = null, val type: String? = null) + +@Serializable +private data class RNRemoteBoostedTx( + val oldTxId: String? = null, + val newTxId: String? = null, + val childTransaction: String? = null, +) + +@Serializable +private data class RNRemoteBlocktankBackup( + val orders: List? = null, + val paidOrders: Map? = null, +) + +@Serializable +private data class RNRemoteBlocktankOrder( + val id: String, + val state: String? = null, + val lspBalanceSat: ULong? = null, + val clientBalanceSat: ULong? = null, + val channelExpiryWeeks: Int? = null, + val createdAt: String? = null, +) + data class PendingChannelMigration( val channelManager: ByteArray, val channelMonitors: List, -) +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PendingChannelMigration + + if (!channelManager.contentEquals(other.channelManager)) return false + if (channelMonitors != other.channelMonitors) return false + + return true + } + + override fun hashCode(): Int { + var result = channelManager.contentHashCode() + result = 31 * result + channelMonitors.hashCode() + return result + } +} @Serializable data class RNSettings( From 9a77974638b0dd230e2fe9d3ca4a31d0b588ed58 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Jan 2026 13:35:23 +0100 Subject: [PATCH 03/10] chore: add imports for local types --- .../to/bitkit/services/MigrationService.kt | 61 +++++++++++-------- 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index b1593b701..fa5e698cb 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.jsonArray @@ -32,6 +33,8 @@ import kotlinx.serialization.json.put import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.WidgetsStore +import to.bitkit.data.dto.price.GraphPeriod +import to.bitkit.data.dto.price.TradingPair import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin import to.bitkit.di.json @@ -43,8 +46,14 @@ import to.bitkit.models.Suggestion import to.bitkit.models.TransactionSpeed import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition +import to.bitkit.models.widget.BlocksPreferences +import to.bitkit.models.widget.FactsPreferences +import to.bitkit.models.widget.HeadlinePreferences +import to.bitkit.models.widget.PricePreferences +import to.bitkit.models.widget.WeatherPreferences import to.bitkit.repositories.ActivityRepo import to.bitkit.services.core.Bip39Service +import to.bitkit.utils.AppError import to.bitkit.utils.Logger import java.io.File import java.security.KeyStore @@ -322,7 +331,7 @@ class MigrationService @Inject constructor( } else { markMigrationChecked() setShowingMigrationLoading(false) - throw to.bitkit.utils.AppError( + throw AppError( "Migration data unavailable. Please restore your wallet using your recovery phrase." ) } @@ -338,13 +347,13 @@ class MigrationService @Inject constructor( val mnemonic = loadStringFromRNKeychain(RNKeychainKey.MNEMONIC) if (mnemonic.isNullOrEmpty()) { - throw to.bitkit.utils.AppError( + throw AppError( "Migration data unavailable. Please restore your wallet using your recovery phrase." ) } bip39Service.validateMnemonic(mnemonic).onFailure { - throw to.bitkit.utils.AppError( + throw AppError( "Recovery phrase is invalid. Please use your 12 or 24 word recovery phrase to restore manually." ) } @@ -849,34 +858,34 @@ class MigrationService @Inject constructor( private suspend fun applyRNWidgetPreferences(widgetOptions: Map) { widgetOptions["price"]?.let { priceData -> try { - val priceJson = json.decodeFromString( + val priceJson = json.decodeFromString( priceData.decodeToString() ) val selectedPairs = priceJson["selectedPairs"]?.jsonArray?.mapNotNull { pairElement -> val pairStr = pairElement.jsonPrimitive.content.replace("_", "/") when (pairStr) { - "BTC/USD" -> to.bitkit.data.dto.price.TradingPair.BTC_USD - "BTC/EUR" -> to.bitkit.data.dto.price.TradingPair.BTC_EUR - "BTC/GBP" -> to.bitkit.data.dto.price.TradingPair.BTC_GBP - "BTC/JPY" -> to.bitkit.data.dto.price.TradingPair.BTC_JPY + "BTC/USD" -> TradingPair.BTC_USD + "BTC/EUR" -> TradingPair.BTC_EUR + "BTC/GBP" -> TradingPair.BTC_GBP + "BTC/JPY" -> TradingPair.BTC_JPY else -> null } - } ?: listOf(to.bitkit.data.dto.price.TradingPair.BTC_USD) + } ?: listOf(TradingPair.BTC_USD) val periodStr = priceJson["selectedPeriod"]?.jsonPrimitive?.content ?: "1D" val period = when (periodStr) { - "1D" -> to.bitkit.data.dto.price.GraphPeriod.ONE_DAY - "1W" -> to.bitkit.data.dto.price.GraphPeriod.ONE_WEEK - "1M" -> to.bitkit.data.dto.price.GraphPeriod.ONE_MONTH - "1Y" -> to.bitkit.data.dto.price.GraphPeriod.ONE_YEAR - else -> to.bitkit.data.dto.price.GraphPeriod.ONE_DAY + "1D" -> GraphPeriod.ONE_DAY + "1W" -> GraphPeriod.ONE_WEEK + "1M" -> GraphPeriod.ONE_MONTH + "1Y" -> GraphPeriod.ONE_YEAR + else -> GraphPeriod.ONE_DAY } val showSource = priceJson["showSource"]?.jsonPrimitive?.content ?.toBooleanStrictOrNull() ?: false widgetsStore.updatePricePreferences( - to.bitkit.models.widget.PricePreferences( + PricePreferences( enabledPairs = selectedPairs, period = period, showSource = showSource @@ -889,7 +898,7 @@ class MigrationService @Inject constructor( widgetOptions["weather"]?.let { weatherData -> try { - val weatherJson = json.decodeFromString( + val weatherJson = json.decodeFromString( weatherData.decodeToString() ) val showTitle = weatherJson["showStatus"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: true @@ -899,7 +908,7 @@ class MigrationService @Inject constructor( ?.toBooleanStrictOrNull() ?: false widgetsStore.updateWeatherPreferences( - to.bitkit.models.widget.WeatherPreferences( + WeatherPreferences( showTitle = showTitle, showDescription = showDescription, showCurrentFee = showCurrentFee, @@ -913,7 +922,7 @@ class MigrationService @Inject constructor( widgetOptions["news"]?.let { newsData -> try { - val newsJson = json.decodeFromString( + val newsJson = json.decodeFromString( newsData.decodeToString() ) val showTime = newsJson["showDate"]?.jsonPrimitive?.content @@ -922,7 +931,7 @@ class MigrationService @Inject constructor( ?.toBooleanStrictOrNull() ?: true widgetsStore.updateHeadlinePreferences( - to.bitkit.models.widget.HeadlinePreferences( + HeadlinePreferences( showTime = showTime, showSource = showSource ) @@ -934,7 +943,7 @@ class MigrationService @Inject constructor( widgetOptions["blocks"]?.let { blocksData -> try { - val blocksJson = json.decodeFromString( + val blocksJson = json.decodeFromString( blocksData.decodeToString() ) val showBlock = blocksJson["height"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: true @@ -946,7 +955,7 @@ class MigrationService @Inject constructor( val showSource = blocksJson["showSource"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: false widgetsStore.updateBlocksPreferences( - to.bitkit.models.widget.BlocksPreferences( + BlocksPreferences( showBlock = showBlock, showTime = showTime, showDate = showDate, @@ -962,13 +971,13 @@ class MigrationService @Inject constructor( widgetOptions["facts"]?.let { factsData -> try { - val factsJson = json.decodeFromString( + val factsJson = json.decodeFromString( factsData.decodeToString() ) val showSource = factsJson["showSource"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: false widgetsStore.updateFactsPreferences( - to.bitkit.models.widget.FactsPreferences( + FactsPreferences( showSource = showSource ) ) @@ -1300,7 +1309,7 @@ class MigrationService @Inject constructor( boostTxIds = updatedParentBoostTxIds, ) - var updatedNewOnchain = newOnchain.copy( + val updatedNewOnchain = newOnchain.copy( isBoosted = false, boostTxIds = newOnchain.boostTxIds.filter { it != oldTxId }, ) @@ -1456,13 +1465,13 @@ class MigrationService @Inject constructor( @Suppress("LongMethod", "CyclomaticComplexMethod") private fun convertRNWidgetPreferences( - widgetsDict: kotlinx.serialization.json.JsonObject? + widgetsDict: JsonObject? ): Map { val result = mutableMapOf() if (widgetsDict == null) return result fun getBool( - source: kotlinx.serialization.json.JsonObject, + source: JsonObject, key: String, fallbackKey: String? = null, defaultValue: Boolean = false, From 2da49dffce16d8a219b52c347fe3c0b2f2594509 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Jan 2026 23:19:15 +0100 Subject: [PATCH 04/10] refactor: replace reflection in deriveSigningKey --- .../java/to/bitkit/services/RNBackupClient.kt | 39 ++++++++----------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/RNBackupClient.kt b/app/src/main/java/to/bitkit/services/RNBackupClient.kt index fc1062e6c..f999dd9b6 100644 --- a/app/src/main/java/to/bitkit/services/RNBackupClient.kt +++ b/app/src/main/java/to/bitkit/services/RNBackupClient.kt @@ -24,12 +24,12 @@ import org.ldk.structs.KeysManager import org.lightningdevkit.ldknode.Network import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher -import to.bitkit.di.json import to.bitkit.env.Env import to.bitkit.utils.AppError import to.bitkit.utils.Crypto import to.bitkit.utils.Logger import javax.crypto.Cipher +import javax.crypto.Mac import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec import javax.inject.Inject @@ -59,7 +59,7 @@ class RNBackupClient @Inject constructor( suspend fun listFiles(fileGroup: String? = "ldk"): RNBackupListResponse? = withContext(ioDispatcher) { runCatching { val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw RNBackupError.NotSetup + ?: throw RNBackupError.NotSetup() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) val bearer = authenticate(mnemonic, passphrase) @@ -81,7 +81,7 @@ class RNBackupClient @Inject constructor( suspend fun retrieve(label: String, fileGroup: String? = null): ByteArray? = withContext(ioDispatcher) { runCatching { val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw RNBackupError.NotSetup + ?: throw RNBackupError.NotSetup() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) val bearer = authenticate(mnemonic, passphrase) @@ -108,8 +108,7 @@ class RNBackupClient @Inject constructor( suspend fun retrieveChannelMonitor(channelId: String): ByteArray? = withContext(ioDispatcher) { runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw RNBackupError.NotSetup + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw RNBackupError.NotSetup() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) val bearer = authenticate(mnemonic, passphrase) @@ -236,7 +235,7 @@ class RNBackupClient @Inject constructor( } if (!challengeResponse.status.isSuccess()) { - throw RNBackupError.AuthFailed + throw RNBackupError.AuthFailed() } val challengeResult = challengeResponse.body() @@ -254,7 +253,7 @@ class RNBackupClient @Inject constructor( } if (!authResponse.status.isSuccess()) { - throw RNBackupError.AuthFailed + throw RNBackupError.AuthFailed() } authResponse.body().also { cachedBearer = it } @@ -283,12 +282,7 @@ class RNBackupClient @Inject constructor( return runCatching { val keysManager = KeysManager.of(bip32Seed, seconds, nanoSeconds) - val method = keysManager.javaClass.getMethod("get_node_secret_key") - when (val nodeSecretKey = method.invoke(keysManager)) { - is ByteArray -> nodeSecretKey - is List<*> -> nodeSecretKey.map { (it as UByte).toByte() }.toByteArray() - else -> throw ClassCastException("Unexpected type: ${nodeSecretKey?.javaClass?.name}") - } + keysManager._node_secret_key }.getOrElse { bip32Seed } } @@ -306,9 +300,12 @@ class RNBackupClient @Inject constructor( return (generator.generateDerivedParameters(512) as KeyParameter).key } + @Suppress("SpellCheckingInspection") private fun deriveMasterKey(seed: ByteArray): ByteArray { - val hmac = javax.crypto.Mac.getInstance("HmacSHA512") - val keySpec = javax.crypto.spec.SecretKeySpec("Bitcoin seed".toByteArray(), "HmacSHA512") + @Suppress("SpellCheckingInspection") + val algorithm = "HmacSHA512" + val hmac = Mac.getInstance(algorithm) + val keySpec = SecretKeySpec("Bitcoin seed".toByteArray(), algorithm) hmac.init(keySpec) val i = hmac.doFinal(seed) return i.sliceArray(0 until 32) @@ -332,9 +329,7 @@ class RNBackupClient @Inject constructor( return cipher.doFinal(ciphertext + tag) } - private fun ByteArray.toHex(): String { - return this.joinToString("") { "%02x".format(it) } - } + private fun ByteArray.toHex(): String = this.joinToString("") { "%02x".format(it) } } @Serializable @@ -355,8 +350,8 @@ data class RNBackupListResponse( ) sealed class RNBackupError(message: String) : AppError(message) { - data object NotSetup : RNBackupError("RN backup client not setup") - data object AuthFailed : RNBackupError("Authentication failed") - data class RequestFailed(override val message: String) : RNBackupError(message) - data class DecryptFailed(override val message: String) : RNBackupError(message) + class NotSetup : RNBackupError("RN backup client not setup") + class AuthFailed : RNBackupError("Authentication failed") + class RequestFailed(override val message: String) : RNBackupError(message) + class DecryptFailed(override val message: String) : RNBackupError(message) } From c1fdb6949157cf2cdca4166ce62903c6ab56fb1e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Jan 2026 15:50:10 +0100 Subject: [PATCH 05/10] chore: fix warnings in migration code --- .../to/bitkit/data/dto/price/TradingPair.kt | 2 - .../to/bitkit/services/MigrationService.kt | 67 ++++++++----------- 2 files changed, 28 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/dto/price/TradingPair.kt b/app/src/main/java/to/bitkit/data/dto/price/TradingPair.kt index ab4ffcd37..0e3b76ace 100644 --- a/app/src/main/java/to/bitkit/data/dto/price/TradingPair.kt +++ b/app/src/main/java/to/bitkit/data/dto/price/TradingPair.kt @@ -15,5 +15,3 @@ enum class TradingPair( val ticker: String get() = "$base$quote" } - -fun String.displayNameToTradingPair() = TradingPair.entries.firstOrNull { it.displayName == this } diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index fa5e698cb..f4d812b26 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.update import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.jsonArray @@ -60,7 +61,6 @@ import java.security.KeyStore import javax.inject.Inject import javax.inject.Singleton - @Suppress("LargeClass", "TooManyFunctions", "LongParameterList") @Singleton class MigrationService @Inject constructor( @@ -144,6 +144,7 @@ class MigrationService @Inject constructor( private val rnLdkAccountPath: File get() { + @Suppress("SpellCheckingInspection") val accountName = buildString { append(RN_WALLET_NAME) append(rnNetworkString) @@ -160,11 +161,6 @@ class MigrationService @Inject constructor( return rnMigrationStore.data.first()[key] == "true" } - suspend fun isMigrationCompleted(): Boolean { - val key = stringPreferencesKey(RN_MIGRATION_COMPLETED_KEY) - return rnMigrationStore.data.first()[key] == "true" - } - suspend fun markMigrationChecked() { val key = stringPreferencesKey(RN_MIGRATION_CHECKED_KEY) rnMigrationStore.edit { it[key] = "true" } @@ -180,19 +176,19 @@ class MigrationService @Inject constructor( } @Suppress("TooGenericExceptionCaught", "SwallowedException") - suspend fun hasNativeWalletData(): Boolean { + fun hasNativeWalletData(): Boolean { return try { keychain.exists(Keychain.Key.BIP39_MNEMONIC.name) - } catch (e: Exception) { + } catch (_: Exception) { false } } - suspend fun hasRNLdkData(): Boolean { + fun hasRNLdkData(): Boolean { return File(rnLdkAccountPath, "channel_manager.bin").exists() } - suspend fun hasRNMmkvData(): Boolean { + fun hasRNMmkvData(): Boolean { return rnMmkvPath.exists() } @@ -267,13 +263,12 @@ class MigrationService @Inject constructor( return try { val keystore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - val alias = service - if (!keystore.containsAlias(alias)) { - Logger.error("Keystore alias '$alias' not found", context = TAG) + if (!keystore.containsAlias(service)) { + Logger.error("Keystore alias '$service' not found", context = TAG) return null } - val secretKey = keystore.getKey(alias, null) as javax.crypto.SecretKey + val secretKey = keystore.getKey(service, null) as javax.crypto.SecretKey val transformation = "AES/GCM/NoPadding" val spec = javax.crypto.spec.GCMParameterSpec(GCM_TAG_LENGTH, iv) @@ -434,7 +429,7 @@ class MigrationService @Inject constructor( }.getOrNull() @Suppress("TooGenericExceptionCaught") - private suspend fun extractRNSettings(mmkvData: Map): RNSettings? { + private fun extractRNSettings(mmkvData: Map): RNSettings? { val rootJson = mmkvData["persist:root"] ?: return null return try { @@ -452,7 +447,7 @@ class MigrationService @Inject constructor( } @Suppress("TooGenericExceptionCaught") - private suspend fun extractRNMetadata(mmkvData: Map): RNMetadata? { + private fun extractRNMetadata(mmkvData: Map): RNMetadata? { val rootJson = mmkvData["persist:root"] ?: return null return try { @@ -470,7 +465,7 @@ class MigrationService @Inject constructor( } @Suppress("TooGenericExceptionCaught") - private suspend fun extractRNTodos(mmkvData: Map): RNTodos? { + private fun extractRNTodos(mmkvData: Map): RNTodos? { val rootJson = mmkvData["persist:root"] ?: return null return try { @@ -488,7 +483,7 @@ class MigrationService @Inject constructor( } @Suppress("TooGenericExceptionCaught") - private suspend fun extractRNWidgets(mmkvData: Map): RNWidgetsWithOptions? { + private fun extractRNWidgets(mmkvData: Map): RNWidgetsWithOptions? { val rootJson = mmkvData["persist:root"] ?: return null return try { @@ -512,7 +507,7 @@ class MigrationService @Inject constructor( } @Suppress("TooGenericExceptionCaught") - private suspend fun extractRNActivities(mmkvData: Map): List? { + private fun extractRNActivities(mmkvData: Map): List? { val rootJson = mmkvData["persist:root"] ?: return null return try { @@ -585,7 +580,7 @@ class MigrationService @Inject constructor( } @Suppress("TooGenericExceptionCaught") - private suspend fun extractRNWalletBackup( + private fun extractRNWalletBackup( mmkvData: Map ): Pair, Map>? { val rootJson = mmkvData["persist:root"] ?: return null @@ -624,7 +619,7 @@ class MigrationService @Inject constructor( ) @Suppress("NestedBlockDepth", "TooGenericExceptionCaught") - private suspend fun extractRNClosedChannels(mmkvData: Map): List? { + private fun extractRNClosedChannels(mmkvData: Map): List? { val rootJson = mmkvData["persist:root"] ?: return null return try { @@ -1015,12 +1010,6 @@ class MigrationService @Inject constructor( } } - suspend fun hasRNRemoteBackup(): Boolean = runCatching { - rnBackupClient.hasBackup() - }.onFailure { e -> - Logger.error("Failed to check RN remote backup", e, context = TAG) - }.getOrDefault(false) - suspend fun getRNRemoteBackupTimestamp(): ULong? = runCatching { rnBackupClient.getLatestBackupTimestamp() }.getOrNull() @@ -1154,7 +1143,7 @@ class MigrationService @Inject constructor( } } - private suspend fun applyRNRemoteMetadata(data: ByteArray) { + private fun applyRNRemoteMetadata(data: ByteArray) { runCatching { pendingRemoteMetadata = decodeBackupData(data) }.onFailure { e -> @@ -1162,7 +1151,7 @@ class MigrationService @Inject constructor( } } - private suspend fun applyRNRemoteWallet(data: ByteArray) { + private fun applyRNRemoteWallet(data: ByteArray) { runCatching { val backup = decodeBackupData(data) @@ -1463,7 +1452,7 @@ class MigrationService @Inject constructor( } } - @Suppress("LongMethod", "CyclomaticComplexMethod") + @Suppress("LongMethod", "CyclomaticComplexMethod", "NestedBlockDepth") private fun convertRNWidgetPreferences( widgetsDict: JsonObject? ): Map { @@ -1479,14 +1468,14 @@ class MigrationService @Inject constructor( val keys = if (fallbackKey != null) listOf(key, fallbackKey) else listOf(key) for (k in keys) { source[k]?.let { element -> - when { - element is kotlinx.serialization.json.JsonPrimitive && element.isString -> { - val str = element.content.lowercase() - return str == "true" || str == "1" - } - element is kotlinx.serialization.json.JsonPrimitive -> { - return element.content.toBooleanStrictOrNull() - ?: defaultValue + when (element) { + is JsonPrimitive -> { + return if (element.isString) { + val str = element.content.lowercase() + str == "true" || str == "1" + } else { + element.content.toBooleanStrictOrNull() ?: defaultValue + } } else -> continue } @@ -1503,7 +1492,7 @@ class MigrationService @Inject constructor( val mappedPairs = pairsArray?.mapNotNull { pairElement -> pairElement.jsonPrimitive.content.replace("_", "/") } ?: emptyList() - val selectedPairs = if (mappedPairs.isNotEmpty()) mappedPairs else listOf("BTC/USD") + val selectedPairs = mappedPairs.ifEmpty { listOf("BTC/USD") } val rnPeriod = prefs["period"]?.jsonPrimitive?.content ?: "1D" val periodMap = mapOf( From 4271fbd16103b68e34c19f9fd153f6e42bbb5140 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Jan 2026 17:04:26 +0100 Subject: [PATCH 06/10] refactor: use runCatching in migration service --- .../to/bitkit/services/MigrationService.kt | 556 ++++++++---------- 1 file changed, 240 insertions(+), 316 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index f4d812b26..6ecaf0216 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -31,6 +31,7 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put +import org.lightningdevkit.ldknode.Network import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.WidgetsStore @@ -78,8 +79,10 @@ class MigrationService @Inject constructor( private const val TAG = "Migration" const val RN_MIGRATION_COMPLETED_KEY = "rnMigrationCompleted" const val RN_MIGRATION_CHECKED_KEY = "rnMigrationChecked" + private const val OPENING_CURLY_BRACE = "{" + private const val MMKV_ROOT = "persist:root" private const val RN_WALLET_NAME = "wallet0" - private const val MILLISECONDS_TO_SECONDS = 1000 + private const val MS_PER_SEC = 1000 private const val GCM_IV_LENGTH = 12 private const val GCM_TAG_LENGTH = 128 } @@ -95,16 +98,12 @@ class MigrationService @Inject constructor( private val _isShowingMigrationLoading = MutableStateFlow(false) val isShowingMigrationLoading: StateFlow = _isShowingMigrationLoading.asStateFlow() - fun setShowingMigrationLoading(value: Boolean) { - _isShowingMigrationLoading.update { value } - } + fun setShowingMigrationLoading(value: Boolean) = _isShowingMigrationLoading.update { value } private val _isRestoringFromRNRemoteBackup = MutableStateFlow(false) val isRestoringFromRNRemoteBackup: StateFlow = _isRestoringFromRNRemoteBackup.asStateFlow() - fun setRestoringFromRNRemoteBackup(value: Boolean) { - _isRestoringFromRNRemoteBackup.update { value } - } + fun setRestoringFromRNRemoteBackup(value: Boolean) = _isRestoringFromRNRemoteBackup.update { value } @Volatile private var pendingChannelMigration: PendingChannelMigration? = null @@ -115,9 +114,7 @@ class MigrationService @Inject constructor( return migration } - fun peekPendingChannelMigration(): PendingChannelMigration? { - return pendingChannelMigration - } + fun peekPendingChannelMigration(): PendingChannelMigration? = pendingChannelMigration @Volatile private var pendingRemoteActivityData: List? = null @@ -131,30 +128,26 @@ class MigrationService @Inject constructor( @Volatile private var pendingRemoteMetadata: RNMetadata? = null - private val rnNetworkString: String - get() = when (Env.network) { - org.lightningdevkit.ldknode.Network.BITCOIN -> "bitcoin" - org.lightningdevkit.ldknode.Network.REGTEST -> "bitcoinRegtest" - org.lightningdevkit.ldknode.Network.TESTNET -> "bitcoinTestnet" - org.lightningdevkit.ldknode.Network.SIGNET -> "signet" + private fun buildRnLdkAccountPath(): File = run { + val rnNetworkString = when (Env.network) { + Network.BITCOIN -> "bitcoin" + Network.REGTEST -> "bitcoinRegtest" + Network.TESTNET -> "bitcoinTestnet" + Network.SIGNET -> "signet" } + val rnLdkBasePath = File(context.filesDir, "ldk") - private val rnLdkBasePath: File - get() = File(context.filesDir, "ldk") - - private val rnLdkAccountPath: File - get() { - @Suppress("SpellCheckingInspection") - val accountName = buildString { - append(RN_WALLET_NAME) - append(rnNetworkString) - append("ldkaccountv3") - } - return File(rnLdkBasePath, accountName) + @Suppress("SpellCheckingInspection") + val accountName = buildString { + append(RN_WALLET_NAME) + append(rnNetworkString) + append("ldkaccountv3") } - private val rnMmkvPath: File - get() = File(context.filesDir, "mmkv/mmkv.default") + return File(rnLdkBasePath, accountName) + } + + private fun getRnMmkvPath(): File = File(context.filesDir, "mmkv/mmkv.default") suspend fun isMigrationChecked(): Boolean { val key = stringPreferencesKey(RN_MIGRATION_CHECKED_KEY) @@ -168,44 +161,26 @@ class MigrationService @Inject constructor( suspend fun hasRNWalletData(): Boolean { val mnemonic = loadStringFromRNKeychain(RNKeychainKey.MNEMONIC) - if (mnemonic?.isNotEmpty() == true) { - return true - } + if (mnemonic?.isNotEmpty() == true) return true return hasRNMmkvData() || hasRNLdkData() } - @Suppress("TooGenericExceptionCaught", "SwallowedException") - fun hasNativeWalletData(): Boolean { - return try { - keychain.exists(Keychain.Key.BIP39_MNEMONIC.name) - } catch (_: Exception) { - false - } - } - - fun hasRNLdkData(): Boolean { - return File(rnLdkAccountPath, "channel_manager.bin").exists() - } + fun hasNativeWalletData() = runCatching { keychain.exists(Keychain.Key.BIP39_MNEMONIC.name) }.getOrDefault(false) + fun hasRNLdkData() = File(buildRnLdkAccountPath(), "channel_manager.bin").exists() + fun hasRNMmkvData() = getRnMmkvPath().exists() - fun hasRNMmkvData(): Boolean { - return rnMmkvPath.exists() - } - - @Suppress("TooGenericExceptionCaught") private suspend fun loadStringFromRNKeychain(key: RNKeychainKey): String? { val datastorePath = File(context.filesDir, "datastore/RN_KEYCHAIN.preferences_pb") - if (!datastorePath.exists()) { - return null - } + if (!datastorePath.exists()) return null - return try { + return runCatching { val preferences = context.rnKeychainDataStore.data.first() val passwordKey = stringPreferencesKey("${key.service}:p") val cipherKey = stringPreferencesKey("${key.service}:c") - val encryptedValue = preferences[passwordKey] ?: return null + val encryptedValue = preferences[passwordKey] ?: return@runCatching null val cipherInfo = preferences[cipherKey] val fullEncryptedValue = if (cipherInfo != null && !encryptedValue.contains(":")) { @@ -214,22 +189,19 @@ class MigrationService @Inject constructor( encryptedValue } decryptRNKeychainValue(fullEncryptedValue, key.service) - } catch (e: Exception) { - Logger.error("Error reading from RN_KEYCHAIN DataStore: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Error reading from RN_KEYCHAIN DataStore: $it", it, context = TAG) + }.getOrNull() } - @Suppress("TooGenericExceptionCaught") private fun decryptRNKeychainValue(encryptedValue: String, service: String): String? { if (!encryptedValue.contains(":")) { - return try { + return runCatching { val encryptedBytes = android.util.Base64.decode(encryptedValue, android.util.Base64.DEFAULT) decryptWithKeystore(encryptedBytes, service) - } catch (e: Exception) { - Logger.error("Failed to decrypt without cipher prefix: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Failed to decrypt without cipher prefix: $it", it, context = TAG) + }.getOrNull() } val parts = encryptedValue.split(":", limit = 2) @@ -241,16 +213,14 @@ class MigrationService @Inject constructor( return null } - return try { + return runCatching { val encryptedBytes = android.util.Base64.decode(encryptedDataBase64, android.util.Base64.DEFAULT) decryptWithKeystore(encryptedBytes, service) - } catch (e: Exception) { - Logger.error("Failed to decrypt RN keychain value: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Failed to decrypt RN keychain value: $it", it, context = TAG) + }.getOrNull() } - @Suppress("TooGenericExceptionCaught") private fun decryptWithKeystore(encryptedBytes: ByteArray, service: String): String? { if (encryptedBytes.size < GCM_IV_LENGTH) { Logger.error("Encrypted data too short: ${encryptedBytes.size} bytes", context = TAG) @@ -260,12 +230,12 @@ class MigrationService @Inject constructor( val iv = encryptedBytes.sliceArray(0 until GCM_IV_LENGTH) val ciphertext = encryptedBytes.sliceArray(GCM_IV_LENGTH until encryptedBytes.size) - return try { + return runCatching { val keystore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } if (!keystore.containsAlias(service)) { Logger.error("Keystore alias '$service' not found", context = TAG) - return null + return@runCatching null } val secretKey = keystore.getKey(service, null) as javax.crypto.SecretKey @@ -278,73 +248,52 @@ class MigrationService @Inject constructor( val decryptedBytes = cipher.doFinal(ciphertext) String(decryptedBytes, Charsets.UTF_8) - } catch (e: Exception) { - Logger.error("Failed to decrypt with Keystore: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Failed to decrypt with Keystore: $it", it, context = TAG) + }.getOrNull() } - @Suppress("TooGenericExceptionCaught") - suspend fun migrateFromReactNative() { + suspend fun migrateFromReactNative() = runCatching { setShowingMigrationLoading(true) - try { - val mnemonicMigrated = try { - migrateMnemonic() - true - } catch (e: Exception) { - Logger.warn( - "Could not migrate mnemonic: $e. User will need to manually restore.", - context = TAG - ) - false - } + val mnemonicMigrated = runCatching { migrateMnemonic() }.map { true }.onFailure { + Logger.warn("Could not migrate mnemonic: $it. User will need to manually restore.", context = TAG) + }.getOrDefault(false) - if (mnemonicMigrated) { - migratePassphrase() - migratePin() - - if (hasRNLdkData()) { - migrateLdkData() - .onFailure { e -> - Logger.warn( - "LDK data migration failed, continuing with other migrations: $e", - e, - context = TAG - ) - } - } + if (mnemonicMigrated) { + migratePassphrase() + migratePin() - if (hasRNMmkvData()) { - migrateMMKVData() + if (hasRNLdkData()) { + migrateLdkData().onFailure { + Logger.warn("LDK data migration failed, continuing with other migrations: $it", it, context = TAG) } + } - rnMigrationStore.edit { - it[stringPreferencesKey(RN_MIGRATION_COMPLETED_KEY)] = "true" - it[stringPreferencesKey(RN_MIGRATION_CHECKED_KEY)] = "true" - } - } else { - markMigrationChecked() - setShowingMigrationLoading(false) - throw AppError( - "Migration data unavailable. Please restore your wallet using your recovery phrase." - ) + if (hasRNMmkvData()) { + migrateMMKVData() } - } catch (e: Exception) { - Logger.error("RN migration failed: $e", e, context = TAG) + + rnMigrationStore.edit { + it[stringPreferencesKey(RN_MIGRATION_COMPLETED_KEY)] = "true" + it[stringPreferencesKey(RN_MIGRATION_CHECKED_KEY)] = "true" + } + } else { markMigrationChecked() setShowingMigrationLoading(false) - throw e + throw AppError("Migration data unavailable. Please restore your wallet using your recovery phrase.") } - } + }.onFailure { + Logger.error("RN migration failed: $it", it, context = TAG) + markMigrationChecked() + setShowingMigrationLoading(false) + }.getOrThrow() private suspend fun migrateMnemonic() { val mnemonic = loadStringFromRNKeychain(RNKeychainKey.MNEMONIC) if (mnemonic.isNullOrEmpty()) { - throw AppError( - "Migration data unavailable. Please restore your wallet using your recovery phrase." - ) + throw AppError("Migration data unavailable. Please restore your wallet using your recovery phrase.") } bip39Service.validateMnemonic(mnemonic).onFailure { @@ -358,17 +307,13 @@ class MigrationService @Inject constructor( private suspend fun migratePassphrase() { val passphrase = loadStringFromRNKeychain(RNKeychainKey.PASSPHRASE) - if (passphrase.isNullOrEmpty()) { - return - } + if (passphrase.isNullOrEmpty()) return keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, passphrase) } private suspend fun migratePin() { val pin = loadStringFromRNKeychain(RNKeychainKey.PIN) - if (pin.isNullOrEmpty()) { - return - } + if (pin.isNullOrEmpty()) return if (pin.length != Env.PIN_LENGTH) { Logger.warn( @@ -386,8 +331,8 @@ class MigrationService @Inject constructor( keychain.saveString(Keychain.Key.PIN.name, pin) } - private suspend fun migrateLdkData() = runCatching { - val accountPath = rnLdkAccountPath + private fun migrateLdkData() = runCatching { + val accountPath = buildRnLdkAccountPath() val managerPath = File(accountPath, "channel_manager.bin") if (!managerPath.exists()) { @@ -412,117 +357,104 @@ class MigrationService @Inject constructor( channelManager = managerData, channelMonitors = monitors, ) - }.onFailure { e -> - Logger.error("Failed to migrate LDK data: $e", e, context = TAG) + }.onFailure { + Logger.error("Failed to migrate LDK data: $it", it, context = TAG) } - suspend fun loadRNMmkvData(): Map? = runCatching { - if (!hasRNMmkvData()) { - return@runCatching null - } + fun loadRNMmkvData(): Map? = runCatching { + if (!hasRNMmkvData()) return@runCatching null - val data = rnMmkvPath.readBytes() + val data = getRnMmkvPath().readBytes() val parser = MmkvParser(data) parser.parse().takeIf { it.isNotEmpty() } - }.onFailure { e -> - Logger.error("Failed to read MMKV data: $e", e, context = TAG) + }.onFailure { + Logger.error("Failed to read MMKV data: $it", it, context = TAG) }.getOrNull() - @Suppress("TooGenericExceptionCaught") private fun extractRNSettings(mmkvData: Map): RNSettings? { - val rootJson = mmkvData["persist:root"] ?: return null + val rootJson = mmkvData[MMKV_ROOT] ?: return null - return try { - val jsonStart = rootJson.indexOf("{") + return runCatching { + val jsonStart = rootJson.indexOf(OPENING_CURLY_BRACE) val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson val root = json.parseToJsonElement(jsonString).jsonObject - val settingsJsonString = root["settings"]?.jsonPrimitive?.content ?: return null + val settingsJsonString = root["settings"]?.jsonPrimitive?.content ?: return@runCatching null json.decodeFromString(settingsJsonString) - } catch (e: Exception) { - Logger.error("Failed to decode RN settings: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Failed to decode RN settings: $it", it, context = TAG) + }.getOrNull() } - @Suppress("TooGenericExceptionCaught") private fun extractRNMetadata(mmkvData: Map): RNMetadata? { - val rootJson = mmkvData["persist:root"] ?: return null + val rootJson = mmkvData[MMKV_ROOT] ?: return null - return try { - val jsonStart = rootJson.indexOf("{") + return runCatching { + val jsonStart = rootJson.indexOf(OPENING_CURLY_BRACE) val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson val root = json.parseToJsonElement(jsonString).jsonObject - val metadataJsonString = root["metadata"]?.jsonPrimitive?.content ?: return null + val metadataJsonString = root["metadata"]?.jsonPrimitive?.content ?: return@runCatching null json.decodeFromString(metadataJsonString) - } catch (e: Exception) { - Logger.error("Failed to decode RN metadata: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Failed to decode RN metadata: $it", it, context = TAG) + }.getOrNull() } - @Suppress("TooGenericExceptionCaught") private fun extractRNTodos(mmkvData: Map): RNTodos? { - val rootJson = mmkvData["persist:root"] ?: return null + val rootJson = mmkvData[MMKV_ROOT] ?: return null - return try { - val jsonStart = rootJson.indexOf("{") + return runCatching { + val jsonStart = rootJson.indexOf(OPENING_CURLY_BRACE) val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson val root = json.parseToJsonElement(jsonString).jsonObject - val todosJsonString = root["todos"]?.jsonPrimitive?.content ?: return null + val todosJsonString = root["todos"]?.jsonPrimitive?.content ?: return@runCatching null json.decodeFromString(todosJsonString) - } catch (e: Exception) { - Logger.error("Failed to decode RN todos: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Failed to decode RN todos: $it", it, context = TAG) + }.getOrNull() } - @Suppress("TooGenericExceptionCaught") private fun extractRNWidgets(mmkvData: Map): RNWidgetsWithOptions? { - val rootJson = mmkvData["persist:root"] ?: return null + val rootJson = mmkvData[MMKV_ROOT] ?: return null - return try { - val jsonStart = rootJson.indexOf("{") + return runCatching { + val jsonStart = rootJson.indexOf(OPENING_CURLY_BRACE) val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson val root = json.parseToJsonElement(jsonString).jsonObject - val widgetsJsonString = root["widgets"]?.jsonPrimitive?.content ?: return null + val widgetsJsonString = root["widgets"]?.jsonPrimitive?.content + ?: return@runCatching null val widgets = json.decodeFromString(widgetsJsonString) val widgetsData = json.parseToJsonElement(widgetsJsonString).jsonObject - val widgetOptions = convertRNWidgetPreferences( - widgetsData["widgets"]?.jsonObject ?: widgetsData - ) + val widgetOptions = convertRNWidgetPreferences(widgetsData["widgets"]?.jsonObject ?: widgetsData) RNWidgetsWithOptions(widgets = widgets, widgetOptions = widgetOptions) - } catch (e: Exception) { - Logger.error("Failed to decode RN widgets: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Failed to decode RN widgets: $it", it, context = TAG) + }.getOrNull() } - @Suppress("TooGenericExceptionCaught") private fun extractRNActivities(mmkvData: Map): List? { - val rootJson = mmkvData["persist:root"] ?: return null + val rootJson = mmkvData[MMKV_ROOT] ?: return null - return try { - val jsonStart = rootJson.indexOf("{") + return runCatching { + val jsonStart = rootJson.indexOf(OPENING_CURLY_BRACE) val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson val root = json.parseToJsonElement(jsonString).jsonObject - val activityJsonString = root["activity"]?.jsonPrimitive?.content ?: return null + val activityJsonString = root["activity"]?.jsonPrimitive?.content ?: return@runCatching null val activityState = json.decodeFromString(activityJsonString) activityState.items ?: emptyList() - } catch (e: Exception) { - Logger.error("Failed to decode RN activities: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Failed to decode RN activities: $it", it, context = TAG) + }.getOrNull() } private fun extractTransfers(transfers: Map>?): Map { @@ -579,55 +511,40 @@ class MigrationService @Inject constructor( } } - @Suppress("TooGenericExceptionCaught") - private fun extractRNWalletBackup( - mmkvData: Map - ): Pair, Map>? { - val rootJson = mmkvData["persist:root"] ?: return null + private fun extractRNWalletBackup(mmkvData: Map): Pair, Map>? { + val rootJson = mmkvData[MMKV_ROOT] ?: return null - return try { - val jsonStart = rootJson.indexOf("{") + return runCatching { + val jsonStart = rootJson.indexOf(OPENING_CURLY_BRACE) val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson val root = json.parseToJsonElement(jsonString).jsonObject - val walletJsonString = root["wallet"]?.jsonPrimitive?.content ?: return null + val walletJsonString = root["wallet"]?.jsonPrimitive?.content ?: return@runCatching null val walletData = json.parseToJsonElement(walletJsonString).jsonObject - val walletState = runCatching { - json.decodeFromJsonElement(walletData) - }.getOrNull() + val walletState = runCatching { json.decodeFromJsonElement(walletData) }.getOrNull() walletState?.let { extractFromWalletState(it) } ?: run { - val walletBackup = runCatching { - json.decodeFromJsonElement(walletData) - }.getOrNull() - walletBackup?.let { extractFromWalletBackup(it) } + runCatching { json.decodeFromJsonElement(walletData) }.getOrNull()?.let { + extractFromWalletBackup(it) + } } - } catch (e: Exception) { - Logger.error("Failed to decode RN wallet backup: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Failed to decode RN wallet backup: $it", it, context = TAG) + }.getOrNull() } - @Serializable - private data class RNWalletState(val wallets: Map? = null) - - @Serializable - private data class RNWalletData( - val transfers: Map>? = null, - val boostedTransactions: Map>? = null, - ) - - @Suppress("NestedBlockDepth", "TooGenericExceptionCaught") + @Suppress("NestedBlockDepth") private fun extractRNClosedChannels(mmkvData: Map): List? { - val rootJson = mmkvData["persist:root"] ?: return null + val rootJson = mmkvData[MMKV_ROOT] ?: return null - return try { - val jsonStart = rootJson.indexOf("{") + return runCatching { + val jsonStart = rootJson.indexOf(OPENING_CURLY_BRACE) val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson val root = json.parseToJsonElement(jsonString).jsonObject - val lightningJsonString = root["lightning"]?.jsonPrimitive?.content ?: return null + val lightningJsonString = root["lightning"]?.jsonPrimitive?.content + ?: return@runCatching null val lightningState = json.decodeFromString(lightningJsonString) val closedChannels = mutableListOf() @@ -643,10 +560,9 @@ class MigrationService @Inject constructor( } closedChannels.takeIf { it.isNotEmpty() } - } catch (e: Exception) { - Logger.error("Failed to decode RN lightning state: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Failed to decode RN lightning state: $it", it, context = TAG) + }.getOrNull() } @Suppress("CyclomaticComplexMethod") @@ -704,16 +620,16 @@ class MigrationService @Inject constructor( activityId = it.id } ActivityTags(activityId = activityId, tags = tagList) - }.onFailure { e -> - Logger.error("Failed to get activity ID for $txId: $e", e, context = TAG) + }.onFailure { + Logger.error("Failed to get activity ID for $txId: $it", it, context = TAG) }.getOrNull() } ?: emptyList() if (allTags.isNotEmpty()) { runCatching { coreService.activity.upsertTags(allTags) - }.onFailure { e -> - Logger.error("Failed to migrate tags: $e", e, context = TAG) + }.onFailure { + Logger.error("Failed to migrate tags: $it", it, context = TAG) } } @@ -751,7 +667,7 @@ class MigrationService @Inject constructor( else -> PaymentState.PENDING } - val timestampSecs = (item.timestamp / MILLISECONDS_TO_SECONDS).toULong() + val timestampSecs = (item.timestamp / MS_PER_SEC).toULong() val invoice = item.address?.takeIf { it.isNotEmpty() } ?: "migrated:${item.id}" Activity.Lightning( @@ -778,14 +694,12 @@ class MigrationService @Inject constructor( } private suspend fun applyRNClosedChannels(channels: List) { - val now = (System.currentTimeMillis() / MILLISECONDS_TO_SECONDS).toULong() + val now = (System.currentTimeMillis() / MS_PER_SEC).toULong() val closedChannels = channels.mapNotNull { channel -> val fundingTxid = channel.fundingTxid ?: return@mapNotNull null - val closedAtSecs = channel.createdAt?.let { - (it / MILLISECONDS_TO_SECONDS).toULong() - } ?: now + val closedAtSecs = channel.createdAt?.let { (it / MS_PER_SEC).toULong() } ?: now val outboundMsat = (channel.outboundCapacitySat ?: 0u) * 1000u val inboundMsat = (channel.inboundCapacitySat ?: 0u) * 1000u @@ -849,10 +763,10 @@ class MigrationService @Inject constructor( } } - @Suppress("LongMethod", "CyclomaticComplexMethod", "TooGenericExceptionCaught") + @Suppress("LongMethod", "CyclomaticComplexMethod") private suspend fun applyRNWidgetPreferences(widgetOptions: Map) { widgetOptions["price"]?.let { priceData -> - try { + runCatching { val priceJson = json.decodeFromString( priceData.decodeToString() ) @@ -886,13 +800,13 @@ class MigrationService @Inject constructor( showSource = showSource ) ) - } catch (e: Exception) { - Logger.error("Failed to migrate price preferences: $e", e, context = TAG) + }.onFailure { + Logger.error("Failed to migrate price preferences: $it", it, context = TAG) } } widgetOptions["weather"]?.let { weatherData -> - try { + runCatching { val weatherJson = json.decodeFromString( weatherData.decodeToString() ) @@ -910,13 +824,13 @@ class MigrationService @Inject constructor( showNextBlockFee = showNextBlockFee ) ) - } catch (e: Exception) { - Logger.error("Failed to migrate weather preferences: $e", e, context = TAG) + }.onFailure { + Logger.error("Failed to migrate weather preferences: $it", it, context = TAG) } } widgetOptions["news"]?.let { newsData -> - try { + runCatching { val newsJson = json.decodeFromString( newsData.decodeToString() ) @@ -931,13 +845,13 @@ class MigrationService @Inject constructor( showSource = showSource ) ) - } catch (e: Exception) { - Logger.error("Failed to migrate news preferences: $e", e, context = TAG) + }.onFailure { + Logger.error("Failed to migrate news preferences: $it", it, context = TAG) } } widgetOptions["blocks"]?.let { blocksData -> - try { + runCatching { val blocksJson = json.decodeFromString( blocksData.decodeToString() ) @@ -959,13 +873,13 @@ class MigrationService @Inject constructor( showSource = showSource ) ) - } catch (e: Exception) { - Logger.error("Failed to migrate blocks preferences: $e", e, context = TAG) + }.onFailure { + Logger.error("Failed to migrate blocks preferences: $it", it, context = TAG) } } widgetOptions["facts"]?.let { factsData -> - try { + runCatching { val factsJson = json.decodeFromString( factsData.decodeToString() ) @@ -976,8 +890,8 @@ class MigrationService @Inject constructor( showSource = showSource ) ) - } catch (e: Exception) { - Logger.error("Failed to migrate facts preferences: $e", e, context = TAG) + }.onFailure { + Logger.error("Failed to migrate facts preferences: $it", it, context = TAG) } } } @@ -985,12 +899,12 @@ class MigrationService @Inject constructor( private suspend fun migrateMMKVData() { val mmkvData = loadRNMmkvData() ?: return - extractRNActivities(mmkvData)?.let { activities -> - applyRNActivities(activities) + extractRNActivities(mmkvData)?.let { + applyRNActivities(it) } - extractRNClosedChannels(mmkvData)?.let { channels -> - applyRNClosedChannels(channels) + extractRNClosedChannels(mmkvData)?.let { + applyRNClosedChannels(it) } extractRNSettings(mmkvData)?.let { settings -> @@ -1369,7 +1283,7 @@ class MigrationService @Inject constructor( var wasUpdated = false if (item.timestamp > 0) { - val migratedTimestamp = (item.timestamp / MILLISECONDS_TO_SECONDS).toULong() + val migratedTimestamp = (item.timestamp / MS_PER_SEC).toULong() if (updated.timestamp != migratedTimestamp) { updated = updated.copy(timestamp = migratedTimestamp) wasUpdated = true @@ -1377,7 +1291,7 @@ class MigrationService @Inject constructor( } item.confirmTimestamp?.let { confirmTimestamp -> if (confirmTimestamp > 0) { - val migratedConfirmTimestamp = (confirmTimestamp / MILLISECONDS_TO_SECONDS).toULong() + val migratedConfirmTimestamp = (confirmTimestamp / MS_PER_SEC).toULong() if (updated.confirmTimestamp != migratedConfirmTimestamp) { updated = updated.copy(confirmTimestamp = migratedConfirmTimestamp) wasUpdated = true @@ -1454,7 +1368,7 @@ class MigrationService @Inject constructor( @Suppress("LongMethod", "CyclomaticComplexMethod", "NestedBlockDepth") private fun convertRNWidgetPreferences( - widgetsDict: JsonObject? + widgetsDict: JsonObject?, ): Map { val result = mutableMapOf() if (widgetsDict == null) return result @@ -1477,6 +1391,7 @@ class MigrationService @Inject constructor( element.content.toBooleanStrictOrNull() ?: defaultValue } } + else -> continue } } @@ -1561,65 +1476,6 @@ class MigrationService @Inject constructor( } } -private val Context.rnMigrationDataStore: DataStore by preferencesDataStore("rn_migration") -private val Context.rnKeychainDataStore: DataStore by preferencesDataStore("RN_KEYCHAIN") - -@Serializable -private data class RNRemoteActivityItem( - val id: String, - val activityType: String, - val txType: String, - val txId: String? = null, - val value: Long, - val fee: Long? = null, - val feeRate: Long? = null, - val address: String? = null, - val confirmed: Boolean? = null, - val timestamp: Long, - val isBoosted: Boolean? = null, - val isTransfer: Boolean? = null, - val exists: Boolean? = null, - val confirmTimestamp: Long? = null, - val channelId: String? = null, - val transferTxId: String? = null, - val status: String? = null, - val message: String? = null, - val preimage: String? = null, - val boostedParents: List? = null, -) - -@Serializable -private data class RNRemoteWalletBackup( - val transfers: Map>? = null, - val boostedTransactions: Map>? = null, -) - -@Serializable -private data class RNRemoteTransfer(val txId: String? = null, val type: String? = null) - -@Serializable -private data class RNRemoteBoostedTx( - val oldTxId: String? = null, - val newTxId: String? = null, - val childTransaction: String? = null, -) - -@Serializable -private data class RNRemoteBlocktankBackup( - val orders: List? = null, - val paidOrders: Map? = null, -) - -@Serializable -private data class RNRemoteBlocktankOrder( - val id: String, - val state: String? = null, - val lspBalanceSat: ULong? = null, - val clientBalanceSat: ULong? = null, - val channelExpiryWeeks: Int? = null, - val createdAt: String? = null, -) - data class PendingChannelMigration( val channelManager: ByteArray, val channelMonitors: List, @@ -1782,8 +1638,76 @@ data class RNWidgetsWithOptions( val widgetOptions: Map, ) +private val Context.rnMigrationDataStore: DataStore by preferencesDataStore("rn_migration") +private val Context.rnKeychainDataStore: DataStore by preferencesDataStore("RN_KEYCHAIN") + private enum class RNKeychainKey(val service: String) { MNEMONIC("wallet0"), PASSPHRASE("wallet0passphrase"), PIN("pin"), } + +@Serializable +private data class RNWalletState(val wallets: Map? = null) + +@Serializable +private data class RNWalletData( + val transfers: Map>? = null, + val boostedTransactions: Map>? = null, +) + +@Serializable +private data class RNRemoteActivityItem( + val id: String, + val activityType: String, + val txType: String, + val txId: String? = null, + val value: Long, + val fee: Long? = null, + val feeRate: Long? = null, + val address: String? = null, + val confirmed: Boolean? = null, + val timestamp: Long, + val isBoosted: Boolean? = null, + val isTransfer: Boolean? = null, + val exists: Boolean? = null, + val confirmTimestamp: Long? = null, + val channelId: String? = null, + val transferTxId: String? = null, + val status: String? = null, + val message: String? = null, + val preimage: String? = null, + val boostedParents: List? = null, +) + +@Serializable +private data class RNRemoteWalletBackup( + val transfers: Map>? = null, + val boostedTransactions: Map>? = null, +) + +@Serializable +private data class RNRemoteTransfer(val txId: String? = null, val type: String? = null) + +@Serializable +private data class RNRemoteBoostedTx( + val oldTxId: String? = null, + val newTxId: String? = null, + val childTransaction: String? = null, +) + +@Serializable +private data class RNRemoteBlocktankBackup( + val orders: List? = null, + val paidOrders: Map? = null, +) + +@Serializable +private data class RNRemoteBlocktankOrder( + val id: String, + val state: String? = null, + val lspBalanceSat: ULong? = null, + val clientBalanceSat: ULong? = null, + val channelExpiryWeeks: Int? = null, + val createdAt: String? = null, +) From 033e93cc49ea09c6772d13b750ce1b85479ebd11 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Jan 2026 17:38:52 +0100 Subject: [PATCH 07/10] refactor: cleanup RNBackupClient and fix warnings --- .../java/to/bitkit/services/RNBackupClient.kt | 72 +++++++------------ 1 file changed, 25 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/RNBackupClient.kt b/app/src/main/java/to/bitkit/services/RNBackupClient.kt index f999dd9b6..e2d87742b 100644 --- a/app/src/main/java/to/bitkit/services/RNBackupClient.kt +++ b/app/src/main/java/to/bitkit/services/RNBackupClient.kt @@ -43,12 +43,15 @@ class RNBackupClient @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val json: Json, ) { + @Suppress("SpellCheckingInspection") companion object { private const val TAG = "RNBackup" private const val VERSION = "v1" private const val SIGNED_MESSAGE_PREFIX = "react-native-ldk backup server auth:" private const val GCM_IV_LENGTH = 12 private const val GCM_TAG_LENGTH = 16 + private const val PBKDF2_ITERATIONS = 2048 + private const val PBKDF2_KEY_LENGTH_BITS = 512 } @Volatile @@ -58,8 +61,7 @@ class RNBackupClient @Inject constructor( suspend fun listFiles(fileGroup: String? = "ldk"): RNBackupListResponse? = withContext(ioDispatcher) { runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw RNBackupError.NotSetup() + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw RNBackupError.NotSetup() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) val bearer = authenticate(mnemonic, passphrase) @@ -68,9 +70,7 @@ class RNBackupClient @Inject constructor( header("Authorization", bearer.bearer) } - if (!response.status.isSuccess()) { - throw RNBackupError.RequestFailed("Status: ${response.status.value}") - } + if (!response.status.isSuccess()) throw RNBackupError.RequestFailed("Status: ${response.status.value}") response.body() }.onFailure { e -> @@ -80,8 +80,7 @@ class RNBackupClient @Inject constructor( suspend fun retrieve(label: String, fileGroup: String? = null): ByteArray? = withContext(ioDispatcher) { runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw RNBackupError.NotSetup() + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw RNBackupError.NotSetup() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) val bearer = authenticate(mnemonic, passphrase) @@ -90,14 +89,13 @@ class RNBackupClient @Inject constructor( header("Authorization", bearer.bearer) } - if (!response.status.isSuccess()) { - throw RNBackupError.RequestFailed("Status: ${response.status.value}") - } + if (!response.status.isSuccess()) throw RNBackupError.RequestFailed("Status: ${response.status.value}") val encryptedData = response.body() if (encryptedData.isEmpty()) throw RNBackupError.RequestFailed("Retrieved data is empty") val encryptionKey = deriveEncryptionKey(mnemonic, passphrase) + decrypt(encryptedData, encryptionKey).also { if (it.isEmpty()) throw RNBackupError.DecryptFailed("Decrypted data is empty") } @@ -123,9 +121,7 @@ class RNBackupClient @Inject constructor( header("Authorization", bearer.bearer) } - if (!response.status.isSuccess()) { - throw RNBackupError.RequestFailed("Status: ${response.status.value}") - } + if (!response.status.isSuccess()) throw RNBackupError.RequestFailed("Status: ${response.status.value}") val encryptedData = response.body() if (encryptedData.isEmpty()) throw RNBackupError.RequestFailed("Retrieved data is empty") @@ -139,18 +135,6 @@ class RNBackupClient @Inject constructor( }.getOrNull() } - suspend fun hasBackup(): Boolean = withContext(ioDispatcher) { - runCatching { - val ldkFiles = listFiles(fileGroup = "ldk") - val bitkitFiles = listFiles(fileGroup = "bitkit") - val hasLdkFiles = !ldkFiles?.list.isNullOrEmpty() || !ldkFiles?.channelMonitors.isNullOrEmpty() - val hasBitkitFiles = !bitkitFiles?.list.isNullOrEmpty() - hasLdkFiles || hasBitkitFiles - }.onFailure { e -> - Logger.error("Failed to check if backup exists", e, context = TAG) - }.getOrDefault(false) - } - suspend fun getLatestBackupTimestamp(): ULong? = withContext(ioDispatcher) { runCatching { val bitkitFiles = listFiles(fileGroup = "bitkit")?.list ?: return@withContext null @@ -173,15 +157,16 @@ class RNBackupClient @Inject constructor( var latestTimestamp: ULong? = null for (label in labels) { - if ("$label.bin" !in bitkitFiles) continue - - val data = retrieve(label, fileGroup = "bitkit") ?: continue - val timestamp = runCatching { - json.decodeFromString(String(data)).metadata?.timestamp - }.getOrNull() ?: continue - - val ts = (timestamp / 1000).toULong() - latestTimestamp = maxOf(latestTimestamp ?: 0uL, ts) + if ("$label.bin" in bitkitFiles) { + retrieve(label, fileGroup = "bitkit")?.let { data -> + runCatching { + json.decodeFromString(String(data)).metadata?.timestamp + }.getOrNull()?.let { timestamp -> + val ts = (timestamp / 1000).toULong() + latestTimestamp = maxOf(latestTimestamp ?: 0uL, ts) + } + } + } } latestTimestamp }.onFailure { e -> @@ -234,9 +219,7 @@ class RNBackupClient @Inject constructor( setBody(challengeBody) } - if (!challengeResponse.status.isSuccess()) { - throw RNBackupError.AuthFailed() - } + if (!challengeResponse.status.isSuccess()) throw RNBackupError.AuthFailed() val challengeResult = challengeResponse.body() val authBody = json.encodeToString( @@ -252,9 +235,7 @@ class RNBackupClient @Inject constructor( setBody(authBody) } - if (!authResponse.status.isSuccess()) { - throw RNBackupError.AuthFailed() - } + if (!authResponse.status.isSuccess()) throw RNBackupError.AuthFailed() authResponse.body().also { cachedBearer = it } } @@ -296,13 +277,12 @@ class RNBackupClient @Inject constructor( val mnemonicBytes = mnemonic.toByteArray(Charsets.UTF_8) val salt = ("mnemonic" + (passphrase ?: "")).toByteArray(Charsets.UTF_8) val generator = PKCS5S2ParametersGenerator(SHA512Digest()) - generator.init(mnemonicBytes, salt, 2048) - return (generator.generateDerivedParameters(512) as KeyParameter).key + generator.init(mnemonicBytes, salt, PBKDF2_ITERATIONS) + + return (generator.generateDerivedParameters(PBKDF2_KEY_LENGTH_BITS) as KeyParameter).key } - @Suppress("SpellCheckingInspection") private fun deriveMasterKey(seed: ByteArray): ByteArray { - @Suppress("SpellCheckingInspection") val algorithm = "HmacSHA512" val hmac = Mac.getInstance(algorithm) val keySpec = SecretKeySpec("Bitcoin seed".toByteArray(), algorithm) @@ -312,9 +292,7 @@ class RNBackupClient @Inject constructor( } private fun decrypt(blob: ByteArray, encryptionKey: ByteArray): ByteArray { - if (blob.size < GCM_IV_LENGTH + GCM_TAG_LENGTH) { - throw RNBackupError.DecryptFailed("Data too short") - } + if (blob.size < GCM_IV_LENGTH + GCM_TAG_LENGTH) throw RNBackupError.DecryptFailed("Data too short") val nonce = blob.sliceArray(0 until GCM_IV_LENGTH) val tag = blob.sliceArray(blob.size - GCM_TAG_LENGTH until blob.size) From d654b8a0a1177d7f2a995cb4ade52ec269fc48e6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Jan 2026 18:08:46 +0100 Subject: [PATCH 08/10] feat: extend /pr to accept freeform input after -- --- .claude/commands/pr.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.claude/commands/pr.md b/.claude/commands/pr.md index d5b507c1c..928aac570 100644 --- a/.claude/commands/pr.md +++ b/.claude/commands/pr.md @@ -1,6 +1,6 @@ --- description: Create a PR on GitHub for the current branch -argument_hint: "[branch] [--dry] [--draft]" +argument_hint: "[branch] [--dry] [--draft] [-- instructions]" allowed_tools: Bash, Read, Glob, Grep, Write, AskUserQuestion, mcp__github__create_pull_request, mcp__github__list_pull_requests, mcp__github__get_file_contents, mcp__github__issue_read --- @@ -12,6 +12,8 @@ Create a PR on GitHub using the `gh` CLI for the currently checked-out branch. - `/pr --dry` - Generate description only, save to `.ai/` - `/pr --draft` - Create as draft PR - `/pr develop --draft` - Draft PR against non-default branch +- `/pr -- focus on commit abc123` - With custom instructions for description +- `/pr master --draft -- describe migration test in QA` - Combined with all options ## Steps @@ -24,6 +26,7 @@ Run `gh pr view --json number,url 2>/dev/null` to check if a PR already exists f - `--dry`: Skip PR creation, only generate and save description - `--draft`: Create PR as draft - First non-flag argument: base branch (default: auto-detected, see Step 2.5) +- Everything after `--` separator (if present): Custom instructions for PR generation - **If no flags provided**: Use `AskUserQuestion` to prompt user: - Open PR (create and publish) - Draft PR (create as draft) @@ -42,6 +45,10 @@ If no base branch argument provided, detect the repo's default branch: - Fetch 10 most recent PRs (open or closed) from the extracted repo for writing style reference - Run `git log $base..HEAD --oneline` for commit messages - Run `git diff $base...HEAD --stat` for understanding scope of changes +- **If custom instructions provided:** + - If instructions reference a specific commit SHA (pattern like `commit [a-f0-9]{7,40}`): + - Read full commit message: `git log -1 --format='%B' ` + - Store instructions for use in description generation ### 4. Extract Linked Issues Scan commits for issue references: @@ -102,6 +109,14 @@ This PR adds support for... - Minimize code and file references like `TheClassName` or `someFunctionName`, `thisFileName.ext` - Exception: for refactoring PRs (1:10 ratio of functionality to code changes), more technical detail is ok +**Custom Instructions (if provided):** +When the user provides custom instructions after `--`: +- Parse any referenced commit SHAs and read their full messages +- Focus the description content on areas the user emphasizes +- Structure QA Notes according to user's specific testing instructions +- Custom instructions take priority over default generation rules for sections they address +- Preserve exact testing steps provided by the user (don't summarize or omit details) + **QA Notes / Testing Scenarios:** - Structure with numbered headings and steps - Make steps easily referenceable From bdf11abd7732d29fec0270e01cdd3d6a920e9249 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 9 Jan 2026 15:18:46 -0500 Subject: [PATCH 09/10] fix: recover channel orders during migration --- .../to/bitkit/services/MigrationService.kt | 165 +++++++++++++++--- 1 file changed, 143 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index 6ecaf0216..810c5f5fc 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -35,8 +35,10 @@ import org.lightningdevkit.ldknode.Network import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.WidgetsStore +import to.bitkit.data.dao.TransferDao import to.bitkit.data.dto.price.GraphPeriod import to.bitkit.data.dto.price.TradingPair +import to.bitkit.data.entities.TransferEntity import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin import to.bitkit.di.json @@ -46,6 +48,7 @@ import to.bitkit.models.CoinSelectionPreference import to.bitkit.models.PrimaryDisplay import to.bitkit.models.Suggestion import to.bitkit.models.TransactionSpeed +import to.bitkit.models.TransferType import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition import to.bitkit.models.widget.BlocksPreferences @@ -74,6 +77,7 @@ class MigrationService @Inject constructor( private val coreService: CoreService, private val rnBackupClient: RNBackupClient, private val bip39Service: Bip39Service, + private val transferDao: TransferDao, ) { companion object { private const val TAG = "Migration" @@ -128,6 +132,9 @@ class MigrationService @Inject constructor( @Volatile private var pendingRemoteMetadata: RNMetadata? = null + @Volatile + private var pendingRemotePaidOrders: Map? = null + private fun buildRnLdkAccountPath(): File = run { val rnNetworkString = when (Env.network) { Network.BITCOIN -> "bitcoin" @@ -534,6 +541,48 @@ class MigrationService @Inject constructor( }.getOrNull() } + private fun extractRNBlocktank(mmkvData: Map): Pair, Map>? { + val rootJson = mmkvData[MMKV_ROOT] ?: return null + + return runCatching { + val jsonStart = rootJson.indexOf(OPENING_CURLY_BRACE) + val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson + + val root = json.parseToJsonElement(jsonString).jsonObject + val blocktankJsonString = root["blocktank"]?.jsonPrimitive?.content ?: return@runCatching null + + val blocktankData = json.parseToJsonElement(blocktankJsonString).jsonObject + val orderIds = mutableListOf() + val paidOrdersMap = mutableMapOf() + + blocktankData["orders"]?.jsonArray?.forEach { orderElement -> + orderElement.jsonObject["id"]?.jsonPrimitive?.content?.let { id -> + orderIds.add(id) + } + } + + blocktankData["paidOrders"]?.jsonObject?.forEach { (orderId, txIdElement) -> + val txId = txIdElement.jsonPrimitive.content + paidOrdersMap[orderId] = txId + if (orderId !in orderIds) { + orderIds.add(orderId) + } + } + + if (orderIds.isEmpty() && paidOrdersMap.isEmpty()) { + return@runCatching null + } + + Logger.info( + "Extracted ${orderIds.size} order IDs and ${paidOrdersMap.size} paid orders from local blocktank", + context = TAG, + ) + Pair(orderIds, paidOrdersMap) + }.onFailure { + Logger.error("Failed to decode RN blocktank: $it", it, context = TAG) + }.getOrNull() + } + @Suppress("NestedBlockDepth") private fun extractRNClosedChannels(mmkvData: Map): List? { val rootJson = mmkvData[MMKV_ROOT] ?: return null @@ -922,6 +971,34 @@ class MigrationService @Inject constructor( extractRNTodos(mmkvData)?.let { todos -> applyRNTodos(todos) } + + extractRNBlocktank(mmkvData)?.let { (orderIds, paidOrders) -> + applyRNBlocktank(orderIds, paidOrders) + } + } + + private suspend fun applyRNBlocktank(orderIds: List, paidOrders: Map) { + if (orderIds.isEmpty()) return + + paidOrders.forEach { (orderId, txId) -> + cacheStore.addPaidOrder(orderId, txId) + } + + runCatching { + val fetchedOrders = coreService.blocktank.orders( + orderIds = orderIds, + filter = null, + refresh = true, + ) + if (fetchedOrders.isNotEmpty()) { + coreService.blocktank.upsertOrderList(fetchedOrders) + if (paidOrders.isNotEmpty()) { + createTransfersForPaidOrders(paidOrders, fetchedOrders) + } + } + }.onFailure { e -> + Logger.warn("Failed to fetch and upsert local Blocktank orders: $e", context = TAG) + } } suspend fun getRNRemoteBackupTimestamp(): ULong? = runCatching { @@ -1108,31 +1185,13 @@ class MigrationService @Inject constructor( private suspend fun applyRNRemoteBlocktank(data: ByteArray) { runCatching { val backup = decodeBackupData(data) - val orderIds = mutableListOf() - backup.orders?.let { orders -> - orderIds.addAll(orders.map { it.id }) - } - - backup.paidOrders?.let { paidOrdersMap -> - orderIds.addAll(paidOrdersMap.keys) - paidOrdersMap.forEach { (orderId, txId) -> + backup.paidOrders?.let { paidOrders -> + paidOrders.forEach { (orderId, txId) -> cacheStore.addPaidOrder(orderId, txId) } - } - - if (orderIds.isNotEmpty()) { - runCatching { - val fetchedOrders = coreService.blocktank.orders( - orderIds = orderIds, - filter = null, - refresh = true, - ) - if (fetchedOrders.isNotEmpty()) { - coreService.blocktank.upsertOrderList(fetchedOrders) - } - }.onFailure { e -> - Logger.warn("Failed to fetch and upsert Blocktank orders: $e", context = TAG) + if (paidOrders.isNotEmpty()) { + pendingRemotePaidOrders = paidOrders } } }.onFailure { e -> @@ -1140,6 +1199,43 @@ class MigrationService @Inject constructor( } } + private suspend fun createTransfersForPaidOrders( + paidOrdersMap: Map, + orders: List, + ) { + val now = System.currentTimeMillis() / 1000 + val transfers = paidOrdersMap.mapNotNull { (orderId, txId) -> + val order = orders.find { it.id == orderId } + when { + order == null -> { + Logger.warn("Paid order $orderId not found in fetched orders", context = TAG) + null + } + order.state2 == com.synonym.bitkitcore.BtOrderState2.EXECUTED -> null + else -> TransferEntity( + id = txId, + type = TransferType.TO_SPENDING, + amountSats = (order.clientBalanceSat + order.feeSat).toLong(), + channelId = null, + fundingTxId = null, + lspOrderId = orderId, + isSettled = false, + createdAt = now, + settledAt = null, + ) + } + } + + if (transfers.isNotEmpty()) { + runCatching { + transferDao.upsert(transfers) + Logger.info("Created ${transfers.size} transfers for paid Blocktank orders", context = TAG) + }.onFailure { e -> + Logger.error("Failed to create transfers for paid orders: $e", context = TAG) + } + } + } + suspend fun reapplyMetadataAfterSync() { if (hasRNMmkvData()) { val mmkvData = loadRNMmkvData() ?: return @@ -1183,6 +1279,31 @@ class MigrationService @Inject constructor( applyRNMetadata(metadata) pendingRemoteMetadata = null } + + pendingRemotePaidOrders?.let { paidOrders -> + applyRemotePaidOrders(paidOrders) + pendingRemotePaidOrders = null + } + } + + private suspend fun applyRemotePaidOrders(paidOrders: Map) { + if (paidOrders.isEmpty()) return + + val orderIds = paidOrders.keys.toList() + + runCatching { + val fetchedOrders = coreService.blocktank.orders( + orderIds = orderIds, + filter = null, + refresh = true, + ) + if (fetchedOrders.isNotEmpty()) { + coreService.blocktank.upsertOrderList(fetchedOrders) + createTransfersForPaidOrders(paidOrders, fetchedOrders) + } + }.onFailure { e -> + Logger.warn("Failed to fetch and process remote paid orders: $e", context = TAG) + } } private suspend fun applyRemoteTransfers(transfers: Map) { From 0d7f670a3d923c892a232e4eecf779a3852481b6 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 9 Jan 2026 17:16:55 -0500 Subject: [PATCH 10/10] fix claude comment --- app/src/main/java/to/bitkit/services/MigrationService.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index 810c5f5fc..97ef0ce33 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -51,6 +51,7 @@ import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition +import to.bitkit.models.safe import to.bitkit.models.widget.BlocksPreferences import to.bitkit.models.widget.FactsPreferences import to.bitkit.models.widget.HeadlinePreferences @@ -1215,7 +1216,7 @@ class MigrationService @Inject constructor( else -> TransferEntity( id = txId, type = TransferType.TO_SPENDING, - amountSats = (order.clientBalanceSat + order.feeSat).toLong(), + amountSats = (order.clientBalanceSat.safe() + order.feeSat.safe()).toLong(), channelId = null, fundingTxId = null, lspOrderId = orderId,